Compare commits

..

11 Commits

Author SHA1 Message Date
Chelsea
215c3d7f95 fix yesterdays schedule blocking todays 2026-02-20 20:04:35 +00:00
fe07b3ebe7 Fix cross-midnight adaptive dose creating false missed reminders
When adaptive timing shifts a late-night dose past midnight (e.g. 23:00
→ 00:42), the scheduler would create a new pending schedule on the next
day even if the dose was already taken. The proximity window was too
narrow to match the take log against the shifted time.

- Skip creating schedules for doses already taken/skipped (checks
  today + yesterday logs against base_time)
- Fix midnight wraparound in proximity check for should_send_nag
- Display base_time (actual dose time) in reminders instead of the
  internal adjusted_time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:58:45 -06:00
019561e7cd Fix bot routine scheduling field mismatch and add debug logging
Bot was sending days_of_week/times but API expects days/time, so
bot-scheduled routines never got reminders. Also handle NULL frequency
from pre-migration rows and add detailed logging to routine reminder
checks for diagnosing further issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:21:22 -06:00
e89656a87c 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>
2026-02-19 23:34:38 -06:00
03da0b0156 Fix timezone mismatch in already-taken check 2026-02-19 20:51:44 -06:00
cf29d17183 Also suppress nags for skipped medications 2026-02-19 20:40:31 -06:00
cc1aace73d Fix presence tracking and med reminder bugs 2026-02-19 20:31:43 -06:00
a19e30db68 Fix medication reminders for already-taken meds
- Convert created_at from UTC to user's local timezone before comparing dates

- Add scheduled_time check in adaptive reminders (was only checking if any dose was taken today)

- Prevents duplicate reminders when user is in a different timezone than UTC
2026-02-19 20:12:22 -06:00
e9a2f96f91 Fix offset-naive/aware datetime error in nag checker
Make last_nag_at timezone-aware before subtracting from the tz-aware
current_time, and store future nag timestamps with timezone.utc to
prevent the mismatch recurring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 19:43:32 -06:00
4c4ff5add3 Allow scheduling multiple medications at once, remove time conflicts
- Multi-med creation form: add any number of medication cards in one session, each with independent name/dosage/unit/frequency/times settings
- Submit button labels dynamically (Add 1 / Add N Medications)
- Removed all schedule conflict checking — medications can now coexist at the same time slot as each other and as routines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 19:38:44 -06:00
33db2629e3 Add every-N-day frequency toggle to new routine page
The schedule editor on /dashboard/routines/new was missing the
Weekly/Every N Days toggle that was added to the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:26:44 -06:00
14 changed files with 5755 additions and 458 deletions

346
README.md
View File

@@ -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 <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.**

Binary file not shown.

Binary file not shown.

Binary file not shown.

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):
@@ -145,56 +146,6 @@ def register(app):
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
return flask.jsonify(meds), 200
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1)
def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None):
"""Check if the proposed medication schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple.
"""
if not new_times:
return False, None
# Check conflicts with routines (duration-aware)
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines:
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if not sched or not sched.get("time"):
continue
routine_days = sched.get("days", [])
if isinstance(routine_days, str):
routine_days = json.loads(routine_days)
if new_days and not any(d in routine_days for d in new_days):
continue
routine_start = _time_str_to_minutes(sched["time"])
routine_dur = _get_routine_duration_minutes(r["id"])
for t in new_times:
med_start = _time_str_to_minutes(t)
# Med falls within routine time range
if routine_start <= med_start < routine_start + routine_dur:
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with other medications
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
if med["id"] == exclude_med_id:
continue
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
if any(t in med_times for t in new_times):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None
@app.route("/api/medications", methods=["POST"])
def api_addMedication():
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
@@ -214,15 +165,6 @@ def register(app):
if not data.get("start_date") or not data.get("interval_days"):
return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400
# Check for schedule conflicts
new_times = data.get("times", [])
new_days = data.get("days_of_week", [])
has_conflict, conflict_msg = _check_med_schedule_conflicts(
user_uuid, new_times, new_days
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
row = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -283,16 +225,6 @@ def register(app):
"days_of_week", "interval_days", "start_date", "next_dose_date",
]
# Check for schedule conflicts if times are being updated
if "times" in data:
new_times = data.get("times", [])
new_days = data.get("days_of_week") or existing.get("days_of_week", [])
has_conflict, conflict_msg = _check_med_schedule_conflicts(
user_uuid, new_times, new_days, exclude_med_id=med_id
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400
@@ -333,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)
@@ -359,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

