""" daemon.py - Background polling loop for scheduled tasks Override poll_callback() with your domain-specific logic. """ import os import time import logging from datetime import datetime, timezone, timedelta import core.postgres as postgres import core.notifications as notifications import core.adaptive_meds as adaptive_meds import core.snitch as snitch 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 offset.""" prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid}) offset_minutes = 0 if prefs and prefs.get("timezone_offset") is not None: offset_minutes = prefs["timezone_offset"] # JS getTimezoneOffset: positive = behind UTC, so negate tz_obj = timezone(timedelta(minutes=-offset_minutes)) return datetime.now(tz_obj) 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() 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 logs = postgres.select( "med_logs", where={"medication_id": med["id"], "action": "taken"} ) already_taken = any( log.get("scheduled_time") == current_time and str(log.get("created_at", ""))[:10] == 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: 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") current_day = now.strftime("%a").lower() if current_time != schedule.get("time"): continue 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.""" 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() # 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 schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med["id"], "adjustment_date": today, "status": "pending", }, ) for sched in schedules: # 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") if check_time != current_time: continue # Check if already taken logs = postgres.select( "med_logs", where={ "medication_id": med["id"], "user_uuid": user_uuid, "action": "taken", }, ) already_taken = any( str(log.get("created_at", ""))[:10] == today.isoformat() for log in logs ) if already_taken: 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 or not settings.get("nagging_enabled"): continue now = datetime.utcnow() today = now.date() # Get today's schedules schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today, "status": "pending", }, ) 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") # 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 poll_callback(): """Called every POLL_INTERVAL seconds.""" # Create daily schedules at midnight now = datetime.utcnow() if now.hour == 0 and now.minute < POLL_INTERVAL / 60: try: create_daily_adaptive_schedules() except Exception as e: logger.warning( f"Could not create adaptive schedules (tables may not exist): {e}" ) # Check reminders - ALWAYS use original check for now # (adaptive check requires database migration) logger.info("Checking medication reminders (using original method)") check_medication_reminders() # Check for nags try: check_nagging() except Exception as e: logger.warning(f"Nagging check failed: {e}") # Original checks check_routine_reminders() check_refills() 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.sleep(POLL_INTERVAL) if __name__ == "__main__": daemon_loop()