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

300
README.md
View File

@@ -1,14 +1,14 @@
# 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)
├── synculous-client/ # Next.js frontend (React, Tailwind)
├── api/ # Flask REST API
│ ├── main.py # App entry point, auth routes
│ └── routes/ # Domain route modules
@@ -19,28 +19,49 @@ synculous/
│ ├── 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
── 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
│ ├── tz.py # Timezone-aware date/time helpers
│ ├── 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
├── 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
├── 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 <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
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
| 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 <token>` 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 <token>` 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 <token>` 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.**

View File

@@ -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)

View File

@@ -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,7 +440,24 @@ 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
@@ -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,
"adjusted_time": scheduled_time,
},
)
}
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,
"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:
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: