Fix adaptive medication timing and update README

- Fix double notifications: remove redundant check_medication_reminders()
  call, use adaptive path as primary with basic as fallback
- Fix nag firing immediately: require nag_interval minutes after scheduled
  dose time before first nag
- Fix missing schedules: create on-demand if midnight window was missed
- Fix wrong timezone: use user_now_for() instead of request-context
  user_now() in calculate_adjusted_times()
- Fix immutable schedules: recalculate pending schedules on wake event
  detection so adaptive timing actually adapts
- Fix take/skip not updating schedule: API endpoints now call
  mark_med_taken/skipped so nags stop after logging a dose
- Fix skipped doses still triggering reminders: check both taken and
  skipped in adaptive reminder and log queries
- Update README with tasks, AI step generation, auth refresh tokens,
  knowledge base improvements, and current architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 23:34:38 -06:00
parent 03da0b0156
commit e89656a87c
4 changed files with 420 additions and 94 deletions

346
README.md
View File

@@ -1,46 +1,67 @@
# Synculous # 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 ## Architecture
``` ```
synculous/ synculous/
├── synculous-client/ # Next.js 16 frontend (React, Tailwind) ├── synculous-client/ # Next.js frontend (React, Tailwind)
├── api/ # Flask REST API ├── api/ # Flask REST API
│ ├── main.py # App entry point, auth routes │ ├── main.py # App entry point, auth routes
│ └── routes/ # Domain route modules │ └── routes/ # Domain route modules
│ ├── routines.py # Routines CRUD + sessions │ ├── routines.py # Routines CRUD + sessions
│ ├── routine_sessions_extended.py # Pause, resume, abort, notes │ ├── routine_sessions_extended.py # Pause, resume, abort, notes
│ ├── routine_stats.py # Completion stats, streaks, weekly summary │ ├── routine_stats.py # Completion stats, streaks, weekly summary
│ ├── routine_templates.py # Premade routine templates │ ├── routine_templates.py # Premade routine templates
│ ├── routine_steps_extended.py # Step instructions, types, media │ ├── routine_steps_extended.py # Step instructions, types, media
│ ├── routine_tags.py # Tagging system │ ├── routine_tags.py # Tagging system
│ ├── medications.py # Medication scheduling + adherence │ ├── medications.py # Medication scheduling + adherence
│ ├── preferences.py # User settings + timezone │ ├── adaptive_meds.py # Adaptive medication timing (learning)
│ ├── notifications.py # Web push subscriptions │ ├── tasks.py # One-off tasks/appointments CRUD
│ ├── rewards.py # Variable reward system │ ├── ai.py # AI-powered step generation
── victories.py # Achievement detection ── preferences.py # User settings + timezone
├── core/ # Shared business logic │ ├── notifications.py # Web push subscriptions
├── postgres.py # Generic PostgreSQL CRUD ├── rewards.py # Variable reward system
├── auth.py # JWT + bcrypt authentication ├── victories.py # Achievement detection
├── users.py # User management └── snitch.py # Peer accountability contacts + notifications
│ ├── routines.py # Routine/session/streak logic ├── core/ # Shared business logic
│ ├── tz.py # Timezone-aware date/time helpers │ ├── postgres.py # Generic PostgreSQL CRUD
── notifications.py # Multi-channel notifications ── 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/ ├── scheduler/
│ └── daemon.py # Background polling for reminders │ └── daemon.py # Background polling for reminders
├── bot/ # Discord bot (optional) ├── bot/ # Discord bot with knowledge RAG
├── ai/ # LLM parser for natural language commands │ ├── 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/ ├── config/
│ ├── schema.sql # Database schema │ ├── schema.sql # Database schema
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates │ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
│ ├── seed_rewards.sql # Variable reward pool │ ├── seed_rewards.sql # Variable reward pool
│ └── .env.example # Environment template │ └── .env.example # Environment template
├── diagrams/ # Architecture diagrams (Mermaid)
├── docker-compose.yml ├── docker-compose.yml
── Dockerfile ── Dockerfile
└── tests/
``` ```
## Quick Start ## Quick Start
@@ -49,10 +70,10 @@ synculous/
# Copy environment config # Copy environment config
cp config/.env.example config/.env 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 nano config/.env
# Start everything # Start everything (db, api, scheduler, optional bot, frontend client)
docker-compose up docker-compose up
``` ```
@@ -61,20 +82,23 @@ This starts five services:
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| |---------|------|-------------|
| `db` | 5432 | PostgreSQL 16 with schema + seed data | | `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 | | `scheduler` | — | Background daemon for medication/routine reminders |
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) | | `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
| `client` | 3000 | Next.js frontend | | `client` | 3001 | Next.js frontend (internal: 3000) |
## Features ## Features
### Routines ### Routines & Sessions
- Create routines with ordered steps (4-7 steps recommended) - Create routines with ordered steps (4-7 steps recommended)
- Run sessions with a guided, one-step-at-a-time focus interface - Run sessions with a guided, one-step-at-a-time focus interface
- Complete, skip, pause, resume, or cancel sessions - Complete, skip, pause, resume, or cancel sessions
- Swipe gestures for step completion on mobile - Swipe gestures for step completion on mobile
- Per-step timing with visual countdown - Per-step timing with visual countdown
- Animated celebration screen on completion with streak stats and variable rewards - 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 ### Premade Templates
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`: 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. 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 ### 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) - "Today's meds" view with cross-midnight lookahead (late night + early morning)
- Take, skip, snooze actions with logging - Take, skip, snooze actions with logging
- Adherence tracking and statistics - Adherence tracking and statistics
- Refill tracking with low-quantity alerts - Refill tracking with low-quantity alerts
- Background reminders via the scheduler daemon - 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 ### Streaks and Stats
- Per-routine streak tracking (current + longest) - 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) - Random reward on routine completion (post-completion only, never mid-routine)
- Reward history tracking per user - Reward history tracking per user
- Common and rare rarity tiers - Common and rare rarity tiers
- Designed to leverage variable-ratio reinforcement schedules
### Notifications ### Notifications
- Web push notifications via VAPID - Web push notifications via VAPID (in-browser)
- Discord webhooks - Discord webhooks (for bot mentions)
- ntfy support - Discord DMs (for sensitive notifications like "snitch" alerts)
- Scheduled reminders for medications and routines - ntfy support (for self-hosted push)
- Timezone-aware scheduled reminders for medications and routines
### Timezone Support ### Timezone Support (Dual-Path)
All date/time operations respect the user's local timezone: All date/time operations respect the user's local timezone via two mechanisms:
- The frontend sends `X-Timezone-Offset` with every API request
- The timezone offset is also persisted to `user_preferences` for background jobs **In request context:**
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date - The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
- The scheduler daemon looks up each user's stored offset for reminder timing - 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 ### User Preferences
- Sound effects (default off — habituation risk) - Sound effects (default off — habituation risk)
- Haptic feedback (default on) - Haptic feedback (default on)
- Launch screen toggle - Launch screen toggle
- Celebration style - 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 <name>` — Start a routine session (guided steps in Discord thread)
- `/routine stats <name>` — View streak and completion stats
- `/med today` — Show today's medications with status
- `/med take <med_name>` — Log a dose as taken
- `/med skip <med_name>` — Log a dose as skipped
- `/task add <description>` — Create a one-off task (supports natural language dates)
- `/task list` — Show upcoming tasks
- `/task done <name>` — Mark a task as complete
- `/ask <question>` — 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 ## API Overview
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`. All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
### Auth ### Auth
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| POST | `/api/register` | Create account | | 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 ### Routines
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| POST | `/api/sessions/:id/resume` | Resume session | | POST | `/api/sessions/:id/resume` | Resume session |
| POST | `/api/sessions/:id/cancel` | Cancel session | | POST | `/api/sessions/:id/cancel` | Cancel session |
| POST | `/api/sessions/:id/abort` | Abort with reason | | POST | `/api/sessions/:id/abort` | Abort with reason |
| POST | `/api/sessions/:id/note` | Add session note |
### Medications ### Medications
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/medications/adherence` | Adherence stats | | GET | `/api/medications/adherence` | Adherence stats |
| GET | `/api/medications/refills-due` | Refills due soon | | 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 ### Stats
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/routines/weekly-summary` | Weekly progress | | GET | `/api/routines/weekly-summary` | Weekly progress |
| GET | `/api/victories` | Achievement detection | | GET | `/api/victories` | Achievement detection |
### Templates, Tags, Rewards, Preferences ### Templates, Tags, Rewards, Preferences, Notifications
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/templates` | List available templates | | GET | `/api/templates` | List available templates |
| POST | `/api/templates/:id/clone` | Clone template to user's routines | | 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 | | 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 ## Environment Variables
| Variable | Description | | Variable | Required | Description |
|----------|-------------| |----------|----------|-------------|
| `DB_HOST` | PostgreSQL host | | `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
| `DB_PORT` | PostgreSQL port | | `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
| `DB_NAME` | Database name | | `DB_NAME` | Yes | Database name (default: `app`) |
| `DB_USER` | Database user | | `DB_USER` | Yes | Database user (default: `app`) |
| `DB_PASS` | Database password | | `DB_PASS` | Yes | Database password |
| `JWT_SECRET` | JWT signing secret | | `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) | | `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
| `API_URL` | API URL for bot (default: `http://app:5000`) | | `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) | | `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) | | `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 ## 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. 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. 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. 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 ## License
MIT MIT
---
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**

View File

@@ -13,6 +13,7 @@ from psycopg2.extras import Json
import core.auth as auth import core.auth as auth
import core.postgres as postgres import core.postgres as postgres
import core.tz as tz import core.tz as tz
import core.adaptive_meds as adaptive_meds
def _get_user_uuid(token): def _get_user_uuid(token):
@@ -264,6 +265,11 @@ def register(app):
"notes": data.get("notes"), "notes": data.get("notes"),
} }
log = postgres.insert("med_logs", log_entry) 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 # Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"): if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med) next_date = _compute_next_dose_date(med)
@@ -290,6 +296,11 @@ def register(app):
"notes": data.get("reason"), "notes": data.get("reason"),
} }
log = postgres.insert("med_logs", log_entry) 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 # Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"): if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med) next_date = _compute_next_dose_date(med)

View File

@@ -13,7 +13,7 @@ import uuid
from datetime import datetime, timedelta, time, timezone from datetime import datetime, timedelta, time, timezone
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
import core.postgres as postgres 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): 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): 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() now = datetime.utcnow()
presence = get_user_presence(user_uuid) presence = get_user_presence(user_uuid)
is_wake_event = False
if presence: 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 # Update existing record
updates = {"is_currently_online": is_online, "updated_at": now} 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) 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): def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
"""Record a presence event in the history.""" """Record a presence event in the history."""
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
# Return base times with 0 offset # Return base times with 0 offset
return [(t, 0) for t in base_times] return [(t, 0) for t in base_times]
# Get user's timezone # Get current time in user's timezone (works in both request and scheduler context)
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid}) user_current_time = user_now_for(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() today = user_current_time.date()
# Determine wake time # Determine wake time
@@ -296,6 +324,14 @@ def should_send_nag(
time_since_last_nag = (current_time - last_nag).total_seconds() / 60 time_since_last_nag = (current_time - last_nag).total_seconds() / 60
if time_since_last_nag < nag_interval: if time_since_last_nag < nag_interval:
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)" 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 # Check if this specific dose was already taken or skipped today
logs = postgres.select( 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]): 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.""" """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) today = user_today_for(user_uuid)
# Check if schedule already exists # 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}, {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
) )
if existing: if existing and not recalculate:
return 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 # Calculate adjusted times
adjusted_times = calculate_adjusted_times(user_uuid, base_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): 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) scheduled_time = _normalize_time(scheduled_time)
today = user_today_for(user_uuid) today = user_today_for(user_uuid)
postgres.update( # Try matching by adjusted_time first
"medication_schedules", where = {
{"status": "taken"}, "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, "user_uuid": user_uuid,
"medication_id": med_id, "medication_id": med_id,
"adjustment_date": today, "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"]})

View File

@@ -263,18 +263,37 @@ def check_adaptive_medication_reminders():
else: else:
continue continue
# Get today's schedule # Get today's schedule (any status — we filter below)
schedules = postgres.select( schedules = postgres.select(
"medication_schedules", "medication_schedules",
where={ where={
"user_uuid": user_uuid, "user_uuid": user_uuid,
"medication_id": med["id"], "medication_id": med["id"],
"adjustment_date": today, "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: 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 # Check if it's time to take this med
if adaptive_enabled: if adaptive_enabled:
# Use adjusted time # Use adjusted time
@@ -292,24 +311,24 @@ def check_adaptive_medication_reminders():
if check_time != current_time: if check_time != current_time:
continue 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( logs = postgres.select(
"med_logs", "med_logs",
where={ where={
"medication_id": med["id"], "medication_id": med["id"],
"user_uuid": user_uuid, "user_uuid": user_uuid,
"action": "taken",
}, },
) )
already_taken = any( already_handled = any(
log.get("scheduled_time") == check_time log.get("action") in ("taken", "skipped")
and log.get("scheduled_time") == check_time
and _utc_to_local_date(log.get("created_at"), user_tz) and _utc_to_local_date(log.get("created_at"), user_tz)
== today.isoformat() == today.isoformat()
for log in logs for log in logs
) )
if already_taken: if already_handled:
continue continue
# Send notification # Send notification
@@ -642,13 +661,14 @@ def poll_callback():
# Create daily schedules per-user at their local midnight # Create daily schedules per-user at their local midnight
_check_per_user_midnight_schedules() _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") logger.info("Checking medication reminders")
check_medication_reminders()
try: try:
check_adaptive_medication_reminders() check_adaptive_medication_reminders()
except Exception as e: except Exception as e:
logger.warning(f"Adaptive medication reminder check failed: {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 # Check for nags - log as error to help with debugging
try: try: