From 79fe51392d489b2a39b4d5706298dd6ba6663982 Mon Sep 17 00:00:00 2001 From: chelsea Date: Mon, 16 Feb 2026 12:37:08 -0600 Subject: [PATCH] fix(api): auto-determine scheduled_time when logging medications --- api/routes/medications.py | 268 +++++++++++++++++++++++++++++--------- 1 file changed, 205 insertions(+), 63 deletions(-) diff --git a/api/routes/medications.py b/api/routes/medications.py index 266490e..d2f2c59 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -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/", 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//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//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({ - "medication": med, - "scheduled_times": [] if is_prn else med.get("times", []), - "taken_times": today_taken, - "skipped_times": today_skipped, - "is_prn": is_prn, - }) + 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({ - "medication": med, - "scheduled_times": early_times, - "taken_times": tomorrow_taken, - "skipped_times": tomorrow_skipped, - "is_prn": False, - "is_next_day": True, - }) + 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({ - "medication": med, - "scheduled_times": late_times, - "taken_times": yesterday_taken, - "skipped_times": yesterday_skipped, - "is_prn": False, - "is_previous_day": True, - }) + 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,15 +641,17 @@ def register(app): else: adherence_pct = 0 - result.append({ - "medication_id": med["id"], - "name": med["name"], - "taken": taken, - "skipped": skipped, - "expected": expected, - "adherence_percent": adherence_pct, - "is_prn": is_prn, - }) + result.append( + { + "medication_id": med["id"], + "name": med["name"], + "taken": taken, + "skipped": skipped, + "expected": expected, + "adherence_percent": adherence_pct, + "is_prn": is_prn, + } + ) return flask.jsonify(result), 200 @app.route("/api/medications//adherence", methods=["GET"]) @@ -547,15 +685,17 @@ def register(app): else: adherence_pct = 0 - return flask.jsonify({ - "medication_id": med_id, - "name": med["name"], - "taken": taken, - "skipped": skipped, - "expected": expected, - "adherence_percent": adherence_pct, - "is_prn": is_prn, - }), 200 + return flask.jsonify( + { + "medication_id": med_id, + "name": med["name"], + "taken": taken, + "skipped": skipped, + "expected": expected, + "adherence_percent": adherence_pct, + "is_prn": is_prn, + } + ), 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"])