Fix issues #6, #7, #11, #12, #13: med reminders, push notifications, auth persistence, scheduling conflicts

- Fix TIME object vs string comparison in scheduler preventing adaptive med
  reminders from ever firing (#12, #6)
- Add frequency filtering to midnight schedule creation for every_n_days meds
- Require start_date and interval_days for every_n_days medications
- Add refresh token support (30-day) to API and bot for persistent sessions (#13)
- Add "trusted device" checkbox to frontend login for long-lived sessions (#7)
- Auto-refresh expired tokens in both bot (apiRequest) and frontend (api.ts)
- Restore bot sessions from cache on restart using refresh tokens
- Duration-aware routine scheduling conflict detection (#11)
- Add conflict check when starting routine sessions against medication times
- Add diagnostic logging to notification delivery channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:05:48 -06:00
parent 6850abf7d2
commit d4adbde3df
10 changed files with 474 additions and 69 deletions

View File

@@ -7,7 +7,7 @@ Routines have ordered steps. Users start sessions to walk through them.
import os
import uuid
import json
from datetime import datetime
from datetime import datetime, timedelta
import flask
import jwt
import core.auth as auth
@@ -420,6 +420,31 @@ def register(app):
return flask.jsonify(
{"error": "already have active session", "session_id": active["id"]}
), 409
# Check if starting now would conflict with medication times
now = tz.user_now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
routine_dur = _get_routine_duration_minutes(routine_id)
routine_start = _time_str_to_minutes(current_time)
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
if med_days and current_day not in med_days:
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
if _ranges_overlap(routine_start, routine_dur, med_start, 1):
return flask.jsonify(
{"error": f"Starting now would conflict with {med.get('name', 'medication')} at {mt}"}
), 409
steps = postgres.select(
"routine_steps",
where={"routine_id": routine_id},
@@ -649,23 +674,54 @@ def register(app):
)
return flask.jsonify(result), 200
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None):
def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1) # At least 1 minute
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _ranges_overlap(start1, dur1, start2, dur2):
"""Check if two time ranges overlap (in minutes since midnight)."""
end1 = start1 + dur1
end2 = start2 + dur2
return start1 < end2 and start2 < end1
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None, new_routine_id=None):
"""Check if the proposed schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple.
"""
if not new_days or not new_time:
return False, None
new_start = _time_str_to_minutes(new_time)
# Get duration of the routine being scheduled
if new_routine_id:
new_dur = _get_routine_duration_minutes(new_routine_id)
else:
new_dur = 1
# Check conflicts with other routines
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines:
if r["id"] == exclude_routine_id:
continue
other_sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if other_sched and other_sched.get("time") == new_time:
other_days = json.loads(other_sched.get("days", "[]"))
if any(d in other_days for d in new_days):
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
if not other_sched or not other_sched.get("time"):
continue
other_days = other_sched.get("days", [])
if isinstance(other_days, str):
other_days = json.loads(other_days)
if not any(d in other_days for d in new_days):
continue
other_start = _time_str_to_minutes(other_sched["time"])
other_dur = _get_routine_duration_minutes(r["id"])
if _ranges_overlap(new_start, new_dur, other_start, other_dur):
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with medications
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
@@ -673,12 +729,16 @@ def register(app):
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
if new_time in med_times:
# Check if medication runs on any of the same days
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
if not med_days or any(d in med_days for d in new_days):
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
# If med has no specific days, it runs every day
if med_days and not any(d in med_days for d in new_days):
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
# Medication takes ~0 minutes, but check if it falls within routine window
if _ranges_overlap(new_start, new_dur, med_start, 1):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None
@@ -702,7 +762,8 @@ def register(app):
new_days = data.get("days", [])
new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409