- DB: tasks table with scheduled_datetime, reminder_minutes_before, advance_notified, status - API: CRUD routes GET/POST /api/tasks, PATCH/DELETE /api/tasks/<id> - Scheduler: check_task_reminders() fires advance + at-time notifications, tracks advance_notified to prevent double-fire - Bot: handle_task() with add/list/done/cancel/delete actions + datetime resolution helper - AI: task interaction type + examples added to command_parser - Web: task list page with overdue/notified color coding + new task form with datetime-local picker - Nav: replaced Templates with Tasks in bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
618 lines
23 KiB
Python
618 lines
23 KiB
Python
"""
|
|
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 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.
|
|
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()
|
|
|
|
# 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")
|
|
|
|
# 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
|
|
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:
|
|
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()
|
|
|
|
# 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
|
|
if not schedules:
|
|
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 reminders - use both original and adaptive checks
|
|
logger.info("Checking medication reminders")
|
|
check_medication_reminders()
|
|
try:
|
|
check_adaptive_medication_reminders()
|
|
except Exception as e:
|
|
logger.warning(f"Adaptive medication reminder check failed: {e}")
|
|
|
|
# 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()
|