fix(api): auto-determine scheduled_time when logging medications

This commit is contained in:
2026-02-16 12:37:08 -06:00
parent 7cf0681deb
commit 79fe51392d

View File

@@ -39,7 +39,10 @@ _JSON_COLS = {"times", "days_of_week"}
def _wrap_json(data):
"""Wrap list/dict values in Json() for psycopg2 when targeting JSON columns."""
return {k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v for k, v in data.items()}
return {
k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v
for k, v in data.items()
}
def _filter_times_in_range(times, start, end):
@@ -61,13 +64,84 @@ def _is_med_due_today(med, today, current_day):
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = start if isinstance(start, date) else datetime.strptime(str(start), "%Y-%m-%d").date()
start_d = (
start
if isinstance(start, date)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
days_since = (today - start_d).days
return days_since >= 0 and days_since % interval == 0
return False
return True
def _get_nearest_scheduled_time(med):
"""Find the nearest scheduled time for a medication.
Looks at the medication's scheduled times and finds the one closest to now.
Returns the time as HH:MM string, or None if medication is as_needed.
"""
freq = med.get("frequency", "daily")
# PRN medications don't have scheduled times
if freq == "as_needed":
return None
times = med.get("times", [])
if not times:
return None
# Get current time
now = datetime.now().time()
now_minutes = now.hour * 60 + now.minute
# Find the time closest to now (within ±4 hours window)
best_time = None
best_diff = float("inf")
window_minutes = 4 * 60 # 4 hour window
for time_str in times:
try:
# Parse time string (e.g., "12:00")
hour, minute = map(int, time_str.split(":"))
time_minutes = hour * 60 + minute
# Calculate difference (handle wrap-around for times near midnight)
diff = abs(time_minutes - now_minutes)
# Also check if it's much earlier/later due to day boundary
alt_diff = abs((time_minutes + 24 * 60) - now_minutes)
diff = min(diff, alt_diff)
# Only consider times within the window
if diff <= window_minutes and diff < best_diff:
best_diff = diff
best_time = time_str
except (ValueError, AttributeError):
continue
# If no time within window, use the most recent past time
if not best_time:
for time_str in times:
try:
hour, minute = map(int, time_str.split(":"))
time_minutes = hour * 60 + minute
# If this time was earlier today, it's a candidate
if time_minutes <= now_minutes:
diff = now_minutes - time_minutes
if diff < best_diff:
best_diff = diff
best_time = time_str
except (ValueError, AttributeError):
continue
# If still no time found, use the first scheduled time
if not best_time and times:
best_time = times[0]
return best_time
def _compute_next_dose_date(med):
"""Compute the next dose date for every_n_days medications."""
interval = med.get("interval_days")
@@ -98,7 +172,11 @@ def _count_expected_doses(med, period_start, days):
start = med.get("start_date")
if not start:
return 0
start_d = start if isinstance(start, date) else datetime.strptime(str(start), "%Y-%m-%d").date()
start_d = (
start
if isinstance(start, date)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
count = 0
for d in range(days):
check_date = period_start + timedelta(days=d)
@@ -125,14 +203,19 @@ def _log_local_date(created_at, user_tz):
def _count_logs_in_period(logs, period_start_str, action, user_tz=None):
"""Count logs of a given action where created_at (local date) >= period_start."""
return sum(
1 for log in logs
1
for log in logs
if log.get("action") == action
and (_log_local_date(log.get("created_at"), user_tz) if user_tz else str(log.get("created_at", ""))[:10]) >= period_start_str
and (
_log_local_date(log.get("created_at"), user_tz)
if user_tz
else str(log.get("created_at", ""))[:10]
)
>= period_start_str
)
def register(app):
# ── Medications CRUD ──────────────────────────────────────────
@app.route("/api/medications", methods=["GET"])
@@ -141,7 +224,9 @@ def register(app):
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
meds = postgres.select(
"medications", where={"user_uuid": user_uuid}, order_by="name"
)
return flask.jsonify(meds), 200
@app.route("/api/medications", methods=["POST"])
@@ -156,7 +241,9 @@ def register(app):
required = ["name", "dosage", "unit", "frequency"]
missing = [f for f in required if not data.get(f)]
if missing:
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
return flask.jsonify(
{"error": f"missing required fields: {', '.join(missing)}"}
), 400
row = {
"id": str(uuid.uuid4()),
@@ -174,7 +261,11 @@ def register(app):
}
# Compute next_dose_date for interval meds
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
if (
data.get("frequency") == "every_n_days"
and data.get("start_date")
and data.get("interval_days")
):
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
today = tz.user_today()
if start > today:
@@ -185,7 +276,9 @@ def register(app):
if remainder == 0:
row["next_dose_date"] = today.isoformat()
else:
row["next_dose_date"] = (today + timedelta(days=data["interval_days"] - remainder)).isoformat()
row["next_dose_date"] = (
today + timedelta(days=data["interval_days"] - remainder)
).isoformat()
med = postgres.insert("medications", _wrap_json(row))
return flask.jsonify(med), 201
@@ -210,17 +303,30 @@ def register(app):
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
existing = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
existing = postgres.select_one(
"medications", {"id": med_id, "user_uuid": user_uuid}
)
if not existing:
return flask.jsonify({"error": "not found"}), 404
allowed = [
"name", "dosage", "unit", "frequency", "times", "notes", "active",
"days_of_week", "interval_days", "start_date", "next_dose_date",
"name",
"dosage",
"unit",
"frequency",
"times",
"notes",
"active",
"days_of_week",
"interval_days",
"start_date",
"next_dose_date",
]
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400
result = postgres.update("medications", _wrap_json(updates), {"id": med_id, "user_uuid": user_uuid})
result = postgres.update(
"medications", _wrap_json(updates), {"id": med_id, "user_uuid": user_uuid}
)
return flask.jsonify(result[0] if result else {}), 200
@app.route("/api/medications/<med_id>", methods=["DELETE"])
@@ -229,7 +335,9 @@ def register(app):
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
existing = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
existing = postgres.select_one(
"medications", {"id": med_id, "user_uuid": user_uuid}
)
if not existing:
return flask.jsonify({"error": "not found"}), 404
postgres.delete("med_logs", {"medication_id": med_id})
@@ -248,12 +356,19 @@ def register(app):
if not med:
return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() or {}
# Determine scheduled_time
scheduled_time = data.get("scheduled_time")
if not scheduled_time:
# Auto-determine from medication schedule
scheduled_time = _get_nearest_scheduled_time(med)
log_entry = {
"id": str(uuid.uuid4()),
"medication_id": med_id,
"user_uuid": user_uuid,
"action": "taken",
"scheduled_time": data.get("scheduled_time"),
"scheduled_time": scheduled_time,
"notes": data.get("notes"),
}
log = postgres.insert("med_logs", log_entry)
@@ -261,7 +376,9 @@ def register(app):
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)
if next_date:
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
postgres.update(
"medications", {"next_dose_date": next_date}, {"id": med_id}
)
return flask.jsonify(log), 201
@app.route("/api/medications/<med_id>/skip", methods=["POST"])
@@ -274,12 +391,19 @@ def register(app):
if not med:
return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() or {}
# Determine scheduled_time
scheduled_time = data.get("scheduled_time")
if not scheduled_time:
# Auto-determine from medication schedule
scheduled_time = _get_nearest_scheduled_time(med)
log_entry = {
"id": str(uuid.uuid4()),
"medication_id": med_id,
"user_uuid": user_uuid,
"action": "skipped",
"scheduled_time": data.get("scheduled_time"),
"scheduled_time": scheduled_time,
"notes": data.get("reason"),
}
log = postgres.insert("med_logs", log_entry)
@@ -287,7 +411,9 @@ def register(app):
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)
if next_date:
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
postgres.update(
"medications", {"next_dose_date": next_date}, {"id": med_id}
)
return flask.jsonify(log), 201
@app.route("/api/medications/<med_id>/snooze", methods=["POST"])
@@ -335,7 +461,9 @@ def register(app):
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
meds = postgres.select(
"medications", where={"user_uuid": user_uuid, "active": True}
)
now = tz.user_now()
user_tz = now.tzinfo
today = now.date()
@@ -371,13 +499,15 @@ def register(app):
and _log_local_date(log.get("created_at"), user_tz) == today_str
]
result.append({
result.append(
{
"medication": med,
"scheduled_times": [] if is_prn else med.get("times", []),
"taken_times": today_taken,
"skipped_times": today_skipped,
"is_prn": is_prn,
})
}
)
seen_med_ids.add(med["id"])
# Late night pass (22:00+): include next-day meds scheduled 00:00-02:00
@@ -415,14 +545,16 @@ def register(app):
and _log_local_date(log.get("created_at"), user_tz) == tomorrow_str
]
result.append({
result.append(
{
"medication": med,
"scheduled_times": early_times,
"taken_times": tomorrow_taken,
"skipped_times": tomorrow_skipped,
"is_prn": False,
"is_next_day": True,
})
}
)
seen_med_ids.add(med["id"])
# Early morning pass (<02:00): include previous-day meds scheduled 22:00-23:59
@@ -460,14 +592,16 @@ def register(app):
and _log_local_date(log.get("created_at"), user_tz) == yesterday_str
]
result.append({
result.append(
{
"medication": med,
"scheduled_times": late_times,
"taken_times": yesterday_taken,
"skipped_times": yesterday_skipped,
"is_prn": False,
"is_previous_day": True,
})
}
)
seen_med_ids.add(med["id"])
return flask.jsonify(result), 200
@@ -481,7 +615,9 @@ def register(app):
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
num_days = flask.request.args.get("days", 30, type=int)
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
meds = postgres.select(
"medications", where={"user_uuid": user_uuid, "active": True}
)
now = tz.user_now()
user_tz = now.tzinfo
today = now.date()
@@ -505,7 +641,8 @@ def register(app):
else:
adherence_pct = 0
result.append({
result.append(
{
"medication_id": med["id"],
"name": med["name"],
"taken": taken,
@@ -513,7 +650,8 @@ def register(app):
"expected": expected,
"adherence_percent": adherence_pct,
"is_prn": is_prn,
})
}
)
return flask.jsonify(result), 200
@app.route("/api/medications/<med_id>/adherence", methods=["GET"])
@@ -547,7 +685,8 @@ def register(app):
else:
adherence_pct = 0
return flask.jsonify({
return flask.jsonify(
{
"medication_id": med_id,
"name": med["name"],
"taken": taken,
@@ -555,7 +694,8 @@ def register(app):
"expected": expected,
"adherence_percent": adherence_pct,
"is_prn": is_prn,
}), 200
}
), 200
# ── Refills ───────────────────────────────────────────────────
@@ -575,7 +715,9 @@ def register(app):
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400
result = postgres.update("medications", updates, {"id": med_id, "user_uuid": user_uuid})
result = postgres.update(
"medications", updates, {"id": med_id, "user_uuid": user_uuid}
)
return flask.jsonify(result[0] if result else {}), 200
@app.route("/api/medications/refills-due", methods=["GET"])