diff --git a/api/routes/adaptive_meds.py b/api/routes/adaptive_meds.py new file mode 100644 index 0000000..b61f41a --- /dev/null +++ b/api/routes/adaptive_meds.py @@ -0,0 +1,185 @@ +""" +api/routes/adaptive_meds.py - API endpoints for adaptive medication settings +""" + +import flask +import jwt +import os +import core.postgres as postgres +import core.adaptive_meds as adaptive_meds + +JWT_SECRET = os.getenv("JWT_SECRET") + + +def _get_user_uuid(request): + """Extract and validate user UUID from JWT token.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + + token = auth_header[7:] + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("sub") + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +def register(app): + @app.route("/api/adaptive-meds/settings", methods=["GET"]) + def get_adaptive_settings(): + """Get user's adaptive medication settings.""" + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "Unauthorized"}), 401 + + settings = adaptive_meds.get_adaptive_settings(user_uuid) + + if not settings: + # Return defaults + return flask.jsonify( + { + "adaptive_timing_enabled": False, + "adaptive_mode": "shift_all", + "presence_tracking_enabled": False, + "nagging_enabled": True, + "nag_interval_minutes": 15, + "max_nag_count": 4, + "quiet_hours_start": None, + "quiet_hours_end": None, + } + ), 200 + + return flask.jsonify( + { + "adaptive_timing_enabled": settings.get( + "adaptive_timing_enabled", False + ), + "adaptive_mode": settings.get("adaptive_mode", "shift_all"), + "presence_tracking_enabled": settings.get( + "presence_tracking_enabled", False + ), + "nagging_enabled": settings.get("nagging_enabled", True), + "nag_interval_minutes": settings.get("nag_interval_minutes", 15), + "max_nag_count": settings.get("max_nag_count", 4), + "quiet_hours_start": settings.get("quiet_hours_start"), + "quiet_hours_end": settings.get("quiet_hours_end"), + } + ), 200 + + @app.route("/api/adaptive-meds/settings", methods=["PUT"]) + def update_adaptive_settings(): + """Update user's adaptive medication settings.""" + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "Unauthorized"}), 401 + + data = flask.request.get_json() + if not data: + return flask.jsonify({"error": "No data provided"}), 400 + + # Validate required fields if enabling adaptive timing + if data.get("adaptive_timing_enabled"): + if not data.get("adaptive_mode"): + return flask.jsonify( + {"error": "adaptive_mode is required when enabling adaptive timing"} + ), 400 + + # Build update data + update_data = { + "adaptive_timing_enabled": data.get("adaptive_timing_enabled", False), + "adaptive_mode": data.get("adaptive_mode", "shift_all"), + "presence_tracking_enabled": data.get("presence_tracking_enabled", False), + "nagging_enabled": data.get("nagging_enabled", True), + "nag_interval_minutes": data.get("nag_interval_minutes", 15), + "max_nag_count": data.get("max_nag_count", 4), + "quiet_hours_start": data.get("quiet_hours_start"), + "quiet_hours_end": data.get("quiet_hours_end"), + } + + # Check if settings exist + existing = adaptive_meds.get_adaptive_settings(user_uuid) + + if existing: + postgres.update( + "adaptive_med_settings", update_data, {"user_uuid": user_uuid} + ) + else: + update_data["user_uuid"] = user_uuid + postgres.insert("adaptive_med_settings", update_data) + + return flask.jsonify({"success": True}), 200 + + @app.route("/api/adaptive-meds/presence", methods=["GET"]) + def get_presence_status(): + """Get user's Discord presence status.""" + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "Unauthorized"}), 401 + + presence = adaptive_meds.get_user_presence(user_uuid) + + if not presence: + return flask.jsonify( + {"is_online": False, "last_online_at": None, "typical_wake_time": None} + ), 200 + + typical_wake = adaptive_meds.calculate_typical_wake_time(user_uuid) + + return flask.jsonify( + { + "is_online": presence.get("is_currently_online", False), + "last_online_at": presence.get("last_online_at").isoformat() + if presence.get("last_online_at") + else None, + "last_offline_at": presence.get("last_offline_at").isoformat() + if presence.get("last_offline_at") + else None, + "typical_wake_time": typical_wake.strftime("%H:%M") + if typical_wake + else None, + } + ), 200 + + @app.route("/api/adaptive-meds/schedule", methods=["GET"]) + def get_today_schedule(): + """Get today's adaptive medication schedule.""" + user_uuid = _get_user_uuid(flask.request) + if not user_uuid: + return flask.jsonify({"error": "Unauthorized"}), 401 + + from datetime import date + + today = date.today() + + # Get all medications for user + meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True}) + + schedule_data = [] + for med in meds: + med_id = med.get("id") + med_schedules = postgres.select( + "medication_schedules", + { + "user_uuid": user_uuid, + "medication_id": med_id, + "adjustment_date": today, + }, + ) + + for sched in med_schedules: + schedule_data.append( + { + "medication_id": med_id, + "medication_name": med.get("name"), + "base_time": sched.get("base_time"), + "adjusted_time": sched.get("adjusted_time"), + "adjustment_minutes": sched.get("adjustment_minutes", 0), + "status": sched.get("status", "pending"), + "nag_count": sched.get("nag_count", 0), + } + ) + + return flask.jsonify(schedule_data), 200 diff --git a/bot/bot.py b/bot/bot.py index e853747..0ec820a 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -577,5 +577,88 @@ async def beforeBackgroundLoop(): await client.wait_until_ready() +# ==================== Discord Presence Tracking ==================== + + +async def update_presence_tracking(): + """Track Discord presence for users with presence tracking enabled.""" + try: + import core.adaptive_meds as adaptive_meds + import core.postgres as postgres + + # Get all users with presence tracking enabled + settings = postgres.select( + "adaptive_med_settings", {"presence_tracking_enabled": True} + ) + + for setting in settings: + user_uuid = setting.get("user_uuid") + + # Get user's Discord ID from notifications table + notif_settings = postgres.select("notifications", {"user_uuid": user_uuid}) + if not notif_settings: + continue + + discord_user_id = notif_settings[0].get("discord_user_id") + if not discord_user_id: + continue + + # Get the user from Discord + try: + discord_user = await client.fetch_user(int(discord_user_id)) + if not discord_user: + continue + + # Check if user is online + is_online = discord_user.status != discord.Status.offline + + # Get current presence from DB + presence = adaptive_meds.get_user_presence(user_uuid) + was_online = presence.get("is_currently_online") if presence else False + + # Update presence if changed + if is_online != was_online: + adaptive_meds.update_user_presence( + user_uuid, discord_user_id, is_online + ) + + # Record the event + from datetime import datetime + + event_type = "online" if is_online else "offline" + adaptive_meds.record_presence_event( + user_uuid, event_type, datetime.utcnow() + ) + + print( + f"Presence update: User {user_uuid} is now {'online' if is_online else 'offline'}" + ) + + except Exception as e: + print(f"Error tracking presence for user {user_uuid}: {e}") + + except Exception as e: + print(f"Error in presence tracking loop: {e}") + + +@tasks.loop(seconds=30) +async def presenceTrackingLoop(): + """Track Discord presence every 30 seconds.""" + await update_presence_tracking() + + +@presenceTrackingLoop.before_loop +async def beforePresenceTrackingLoop(): + await client.wait_until_ready() + + +@client.event +async def on_ready(): + print(f"Bot logged in as {client.user}") + loadCache() + backgroundLoop.start() + presenceTrackingLoop.start() + + if __name__ == "__main__": client.run(DISCORD_BOT_TOKEN) diff --git a/bot/config.json b/bot/config.json new file mode 100644 index 0000000..9420556 --- /dev/null +++ b/bot/config.json @@ -0,0 +1,12 @@ +{ + "openrouter_api_key": "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", + "embedding_file": "dbt_knowledge.json", + "models": { + "generator": "moonshotai/kimi-k2.5", + "jury_clinical": "z-ai/glm-5", + "jury_safety": "deepseek/deepseek-v3.2", + "jury_empathy": "openai/gpt-4o-2024-08-06", + "jury_hallucination": "qwen/qwen3-235b-a22b-2507" + }, + "system_prompt": "You are a DBT assistant. Answer based ONLY on the provided context." +} diff --git a/config/schema.sql b/config/schema.sql index 59ec320..e118693 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -200,3 +200,53 @@ CREATE TABLE IF NOT EXISTS med_logs ( notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +-- ── Adaptive Medication Settings ───────────────────────────── + +CREATE TABLE IF NOT EXISTS adaptive_med_settings ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + adaptive_timing_enabled BOOLEAN DEFAULT FALSE, + adaptive_mode VARCHAR(20) DEFAULT 'shift_all', + presence_tracking_enabled BOOLEAN DEFAULT FALSE, + nagging_enabled BOOLEAN DEFAULT TRUE, + nag_interval_minutes INTEGER DEFAULT 15, + max_nag_count INTEGER DEFAULT 4, + quiet_hours_start TIME, + quiet_hours_end TIME, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ── User Discord Presence Tracking ──────────────────────────── + +CREATE TABLE IF NOT EXISTS user_presence ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + discord_user_id VARCHAR(255), + last_online_at TIMESTAMP, + last_offline_at TIMESTAMP, + is_currently_online BOOLEAN DEFAULT FALSE, + typical_wake_time TIME, + presence_history JSONB DEFAULT '[]', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ── Adaptive Medication Schedules (Daily Tracking) ─────────── + +CREATE TABLE IF NOT EXISTS medication_schedules ( + id UUID PRIMARY KEY, + medication_id UUID REFERENCES medications(id) ON DELETE CASCADE, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + base_time TIME NOT NULL, + adjusted_time TIME, + adjustment_date DATE NOT NULL, + adjustment_minutes INTEGER DEFAULT 0, + nag_count INTEGER DEFAULT 0, + last_nag_at TIMESTAMP, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date); +CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status); diff --git a/core/adaptive_meds.py b/core/adaptive_meds.py new file mode 100644 index 0000000..44b9e06 --- /dev/null +++ b/core/adaptive_meds.py @@ -0,0 +1,358 @@ +""" +core/adaptive_meds.py - Adaptive medication timing and nagging logic + +This module handles: +- Discord presence tracking for wake detection +- Adaptive medication schedule calculations +- Nagging logic for missed medications +- Quiet hours enforcement +""" + +import json +from datetime import datetime, timedelta, time +from typing import Optional, Dict, List, Tuple +import core.postgres as postgres +from core.tz import user_now + + +def get_adaptive_settings(user_uuid: str) -> Optional[Dict]: + """Get user's adaptive medication settings.""" + rows = postgres.select("adaptive_med_settings", {"user_uuid": user_uuid}) + if rows: + return rows[0] + return None + + +def get_user_presence(user_uuid: str) -> Optional[Dict]: + """Get user's Discord presence data.""" + rows = postgres.select("user_presence", {"user_uuid": user_uuid}) + if rows: + return rows[0] + return None + + +def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool): + """Update user's presence status.""" + now = datetime.utcnow() + + presence = get_user_presence(user_uuid) + + if presence: + # Update existing record + updates = {"is_currently_online": is_online, "updated_at": now} + + if is_online: + updates["last_online_at"] = now + else: + updates["last_offline_at"] = now + + postgres.update("user_presence", updates, {"user_uuid": user_uuid}) + else: + # Create new record + data = { + "user_uuid": user_uuid, + "discord_user_id": discord_user_id, + "is_currently_online": is_online, + "last_online_at": now if is_online else None, + "last_offline_at": now if not is_online else None, + "presence_history": json.dumps([]), + "updated_at": now, + } + postgres.insert("user_presence", data) + + +def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime): + """Record a presence event in the history.""" + presence = get_user_presence(user_uuid) + if not presence: + return + + history = json.loads(presence.get("presence_history", "[]")) + + # Add new event + history.append({"type": event_type, "timestamp": timestamp.isoformat()}) + + # Keep only last 7 days of history (up to 100 events) + history = history[-100:] + + postgres.update( + "user_presence", + {"presence_history": json.dumps(history)}, + {"user_uuid": user_uuid}, + ) + + +def calculate_typical_wake_time(user_uuid: str) -> Optional[time]: + """Calculate user's typical wake time based on presence history.""" + presence = get_user_presence(user_uuid) + if not presence: + return None + + history = json.loads(presence.get("presence_history", "[]")) + if len(history) < 3: + return None + + # Get all "online" events + wake_times = [] + for event in history: + if event["type"] == "online": + ts = datetime.fromisoformat(event["timestamp"]) + wake_times.append(ts.time()) + + if not wake_times: + return None + + # Calculate average wake time (convert to minutes since midnight) + total_minutes = sum(t.hour * 60 + t.minute for t in wake_times) + avg_minutes = total_minutes // len(wake_times) + + return time(avg_minutes // 60, avg_minutes % 60) + + +def detect_wake_event(user_uuid: str, current_time: datetime) -> Optional[datetime]: + """Detect if user just woke up based on presence change.""" + presence = get_user_presence(user_uuid) + if not presence: + return None + + # Check if they just came online + if presence.get("is_currently_online"): + last_online = presence.get("last_online_at") + last_offline = presence.get("last_offline_at") + + if last_online and last_offline: + offline_duration = last_online - last_offline + # If they were offline for more than 30 minutes, consider it a wake event + if offline_duration.total_seconds() > 1800: # 30 minutes + return last_online + + return None + + +def is_quiet_hours(user_uuid: str, check_time: datetime) -> bool: + """Check if current time is within user's quiet hours.""" + settings = get_adaptive_settings(user_uuid) + if not settings: + return False + + quiet_start = settings.get("quiet_hours_start") + quiet_end = settings.get("quiet_hours_end") + + if not quiet_start or not quiet_end: + return False + + current_time = check_time.time() + + # Handle quiet hours that span midnight + if quiet_start > quiet_end: + return current_time >= quiet_start or current_time <= quiet_end + else: + return quiet_start <= current_time <= quiet_end + + +def calculate_adjusted_times( + user_uuid: str, base_times: List[str], wake_time: Optional[datetime] = None +) -> List[Tuple[str, int]]: + """ + Calculate adjusted medication times based on wake time. + + Args: + user_uuid: User's UUID + base_times: List of base times in "HH:MM" format + wake_time: Optional wake time to use for adjustment + + Returns: + List of (adjusted_time_str, offset_minutes) tuples + """ + settings = get_adaptive_settings(user_uuid) + if not settings or not settings.get("adaptive_timing_enabled"): + # Return base times with 0 offset + return [(t, 0) for t in base_times] + + # Get user's timezone + prefs = postgres.select("user_preferences", {"user_uuid": user_uuid}) + offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0 + + # Get current time in user's timezone + user_current_time = user_now(offset_minutes) + today = user_current_time.date() + + # Determine wake time + if wake_time is None: + # Try to get from presence detection + wake_time = detect_wake_event(user_uuid, user_current_time) + + if wake_time is None: + # Use typical wake time if available + typical_wake = calculate_typical_wake_time(user_uuid) + if typical_wake: + wake_time = datetime.combine(today, typical_wake) + + if wake_time is None: + # Default wake time (8 AM) + wake_time = datetime.combine(today, time(8, 0)) + + # Calculate offset from first med time + if not base_times: + return [] + + first_med_time = datetime.strptime(base_times[0], "%H:%M").time() + first_med_datetime = datetime.combine(today, first_med_time) + + # Calculate how late they are + if wake_time.time() > first_med_time: + # They woke up after their first med time + offset_minutes = int((wake_time - first_med_datetime).total_seconds() / 60) + else: + offset_minutes = 0 + + # Adjust all times + adjusted = [] + for base_time_str in base_times: + base_time = datetime.strptime(base_time_str, "%H:%M").time() + base_datetime = datetime.combine(today, base_time) + + # Add offset + adjusted_datetime = base_datetime + timedelta(minutes=offset_minutes) + adjusted_time_str = adjusted_datetime.strftime("%H:%M") + + adjusted.append((adjusted_time_str, offset_minutes)) + + return adjusted + + +def should_send_nag( + user_uuid: str, med_id: str, scheduled_time: str, current_time: datetime +) -> Tuple[bool, str]: + """ + Determine if we should send a nag notification. + + Returns: + (should_nag: bool, reason: str) + """ + settings = get_adaptive_settings(user_uuid) + if not settings: + return False, "No settings" + + if not settings.get("nagging_enabled"): + return False, "Nagging disabled" + + # Check quiet hours + if is_quiet_hours(user_uuid, current_time): + return False, "Quiet hours" + + # Check if user is online (don't nag if offline unless presence tracking disabled) + presence = get_user_presence(user_uuid) + if presence and settings.get("presence_tracking_enabled"): + if not presence.get("is_currently_online"): + return False, "User offline" + + # Get today's schedule record + today = current_time.date() + schedules = postgres.select( + "medication_schedules", + {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}, + ) + + if not schedules: + return False, "No schedule found" + + schedule = schedules[0] + nag_count = schedule.get("nag_count", 0) + max_nags = settings.get("max_nag_count", 4) + + if nag_count >= max_nags: + return False, f"Max nags reached ({max_nags})" + + # Check if it's time to nag + last_nag = schedule.get("last_nag_at") + nag_interval = settings.get("nag_interval_minutes", 15) + + if last_nag: + time_since_last_nag = (current_time - last_nag).total_seconds() / 60 + if time_since_last_nag < nag_interval: + return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)" + + # Check if medication was already taken today + logs = postgres.select( + "med_logs", {"medication_id": med_id, "user_uuid": user_uuid, "action": "taken"} + ) + + # Filter to today's logs + today_logs = [ + log + for log in logs + if log.get("created_at") and log["created_at"].date() == today + ] + + if today_logs: + return False, "Already taken today" + + return True, "Time to nag" + + +def record_nag_sent(user_uuid: str, med_id: str, scheduled_time: str): + """Record that a nag was sent.""" + today = datetime.utcnow().date() + + schedules = postgres.select( + "medication_schedules", + {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}, + ) + + if schedules: + schedule = schedules[0] + new_nag_count = schedule.get("nag_count", 0) + 1 + + postgres.update( + "medication_schedules", + {"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()}, + {"id": schedule["id"]}, + ) + + +def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]): + """Create today's medication schedule with adaptive adjustments.""" + today = datetime.utcnow().date() + + # Check if schedule already exists + existing = postgres.select( + "medication_schedules", + {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}, + ) + + if existing: + return + + # Calculate adjusted times + adjusted_times = calculate_adjusted_times(user_uuid, base_times) + + # Create schedule records for each time + for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times): + data = { + "user_uuid": user_uuid, + "medication_id": med_id, + "base_time": base_time, + "adjusted_time": adjusted_time, + "adjustment_date": today, + "adjustment_minutes": offset, + "nag_count": 0, + "status": "pending", + } + postgres.insert("medication_schedules", data) + + +def mark_med_taken(user_uuid: str, med_id: str, scheduled_time: str): + """Mark a medication as taken.""" + today = datetime.utcnow().date() + + postgres.update( + "medication_schedules", + {"status": "taken"}, + { + "user_uuid": user_uuid, + "medication_id": med_id, + "adjustment_date": today, + "adjusted_time": scheduled_time, + }, + ) diff --git a/scheduler/daemon.py b/scheduler/daemon.py index 03d0acd..c36ebdb 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone, timedelta import core.postgres as postgres import core.notifications as notifications +import core.adaptive_meds as adaptive_meds logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -155,9 +156,226 @@ def check_refills(): logger.error(f"Error checking refills: {e}") +def create_daily_adaptive_schedules(): + """Create today's medication schedules with adaptive timing.""" + try: + from datetime import date as date_type + + meds = postgres.select("medications", where={"active": True}) + + for med in meds: + user_uuid = med.get("user_uuid") + med_id = med.get("id") + times = med.get("times", []) + + if not times: + continue + + # Create daily schedule with adaptive adjustments + adaptive_meds.create_daily_schedule(user_uuid, med_id, times) + + except Exception as e: + logger.error(f"Error creating daily adaptive schedules: {e}") + + +def check_adaptive_medication_reminders(): + """Check for medications due now with adaptive timing.""" + try: + from datetime import date as date_type + + meds = postgres.select("medications", where={"active": True}) + + # Group by user + user_meds = {} + for med in meds: + uid = med.get("user_uuid") + if uid not in user_meds: + user_meds[uid] = [] + user_meds[uid].append(med) + + for user_uuid, user_med_list in user_meds.items(): + now = _user_now_for(user_uuid) + current_time = now.strftime("%H:%M") + today = now.date() + + # Check if adaptive timing is enabled + settings = adaptive_meds.get_adaptive_settings(user_uuid) + adaptive_enabled = settings and settings.get("adaptive_timing_enabled") + + for med in user_med_list: + freq = med.get("frequency", "daily") + + if freq == "as_needed": + continue + + # Day-of-week check + if freq == "specific_days": + current_day = now.strftime("%a").lower() + med_days = med.get("days_of_week", []) + if current_day not in med_days: + continue + + # Interval check + if freq == "every_n_days": + start = med.get("start_date") + interval = med.get("interval_days") + if start and interval: + start_d = ( + start + if isinstance(start, date_type) + else datetime.strptime(str(start), "%Y-%m-%d").date() + ) + if (today - start_d).days < 0 or ( + today - start_d + ).days % interval != 0: + continue + else: + continue + + # Get today's schedule + schedules = postgres.select( + "medication_schedules", + where={ + "user_uuid": user_uuid, + "medication_id": med["id"], + "adjustment_date": today, + "status": "pending", + }, + ) + + for sched in schedules: + # Check if it's time to take this med + if adaptive_enabled: + # Use adjusted time + check_time = sched.get("adjusted_time") + else: + # Use base time + check_time = sched.get("base_time") + + if check_time != current_time: + continue + + # Check if already taken + logs = postgres.select( + "med_logs", + where={ + "medication_id": med["id"], + "user_uuid": user_uuid, + "action": "taken", + }, + ) + + already_taken = any( + str(log.get("created_at", ""))[:10] == today.isoformat() + for log in logs + ) + + if already_taken: + continue + + # Send notification + user_settings = notifications.getNotificationSettings(user_uuid) + if user_settings: + offset = sched.get("adjustment_minutes", 0) + if offset > 0: + msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)" + else: + msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}" + + notifications._sendToEnabledChannels( + user_settings, msg, user_uuid=user_uuid + ) + + except Exception as e: + logger.error(f"Error checking adaptive medication reminders: {e}") + + +def check_nagging(): + """Check for missed medications and send nag notifications.""" + try: + from datetime import date as date_type + + # Get all active medications + meds = postgres.select("medications", where={"active": True}) + + for med in meds: + user_uuid = med.get("user_uuid") + med_id = med.get("id") + + # Get user's settings + settings = adaptive_meds.get_adaptive_settings(user_uuid) + if not settings or not settings.get("nagging_enabled"): + continue + + now = datetime.utcnow() + today = now.date() + + # Get today's schedules + schedules = postgres.select( + "medication_schedules", + where={ + "user_uuid": user_uuid, + "medication_id": med_id, + "adjustment_date": today, + "status": "pending", + }, + ) + + for sched in schedules: + # Check if we should nag + should_nag, reason = adaptive_meds.should_send_nag( + user_uuid, med_id, sched.get("adjusted_time"), now + ) + + if not should_nag: + continue + + # Get the time to display + adaptive_enabled = settings.get("adaptive_timing_enabled") + if adaptive_enabled: + display_time = sched.get("adjusted_time") + else: + display_time = sched.get("base_time") + + # Send nag notification + user_settings = notifications.getNotificationSettings(user_uuid) + if user_settings: + nag_count = sched.get("nag_count", 0) + 1 + max_nags = settings.get("max_nag_count", 4) + + msg = f"🔔 {med['name']} reminder {nag_count}/{max_nags}: You missed your {display_time} dose. Please take it now!" + + notifications._sendToEnabledChannels( + user_settings, msg, user_uuid=user_uuid + ) + + # Record that we sent a nag + adaptive_meds.record_nag_sent( + user_uuid, med_id, sched.get("adjusted_time") + ) + + logger.info( + f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}" + ) + + except Exception as e: + logger.error(f"Error checking nags: {e}") + + def poll_callback(): """Called every POLL_INTERVAL seconds.""" - check_medication_reminders() + # Create daily schedules at midnight + now = datetime.utcnow() + if now.hour == 0 and now.minute < POLL_INTERVAL / 60: + create_daily_adaptive_schedules() + + # Check reminders with adaptive timing + check_adaptive_medication_reminders() + + # Check for nags + check_nagging() + + # Original checks check_routine_reminders() check_refills()