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:
@@ -145,6 +145,17 @@ def register(app):
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
|
||||
return flask.jsonify(meds), 200
|
||||
|
||||
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 _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)
|
||||
|
||||
def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None):
|
||||
"""Check if the proposed medication schedule conflicts with existing routines or medications.
|
||||
Returns (has_conflict, conflict_message) tuple.
|
||||
@@ -152,13 +163,23 @@ def register(app):
|
||||
if not new_times:
|
||||
return False, None
|
||||
|
||||
# Check conflicts with routines
|
||||
# Check conflicts with routines (duration-aware)
|
||||
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
|
||||
for r in user_routines:
|
||||
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
|
||||
if sched and sched.get("time") in new_times:
|
||||
routine_days = json.loads(sched.get("days", "[]"))
|
||||
if not new_days or any(d in routine_days for d in new_days):
|
||||
if not sched or not sched.get("time"):
|
||||
continue
|
||||
routine_days = sched.get("days", [])
|
||||
if isinstance(routine_days, str):
|
||||
routine_days = json.loads(routine_days)
|
||||
if new_days and not any(d in routine_days for d in new_days):
|
||||
continue
|
||||
routine_start = _time_str_to_minutes(sched["time"])
|
||||
routine_dur = _get_routine_duration_minutes(r["id"])
|
||||
for t in new_times:
|
||||
med_start = _time_str_to_minutes(t)
|
||||
# Med falls within routine time range
|
||||
if routine_start <= med_start < routine_start + routine_dur:
|
||||
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
|
||||
|
||||
# Check conflicts with other medications
|
||||
@@ -188,6 +209,11 @@ def register(app):
|
||||
if missing:
|
||||
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
|
||||
|
||||
# Validate every_n_days required fields
|
||||
if data.get("frequency") == "every_n_days":
|
||||
if not data.get("start_date") or not data.get("interval_days"):
|
||||
return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400
|
||||
|
||||
# Check for schedule conflicts
|
||||
new_times = data.get("times", [])
|
||||
new_days = data.get("days_of_week", [])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user