Fix adaptive medication timing and update README

- Fix double notifications: remove redundant check_medication_reminders()
  call, use adaptive path as primary with basic as fallback
- Fix nag firing immediately: require nag_interval minutes after scheduled
  dose time before first nag
- Fix missing schedules: create on-demand if midnight window was missed
- Fix wrong timezone: use user_now_for() instead of request-context
  user_now() in calculate_adjusted_times()
- Fix immutable schedules: recalculate pending schedules on wake event
  detection so adaptive timing actually adapts
- Fix take/skip not updating schedule: API endpoints now call
  mark_med_taken/skipped so nags stop after logging a dose
- Fix skipped doses still triggering reminders: check both taken and
  skipped in adaptive reminder and log queries
- Update README with tasks, AI step generation, auth refresh tokens,
  knowledge base improvements, and current architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 23:34:38 -06:00
parent 03da0b0156
commit e89656a87c
4 changed files with 420 additions and 94 deletions

View File

@@ -13,7 +13,7 @@ import uuid
from datetime import datetime, timedelta, time, timezone
from typing import Optional, Dict, List, Tuple
import core.postgres as postgres
from core.tz import user_now, user_today_for, tz_for_user
from core.tz import user_now, user_now_for, user_today_for, tz_for_user
def _normalize_time(val):
@@ -42,12 +42,24 @@ def get_user_presence(user_uuid: str) -> Optional[Dict]:
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
"""Update user's presence status."""
"""Update user's presence status. If a wake event is detected (came online
after 30+ minutes offline), recalculates today's adaptive medication schedules."""
now = datetime.utcnow()
presence = get_user_presence(user_uuid)
is_wake_event = False
if presence:
# Detect wake event before updating
if is_online and not presence.get("is_currently_online"):
last_offline = presence.get("last_offline_at")
if last_offline:
if isinstance(last_offline, datetime) and last_offline.tzinfo is None:
last_offline = last_offline.replace(tzinfo=timezone.utc)
offline_duration = (now.replace(tzinfo=timezone.utc) - last_offline).total_seconds()
if offline_duration > 1800: # 30 minutes
is_wake_event = True
# Update existing record
updates = {"is_currently_online": is_online, "updated_at": now}
@@ -71,6 +83,26 @@ def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
}
postgres.insert("user_presence", data)
# On wake event, recalculate today's adaptive schedules
if is_wake_event:
_recalculate_schedules_on_wake(user_uuid, now)
def _recalculate_schedules_on_wake(user_uuid: str, wake_time: datetime):
"""Recalculate today's pending adaptive schedules using the actual wake time."""
settings = get_adaptive_settings(user_uuid)
if not settings or not settings.get("adaptive_timing_enabled"):
return
try:
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in meds:
times = med.get("times", [])
if times:
create_daily_schedule(user_uuid, med["id"], times, recalculate=True)
except Exception:
pass # Best-effort — don't break presence tracking if this fails
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
"""Record a presence event in the history."""
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
# Return base times with 0 offset
return [(t, 0) for t in base_times]
# Get user's timezone
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid})
offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0
# Get current time in user's timezone
user_current_time = user_now(offset_minutes)
# Get current time in user's timezone (works in both request and scheduler context)
user_current_time = user_now_for(user_uuid)
today = user_current_time.date()
# Determine wake time
@@ -296,6 +324,14 @@ def should_send_nag(
time_since_last_nag = (current_time - last_nag).total_seconds() / 60
if time_since_last_nag < nag_interval:
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
else:
# First nag: require at least nag_interval minutes since the scheduled dose time
if scheduled_time:
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
sched_dt = current_time.replace(hour=sched_hour, minute=sched_min, second=0, microsecond=0)
minutes_since_dose = (current_time - sched_dt).total_seconds() / 60
if minutes_since_dose < nag_interval:
return False, f"Too soon after dose time ({int(minutes_since_dose)} < {nag_interval} min)"
# Check if this specific dose was already taken or skipped today
logs = postgres.select(
@@ -389,8 +425,13 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
)
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
"""Create today's medication schedule with adaptive adjustments."""
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str], recalculate: bool = False):
"""Create today's medication schedule with adaptive adjustments.
If recalculate=True, deletes existing *pending* schedules and recreates them
with updated adaptive timing (e.g. after a wake event is detected).
Already-taken or skipped schedules are preserved.
"""
today = user_today_for(user_uuid)
# Check if schedule already exists
@@ -399,9 +440,26 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
if existing:
if existing and not recalculate:
return
if existing and recalculate:
# Only delete pending schedules — preserve taken/skipped
for sched in existing:
if sched.get("status") == "pending":
postgres.delete("medication_schedules", {"id": sched["id"]})
# Check if any pending remain to create
remaining = [s for s in existing if s.get("status") != "pending"]
completed_base_times = set()
for s in remaining:
bt = _normalize_time(s.get("base_time"))
if bt:
completed_base_times.add(bt)
# Only create schedules for times that haven't been taken/skipped
base_times = [t for t in base_times if t not in completed_base_times]
if not base_times:
return
# Calculate adjusted times
adjusted_times = calculate_adjusted_times(user_uuid, base_times)
@@ -422,17 +480,40 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication as taken."""
"""Mark a medication schedule as taken."""
_mark_med_status(user_uuid, med_id, scheduled_time, "taken")
def mark_med_skipped(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication schedule as skipped."""
_mark_med_status(user_uuid, med_id, scheduled_time, "skipped")
def _mark_med_status(user_uuid: str, med_id: str, scheduled_time, status: str):
"""Update a medication schedule's status for today."""
scheduled_time = _normalize_time(scheduled_time)
today = user_today_for(user_uuid)
postgres.update(
"medication_schedules",
{"status": "taken"},
{
# Try matching by adjusted_time first
where = {
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
}
if scheduled_time is not None:
where["adjusted_time"] = scheduled_time
schedules = postgres.select("medication_schedules", where)
if schedules:
postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]})
elif scheduled_time is not None:
# Fallback: try matching by base_time (in case adjusted == base)
where_base = {
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"adjusted_time": scheduled_time,
},
)
"base_time": scheduled_time,
}
schedules_base = postgres.select("medication_schedules", where_base)
if schedules_base:
postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]})