@@ -128,6 +128,7 @@ class JurySystem:
async def retrieve(self, query_text, top_k=5):
"""Async retrieval — returns list of {metadata, score} dicts."""
import asyncio
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
async def query(self, query_text):
@@ -147,6 +148,7 @@ If the answer is not in the context, say you don't know based on the provided te
Be concise, compassionate, and practical."""
from ai.jury_council import generate_rag_answer
return await generate_rag_answer(query_text, context_text, system_prompt)
except Exception as e:
return f"Error querying DBT knowledge base: {e}"
@@ -181,7 +183,9 @@ def apiRequest(method, endpoint, token=None, data=None, _retried=False):
if resp.status_code == 401 and not _retried:
new_token = _try_refresh_token_for_session(token)
if new_token:
return apiRequest(method, endpoint, token=new_token, data=data, _retried=True)
return apiRequest(
method, endpoint, token=new_token, data=data, _retried=True
)
try:
return resp.json(), resp.status_code
except ValueError:
@@ -201,9 +205,12 @@ def _try_refresh_token_for_session(expired_token):
if cached:
refresh_token = cached.get("refresh_token")
if refresh_token:
result, status = apiRequest("post", "/api/refresh",
data={"refresh_token": refresh_token},
_retried=True)
result, status = apiRequest(
"post",
"/api/refresh",
data={"refresh_token": refresh_token},
_retried=True,
)
if status == 200 and "token" in result:
new_token = result["token"]
session["token"] = new_token
@@ -258,7 +265,8 @@ def negotiateToken(discord_id, username, password):
# Try refresh token first (avoids sending password)
if cached and cached.get("refresh_token"):
result, status = apiRequest(
"post", "/api/refresh",
"post",
"/api/refresh",
data={"refresh_token": cached["refresh_token"]},
_retried=True,
)
@@ -279,7 +287,9 @@ def negotiateToken(discord_id, username, password):
and cached.get("hashed_password")
and verifyPassword(password, cached.get("hashed_password"))
):
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
result, status = apiRequest(
"post", "/api/login", data=login_data, _retried=True
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
@@ -530,7 +540,9 @@ async def handleDBTQuery(message):
if not jury_result.safe_questions:
return True
await message.channel.send("🔍 Searching knowledge base with approved questions...")
await message.channel.send(
"🔍 Searching knowledge base with approved questions..."
)
# Step 3: Multi-query retrieval — deduplicated by chunk ID
seen_ids = set()
@@ -544,7 +556,9 @@ async def handleDBTQuery(message):
context_chunks.append(r["metadata"]["text"])
if not context_chunks:
await message.channel.send("⚠️ No relevant content found in the knowledge base.")
await message.channel.send(
"⚠️ No relevant content found in the knowledge base."
)
return True
context = "\n\n---\n\n".join(context_chunks)
@@ -644,7 +658,8 @@ def _restore_sessions_from_cache():
if not refresh_token:
continue
result, status = apiRequest(
"post", "/api/refresh",
"post",
"/api/refresh",
data={"refresh_token": refresh_token},
_retried=True,
)
@@ -705,15 +720,20 @@ async def update_presence_tracking():
import core.adaptive_meds as adaptive_meds
import core.postgres as postgres
print(f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}", flush=True)
print(
f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}",
flush=True,
)
for guild in client.guilds:
print(f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}")
print(
f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}"
)
# Get all users with presence tracking enabled
settings = postgres.select(
"adaptive_med_settings", {"presence_tracking_enabled": True}
)
print(f"[DEBUG] Found {len(settings)} users with presence tracking enabled")
for setting in settings:
@@ -733,27 +753,46 @@ async def update_presence_tracking():
# Get the member from a shared guild (needed for presence data)
try:
member = None
target_id = int(discord_user_id)
try:
target_id = int(discord_user_id)
except (ValueError, TypeError):
print(
f"[DEBUG] Invalid Discord ID for user {user_uuid}: {discord_user_id}",
flush=True,
)
continue
# Search through all guilds the bot is in
for guild in client.guilds:
member = guild.get_member(target_id)
print(f"[DEBUG] Checked guild {guild.name}, member: {member}", flush=True)
print(
f"[DEBUG] Checked guild {guild.name}, member: {member}",
flush=True,
)
if member:
break
if not member:
print(f"[DEBUG] User {discord_user_id} not found in any shared guild", flush=True)
print(
f"[DEBUG] User {discord_user_id} not found in any shared guild",
flush=True,
)
continue
# Check if user is online
is_online = member.status != discord.Status.offline
print(f"[DEBUG] User status: {member.status}, is_online: {is_online}", flush=True)
print(
f"[DEBUG] User status: {member.status}, is_online: {is_online}",
flush=True,
)
# Get current presence from DB
presence = adaptive_meds.get_user_presence(user_uuid)
was_online = presence.get("is_currently_online") if presence else False
print(f"[DEBUG] Previous state: {was_online}, Current: {is_online}", flush=True)
print(
f"[DEBUG] Previous state: {was_online}, Current: {is_online}",
flush=True,
)
# Update presence if changed
if is_online != was_online:
@@ -788,6 +827,7 @@ async def presenceTrackingLoop():
except Exception as e:
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
import traceback
traceback.print_exc()

View File

@@ -217,12 +217,13 @@ async def handle_routine(message, session, parsed):
await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')")
return
# Build schedule data
# Build schedule data (API expects "days" and "time")
schedule_data = {}
if days_of_week:
schedule_data["days_of_week"] = days_of_week
schedule_data["days"] = days_of_week
if times:
schedule_data["times"] = times
schedule_data["time"] = times[0]
schedule_data["remind"] = True
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
if status == 200:

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,10 @@ This module handles:
import json
import uuid
from datetime import datetime, timedelta, time
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
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
@@ -270,7 +298,7 @@ def should_send_nag(
return False, "User offline"
# Get today's schedule record for this specific time slot
today = current_time.date()
today = user_today_for(user_uuid)
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
if scheduled_time is not None:
query["adjusted_time"] = scheduled_time
@@ -291,30 +319,89 @@ def should_send_nag(
nag_interval = settings.get("nag_interval_minutes", 15)
if last_nag:
if isinstance(last_nag, datetime) and last_nag.tzinfo is None:
last_nag = last_nag.replace(tzinfo=timezone.utc)
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 today
# Check if this specific dose was already taken or skipped today
logs = postgres.select(
"med_logs",
{
"medication_id": med_id,
"user_uuid": user_uuid,
"action": "taken",
"scheduled_time": scheduled_time,
},
)
# Filter to today's logs for this time slot
today_logs = [
log
for log in logs
if log.get("created_at") and log["created_at"].date() == today
]
# Get medication times to calculate dose interval for proximity check
med = postgres.select_one("medications", {"id": med_id})
dose_interval_minutes = 60 # default fallback
if med and med.get("times"):
times = med["times"]
if len(times) >= 2:
time_minutes = []
for t in times:
t = _normalize_time(t)
if t:
h, m = int(t[:2]), int(t[3:5])
time_minutes.append(h * 60 + m)
time_minutes.sort()
intervals = []
for i in range(1, len(time_minutes)):
intervals.append(time_minutes[i] - time_minutes[i - 1])
if intervals:
dose_interval_minutes = min(intervals)
if today_logs:
return False, "Already taken today"
proximity_window = max(30, dose_interval_minutes // 2)
# Filter to today's logs and check for this specific dose
user_tz = tz_for_user(user_uuid)
for log in logs:
action = log.get("action")
if action not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
# created_at is stored as UTC but timezone-naive; convert to user's timezone
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
created_at_local = created_at.astimezone(user_tz)
if created_at_local.date() != today:
continue
log_scheduled_time = log.get("scheduled_time")
if log_scheduled_time:
log_scheduled_time = _normalize_time(log_scheduled_time)
if log_scheduled_time == scheduled_time:
return False, f"Already {action} today"
else:
if scheduled_time:
log_hour = created_at_local.hour
log_min = created_at_local.minute
sched_hour, sched_min = (
int(scheduled_time[:2]),
int(scheduled_time[3:5]),
)
log_mins = log_hour * 60 + log_min
sched_mins = sched_hour * 60 + sched_min
diff_minutes = abs(log_mins - sched_mins)
# Handle midnight wraparound (e.g. 23:00 vs 00:42)
diff_minutes = min(diff_minutes, 1440 - diff_minutes)
if diff_minutes <= proximity_window:
return False, f"Already {action} today"
return True, "Time to nag"
@@ -335,13 +422,18 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
postgres.update(
"medication_schedules",
{"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()},
{"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
{"id": schedule["id"]},
)
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
"""Create today's medication schedule with adaptive adjustments."""
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
@@ -350,14 +442,62 @@ 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)
# Check recent med logs to skip doses already taken/skipped.
# Handles cross-midnight: if adaptive offset shifts 23:00 → 00:42 today,
# but the user already took the 23:00 dose last night, don't schedule it.
# Yesterday's logs only suppress if the scheduled_time is late-night
# (21:00+), since only those could plausibly cross midnight with an offset.
user_tz = tz_for_user(user_uuid)
yesterday = today - timedelta(days=1)
recent_logs = postgres.select("med_logs", {"medication_id": med_id, "user_uuid": user_uuid})
taken_base_times = set()
for log in recent_logs:
if log.get("action") not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
log_date = created_at.astimezone(user_tz).date()
if log_date == today:
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched:
taken_base_times.add(log_sched)
elif log_date == yesterday:
# Only suppress cross-midnight doses (late-night times like 21:00+)
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched and log_sched >= "21:00":
taken_base_times.add(log_sched)
# Create schedule records for each time
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
if base_time in taken_base_times:
continue
data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -373,17 +513,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"]})

