""" daemon.py - Background polling loop for scheduled tasks Override poll_callback() with your domain-specific logic. """ import os import time as time_module import logging from datetime import datetime, timezone, timedelta, time as time_type import core.postgres as postgres import core.notifications as notifications import core.adaptive_meds as adaptive_meds import core.snitch as snitch import core.tz as tz logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60)) def _user_now_for(user_uuid): """Get current datetime in a user's timezone using their stored preferences.""" return tz.user_now_for(user_uuid) def _utc_to_local_date(created_at, user_tz): """Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD.""" if created_at is None: return "" if isinstance(created_at, datetime): if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) return created_at.astimezone(user_tz).date().isoformat() return str(created_at)[:10] def check_medication_reminders(): """Check for medications due now and send notifications.""" try: from datetime import date as date_type meds = postgres.select("medications", where={"active": True}) # Group by user so we only look up timezone once per user user_meds = {} for med in meds: uid = med.get("user_uuid") if uid not in user_meds: user_meds[uid] = [] user_meds[uid].append(med) for user_uuid, user_med_list in user_meds.items(): now = _user_now_for(user_uuid) current_time = now.strftime("%H:%M") current_day = now.strftime("%a").lower() today = now.date() today_str = today.isoformat() user_tz = tz.tz_for_user(user_uuid) for med in user_med_list: freq = med.get("frequency", "daily") # Skip as_needed -- no scheduled reminders for PRN if freq == "as_needed": continue # Day-of-week check for specific_days if freq == "specific_days": med_days = med.get("days_of_week", []) if current_day not in med_days: continue # Interval check for every_n_days if freq == "every_n_days": start = med.get("start_date") interval = med.get("interval_days") if start and interval: start_d = ( start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date() ) if (today - start_d).days < 0 or ( today - start_d ).days % interval != 0: continue else: continue # Time check times = med.get("times", []) if current_time not in times: continue # Already taken today? Check by created_at date in user's timezone logs = postgres.select( "med_logs", where={"medication_id": med["id"], "action": "taken"} ) already_taken = any( log.get("scheduled_time") == current_time and _utc_to_local_date(log.get("created_at"), user_tz) == today_str for log in logs ) if already_taken: continue user_settings = notifications.getNotificationSettings(user_uuid) if user_settings: msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']}) · {current_time}" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=user_uuid ) except Exception as e: logger.error(f"Error checking medication reminders: {e}") def check_routine_reminders(): """Check for scheduled routines due now and send notifications.""" try: from datetime import date as date_type schedules = postgres.select("routine_schedules", where={"remind": True}) for schedule in schedules: routine = postgres.select_one("routines", {"id": schedule["routine_id"]}) if not routine: continue now = _user_now_for(routine["user_uuid"]) current_time = now.strftime("%H:%M") today = now.date() if current_time != schedule.get("time"): continue frequency = schedule.get("frequency", "weekly") if frequency == "every_n_days": start = schedule.get("start_date") interval = schedule.get("interval_days") if start and interval: start_d = ( start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date() ) if (today - start_d).days < 0 or ( today - start_d ).days % interval != 0: continue else: continue else: current_day = now.strftime("%a").lower() days = schedule.get("days", []) if current_day not in days: continue user_settings = notifications.getNotificationSettings(routine["user_uuid"]) if user_settings: msg = f"Time to start your routine: {routine['name']}" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=routine["user_uuid"] ) except Exception as e: logger.error(f"Error checking routine reminders: {e}") def check_refills(): """Check for medications running low on refills.""" try: meds = postgres.select("medications") for med in meds: qty = med.get("quantity_remaining") if qty is not None and qty <= 7: user_settings = notifications.getNotificationSettings(med["user_uuid"]) if user_settings: msg = f"Low on {med['name']}: only {qty} doses remaining. Time to refill!" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=med["user_uuid"] ) except Exception as e: logger.error(f"Error checking refills: {e}") def create_daily_adaptive_schedules(): """Create today's medication schedules with adaptive timing. Called per-user when it's midnight in their timezone.""" try: from datetime import date as date_type meds = postgres.select("medications", where={"active": True}) for med in meds: user_uuid = med.get("user_uuid") med_id = med.get("id") times = med.get("times", []) if not times: continue # Create daily schedule with adaptive adjustments adaptive_meds.create_daily_schedule(user_uuid, med_id, times) except Exception as e: logger.error(f"Error creating daily adaptive schedules: {e}") def check_adaptive_medication_reminders(): """Check for medications due now with adaptive timing.""" try: from datetime import date as date_type meds = postgres.select("medications", where={"active": True}) # Group by user user_meds = {} for med in meds: uid = med.get("user_uuid") if uid not in user_meds: user_meds[uid] = [] user_meds[uid].append(med) for user_uuid, user_med_list in user_meds.items(): now = _user_now_for(user_uuid) current_time = now.strftime("%H:%M") today = now.date() user_tz = tz.tz_for_user(user_uuid) # Check if adaptive timing is enabled settings = adaptive_meds.get_adaptive_settings(user_uuid) adaptive_enabled = settings and settings.get("adaptive_timing_enabled") for med in user_med_list: freq = med.get("frequency", "daily") if freq == "as_needed": continue # Day-of-week check if freq == "specific_days": current_day = now.strftime("%a").lower() med_days = med.get("days_of_week", []) if current_day not in med_days: continue # Interval check if freq == "every_n_days": start = med.get("start_date") interval = med.get("interval_days") if start and interval: start_d = ( start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date() ) if (today - start_d).days < 0 or ( today - start_d ).days % interval != 0: continue else: continue # Get today's schedule (any status — we filter below) schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med["id"], "adjustment_date": today, }, ) # If no schedules exist yet, create them on demand if not schedules: times = med.get("times", []) if times: try: adaptive_meds.create_daily_schedule(user_uuid, med["id"], times) schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med["id"], "adjustment_date": today, }, ) except Exception as e: logger.warning(f"Could not create on-demand schedule for {med['id']}: {e}") for sched in schedules: # Skip already-taken or skipped schedules if sched.get("status") in ("taken", "skipped"): continue # Check if it's time to take this med if adaptive_enabled: # Use adjusted time check_time = sched.get("adjusted_time") else: # Use base time check_time = sched.get("base_time") # Normalize TIME objects to "HH:MM" strings for comparison if isinstance(check_time, time_type): check_time = check_time.strftime("%H:%M") elif check_time is not None: check_time = str(check_time)[:5] if check_time != current_time: continue # Check if already taken or skipped for this time slot today logs = postgres.select( "med_logs", where={ "medication_id": med["id"], "user_uuid": user_uuid, }, ) already_handled = any( log.get("action") in ("taken", "skipped") and log.get("scheduled_time") == check_time and _utc_to_local_date(log.get("created_at"), user_tz) == today.isoformat() for log in logs ) if already_handled: continue # Send notification user_settings = notifications.getNotificationSettings(user_uuid) if user_settings: offset = sched.get("adjustment_minutes", 0) if offset > 0: msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)" else: msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=user_uuid ) except Exception as e: logger.error(f"Error checking adaptive medication reminders: {e}") def check_nagging(): """Check for missed medications and send nag notifications.""" try: from datetime import date as date_type # Get all active medications meds = postgres.select("medications", where={"active": True}) for med in meds: user_uuid = med.get("user_uuid") med_id = med.get("id") # Get user's settings settings = adaptive_meds.get_adaptive_settings(user_uuid) if not settings: logger.debug(f"No adaptive settings for user {user_uuid}") continue if not settings.get("nagging_enabled"): logger.debug(f"Nagging disabled for user {user_uuid}") continue now = _user_now_for(user_uuid) today = now.date() # Skip nagging if medication is not due today if not _is_med_due_today(med, today): continue # Get today's schedules try: schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today, "status": "pending", }, ) except Exception as e: logger.warning( f"Could not query medication_schedules for {med_id}: {e}" ) # Table may not exist yet continue # If no schedules exist, try to create them — but only if med is due today if not schedules: if not _is_med_due_today(med, today): continue logger.info( f"No schedules found for medication {med_id}, attempting to create" ) times = med.get("times", []) if times: try: adaptive_meds.create_daily_schedule(user_uuid, med_id, times) # Re-query for schedules schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today, "status": "pending", }, ) except Exception as e: logger.warning(f"Could not create schedules for {med_id}: {e}") continue if not schedules: logger.debug(f"No pending schedules for medication {med_id}") continue for sched in schedules: # Check if we should nag should_nag, reason = adaptive_meds.should_send_nag( user_uuid, med_id, sched.get("adjusted_time"), now ) if not should_nag: continue # Get the time to display adaptive_enabled = settings.get("adaptive_timing_enabled") if adaptive_enabled: display_time = sched.get("adjusted_time") else: display_time = sched.get("base_time") # Normalize TIME objects for display if isinstance(display_time, time_type): display_time = display_time.strftime("%H:%M") elif display_time is not None: display_time = str(display_time)[:5] # Send nag notification user_settings = notifications.getNotificationSettings(user_uuid) if user_settings: nag_count = sched.get("nag_count", 0) + 1 max_nags = settings.get("max_nag_count", 4) msg = f"🔔 {med['name']} reminder {nag_count}/{max_nags}: You missed your {display_time} dose. Please take it now!" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=user_uuid ) # Record that we sent a nag adaptive_meds.record_nag_sent( user_uuid, med_id, sched.get("adjusted_time") ) logger.info( f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}" ) # Check if we should snitch (max nags reached) should_snitch, trigger_reason, snitch_settings = ( snitch.should_snitch( user_uuid, med_id, nag_count, missed_doses=1 ) ) if should_snitch: logger.info( f"Triggering snitch for {med['name']} - {trigger_reason}" ) results = snitch.send_snitch( user_uuid=user_uuid, med_id=med_id, med_name=med["name"], nag_count=nag_count, missed_doses=1, trigger_reason=trigger_reason, ) # Log results for result in results: if result.get("delivered"): logger.info( f"Snitch sent to {result['contact_name']} via {result['contact_type']}" ) else: logger.error( f"Failed to snitch to {result['contact_name']}: {result.get('error')}" ) except Exception as e: logger.error(f"Error checking nags: {e}") def _get_distinct_user_uuids(): """Return a set of user UUIDs that have active medications or routines.""" uuids = set() try: meds = postgres.select("medications", where={"active": True}) for m in meds: uid = m.get("user_uuid") if uid: uuids.add(uid) except Exception: pass try: routines = postgres.select("routines") for r in routines: uid = r.get("user_uuid") if uid: uuids.add(uid) except Exception: pass return uuids def _is_med_due_today(med, today): """Check if a medication is due on the given date based on its frequency.""" from datetime import date as date_type freq = med.get("frequency", "daily") if freq == "as_needed": return False if freq == "specific_days": current_day = today.strftime("%a").lower() med_days = med.get("days_of_week", []) if current_day not in med_days: return False if freq == "every_n_days": start = med.get("start_date") interval = med.get("interval_days") if start and interval: start_d = ( start if isinstance(start, date_type) else datetime.strptime(str(start), "%Y-%m-%d").date() ) days_since = (today - start_d).days if days_since < 0 or days_since % interval != 0: return False else: return False return True def _check_per_user_midnight_schedules(): """Create daily adaptive schedules for each user when it's midnight in their timezone (within the poll window).""" for user_uuid in _get_distinct_user_uuids(): try: now = _user_now_for(user_uuid) if now.hour == 0 and now.minute < POLL_INTERVAL / 60: today = now.date() user_meds = postgres.select( "medications", where={"user_uuid": user_uuid, "active": True} ) for med in user_meds: if not _is_med_due_today(med, today): continue times = med.get("times", []) if times: adaptive_meds.create_daily_schedule(user_uuid, med["id"], times) except Exception as e: logger.warning( f"Could not create adaptive schedules for user {user_uuid}: {e}" ) def check_task_reminders(): """Check one-off tasks for advance and at-time reminders.""" from datetime import timedelta try: tasks = postgres.select("tasks", where={"status": "pending"}) if not tasks: return user_tasks = {} for task in tasks: uid = task.get("user_uuid") user_tasks.setdefault(uid, []).append(task) for user_uuid, task_list in user_tasks.items(): now = _user_now_for(user_uuid) current_hhmm = now.strftime("%H:%M") current_date = now.date() user_settings = None # lazy-load once per user for task in task_list: raw_dt = task.get("scheduled_datetime") if not raw_dt: continue sched_dt = ( raw_dt if isinstance(raw_dt, datetime) else datetime.fromisoformat(str(raw_dt)) ) sched_date = sched_dt.date() sched_hhmm = sched_dt.strftime("%H:%M") reminder_min = task.get("reminder_minutes_before") or 0 # Advance reminder if reminder_min > 0 and not task.get("advance_notified"): adv_dt = sched_dt - timedelta(minutes=reminder_min) if ( adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm ): if user_settings is None: user_settings = notifications.getNotificationSettings( user_uuid ) if user_settings: msg = f"⏰ In {reminder_min} min: {task['title']}" if task.get("description"): msg += f" — {task['description']}" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=user_uuid ) postgres.update( "tasks", {"advance_notified": True}, {"id": task["id"]} ) # At-time reminder if sched_date == current_date and sched_hhmm == current_hhmm: if user_settings is None: user_settings = notifications.getNotificationSettings(user_uuid) if user_settings: msg = f"📋 Now: {task['title']}" if task.get("description"): msg += f"\n{task['description']}" notifications._sendToEnabledChannels( user_settings, msg, user_uuid=user_uuid ) postgres.update( "tasks", { "status": "notified", "updated_at": datetime.utcnow().isoformat(), }, {"id": task["id"]}, ) except Exception as e: logger.error(f"Error checking task reminders: {e}") def poll_callback(): """Called every POLL_INTERVAL seconds.""" # Create daily schedules per-user at their local midnight _check_per_user_midnight_schedules() # Check medication reminders (adaptive path handles both adaptive and non-adaptive) logger.info("Checking medication reminders") try: check_adaptive_medication_reminders() except Exception as e: logger.warning(f"Adaptive medication reminder check failed: {e}") # Fall back to basic reminders if adaptive check fails entirely check_medication_reminders() # Check for nags - log as error to help with debugging try: check_nagging() except Exception as e: logger.error(f"Nagging check failed: {e}") # Original checks check_routine_reminders() check_refills() check_task_reminders() def daemon_loop(): logger.info("Scheduler daemon starting") while True: try: poll_callback() except Exception as e: logger.error(f"Poll callback error: {e}") time_module.sleep(POLL_INTERVAL) if __name__ == "__main__": daemon_loop()