When adaptive timing shifts a late-night dose past midnight (e.g. 23:00 → 00:42), the scheduler would create a new pending schedule on the next day even if the dose was already taken. The proximity window was too narrow to match the take log against the shifted time. - Skip creating schedules for doses already taken/skipped (checks today + yesterday logs against base_time) - Fix midnight wraparound in proximity check for should_send_nag - Display base_time (actual dose time) in reminders instead of the internal adjusted_time Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
547 lines
19 KiB
Python
547 lines
19 KiB
Python
"""
|
|
core/adaptive_meds.py - Adaptive medication timing and nagging logic
|
|
|
|
This module handles:
|
|
- Discord presence tracking for wake detection
|
|
- Adaptive medication schedule calculations
|
|
- Nagging logic for missed medications
|
|
- Quiet hours enforcement
|
|
"""
|
|
|
|
import json
|
|
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_now_for, user_today_for, tz_for_user
|
|
|
|
|
|
def _normalize_time(val):
|
|
"""Convert datetime.time objects to 'HH:MM' strings for use in VARCHAR queries."""
|
|
if isinstance(val, time):
|
|
return val.strftime("%H:%M")
|
|
if val is not None:
|
|
return str(val)[:5]
|
|
return val
|
|
|
|
|
|
def get_adaptive_settings(user_uuid: str) -> Optional[Dict]:
|
|
"""Get user's adaptive medication settings."""
|
|
rows = postgres.select("adaptive_med_settings", {"user_uuid": user_uuid})
|
|
if rows:
|
|
return rows[0]
|
|
return None
|
|
|
|
|
|
def get_user_presence(user_uuid: str) -> Optional[Dict]:
|
|
"""Get user's Discord presence data."""
|
|
rows = postgres.select("user_presence", {"user_uuid": user_uuid})
|
|
if rows:
|
|
return rows[0]
|
|
return None
|
|
|
|
|
|
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
|
|
"""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}
|
|
|
|
if is_online:
|
|
updates["last_online_at"] = now
|
|
else:
|
|
updates["last_offline_at"] = now
|
|
|
|
postgres.update("user_presence", updates, {"user_uuid": user_uuid})
|
|
else:
|
|
# Create new record
|
|
data = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_uuid": user_uuid,
|
|
"discord_user_id": discord_user_id,
|
|
"is_currently_online": is_online,
|
|
"last_online_at": now if is_online else None,
|
|
"last_offline_at": now if not is_online else None,
|
|
"presence_history": json.dumps([]),
|
|
"updated_at": now,
|
|
}
|
|
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."""
|
|
presence = get_user_presence(user_uuid)
|
|
if not presence:
|
|
return
|
|
|
|
raw_history = presence.get("presence_history", [])
|
|
history = json.loads(raw_history) if isinstance(raw_history, str) else raw_history
|
|
|
|
# Add new event
|
|
history.append({"type": event_type, "timestamp": timestamp.isoformat()})
|
|
|
|
# Keep only last 7 days of history (up to 100 events)
|
|
history = history[-100:]
|
|
|
|
postgres.update(
|
|
"user_presence",
|
|
{"presence_history": json.dumps(history)},
|
|
{"user_uuid": user_uuid},
|
|
)
|
|
|
|
|
|
def calculate_typical_wake_time(user_uuid: str) -> Optional[time]:
|
|
"""Calculate user's typical wake time based on presence history."""
|
|
presence = get_user_presence(user_uuid)
|
|
if not presence:
|
|
return None
|
|
|
|
raw_history = presence.get("presence_history", [])
|
|
history = json.loads(raw_history) if isinstance(raw_history, str) else raw_history
|
|
if len(history) < 3:
|
|
return None
|
|
|
|
# Get all "online" events
|
|
wake_times = []
|
|
for event in history:
|
|
if event["type"] == "online":
|
|
ts = datetime.fromisoformat(event["timestamp"])
|
|
wake_times.append(ts.time())
|
|
|
|
if not wake_times:
|
|
return None
|
|
|
|
# Calculate average wake time (convert to minutes since midnight)
|
|
total_minutes = sum(t.hour * 60 + t.minute for t in wake_times)
|
|
avg_minutes = total_minutes // len(wake_times)
|
|
|
|
return time(avg_minutes // 60, avg_minutes % 60)
|
|
|
|
|
|
def detect_wake_event(user_uuid: str, current_time: datetime) -> Optional[datetime]:
|
|
"""Detect if user just woke up based on presence change."""
|
|
presence = get_user_presence(user_uuid)
|
|
if not presence:
|
|
return None
|
|
|
|
# Check if they just came online
|
|
if presence.get("is_currently_online"):
|
|
last_online = presence.get("last_online_at")
|
|
last_offline = presence.get("last_offline_at")
|
|
|
|
if last_online and last_offline:
|
|
offline_duration = last_online - last_offline
|
|
# If they were offline for more than 30 minutes, consider it a wake event
|
|
if offline_duration.total_seconds() > 1800: # 30 minutes
|
|
return last_online
|
|
|
|
return None
|
|
|
|
|
|
def is_quiet_hours(user_uuid: str, check_time: datetime) -> bool:
|
|
"""Check if current time is within user's quiet hours."""
|
|
settings = get_adaptive_settings(user_uuid)
|
|
if not settings:
|
|
return False
|
|
|
|
quiet_start = settings.get("quiet_hours_start")
|
|
quiet_end = settings.get("quiet_hours_end")
|
|
|
|
if not quiet_start or not quiet_end:
|
|
return False
|
|
|
|
current_time = check_time.time()
|
|
|
|
# Handle quiet hours that span midnight
|
|
if quiet_start > quiet_end:
|
|
return current_time >= quiet_start or current_time <= quiet_end
|
|
else:
|
|
return quiet_start <= current_time <= quiet_end
|
|
|
|
|
|
def calculate_adjusted_times(
|
|
user_uuid: str, base_times: List[str], wake_time: Optional[datetime] = None
|
|
) -> List[Tuple[str, int]]:
|
|
"""
|
|
Calculate adjusted medication times based on wake time.
|
|
|
|
Args:
|
|
user_uuid: User's UUID
|
|
base_times: List of base times in "HH:MM" format
|
|
wake_time: Optional wake time to use for adjustment
|
|
|
|
Returns:
|
|
List of (adjusted_time_str, offset_minutes) tuples
|
|
"""
|
|
settings = get_adaptive_settings(user_uuid)
|
|
if not settings or not settings.get("adaptive_timing_enabled"):
|
|
# Return base times with 0 offset
|
|
return [(t, 0) for t in base_times]
|
|
|
|
# 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
|
|
if wake_time is None:
|
|
# Try to get from presence detection
|
|
wake_time = detect_wake_event(user_uuid, user_current_time)
|
|
|
|
if wake_time is None:
|
|
# Use typical wake time if available
|
|
typical_wake = calculate_typical_wake_time(user_uuid)
|
|
if typical_wake:
|
|
wake_time = datetime.combine(today, typical_wake)
|
|
|
|
if wake_time is None:
|
|
# Default wake time (8 AM)
|
|
wake_time = datetime.combine(today, time(8, 0))
|
|
|
|
# Calculate offset from first med time
|
|
if not base_times:
|
|
return []
|
|
|
|
first_med_time = datetime.strptime(base_times[0], "%H:%M").time()
|
|
first_med_datetime = datetime.combine(today, first_med_time)
|
|
|
|
# Calculate how late they are
|
|
if wake_time.time() > first_med_time:
|
|
# They woke up after their first med time
|
|
offset_minutes = int((wake_time - first_med_datetime).total_seconds() / 60)
|
|
else:
|
|
offset_minutes = 0
|
|
|
|
# Adjust all times
|
|
adjusted = []
|
|
for base_time_str in base_times:
|
|
base_time = datetime.strptime(base_time_str, "%H:%M").time()
|
|
base_datetime = datetime.combine(today, base_time)
|
|
|
|
# Add offset
|
|
adjusted_datetime = base_datetime + timedelta(minutes=offset_minutes)
|
|
adjusted_time_str = adjusted_datetime.strftime("%H:%M")
|
|
|
|
adjusted.append((adjusted_time_str, offset_minutes))
|
|
|
|
return adjusted
|
|
|
|
|
|
def should_send_nag(
|
|
user_uuid: str, med_id: str, scheduled_time, current_time: datetime
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Determine if we should send a nag notification.
|
|
|
|
Returns:
|
|
(should_nag: bool, reason: str)
|
|
"""
|
|
scheduled_time = _normalize_time(scheduled_time)
|
|
|
|
# Don't nag for doses that aren't due yet
|
|
if scheduled_time:
|
|
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
|
|
sched_as_time = time(sched_hour, sched_min)
|
|
if current_time.time() < sched_as_time:
|
|
return False, "Not yet due"
|
|
|
|
settings = get_adaptive_settings(user_uuid)
|
|
if not settings:
|
|
return False, "No settings"
|
|
|
|
if not settings.get("nagging_enabled"):
|
|
return False, "Nagging disabled"
|
|
|
|
# Check quiet hours
|
|
if is_quiet_hours(user_uuid, current_time):
|
|
return False, "Quiet hours"
|
|
|
|
# Check if user is online (don't nag if offline unless presence tracking disabled)
|
|
presence = get_user_presence(user_uuid)
|
|
if presence and settings.get("presence_tracking_enabled"):
|
|
if not presence.get("is_currently_online"):
|
|
return False, "User offline"
|
|
|
|
# Get today's schedule record for this specific time slot
|
|
today = user_today_for(user_uuid)
|
|
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
|
|
if scheduled_time is not None:
|
|
query["adjusted_time"] = scheduled_time
|
|
schedules = postgres.select("medication_schedules", query)
|
|
|
|
if not schedules:
|
|
return False, "No schedule found"
|
|
|
|
schedule = schedules[0]
|
|
nag_count = schedule.get("nag_count", 0)
|
|
max_nags = settings.get("max_nag_count", 4)
|
|
|
|
if nag_count >= max_nags:
|
|
return False, f"Max nags reached ({max_nags})"
|
|
|
|
# Check if it's time to nag
|
|
last_nag = schedule.get("last_nag_at")
|
|
nag_interval = settings.get("nag_interval_minutes", 15)
|
|
|
|
if last_nag:
|
|
if isinstance(last_nag, datetime) and last_nag.tzinfo is None:
|
|
last_nag = last_nag.replace(tzinfo=timezone.utc)
|
|
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(
|
|
"med_logs",
|
|
{
|
|
"medication_id": med_id,
|
|
"user_uuid": user_uuid,
|
|
},
|
|
)
|
|
|
|
# Get medication times to calculate dose interval for proximity check
|
|
med = postgres.select_one("medications", {"id": med_id})
|
|
dose_interval_minutes = 60 # default fallback
|
|
if med and med.get("times"):
|
|
times = med["times"]
|
|
if len(times) >= 2:
|
|
time_minutes = []
|
|
for t in times:
|
|
t = _normalize_time(t)
|
|
if t:
|
|
h, m = int(t[:2]), int(t[3:5])
|
|
time_minutes.append(h * 60 + m)
|
|
time_minutes.sort()
|
|
intervals = []
|
|
for i in range(1, len(time_minutes)):
|
|
intervals.append(time_minutes[i] - time_minutes[i - 1])
|
|
if intervals:
|
|
dose_interval_minutes = min(intervals)
|
|
|
|
proximity_window = max(30, dose_interval_minutes // 2)
|
|
|
|
# Filter to today's logs and check for this specific dose
|
|
user_tz = tz_for_user(user_uuid)
|
|
for log in logs:
|
|
action = log.get("action")
|
|
if action not in ("taken", "skipped"):
|
|
continue
|
|
|
|
created_at = log.get("created_at")
|
|
if not created_at:
|
|
continue
|
|
|
|
# created_at is stored as UTC but timezone-naive; convert to user's timezone
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
created_at_local = created_at.astimezone(user_tz)
|
|
|
|
if created_at_local.date() != today:
|
|
continue
|
|
|
|
log_scheduled_time = log.get("scheduled_time")
|
|
if log_scheduled_time:
|
|
log_scheduled_time = _normalize_time(log_scheduled_time)
|
|
if log_scheduled_time == scheduled_time:
|
|
return False, f"Already {action} today"
|
|
else:
|
|
if scheduled_time:
|
|
log_hour = created_at_local.hour
|
|
log_min = created_at_local.minute
|
|
sched_hour, sched_min = (
|
|
int(scheduled_time[:2]),
|
|
int(scheduled_time[3:5]),
|
|
)
|
|
log_mins = log_hour * 60 + log_min
|
|
sched_mins = sched_hour * 60 + sched_min
|
|
diff_minutes = abs(log_mins - sched_mins)
|
|
# Handle midnight wraparound (e.g. 23:00 vs 00:42)
|
|
diff_minutes = min(diff_minutes, 1440 - diff_minutes)
|
|
if diff_minutes <= proximity_window:
|
|
return False, f"Already {action} today"
|
|
|
|
return True, "Time to nag"
|
|
|
|
|
|
def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
|
|
"""Record that a nag was sent."""
|
|
scheduled_time = _normalize_time(scheduled_time)
|
|
today = user_today_for(user_uuid)
|
|
|
|
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
|
|
if scheduled_time is not None:
|
|
query["adjusted_time"] = scheduled_time
|
|
schedules = postgres.select("medication_schedules", query)
|
|
|
|
if schedules:
|
|
schedule = schedules[0]
|
|
new_nag_count = schedule.get("nag_count", 0) + 1
|
|
|
|
postgres.update(
|
|
"medication_schedules",
|
|
{"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
|
|
{"id": schedule["id"]},
|
|
)
|
|
|
|
|
|
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
|
|
existing = postgres.select(
|
|
"medication_schedules",
|
|
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
|
|
)
|
|
|
|
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)
|
|
|
|
# Check recent med logs to skip doses already taken/skipped.
|
|
# Handles cross-midnight: if adaptive offset shifts 23:00 → 00:42 today,
|
|
# but the user already took the 23:00 dose last night, don't schedule it.
|
|
user_tz = tz_for_user(user_uuid)
|
|
yesterday = today - timedelta(days=1)
|
|
recent_logs = postgres.select("med_logs", {"medication_id": med_id, "user_uuid": user_uuid})
|
|
taken_base_times = set()
|
|
for log in recent_logs:
|
|
if log.get("action") not in ("taken", "skipped"):
|
|
continue
|
|
created_at = log.get("created_at")
|
|
if not created_at:
|
|
continue
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
log_date = created_at.astimezone(user_tz).date()
|
|
if log_date not in (today, yesterday):
|
|
continue
|
|
log_sched = _normalize_time(log.get("scheduled_time"))
|
|
if log_sched:
|
|
taken_base_times.add(log_sched)
|
|
|
|
# Create schedule records for each time
|
|
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
|
|
if base_time in taken_base_times:
|
|
continue
|
|
|
|
data = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_uuid": user_uuid,
|
|
"medication_id": med_id,
|
|
"base_time": base_time,
|
|
"adjusted_time": adjusted_time,
|
|
"adjustment_date": today,
|
|
"adjustment_minutes": offset,
|
|
"nag_count": 0,
|
|
"status": "pending",
|
|
}
|
|
postgres.insert("medication_schedules", data)
|
|
|
|
|
|
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
|
|
"""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)
|
|
|
|
# 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,
|
|
"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"]})
|