56
regenerate_embeddings.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Regenerate DBT embeddings with qwen/qwen3-embedding-8b model (384 dimensions)"""
import json
import os
from openai import OpenAI
import time
# Load config
with open("config.json", "r") as f:
config = json.load(f)
# Initialize OpenAI client with OpenRouter
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=config["openrouter_api_key"],
)
# Load text data
with open("bot/data/dbt_knowledge.text.json", "r") as f:
text_data = json.load(f)
print(f"Regenerating embeddings for {len(text_data)} chunks...")
# Generate embeddings
embeddings_data = []
for i, item in enumerate(text_data):
try:
response = client.embeddings.create(
model="qwen/qwen3-embedding-8b",
input=item["text"]
)
embedding = response.data[0].embedding
embeddings_data.append({
"id": item["id"],
"source": item["source"],
"text": item["text"],
"embedding": embedding
})
if (i + 1) % 10 == 0:
print(f"Processed {i + 1}/{len(text_data)} chunks...")
# Small delay to avoid rate limits
time.sleep(0.1)
except Exception as e:
print(f"Error processing item {i}: {e}")
continue
# Save new embeddings
with open("bot/data/dbt_knowledge.embeddings.json", "w") as f:
json.dump(embeddings_data, f)
print(f"\nDone! Generated {len(embeddings_data)} embeddings with {len(embeddings_data[0]['embedding'])} dimensions")

View File

@@ -26,6 +26,17 @@ def _user_now_for(user_uuid):
return tz.user_now_for(user_uuid)
def _utc_to_local_date(created_at, user_tz):
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
if created_at is None:
return ""
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return created_at.astimezone(user_tz).date().isoformat()
return str(created_at)[:10]
def check_medication_reminders():
"""Check for medications due now and send notifications."""
try:
@@ -47,6 +58,7 @@ def check_medication_reminders():
current_day = now.strftime("%a").lower()
today = now.date()
today_str = today.isoformat()
user_tz = tz.tz_for_user(user_uuid)
for med in user_med_list:
freq = med.get("frequency", "daily")
@@ -83,13 +95,13 @@ def check_medication_reminders():
if current_time not in times:
continue
# Already taken today? Check by created_at date
# Already taken today? Check by created_at date in user's timezone
logs = postgres.select(
"med_logs", where={"medication_id": med["id"], "action": "taken"}
)
already_taken = any(
log.get("scheduled_time") == current_time
and str(log.get("created_at", ""))[:10] == today_str
and _utc_to_local_date(log.get("created_at"), user_tz) == today_str
for log in logs
)
if already_taken:
@@ -111,20 +123,25 @@ def check_routine_reminders():
from datetime import date as date_type
schedules = postgres.select("routine_schedules", where={"remind": True})
logger.info(f"Routine reminders: found {len(schedules)} schedule(s) with remind=True")
for schedule in schedules:
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
if not routine:
logger.warning(f"Routine not found for schedule {schedule['id']}")
continue
now = _user_now_for(routine["user_uuid"])
current_time = now.strftime("%H:%M")
today = now.date()
if current_time != schedule.get("time"):
sched_time = schedule.get("time")
if current_time != sched_time:
continue
frequency = schedule.get("frequency", "weekly")
logger.info(f"Routine '{routine['name']}' time match at {current_time}")
frequency = schedule.get("frequency") or "weekly"
if frequency == "every_n_days":
start = schedule.get("start_date")
interval = schedule.get("interval_days")
@@ -134,14 +151,19 @@ def check_routine_reminders():
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
logger.info(f"Routine '{routine['name']}' skipped: not due today (every {interval} days from {start_d})")
continue
else:
logger.warning(f"Routine '{routine['name']}' skipped: every_n_days but missing start_date={start} or interval_days={interval}")
continue
else:
current_day = now.strftime("%a").lower()
days = schedule.get("days", [])
if current_day not in days:
logger.info(f"Routine '{routine['name']}' skipped: {current_day} not in {days}")
continue
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
@@ -150,8 +172,11 @@ def check_routine_reminders():
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=routine["user_uuid"]
)
logger.info(f"Routine reminder sent for '{routine['name']}'")
else:
logger.warning(f"No notification settings for user {routine['user_uuid']}, skipping routine '{routine['name']}'")
except Exception as e:
logger.error(f"Error checking routine reminders: {e}")
logger.error(f"Error checking routine reminders: {e}", exc_info=True)
def check_refills():
@@ -213,6 +238,7 @@ def check_adaptive_medication_reminders():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
today = now.date()
user_tz = tz.tz_for_user(user_uuid)
# Check if adaptive timing is enabled
settings = adaptive_meds.get_adaptive_settings(user_uuid)
@@ -248,18 +274,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
@@ -277,32 +322,40 @@ def check_adaptive_medication_reminders():
if check_time != current_time:
continue
# Check if already taken
# 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(
str(log.get("created_at", ""))[:10] == today.isoformat()
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
# Send notification — display base_time to the user
base_display = sched.get("base_time")
if isinstance(base_display, time_type):
base_display = base_display.strftime("%H:%M")
elif base_display is not None:
base_display = str(base_display)[:5]
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
offset = sched.get("adjustment_minutes", 0)
if offset > 0:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)"
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display} (adjusted +{offset}min)"
else:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}"
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
@@ -352,7 +405,9 @@ def check_nagging():
},
)
except Exception as e:
logger.warning(f"Could not query medication_schedules for {med_id}: {e}")
logger.warning(
f"Could not query medication_schedules for {med_id}: {e}"
)
# Table may not exist yet
continue
@@ -360,7 +415,9 @@ def check_nagging():
if not schedules:
if not _is_med_due_today(med, today):
continue
logger.info(f"No schedules found for medication {med_id}, attempting to create")
logger.info(
f"No schedules found for medication {med_id}, attempting to create"
)
times = med.get("times", [])
if times:
try:
@@ -392,12 +449,9 @@ def check_nagging():
if not should_nag:
continue
# Get the time to display
adaptive_enabled = settings.get("adaptive_timing_enabled")
if adaptive_enabled:
display_time = sched.get("adjusted_time")
else:
display_time = sched.get("base_time")
# Always display the base_time (the user's actual dose time),
# not the internal adjusted_time used for scheduling.
display_time = sched.get("base_time")
# Normalize TIME objects for display
if isinstance(display_time, time_type):
display_time = display_time.strftime("%H:%M")
@@ -531,9 +585,7 @@ def _check_per_user_midnight_schedules():
continue
times = med.get("times", [])
if times:
adaptive_meds.create_daily_schedule(
user_uuid, med["id"], times
)
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
except Exception as e:
logger.warning(
f"Could not create adaptive schedules for user {user_uuid}: {e}"
@@ -543,6 +595,7 @@ def _check_per_user_midnight_schedules():
def check_task_reminders():
"""Check one-off tasks for advance and at-time reminders."""
from datetime import timedelta
try:
tasks = postgres.select("tasks", where={"status": "pending"})
if not tasks:
@@ -575,15 +628,24 @@ def check_task_reminders():
# Advance reminder
if reminder_min > 0 and not task.get("advance_notified"):
adv_dt = sched_dt - timedelta(minutes=reminder_min)
if adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm:
if (
adv_dt.date() == current_date
and adv_dt.strftime("%H:%M") == current_hhmm
):
if user_settings is None:
user_settings = notifications.getNotificationSettings(user_uuid)
user_settings = notifications.getNotificationSettings(
user_uuid
)
if user_settings:
msg = f"⏰ In {reminder_min} min: {task['title']}"
if task.get("description"):
msg += f"{task['description']}"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
postgres.update("tasks", {"advance_notified": True}, {"id": task["id"]})
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks", {"advance_notified": True}, {"id": task["id"]}
)
# At-time reminder
if sched_date == current_date and sched_hhmm == current_hhmm:
@@ -593,10 +655,15 @@ def check_task_reminders():
msg = f"📋 Now: {task['title']}"
if task.get("description"):
msg += f"\n{task['description']}"
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks",
{"status": "notified", "updated_at": datetime.utcnow().isoformat()},
{
"status": "notified",
"updated_at": datetime.utcnow().isoformat(),
},
{"id": task["id"]},
)
except Exception as e:
@@ -608,13 +675,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:

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons';
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
@@ -15,63 +15,258 @@ const DAY_OPTIONS = [
{ value: 'sun', label: 'Sun' },
];
interface MedEntry {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
daysOfWeek: string[];
intervalDays: number;
startDate: string;
}
function blankEntry(): MedEntry {
return {
id: `med-${Date.now()}-${Math.random()}`,
name: '',
dosage: '',
unit: 'mg',
frequency: 'daily',
times: ['08:00'],
daysOfWeek: [],
intervalDays: 7,
startDate: new Date().toISOString().slice(0, 10),
};
}
function MedCard({
entry,
index,
total,
onChange,
onRemove,
}: {
entry: MedEntry;
index: number;
total: number;
onChange: (updates: Partial<MedEntry>) => void;
onRemove: () => void;
}) {
const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] });
const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) });
const handleTimeChange = (i: number, val: string) => {
const t = [...entry.times];
t[i] = val;
onChange({ times: t });
};
const toggleDay = (day: string) =>
onChange({
daysOfWeek: entry.daysOfWeek.includes(day)
? entry.daysOfWeek.filter(d => d !== day)
: [...entry.daysOfWeek, day],
});
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400">
Medication {index + 1}
</span>
{total > 1 && (
<button type="button" onClick={onRemove} className="text-red-500 dark:text-red-400 p-1">
<TrashIcon size={18} />
</button>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
type="text"
value={entry.name}
onChange={e => onChange({ name: e.target.value })}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
<input
type="text"
value={entry.dosage}
onChange={e => onChange({ dosage: e.target.value })}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
<select
value={entry.unit}
onChange={e => onChange({ unit: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
<select
value={entry.frequency}
onChange={e => onChange({ frequency: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{entry.frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
entry.daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{entry.frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={entry.intervalDays}
onChange={e => onChange({ intervalDays: parseInt(e.target.value) || 1 })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
<input
type="date"
value={entry.startDate}
onChange={e => onChange({ startDate: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{entry.frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
>
+ Add Time
</button>
</div>
<div className="space-y-2">
{entry.times.map((time, i) => (
<div key={i} className="flex gap-2">
<input
type="time"
value={time}
onChange={e => handleTimeChange(i, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{entry.times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(i)}
className="text-red-500 dark:text-red-400 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
export default function NewMedicationPage() {
const router = useRouter();
const [name, setName] = useState('');
const [dosage, setDosage] = useState('');
const [unit, setUnit] = useState('mg');
const [frequency, setFrequency] = useState('daily');
const [times, setTimes] = useState<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
};
const handleRemoveTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index));
};
const handleTimeChange = (index: number, value: string) => {
const newTimes = [...times];
newTimes[index] = value;
setTimes(newTimes);
};
const toggleDay = (day: string) => {
setDaysOfWeek(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
const removeEntry = (index: number) => {
setEntries(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !dosage.trim()) {
setError('Name and dosage are required');
return;
}
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
setError('Select at least one day of the week');
return;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.name.trim() || !entry.dosage.trim()) {
setError(`Medication ${i + 1}: name and dosage are required`);
return;
}
if (entry.frequency === 'specific_days' && entry.daysOfWeek.length === 0) {
setError(`Medication ${i + 1}: select at least one day of the week`);
return;
}
}
setIsLoading(true);
setError('');
try {
await api.medications.create({
name,
dosage,
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
});
for (const entry of entries) {
await api.medications.create({
name: entry.name,
dosage: entry.dosage,
unit: entry.unit,
frequency: entry.frequency,
times: entry.frequency === 'as_needed' ? [] : entry.times,
...(entry.frequency === 'specific_days' && { days_of_week: entry.daysOfWeek }),
...(entry.frequency === 'every_n_days' && {
interval_days: entry.intervalDays,
start_date: entry.startDate,
}),
});
}
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
@@ -80,6 +275,8 @@ export default function NewMedicationPage() {
}
};
const count = entries.length;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
@@ -87,167 +284,47 @@ export default function NewMedicationPage() {
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medication</h1>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medications</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
{entries.map((entry, index) => (
<MedCard
key={entry.id}
entry={entry}
index={index}
total={count}
onChange={updates => updateEntry(index, updates)}
onRemove={() => removeEntry(index)}
/>
))}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={(e) => setDosage(e.target.value)}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
>
+ Add Time
</button>
</div>
{frequency === 'daily' && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">Add multiple times for 2x, 3x, or more doses per day</p>
)}
<div className="space-y-2">
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
className="text-red-500 dark:text-red-400 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<button
type="button"
onClick={() => setEntries(prev => [...prev, blankEntry()])}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-indigo-300 dark:border-indigo-700 rounded-xl text-indigo-600 dark:text-indigo-400 font-medium hover:border-indigo-400 dark:hover:border-indigo-600 transition-colors"
>
<PlusIcon size={18} />
Add Another Medication
</button>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Medication'}
{isLoading
? 'Adding...'
: count === 1
? 'Add Medication'
: `Add ${count} Medications`}
</button>
</form>
</div>

View File

@@ -50,6 +50,9 @@ export default function NewRoutinePage() {
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [scheduleTime, setScheduleTime] = useState('08:00');
const [scheduleRemind, setScheduleRemind] = useState(true);
const [scheduleFrequency, setScheduleFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
const [scheduleIntervalDays, setScheduleIntervalDays] = useState(2);
const [scheduleStartDate, setScheduleStartDate] = useState(() => new Date().toISOString().split('T')[0]);
const toggleDay = (day: string) => {
setScheduleDays(prev =>
@@ -128,11 +131,16 @@ export default function NewRoutinePage() {
});
}
if (scheduleDays.length > 0) {
if (scheduleFrequency === 'every_n_days' || scheduleDays.length > 0) {
await api.routines.setSchedule(routine.id, {
days: scheduleDays,
time: scheduleTime,
remind: scheduleRemind,
frequency: scheduleFrequency,
...(scheduleFrequency === 'every_n_days' && {
interval_days: scheduleIntervalDays,
start_date: scheduleStartDate,
}),
});
}
@@ -226,58 +234,110 @@ export default function NewRoutinePage() {
{/* Schedule */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
{/* Quick select buttons */}
{/* Frequency selector */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleFrequency('weekly')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Every day
Weekly
</button>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleFrequency('every_n_days')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
Every N Days
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
{scheduleFrequency === 'every_n_days' ? (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
<div className="flex items-center gap-2">
<input
type="number"
min={2}
max={365}
value={scheduleIntervalDays}
onChange={(e) => setScheduleIntervalDays(Math.max(2, Number(e.target.value)))}
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
<input
type="date"
value={scheduleStartDate}
onChange={(e) => setScheduleStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
) : (
<>
{/* Quick select buttons */}
<div className="flex gap-2">
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{day.label}
Every day
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{day.label}
</button>
))}
</div>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
<input
@@ -287,7 +347,7 @@ export default function NewRoutinePage() {
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Send reminder</p>

View File

@@ -396,14 +396,24 @@ export default function SettingsPage() {
</button>
</div>
{notif.discord_enabled && (
<input
type="text"
placeholder="Your Discord user ID"
value={notif.discord_user_id}
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700"
/>
<div className="space-y-1">
<input
type="text"
placeholder="Your Discord user ID (numbers only)"
value={notif.discord_user_id}
onChange={(e) => {
const val = e.target.value;
if (val === '' || /^\d+$/.test(val)) {
setNotif({ ...notif, discord_user_id: val });
}
}}
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enable Developer Mode in Discord, right-click your profile, and copy User ID
</p>
</div>
)}
</div>
</div>
@@ -831,7 +841,7 @@ export default function SettingsPage() {
/>
<select
value={newContact.contact_type}
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value })}
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value, contact_value: '' })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
>
<option value="discord">Discord</option>
@@ -840,9 +850,18 @@ export default function SettingsPage() {
</select>
<input
type="text"
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID (numbers only)' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_value}
onChange={(e) => setNewContact({ ...newContact, contact_value: e.target.value })}
onChange={(e) => {
const val = e.target.value;
if (newContact.contact_type === 'discord') {
if (val === '' || /^\d+$/.test(val)) {
setNewContact({ ...newContact, contact_value: val });
}
} else {
setNewContact({ ...newContact, contact_value: val });
}
}}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
/>
<div className="flex items-center justify-between">