diff --git a/README.md b/README.md index 79969f2..616ce74 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,67 @@ # Synculous -A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD. +A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD. -The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. +The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform. ## Architecture ``` synculous/ -├── synculous-client/ # Next.js 16 frontend (React, Tailwind) -├── api/ # Flask REST API -│ ├── main.py # App entry point, auth routes -│ └── routes/ # Domain route modules -│ ├── routines.py # Routines CRUD + sessions +├── synculous-client/ # Next.js frontend (React, Tailwind) +├── api/ # Flask REST API +│ ├── main.py # App entry point, auth routes +│ └── routes/ # Domain route modules +│ ├── routines.py # Routines CRUD + sessions │ ├── routine_sessions_extended.py # Pause, resume, abort, notes -│ ├── routine_stats.py # Completion stats, streaks, weekly summary -│ ├── routine_templates.py # Premade routine templates -│ ├── routine_steps_extended.py # Step instructions, types, media -│ ├── routine_tags.py # Tagging system -│ ├── medications.py # Medication scheduling + adherence -│ ├── preferences.py # User settings + timezone -│ ├── notifications.py # Web push subscriptions -│ ├── rewards.py # Variable reward system -│ └── victories.py # Achievement detection -├── core/ # Shared business logic -│ ├── postgres.py # Generic PostgreSQL CRUD -│ ├── auth.py # JWT + bcrypt authentication -│ ├── users.py # User management -│ ├── routines.py # Routine/session/streak logic -│ ├── tz.py # Timezone-aware date/time helpers -│ └── notifications.py # Multi-channel notifications +│ ├── routine_stats.py # Completion stats, streaks, weekly summary +│ ├── routine_templates.py # Premade routine templates +│ ├── routine_steps_extended.py # Step instructions, types, media +│ ├── routine_tags.py # Tagging system +│ ├── medications.py # Medication scheduling + adherence +│ ├── adaptive_meds.py # Adaptive medication timing (learning) +│ ├── tasks.py # One-off tasks/appointments CRUD +│ ├── ai.py # AI-powered step generation +│ ├── preferences.py # User settings + timezone +│ ├── notifications.py # Web push subscriptions +│ ├── rewards.py # Variable reward system +│ ├── victories.py # Achievement detection +│ └── snitch.py # Peer accountability contacts + notifications +├── core/ # Shared business logic +│ ├── postgres.py # Generic PostgreSQL CRUD +│ ├── auth.py # JWT + bcrypt authentication +│ ├── users.py # User management +│ ├── routines.py # Routine/session/streak logic +│ ├── stats.py # Statistics calculations (completion rates, streaks) +│ ├── snitch.py # Snitch trigger logic + notification delivery +│ ├── adaptive_meds.py # Adaptive medication timing logic +│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset) +│ └── notifications.py # Multi-channel notifications ├── scheduler/ -│ └── daemon.py # Background polling for reminders -├── bot/ # Discord bot (optional) -├── ai/ # LLM parser for natural language commands +│ └── daemon.py # Background polling for reminders +├── bot/ # Discord bot with knowledge RAG +│ ├── bot.py # Bot entry point + session management +│ ├── command_registry.py # Module-based command routing +│ ├── commands/ # Command modules +│ │ ├── routines.py # /routine commands +│ │ ├── medications.py # /med, /take, /skip commands +│ │ ├── tasks.py # /task commands (one-off tasks/appointments) +│ │ └── knowledge.py # /ask command (jury-filtered RAG) +│ └── hooks.py # Event listeners +├── ai/ # LLM-powered features +│ ├── parser.py # OpenRouter API client +│ ├── jury_council.py # 5-juror safety filtration system +│ ├── ai_config.json # Model + prompt configuration +│ └── (optional) RAG embeddings ├── config/ -│ ├── schema.sql # Database schema -│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates -│ ├── seed_rewards.sql # Variable reward pool -│ └── .env.example # Environment template +│ ├── schema.sql # Database schema +│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates +│ ├── seed_rewards.sql # Variable reward pool +│ └── .env.example # Environment template +├── diagrams/ # Architecture diagrams (Mermaid) ├── docker-compose.yml -└── Dockerfile +├── Dockerfile +└── tests/ ``` ## Quick Start @@ -49,10 +70,10 @@ synculous/ # Copy environment config cp config/.env.example config/.env -# Edit with your values +# Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY) nano config/.env -# Start everything +# Start everything (db, api, scheduler, optional bot, frontend client) docker-compose up ``` @@ -61,20 +82,23 @@ This starts five services: | Service | Port | Description | |---------|------|-------------| | `db` | 5432 | PostgreSQL 16 with schema + seed data | -| `app` | 8080 | Flask API | +| `app` | 8010 | Flask API (internal: 5000) | | `scheduler` | — | Background daemon for medication/routine reminders | -| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) | -| `client` | 3000 | Next.js frontend | +| `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) | +| `client` | 3001 | Next.js frontend (internal: 3000) | ## Features -### Routines +### Routines & Sessions - Create routines with ordered steps (4-7 steps recommended) - Run sessions with a guided, one-step-at-a-time focus interface - Complete, skip, pause, resume, or cancel sessions - Swipe gestures for step completion on mobile - Per-step timing with visual countdown - Animated celebration screen on completion with streak stats and variable rewards +- Every-N-day frequency option for routines +- Tagging system for organizing routines by category +- Session notes for logging context or blockers ### Premade Templates 12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`: @@ -96,13 +120,44 @@ This starts five services: All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max. +### One-Off Tasks & Appointments +- Create standalone tasks and appointments outside of routines +- Scheduled date/time with optional reminders +- Quick-complete action for fast check-off +- Tasks appear on the routines timeline for a unified daily view +- AI-powered task composition via bot and web client +- Natural language date parsing in bot commands + +### AI-Powered Step Generation +- Generate ADHD-friendly routine steps from a plain-language goal description +- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps +- Each step includes name and estimated duration +- Available via API endpoint and integrated into the web client's routine creation flow + ### Medications -- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN) +- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once - "Today's meds" view with cross-midnight lookahead (late night + early morning) - Take, skip, snooze actions with logging - Adherence tracking and statistics - Refill tracking with low-quantity alerts - Background reminders via the scheduler daemon +- Medication editing (update name, dose, schedule, refill count) +- Web push and Discord notifications for doses + +#### Adaptive Medication Timing +- Machine-learning-based timing predictions based on user adherence patterns +- System learns when you're most likely to take meds +- Automatic reminder optimization (fewer false-positive reminders) +- Override and manual timing adjustments always available +- Useful for people with irregular schedules + +### Peer Accountability: "Snitch" Feature +- Designate trusted contacts to receive medication adherence notifications +- Contacts don't need an account or login +- Granular privacy controls: users choose *what* to share (meds, streaks, notes) +- Contacts receive weekly summaries or real-time alerts for missed doses +- Based on research showing peer accountability improves adherence +- Optional consent-based consent flow for contact approvals ### Streaks and Stats - Per-routine streak tracking (current + longest) @@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret - Random reward on routine completion (post-completion only, never mid-routine) - Reward history tracking per user - Common and rare rarity tiers +- Designed to leverage variable-ratio reinforcement schedules ### Notifications -- Web push notifications via VAPID -- Discord webhooks -- ntfy support -- Scheduled reminders for medications and routines +- Web push notifications via VAPID (in-browser) +- Discord webhooks (for bot mentions) +- Discord DMs (for sensitive notifications like "snitch" alerts) +- ntfy support (for self-hosted push) +- Timezone-aware scheduled reminders for medications and routines -### Timezone Support -All date/time operations respect the user's local timezone: -- The frontend sends `X-Timezone-Offset` with every API request -- The timezone offset is also persisted to `user_preferences` for background jobs -- Streaks, "today's meds," weekly stats, and reminders all use the user's local date -- The scheduler daemon looks up each user's stored offset for reminder timing +### Timezone Support (Dual-Path) +All date/time operations respect the user's local timezone via two mechanisms: + +**In request context:** +- The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header +- Handlers use these headers for real-time API operations + +**In background jobs (scheduler daemon):** +- Timezone is stored in `user_preferences.timezone_name` (IANA format) +- Scheduler retrieves stored timezone for each user +- Falls back to numeric offset, then UTC if name is unavailable +- Enables accurate reminder delivery even if user's browser context is offline + +**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date. + +### Authentication & Session Persistence +- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days) +- "Trust this device" option on login issues a refresh token for seamless re-auth +- Bot session caching with persistent pickle storage across restarts ### User Preferences - Sound effects (default off — habituation risk) - Haptic feedback (default on) - Launch screen toggle - Celebration style -- Timezone offset (auto-synced from browser) +- Timezone (IANA name + numeric offset, auto-synced from browser) +- Discord presence indicator toggle (shows online/offline in Discord) + +### Discord Bot Integration +Full-featured Discord bot for managing routines and medications without opening the app: + +#### Bot Commands +- `/routine list` — List all routines +- `/routine start ` — Start a routine session (guided steps in Discord thread) +- `/routine stats ` — View streak and completion stats +- `/med today` — Show today's medications with status +- `/med take ` — Log a dose as taken +- `/med skip ` — Log a dose as skipped +- `/task add ` — Create a one-off task (supports natural language dates) +- `/task list` — Show upcoming tasks +- `/task done ` — Mark a task as complete +- `/ask ` — Query the knowledge base with jury-filtered safety checks +- Discord presence automatically shows your routine/meditation status + +#### Jury Council Safety System +The `/ask` command uses a sophisticated 5-juror safety filtration pipeline: + +**Two-stage process:** +1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions +2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens + +**Juror Roles:** +- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means +- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language +- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education +- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base +- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play + +**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers. + +This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts). + +### Knowledge Base & RAG +- Embeddings-based retrieval of ADHD, DBT, and mental health educational content +- Multi-book support with user-selectable knowledge bases +- Jury-filtered questions for safety +- LLM-powered intent classification routes general questions to the knowledge base automatically +- Multi-query retrieval with deduplication for better coverage +- DBT advice evaluation mode (checks advice against DBT principles) +- Discord bot `/ask` command uses RAG with jury council checks +- Extensible knowledge source (can add more documents) ## API Overview -All endpoints require `Authorization: Bearer ` except `/api/register` and `/api/login`. +All endpoints require `Authorization: Bearer ` except `/api/register`, `/api/login`, and `/api/refresh`. ### Auth | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/register` | Create account | -| POST | `/api/login` | Get JWT token | +| POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) | +| POST | `/api/refresh` | Exchange refresh token for new access token | ### Routines | Method | Endpoint | Description | @@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer ` except `/api/register` and | POST | `/api/sessions/:id/resume` | Resume session | | POST | `/api/sessions/:id/cancel` | Cancel session | | POST | `/api/sessions/:id/abort` | Abort with reason | +| POST | `/api/sessions/:id/note` | Add session note | ### Medications | Method | Endpoint | Description | @@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer ` except `/api/register` and | GET | `/api/medications/adherence` | Adherence stats | | GET | `/api/medications/refills-due` | Refills due soon | +### Adaptive Medications +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication | +| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences | +| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule | +| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats | + +### Snitch (Peer Accountability) +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/snitch/contacts` | List designated accountability contacts | +| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) | +| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) | +| DELETE | `/api/snitch/contacts/:id` | Remove a contact | +| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link | +| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status | +| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing | +| GET | `/api/snitch/history` | View recent alerts sent to contacts | +| POST | `/api/snitch/test-send` | Send test alert to a contact | + +### Tasks +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/tasks` | List user's tasks | +| POST | `/api/tasks` | Create a task | +| PUT | `/api/tasks/:id` | Update a task | +| DELETE | `/api/tasks/:id` | Delete a task | +| POST | `/api/tasks/:id/complete` | Mark task as complete | + +### AI +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description | + ### Stats | Method | Endpoint | Description | |--------|----------|-------------| @@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer ` except `/api/register` and | GET | `/api/routines/weekly-summary` | Weekly progress | | GET | `/api/victories` | Achievement detection | -### Templates, Tags, Rewards, Preferences +### Templates, Tags, Rewards, Preferences, Notifications | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/templates` | List available templates | | POST | `/api/templates/:id/clone` | Clone template to user's routines | -| GET/PUT | `/api/preferences` | User settings | +| GET/PUT | `/api/preferences` | User settings and timezone | | GET | `/api/rewards/random` | Random completion reward | +| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) | +| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications | ## Environment Variables -| Variable | Description | -|----------|-------------| -| `DB_HOST` | PostgreSQL host | -| `DB_PORT` | PostgreSQL port | -| `DB_NAME` | Database name | -| `DB_USER` | Database user | -| `DB_PASS` | Database password | -| `JWT_SECRET` | JWT signing secret | -| `DISCORD_BOT_TOKEN` | Discord bot token (optional) | -| `API_URL` | API URL for bot (default: `http://app:5000`) | -| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) | -| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) | +| Variable | Required | Description | +|----------|----------|-------------| +| `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) | +| `DB_PORT` | Yes | PostgreSQL port (default: `5432`) | +| `DB_NAME` | Yes | Database name (default: `app`) | +| `DB_USER` | Yes | Database user (default: `app`) | +| `DB_PASS` | Yes | Database password | +| `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) | +| `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) | +| `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) | +| `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) | +| `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) | +| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications | +| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications | ## Design Framework -Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles: +Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature: +### Core Principles 1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration. 2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine. 3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere. +### Behavioral Foundations +- **Two-Minute Rule Entry Points** — Every routine starts with something achievable in under 2 minutes (lower activation energy) +- **Variable Reward Scheduling** — Random rewards on completion leverage variable-ratio reinforcement (proven for habit building) +- **Streak-Based Identity** — Streaks build intrinsic motivation by making completion a visible, accumulating identity signal +- **Peer Accountability** — "Snitch" contacts provide external accountability without shame (research shows this improves adherence) +- **Adaptive Timing** — System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load) + +### Safety & Ethics +- **Jury Council** — 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users +- **Crisis Awareness** — System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation +- **Transparent Limitations** — All system messages clarify "this is educational, not treatment" and encourage professional care +- **User Agency** — All adaptive and automated features can be overridden; manual controls are always available + +## Development & Testing + +### Running Tests +```bash +# Run pytest on all tests +pytest + +# Run specific test file +pytest tests/test_routines.py + +# Run with coverage +pytest --cov=core --cov=api tests/ +``` + +### Database Migrations +For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually: +```bash +psql -h localhost -U app -d app -f config/migration_name.sql +``` + +### Timezone Testing +The system uses dual-path timezone support. Test both: +1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset +2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler + +### Discord Bot Development +Bot commands are modular in `bot/commands/`. To add a command: +1. Create a new command file in `bot/commands/` +2. Import and register in `bot/bot.py` +3. Bot automatically syncs commands to Discord on startup + +## Documentation + +Comprehensive documentation is available in `DOCUMENTATION.md`: +- Detailed feature explanations +- Database schema reference +- Jury Council safety model (full spec) +- Deployment & configuration +- Contributing guidelines + ## License MIT + +--- + +**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.** diff --git a/api/routes/medications.py b/api/routes/medications.py index a12c33f..8c53c8a 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -13,6 +13,7 @@ from psycopg2.extras import Json import core.auth as auth import core.postgres as postgres import core.tz as tz +import core.adaptive_meds as adaptive_meds def _get_user_uuid(token): @@ -264,6 +265,11 @@ def register(app): "notes": data.get("notes"), } log = postgres.insert("med_logs", log_entry) + # Update adaptive schedule status so nags stop + try: + adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time")) + except Exception: + pass # Don't fail the take action if schedule update fails # Advance next_dose_date for interval meds if med.get("frequency") == "every_n_days" and med.get("interval_days"): next_date = _compute_next_dose_date(med) @@ -290,6 +296,11 @@ def register(app): "notes": data.get("reason"), } log = postgres.insert("med_logs", log_entry) + # Update adaptive schedule status so nags stop + try: + adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time")) + except Exception: + pass # Don't fail the skip action if schedule update fails # Advance next_dose_date for interval meds if med.get("frequency") == "every_n_days" and med.get("interval_days"): next_date = _compute_next_dose_date(med) diff --git a/core/adaptive_meds.py b/core/adaptive_meds.py index 252d707..17be95b 100644 --- a/core/adaptive_meds.py +++ b/core/adaptive_meds.py @@ -13,7 +13,7 @@ import uuid from datetime import datetime, timedelta, time, timezone from typing import Optional, Dict, List, Tuple import core.postgres as postgres -from core.tz import user_now, user_today_for, tz_for_user +from core.tz import user_now, user_now_for, user_today_for, tz_for_user def _normalize_time(val): @@ -42,12 +42,24 @@ def get_user_presence(user_uuid: str) -> Optional[Dict]: def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool): - """Update user's presence status.""" + """Update user's presence status. If a wake event is detected (came online + after 30+ minutes offline), recalculates today's adaptive medication schedules.""" now = datetime.utcnow() presence = get_user_presence(user_uuid) + is_wake_event = False if presence: + # Detect wake event before updating + if is_online and not presence.get("is_currently_online"): + last_offline = presence.get("last_offline_at") + if last_offline: + if isinstance(last_offline, datetime) and last_offline.tzinfo is None: + last_offline = last_offline.replace(tzinfo=timezone.utc) + offline_duration = (now.replace(tzinfo=timezone.utc) - last_offline).total_seconds() + if offline_duration > 1800: # 30 minutes + is_wake_event = True + # Update existing record updates = {"is_currently_online": is_online, "updated_at": now} @@ -71,6 +83,26 @@ def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool): } postgres.insert("user_presence", data) + # On wake event, recalculate today's adaptive schedules + if is_wake_event: + _recalculate_schedules_on_wake(user_uuid, now) + + +def _recalculate_schedules_on_wake(user_uuid: str, wake_time: datetime): + """Recalculate today's pending adaptive schedules using the actual wake time.""" + settings = get_adaptive_settings(user_uuid) + if not settings or not settings.get("adaptive_timing_enabled"): + return + + try: + meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True}) + for med in meds: + times = med.get("times", []) + if times: + create_daily_schedule(user_uuid, med["id"], times, recalculate=True) + except Exception: + pass # Best-effort — don't break presence tracking if this fails + def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime): """Record a presence event in the history.""" @@ -182,12 +214,8 @@ def calculate_adjusted_times( # 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) + # Get current time in user's timezone (works in both request and scheduler context) + user_current_time = user_now_for(user_uuid) today = user_current_time.date() # Determine wake time @@ -296,6 +324,14 @@ def should_send_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)" + else: + # First nag: require at least nag_interval minutes since the scheduled dose time + if scheduled_time: + sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5]) + sched_dt = current_time.replace(hour=sched_hour, minute=sched_min, second=0, microsecond=0) + minutes_since_dose = (current_time - sched_dt).total_seconds() / 60 + if minutes_since_dose < nag_interval: + return False, f"Too soon after dose time ({int(minutes_since_dose)} < {nag_interval} min)" # Check if this specific dose was already taken or skipped today logs = postgres.select( @@ -389,8 +425,13 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time): ) -def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]): - """Create today's medication schedule with adaptive adjustments.""" +def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str], recalculate: bool = False): + """Create today's medication schedule with adaptive adjustments. + + If recalculate=True, deletes existing *pending* schedules and recreates them + with updated adaptive timing (e.g. after a wake event is detected). + Already-taken or skipped schedules are preserved. + """ today = user_today_for(user_uuid) # Check if schedule already exists @@ -399,9 +440,26 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]): {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}, ) - if existing: + if existing and not recalculate: return + if existing and recalculate: + # Only delete pending schedules — preserve taken/skipped + for sched in existing: + if sched.get("status") == "pending": + postgres.delete("medication_schedules", {"id": sched["id"]}) + # Check if any pending remain to create + remaining = [s for s in existing if s.get("status") != "pending"] + completed_base_times = set() + for s in remaining: + bt = _normalize_time(s.get("base_time")) + if bt: + completed_base_times.add(bt) + # Only create schedules for times that haven't been taken/skipped + base_times = [t for t in base_times if t not in completed_base_times] + if not base_times: + return + # Calculate adjusted times adjusted_times = calculate_adjusted_times(user_uuid, base_times) @@ -422,17 +480,40 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]): def mark_med_taken(user_uuid: str, med_id: str, scheduled_time): - """Mark a medication as taken.""" + """Mark a medication schedule as taken.""" + _mark_med_status(user_uuid, med_id, scheduled_time, "taken") + + +def mark_med_skipped(user_uuid: str, med_id: str, scheduled_time): + """Mark a medication schedule as skipped.""" + _mark_med_status(user_uuid, med_id, scheduled_time, "skipped") + + +def _mark_med_status(user_uuid: str, med_id: str, scheduled_time, status: str): + """Update a medication schedule's status for today.""" scheduled_time = _normalize_time(scheduled_time) today = user_today_for(user_uuid) - postgres.update( - "medication_schedules", - {"status": "taken"}, - { + # Try matching by adjusted_time first + where = { + "user_uuid": user_uuid, + "medication_id": med_id, + "adjustment_date": today, + } + if scheduled_time is not None: + where["adjusted_time"] = scheduled_time + + schedules = postgres.select("medication_schedules", where) + if schedules: + postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]}) + elif scheduled_time is not None: + # Fallback: try matching by base_time (in case adjusted == base) + where_base = { "user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today, - "adjusted_time": scheduled_time, - }, - ) + "base_time": scheduled_time, + } + schedules_base = postgres.select("medication_schedules", where_base) + if schedules_base: + postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]}) diff --git a/scheduler/daemon.py b/scheduler/daemon.py index 2c1968b..a337150 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -263,18 +263,37 @@ def check_adaptive_medication_reminders(): else: continue - # Get today's schedule + # Get today's schedule (any status — we filter below) schedules = postgres.select( "medication_schedules", where={ "user_uuid": user_uuid, "medication_id": med["id"], "adjustment_date": today, - "status": "pending", }, ) + # If no schedules exist yet, create them on demand + if not schedules: + times = med.get("times", []) + if times: + try: + adaptive_meds.create_daily_schedule(user_uuid, med["id"], times) + schedules = postgres.select( + "medication_schedules", + where={ + "user_uuid": user_uuid, + "medication_id": med["id"], + "adjustment_date": today, + }, + ) + except Exception as e: + logger.warning(f"Could not create on-demand schedule for {med['id']}: {e}") + for sched in schedules: + # Skip already-taken or skipped schedules + if sched.get("status") in ("taken", "skipped"): + continue # Check if it's time to take this med if adaptive_enabled: # Use adjusted time @@ -292,24 +311,24 @@ def check_adaptive_medication_reminders(): if check_time != current_time: continue - # Check if already taken for this specific time slot today + # Check if already taken or skipped for this time slot today logs = postgres.select( "med_logs", where={ "medication_id": med["id"], "user_uuid": user_uuid, - "action": "taken", }, ) - already_taken = any( - log.get("scheduled_time") == check_time + already_handled = any( + log.get("action") in ("taken", "skipped") + and log.get("scheduled_time") == check_time and _utc_to_local_date(log.get("created_at"), user_tz) == today.isoformat() for log in logs ) - if already_taken: + if already_handled: continue # Send notification @@ -642,13 +661,14 @@ def poll_callback(): # Create daily schedules per-user at their local midnight _check_per_user_midnight_schedules() - # Check reminders - use both original and adaptive checks + # Check medication reminders (adaptive path handles both adaptive and non-adaptive) logger.info("Checking medication reminders") - check_medication_reminders() try: check_adaptive_medication_reminders() except Exception as e: logger.warning(f"Adaptive medication reminder check failed: {e}") + # Fall back to basic reminders if adaptive check fails entirely + check_medication_reminders() # Check for nags - log as error to help with debugging try: