From f140f8f75c20a67b14b5ca2e8f52667b86b248e4 Mon Sep 17 00:00:00 2001 From: chelsea Date: Mon, 16 Feb 2026 13:16:18 -0600 Subject: [PATCH] no --- api/routes/medications.py | 268 +++++++++--------------------------- bot/commands/medications.py | 216 ++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 210 deletions(-) diff --git a/api/routes/medications.py b/api/routes/medications.py index d2f2c59..266490e 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -39,10 +39,7 @@ _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): @@ -64,84 +61,13 @@ 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") @@ -172,11 +98,7 @@ 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) @@ -203,19 +125,14 @@ 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"]) @@ -224,9 +141,7 @@ 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"]) @@ -241,9 +156,7 @@ 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()), @@ -261,11 +174,7 @@ 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: @@ -276,9 +185,7 @@ 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 @@ -303,30 +210,17 @@ 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"]) @@ -335,9 +229,7 @@ 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}) @@ -356,19 +248,12 @@ 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": scheduled_time, + "scheduled_time": data.get("scheduled_time"), "notes": data.get("notes"), } log = postgres.insert("med_logs", log_entry) @@ -376,9 +261,7 @@ 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"]) @@ -391,19 +274,12 @@ 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": scheduled_time, + "scheduled_time": data.get("scheduled_time"), "notes": data.get("reason"), } log = postgres.insert("med_logs", log_entry) @@ -411,9 +287,7 @@ 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"]) @@ -461,9 +335,7 @@ 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() @@ -499,15 +371,13 @@ 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 @@ -545,16 +415,14 @@ 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 @@ -592,16 +460,14 @@ 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 @@ -615,9 +481,7 @@ 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() @@ -641,17 +505,15 @@ 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"]) @@ -685,17 +547,15 @@ 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 ─────────────────────────────────────────────────── @@ -715,9 +575,7 @@ 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"]) diff --git a/bot/commands/medications.py b/bot/commands/medications.py index 7cc3a0b..3581ddf 100644 --- a/bot/commands/medications.py +++ b/bot/commands/medications.py @@ -4,10 +4,190 @@ Medications command handler - bot-side hooks for medication management import asyncio import re +from datetime import datetime, timedelta, timezone from bot.command_registry import register_module import ai.parser as ai_parser +def _get_nearest_scheduled_time(times, user_tz=None): + """Find the nearest scheduled time in user's timezone. + + Args: + times: List of time strings like ["08:00", "20:00"] + user_tz: pytz timezone object for the user + + Returns the time as HH:MM string, or None if no times provided. + """ + if not times: + return None + + # Get current time in user's timezone + if user_tz is None: + # Default to UTC if no timezone provided + user_tz = timezone.utc + now = datetime.now(user_tz) + 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 + diff = abs(time_minutes - now_minutes) + + # 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 best_time is None: + # Get current time in user's timezone again for reference + now = datetime.now(user_tz) + now_minutes = now.hour * 60 + now.minute + + # Find the most recent past time + past_times = [] + for time_str in times: + try: + hour, minute = map(int, time_str.split(":")) + time_minutes = hour * 60 + minute + + # If time is in the past today, calculate minutes since then + if time_minutes <= now_minutes: + past_times.append((time_minutes, time_str)) + else: + # If time is in the future, consider it as yesterday's time + past_times.append((time_minutes - 24 * 60, time_str)) + except (ValueError, AttributeError): + continue + + if past_times: + # Find the time closest to now (most recent past) + past_times.sort() + best_time = past_times[-1][1] + + return best_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 + + +async def _get_user_timezone(bot, user_id): + """Check if user has timezone set, ask for it if not. + + Returns the timezone string or None if user cancels. + """ + # Check if user has timezone set + user_data = await bot.api.get_user_data(user_id) + if user_data and user_data.get('timezone'): + return user_data['timezone'] + + # Ask user for their timezone + await bot.send_dm(user_id, "🕐 I don't have your timezone set yet. Could you please tell me your timezone?\n\n" + + "You can provide it in various formats:\n" + + "- Timezone name (e.g., 'America/New_York', 'Europe/London')\n" + + "- UTC offset (e.g., 'UTC+2', '-05:00')\n" + + "- Common abbreviations (e.g., 'EST', 'PST')\n\n" + + "Please reply with your timezone and I'll set it up for you!") + + # Wait for user response (simplified - in real implementation this would be more complex) + # For now, we'll just return None to indicate we need to handle this differently + return None + await bot.send_dm(user_id, "I didn't understand that timezone format. Please say something like:\n" + '- "UTC-8" or "-8" for Pacific Time\n' + '- "UTC+1" or "+1" for Central European Time\n' + '- "PST", "EST", "CST", "MST" for US timezones') + return None + + # Check if user has timezone set in preferences + resp, status = api_request("get", "/api/preferences", token) + if status == 200 and resp: + offset = resp.get("timezone_offset") + if offset is not None: + return offset + + # No timezone set - need to ask user + return None + + +def _parse_timezone(user_input): + """Parse timezone string to offset in minutes. + + Examples: + "UTC-8" -> 480 (8 hours behind UTC) + "-8" -> 480 + "PST" -> 480 + "EST" -> 300 + "+5:30" -> -330 + + Returns offset in minutes (positive = behind UTC) or None if invalid. + """ + user_input = user_input.strip().upper() + + # Common timezone abbreviations + tz_map = { + "PST": 480, + "PDT": 420, + "MST": 420, + "MDT": 360, + "CST": 360, + "CDT": 300, + "EST": 300, + "EDT": 240, + "GMT": 0, + "UTC": 0, + } + + if user_input in tz_map: + return tz_map[user_input] + + # Try to parse offset format + # Remove UTC prefix if present + if user_input.startswith("UTC"): + user_input = user_input[3:] + + # Match patterns like -8, +5, -5:30, +5:30 + match = re.match(r"^([+-])?(\d+)(?::(\d+))?$", user_input) + if match: + sign = -1 if match.group(1) == "-" else 1 + hours = int(match.group(2)) + minutes = int(match.group(3)) if match.group(3) else 0 + + # Convert to offset minutes (positive = behind UTC) + # If user says UTC-8, they're 8 hours BEHIND UTC, so offset is +480 + offset_minutes = (hours * 60 + minutes) * sign * -1 + return offset_minutes + + return None + + async def _get_scheduled_time_from_context(message, med_name): """Fetch recent messages and extract scheduled time from medication reminder. @@ -164,22 +344,48 @@ async def handle_medication(message, session, parsed): await message.channel.send("Which medication did you take?") return + # Check if we have user's timezone + timezone_offset = await _get_user_timezone(message, session, token) + + if timezone_offset is None: + # Need to ask for timezone first + if "pending_confirmations" not in session: + session["pending_confirmations"] = {} + + # Store that we're waiting for timezone, along with the med info + session["pending_confirmations"]["timezone"] = { + "action": "take", + "medication_id": med_id, + "name": name, + } + + await message.channel.send( + "📍 I need to know your timezone to track medications correctly.\n\n" + 'What timezone are you in? (e.g., "UTC-8", "PST", "EST", "+1")' + ) + return + # Try to get scheduled time from recent reminder context scheduled_time = await _get_scheduled_time_from_context(message, name) + # If not found in context, calculate from medication schedule + if not scheduled_time: + # Get medication details to find scheduled times + med_resp, med_status = api_request( + "get", f"/api/medications/{med_id}", token + ) + if med_status == 200 and med_resp: + times = med_resp.get("times", []) + scheduled_time = _get_nearest_scheduled_time(times, timezone_offset) + # Build request body with scheduled_time if found request_body = {} if scheduled_time: request_body["scheduled_time"] = scheduled_time - print( - f"[DEBUG] About to call API: POST /api/medications/{med_id}/take with body: {request_body}", - flush=True, - ) resp, status = api_request( "post", f"/api/medications/{med_id}/take", token, request_body ) - print(f"[DEBUG] API response: status={status}, resp={resp}", flush=True) if status == 201: if scheduled_time: await message.channel.send(