""" Hourly scheduler that sends push notifications to parents when their children's scheduled chores are going to expire within the next 75 minutes and haven't been marked completed or submitted for confirmation yet. Window rationale (75 min): The spec requires that chores expiring between 9:00 pm and 9:15 pm both generate a notification at 8:00 pm. A 75-minute look-ahead window (now < deadline <= now + 75 min) satisfies this for the hourly cron run at the top of each hour. Double-notification note: A chore deadline that falls within the overlap of two consecutive 75-minute windows (e.g. 9:10 pm appears in both the 8:00 pm and 9:00 pm windows) may produce two push notifications. No deduplication is applied; this is intentional per the product decision made during spec review. """ import logging import os from datetime import datetime, timedelta, timezone from apscheduler.schedulers.background import BackgroundScheduler from tinydb import Query from utils.schedule_utils import get_due_time_today, is_scheduled_today from utils.push_sender import send_push_to_user logger = logging.getLogger(__name__) _WINDOW_MINUTES = 75 def get_expiring_chores_for_user( user_id: str, tz_str: str | None, now_dt: datetime, ) -> list[dict]: """Return chores that will expire within the next _WINDOW_MINUTES minutes. Each entry is:: { 'child_id': str, 'child_name': str, 'task_id': str, 'task_name': str, } A chore is included only when ALL of the following are true: - It has a ChoreSchedule with enabled=True - It is scheduled today (days or interval mode) - It has a deadline (not Anytime) - The deadline falls in (now_dt, now_dt + _WINDOW_MINUTES] - There is no existing pending_confirmation record for this child + task (status='pending' means awaiting approval; status='approved' means done) Must be called within a Flask app context. """ from db.db import child_db, task_db, pending_confirmations_db from db.chore_schedules import get_schedule try: from zoneinfo import ZoneInfo local_now = datetime.now(ZoneInfo(tz_str)) if tz_str else now_dt except Exception: local_now = now_dt local_date = local_now.date() window_end = local_now + timedelta(minutes=_WINDOW_MINUTES) ChildQ = Query() children = child_db.search(ChildQ.user_id == user_id) expiring: list[dict] = [] for child_dict in children: child_id = child_dict.get('id') child_name = child_dict.get('name', 'Unknown') task_ids = child_dict.get('tasks', []) TaskQ = Query() for task_id in task_ids: task = task_db.get( (TaskQ.id == task_id) & (TaskQ.type == 'chore') & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None)) ) if not task: continue schedule = get_schedule(child_id, task_id) if schedule is None: continue schedule_dict = schedule.to_dict() if not schedule_dict.get('enabled', True): continue # paused schedules have no deadline if not is_scheduled_today(schedule_dict, local_date): continue due = get_due_time_today(schedule_dict, local_date) if due is None: continue # Anytime — no expiry due_hour, due_minute = due # Build a timezone-aware deadline datetime for today deadline_dt = local_now.replace( hour=due_hour, minute=due_minute, second=0, microsecond=0 ) # Include only when deadline is strictly after now and within window if not (local_now < deadline_dt <= window_end): continue # Skip if any confirmation record exists (pending OR approved = done today) PendingQ = Query() has_confirmation = pending_confirmations_db.get( (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id) ) if has_confirmation: continue expiring.append({ 'child_id': child_id, 'child_name': child_name, 'task_id': task_id, 'task_name': task.get('name', 'Unknown'), }) return expiring def _build_push_payload(expiring: list[dict]) -> dict: """Build the push notification payload from a list of expiring chore entries.""" if len(expiring) == 1: entry = expiring[0] title = f"Chore ending soon: {entry['task_name']}" child_id = entry['child_id'] entity_id = entry['task_id'] else: title = 'Chores ending soon' child_id = None entity_id = None # Group by child and format body text by_child: dict[str, list[str]] = {} for entry in expiring: by_child.setdefault(entry['child_name'], []).append(entry['task_name']) body_lines = [ f"{name}: {', '.join(tasks)}" for name, tasks in by_child.items() ] body = '\n'.join(body_lines) return { 'type': 'chore_expiring_soon', 'title': title, 'body': body, 'child_id': child_id, 'entity_id': entity_id, 'entity_type': 'chore', } def send_chore_expiry_notifications_for_user(user_id: str, tz_str: str | None) -> int: """Check for expiring chores and send a push notification if any are found. Returns the number of chores included in the notification (0 = nothing sent). Must be called within a Flask app context. """ now_dt = datetime.now(timezone.utc) expiring = get_expiring_chores_for_user(user_id, tz_str, now_dt) if not expiring: return 0 payload = _build_push_payload(expiring) send_push_to_user(user_id, payload) logger.info( f'Chore expiry: sent notification for {len(expiring)} chore(s) to user {user_id}' ) return len(expiring) def run_chore_expiry_check(app) -> None: """Check all verified, push-enabled users for soon-to-expire chores.""" if os.environ.get('DB_ENV') == 'e2e': return with app.app_context(): try: from db.db import users_db UserQ = Query() users = users_db.search(UserQ.verified == True) for user_dict in users: user_id = user_dict.get('id') tz_str = user_dict.get('timezone') if not user_dict.get('push_notifications_enabled', True): continue try: count = send_chore_expiry_notifications_for_user(user_id, tz_str) if count: logger.info( f'Chore expiry: notified {count} expiring chore(s) for user {user_id}' ) except Exception as e: logger.error(f'Chore expiry: failed for user {user_id}: {e}') except Exception as e: logger.error(f'Error in run_chore_expiry_check: {e}') def start_chore_expiry_notification_scheduler(app) -> BackgroundScheduler: """Start the hourly chore-expiry notification scheduler. Runs at the top of every hour (minute=0), matching the existing schedulers. Returns the scheduler instance. """ scheduler = BackgroundScheduler() scheduler.add_job( run_chore_expiry_check, 'cron', minute=0, args=[app], ) scheduler.start() logger.info('Chore expiry notification scheduler started (runs every hour at :00)') return scheduler