Compare commits

..

44 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
ecb79af44e Fix bugs, add auto-refresh, quick-complete tasks, and every-N-day routines
- Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13)
- Fix push notifications: pass Uint8Array directly as applicationServerKey (#6)
- Show specific conflict reason on schedule save instead of generic error (#17)
- Add inline checkmark button to complete tasks on routines timeline (#18)
- Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15)
- Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:04:52 -06:00
24a1d18b25 Show tasks on the routines timeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:02:30 -06:00
d45929ddc0 Fix off-day med reminders and add medication editing
Scheduler: check_nagging() now calls _is_med_due_today() before creating
on-demand schedules or processing existing ones — prevents nagging
for specific_days / every_n_days meds on days they are not scheduled.

Web client: add Edit button (pencil icon) on each medication card linking
to /dashboard/medications/[id]/edit — new page pre-populates the full
form (name, dosage, unit, frequency, times, days, interval, notes)
and submits PUT /api/medications/:id on save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:49:12 -06:00
bebc609091 Add one-off tasks/appointments feature
- DB: tasks table with scheduled_datetime, reminder_minutes_before, advance_notified, status
- API: CRUD routes GET/POST /api/tasks, PATCH/DELETE /api/tasks/<id>
- Scheduler: check_task_reminders() fires advance + at-time notifications, tracks advance_notified to prevent double-fire
- Bot: handle_task() with add/list/done/cancel/delete actions + datetime resolution helper
- AI: task interaction type + examples added to command_parser
- Web: task list page with overdue/notified color coding + new task form with datetime-local picker
- Nav: replaced Templates with Tasks in bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:43:42 -06:00
2951382c51 Fix auth persistence: web client session timeout + bot cache loss
Web client: trustDevice now defaults to true so a refresh token is always
issued on login, preventing deauth after the 1-hour access token expiry.
Users can still uncheck the box on shared devices.

Bot: cache file path is now env-configurable (BOT_CACHE_FILE) and
defaults to /app/cache/user_cache.pkl. Docker Compose mounts a named
volume at /app/cache so the session cache survives container restarts.
saveCache() now creates the directory if it doesn't exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:32:07 -06:00
95ebae6766 Add AI task composition for routines (bot + web client)
Users can now describe a goal and have AI auto-generate 4-7 ADHD-friendly
steps, which they can review and modify before saving.

- ai/ai_config.json: Add step_generator prompt and ai_compose examples
  to command_parser so bot recognises vague task descriptions
- api/routes/ai.py: New POST /api/ai/generate-steps endpoint — calls
  LLM via ai_parser, validates and sanitises returned steps
- api/main.py: Register new ai_routes module
- bot/commands/routines.py: Add ai_compose action — generates steps,
  shows numbered list with durations, uses existing yes/no confirm flow
- synculous-client/src/lib/api.ts: Add api.ai.generateSteps(goal)
- synculous-client/src/app/dashboard/routines/new/page.tsx: Add
  Generate with AI panel with collapsible textarea, loading spinner,
  and inline error; generated steps slot into existing editable list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:56:54 -06:00
9fb56edf74 Route general questions to knowledge base via LLM classification
The command parser was returning needs_clarification for advice/how-to
questions that didn't explicitly reference a book. Updated ai_config.json
to classify any question that isn't about meds or routines as a knowledge
query, added book hints for DBT vs ADHD, and added concrete examples
(e.g. "how do I do things I don't want to do?").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:25:13 -06:00
288e447d3e Fix nagging for doses not yet due — check scheduled time before nagging
should_send_nag() was iterating all pending schedules for today without
verifying the scheduled time had actually passed. A dose scheduled for
18:00 would get nagged at 13:15. Add an early return when current_time
is before the scheduled dose time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:17:51 -06:00
d2b074d39b Fix VARCHAR vs TIME type mismatch in med_logs queries (#15)
The medication_schedules.adjusted_time column is a TIME type, but
med_logs.scheduled_time is VARCHAR. When adjusted_time values were
passed into queries against med_logs, PostgreSQL rejected the
comparison. Add _normalize_time() to convert datetime.time objects
to "HH:MM" strings at the entry points of should_send_nag,
record_nag_sent, and mark_med_taken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:14:20 -06:00
d4adbde3df Fix issues #6, #7, #11, #12, #13: med reminders, push notifications, auth persistence, scheduling conflicts
- Fix TIME object vs string comparison in scheduler preventing adaptive med
  reminders from ever firing (#12, #6)
- Add frequency filtering to midnight schedule creation for every_n_days meds
- Require start_date and interval_days for every_n_days medications
- Add refresh token support (30-day) to API and bot for persistent sessions (#13)
- Add "trusted device" checkbox to frontend login for long-lived sessions (#7)
- Auto-refresh expired tokens in both bot (apiRequest) and frontend (api.ts)
- Restore bot sessions from cache on restart using refresh tokens
- Duration-aware routine scheduling conflict detection (#11)
- Add conflict check when starting routine sessions against medication times
- Add diagnostic logging to notification delivery channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:05:48 -06:00
6850abf7d2 fix partial-update overwrite bug in snitch and adaptive meds PUT handlers
Both handlers were building update_data with defaults for every field,
so a partial save (e.g. toggling one toggle) would silently reset all
other settings back to their defaults. Now only fields explicitly
present in the request body are written to the DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 19:05:09 -06:00
f826e511d5 fixing snitch and adaptive medication timing persistance and logic 2026-02-17 18:52:35 -06:00
cf2b4be033 med scheduling fixes 2026-02-17 18:33:43 -06:00
8ac7a5129a fixed spamming i hope 2026-02-17 18:11:44 -06:00
80ebecf0b1 Changes Made
1. config/schema.sql — Added timezone_name VARCHAR(100) to user_preferences table + an ALTER TABLE migration at the bottom for existing DBs.
  2. core/tz.py — Rewrote with dual-path timezone support:
  - Request context: _get_request_tz() now checks X-Timezone-Name header (IANA) first, falls back to X-Timezone-Offset
  - Background jobs: New tz_for_user(user_uuid) and user_now_for(user_uuid) read stored timezone_name from prefs, fall back to numeric offset, then UTC
  - All existing function signatures (user_now(), user_today()) preserved for backward compat

  3. scheduler/daemon.py — Fixed 3 bugs:
  - _user_now_for() now delegates to tz.user_now_for() which uses IANA timezone names (DST-safe)
  - check_nagging() — replaced datetime.utcnow() with _user_now_for(user_uuid) so nags evaluate in user's timezone
  - poll_callback() — replaced single UTC midnight check with _check_per_user_midnight_schedules() that iterates users and creates daily schedules at their local midnight

  4. api/routes/preferences.py — Added "timezone_name" to allowed PUT fields.

  5. synculous-client/src/lib/api.ts — Added X-Timezone-Name header to every request + added timezone_name to preferences update type.

  6. synculous-client/src/app/dashboard/layout.tsx — Now syncs both timezone_offset and timezone_name (via Intl.DateTimeFormat().resolvedOptions().timeZone) on session start.
2026-02-17 18:02:07 -06:00
0e28e1ac9d logo update from ai logo to human made 2026-02-17 16:43:23 -06:00
ac27a9fb69 you can pick your friends but you shouldnt pick your skin 2026-02-17 03:39:43 -06:00
d673d73530 opus pass 2 2026-02-17 00:30:05 -06:00
123a7ce3e7 claude bugpass 2026-02-17 00:20:06 -06:00
Chelsea
3d3b80fe96 partial 2026-02-17 05:46:59 +00:00
Chelsea
596467628f feat: add Discord presence status indicator in settings
Add a visual status indicator showing:
- Online/offline status with colored dot indicator
- Last seen timestamp
- Typical wake time (if available)

The indicator now displays whenever Discord notifications are enabled,
not just when presence tracking is active.
2026-02-17 04:37:13 +00:00
Chelsea
a0126d0aba fix: include adaptive_mode when enabling adaptive timing toggle
The API requires adaptive_mode when adaptive_timing_enabled is true,
but the frontend was only sending the enabled flag. This caused 400
errors when users tried to toggle adaptive timing on.

Now the toggle sends both fields when enabling, satisfying the API
validation requirements.
2026-02-17 04:35:55 +00:00
Chelsea
a2c7940a5c Fix issues #8, #9, #10, #11: scheduling conflicts, med reminders, adaptive timing, nagging
- #11: Add validation to prevent simultaneous scheduling of routines and medications
  - Added _check_schedule_conflicts() in routines.py
  - Added _check_med_schedule_conflicts() in medications.py
  - Returns HTTP 409 with descriptive error on conflict

- #10: Fix medication reminders not being sent
  - Added call to check_adaptive_medication_reminders() in daemon poll loop

- #9: Fix can't enable adaptive timing
  - Added proper error handling and logging in update_adaptive_settings()
  - Returns meaningful error message on database failures

- #8: Fix nagging not working
  - Added debug logging for missing settings
  - Auto-create medication schedules if they don't exist
  - Improved error logging (warning -> error)
2026-02-17 04:20:34 +00:00
3aad7a4867 Fix snitch test to actually insert a log entry that the bot will send 2026-02-16 21:20:25 -06:00
98706702da Fix snitch test to not send to wrong user, implement Discord DM sending for snitches 2026-02-16 21:16:33 -06:00
1d79516794 Temporarily disable adaptive medication check until database migration is run 2026-02-16 21:09:05 -06:00
f740fe8be2 Add fallback to original medication reminders when adaptive tables don't exist 2026-02-16 21:02:34 -06:00
6e875186b4 Fix datetime import and add database migration script 2026-02-16 20:21:07 -06:00
35f51e6d27 Add complete snitch system UI to settings page with contact management and consent flow 2026-02-16 20:16:29 -06:00
a6ae4e13fd Add complete snitch feature with contact management, consent system, and notification delivery 2026-02-16 20:14:03 -06:00
69163a37d1 Add complete UI for adaptive medication settings with presence tracking and nagging configuration 2026-02-16 20:04:58 -06:00
84c6032dc9 Register adaptive medication API routes 2026-02-16 20:01:27 -06:00
d4fb41ae6b Add adaptive medication timing, Discord presence tracking, and nagging system 2026-02-16 20:00:53 -06:00
52 changed files with 10835 additions and 510 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.**

File diff suppressed because one or more lines are too long

View File

@@ -61,7 +61,7 @@ def _call_llm_sync(system_prompt, user_prompt):
return extracted
return None
except Exception as e:
print(f"LLM error: {type(e).__name__}: {e}", flush=True)
print(f"LLM error ({AI_CONFIG['model']}): {type(e).__name__}: {e}", flush=True)
return None
@@ -119,7 +119,7 @@ async def parse(user_input, interaction_type, retry_count=0, errors=None, histor
response_text = await _call_llm(prompt_config["system"], user_prompt)
if not response_text:
return {"error": "AI service unavailable", "user_input": user_input}
return {"error": f"AI service unavailable (model: {AI_CONFIG['model']})", "user_input": user_input}
try:
parsed = json.loads(response_text)

View File

@@ -21,6 +21,10 @@ import api.routes.notifications as notifications_routes
import api.routes.preferences as preferences_routes
import api.routes.rewards as rewards_routes
import api.routes.victories as victories_routes
import api.routes.adaptive_meds as adaptive_meds_routes
import api.routes.snitch as snitch_routes
import api.routes.ai as ai_routes
import api.routes.tasks as tasks_routes
app = flask.Flask(__name__)
CORS(app)
@@ -37,6 +41,10 @@ ROUTE_MODULES = [
preferences_routes,
rewards_routes,
victories_routes,
adaptive_meds_routes,
snitch_routes,
ai_routes,
tasks_routes,
]
@@ -71,11 +79,33 @@ def api_login():
return flask.jsonify({"error": "username and password required"}), 400
token = auth.getLoginToken(username, password)
if token:
return flask.jsonify({"token": token}), 200
response = {"token": token}
# Issue refresh token when trusted device is requested
if data.get("trust_device"):
import jwt as pyjwt
payload = pyjwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
user_uuid = payload.get("sub")
if user_uuid:
response["refresh_token"] = auth.createRefreshToken(user_uuid)
return flask.jsonify(response), 200
else:
return flask.jsonify({"error": "invalid credentials"}), 401
@app.route("/api/refresh", methods=["POST"])
def api_refresh():
"""Exchange a refresh token for a new access token."""
data = flask.request.get_json()
refresh_token = data.get("refresh_token") if data else None
if not refresh_token:
return flask.jsonify({"error": "refresh_token required"}), 400
access_token, user_uuid = auth.refreshAccessToken(refresh_token)
if access_token:
return flask.jsonify({"token": access_token}), 200
else:
return flask.jsonify({"error": "invalid or expired refresh token"}), 401
# ── User Routes ────────────────────────────────────────────────────
@@ -161,8 +191,13 @@ def _seed_templates_if_empty():
count = postgres.count("routine_templates")
if count == 0:
import logging
logging.getLogger(__name__).info("No templates found, seeding from seed_templates.sql...")
seed_path = os.path.join(os.path.dirname(__file__), "..", "config", "seed_templates.sql")
logging.getLogger(__name__).info(
"No templates found, seeding from seed_templates.sql..."
)
seed_path = os.path.join(
os.path.dirname(__file__), "..", "config", "seed_templates.sql"
)
if os.path.exists(seed_path):
with open(seed_path, "r") as f:
sql = f.read()
@@ -171,6 +206,7 @@ def _seed_templates_if_empty():
logging.getLogger(__name__).info("Templates seeded successfully.")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
@@ -180,8 +216,13 @@ def _seed_rewards_if_empty():
count = postgres.count("reward_pool")
if count == 0:
import logging
logging.getLogger(__name__).info("No rewards found, seeding from seed_rewards.sql...")
seed_path = os.path.join(os.path.dirname(__file__), "..", "config", "seed_rewards.sql")
logging.getLogger(__name__).info(
"No rewards found, seeding from seed_rewards.sql..."
)
seed_path = os.path.join(
os.path.dirname(__file__), "..", "config", "seed_rewards.sql"
)
if os.path.exists(seed_path):
with open(seed_path, "r") as f:
sql = f.read()
@@ -190,6 +231,7 @@ def _seed_rewards_if_empty():
logging.getLogger(__name__).info("Rewards seeded successfully.")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")

Binary file not shown.

Binary file not shown.

Binary file not shown.

194
api/routes/adaptive_meds.py Normal file
View File

@@ -0,0 +1,194 @@
"""
api/routes/adaptive_meds.py - API endpoints for adaptive medication settings
"""
import logging
import uuid
import flask
import jwt
import os
import core.postgres as postgres
import core.adaptive_meds as adaptive_meds
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def register(app):
@app.route("/api/adaptive-meds/settings", methods=["GET"])
def get_adaptive_settings():
"""Get user's adaptive medication settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
settings = adaptive_meds.get_adaptive_settings(user_uuid)
if not settings:
# Return defaults
return flask.jsonify(
{
"adaptive_timing_enabled": False,
"adaptive_mode": "shift_all",
"presence_tracking_enabled": False,
"nagging_enabled": True,
"nag_interval_minutes": 15,
"max_nag_count": 4,
"quiet_hours_start": None,
"quiet_hours_end": None,
}
), 200
return flask.jsonify(
{
"adaptive_timing_enabled": settings.get(
"adaptive_timing_enabled", False
),
"adaptive_mode": settings.get("adaptive_mode", "shift_all"),
"presence_tracking_enabled": settings.get(
"presence_tracking_enabled", False
),
"nagging_enabled": settings.get("nagging_enabled", True),
"nag_interval_minutes": settings.get("nag_interval_minutes", 15),
"max_nag_count": settings.get("max_nag_count", 4),
"quiet_hours_start": settings.get("quiet_hours_start"),
"quiet_hours_end": settings.get("quiet_hours_end"),
}
), 200
@app.route("/api/adaptive-meds/settings", methods=["PUT"])
def update_adaptive_settings():
"""Update user's adaptive medication settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "No data provided"}), 400
# Validate required fields if enabling adaptive timing
if data.get("adaptive_timing_enabled"):
if not data.get("adaptive_mode"):
return flask.jsonify(
{"error": "adaptive_mode is required when enabling adaptive timing"}
), 400
# Only update fields explicitly provided in the request — never overwrite with defaults
allowed_fields = [
"adaptive_timing_enabled", "adaptive_mode", "presence_tracking_enabled",
"nagging_enabled", "nag_interval_minutes", "max_nag_count",
"quiet_hours_start", "quiet_hours_end",
]
update_data = {field: data[field] for field in allowed_fields if field in data}
if not update_data:
return flask.jsonify({"success": True}), 200
try:
existing = adaptive_meds.get_adaptive_settings(user_uuid)
if existing:
postgres.update(
"adaptive_med_settings", update_data, {"user_uuid": user_uuid}
)
else:
update_data["id"] = str(uuid.uuid4())
update_data["user_uuid"] = user_uuid
postgres.insert("adaptive_med_settings", update_data)
return flask.jsonify({"success": True}), 200
except Exception as e:
logger.error(f"Failed to save adaptive settings: {e}")
return flask.jsonify({
"error": "Failed to save settings",
"details": str(e)
}), 500
@app.route("/api/adaptive-meds/presence", methods=["GET"])
def get_presence_status():
"""Get user's Discord presence status."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
presence = adaptive_meds.get_user_presence(user_uuid)
if not presence:
return flask.jsonify(
{"is_online": False, "last_online_at": None, "typical_wake_time": None}
), 200
typical_wake = adaptive_meds.calculate_typical_wake_time(user_uuid)
return flask.jsonify(
{
"is_online": presence.get("is_currently_online", False),
"last_online_at": presence.get("last_online_at").isoformat()
if presence.get("last_online_at")
else None,
"last_offline_at": presence.get("last_offline_at").isoformat()
if presence.get("last_offline_at")
else None,
"typical_wake_time": typical_wake.strftime("%H:%M")
if typical_wake
else None,
}
), 200
@app.route("/api/adaptive-meds/schedule", methods=["GET"])
def get_today_schedule():
"""Get today's adaptive medication schedule."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
from datetime import date
today = date.today()
# Get all medications for user
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
schedule_data = []
for med in meds:
med_id = med.get("id")
med_schedules = postgres.select(
"medication_schedules",
{
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
},
)
for sched in med_schedules:
schedule_data.append(
{
"medication_id": med_id,
"medication_name": med.get("name"),
"base_time": sched.get("base_time"),
"adjusted_time": sched.get("adjusted_time"),
"adjustment_minutes": sched.get("adjustment_minutes", 0),
"status": sched.get("status", "pending"),
"nag_count": sched.get("nag_count", 0),
}
)
return flask.jsonify(schedule_data), 200

76
api/routes/ai.py Normal file
View File

@@ -0,0 +1,76 @@
"""
api/routes/ai.py - AI-powered generation endpoints
"""
import asyncio
import flask
import jwt
import os
import ai.parser as ai_parser
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def register(app):
@app.route("/api/ai/generate-steps", methods=["POST"])
def api_generate_steps():
"""
Generate ADHD-friendly routine steps from a goal description.
Body: {"goal": string}
Returns: {"steps": [{"name": string, "duration_minutes": int}]}
"""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
goal = data.get("goal", "").strip()
if not goal:
return flask.jsonify({"error": "missing required field: goal"}), 400
if len(goal) > 500:
return flask.jsonify({"error": "goal too long (max 500 characters)"}), 400
try:
result = asyncio.run(ai_parser.parse(goal, "step_generator"))
except Exception as e:
return flask.jsonify({"error": f"AI service error: {str(e)}"}), 500
if "error" in result:
return flask.jsonify({"error": result["error"]}), 500
steps = result.get("steps", [])
validated = []
for s in steps:
if not isinstance(s, dict):
continue
name = str(s.get("name", "")).strip()
if not name:
continue
try:
dur = max(1, min(60, int(s.get("duration_minutes", 5))))
except (ValueError, TypeError):
dur = 5
validated.append({"name": name, "duration_minutes": dur})
if len(validated) < 2:
return flask.jsonify({"error": "AI failed to generate valid steps"}), 500
return flask.jsonify({"steps": validated}), 200

View File

@@ -2,6 +2,7 @@
Medications API - medication scheduling, logging, and adherence tracking
"""
import json
import os
import uuid
from datetime import datetime, date, timedelta, timezone
@@ -12,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):
@@ -158,6 +160,11 @@ def register(app):
if missing:
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
# Validate every_n_days required fields
if data.get("frequency") == "every_n_days":
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
row = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -217,6 +224,7 @@ def register(app):
"name", "dosage", "unit", "frequency", "times", "notes", "active",
"days_of_week", "interval_days", "start_date", "next_dose_date",
]
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
@@ -257,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)
@@ -283,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

@@ -58,7 +58,7 @@ def register(app):
if not data:
return flask.jsonify({"error": "missing body"}), 400
allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset"]
allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset", "timezone_name"]
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields"}), 400

View File

@@ -7,7 +7,7 @@ Routines have ordered steps. Users start sessions to walk through them.
import os
import uuid
import json
from datetime import datetime
from datetime import datetime, timedelta
import flask
import jwt
import core.auth as auth
@@ -420,6 +420,31 @@ def register(app):
return flask.jsonify(
{"error": "already have active session", "session_id": active["id"]}
), 409
# Check if starting now would conflict with medication times
now = tz.user_now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
routine_dur = _get_routine_duration_minutes(routine_id)
routine_start = _time_str_to_minutes(current_time)
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
if med_days and current_day not in med_days:
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
if _ranges_overlap(routine_start, routine_dur, med_start, 1):
return flask.jsonify(
{"error": f"Starting now would conflict with {med.get('name', 'medication')} at {mt}"}
), 409
steps = postgres.select(
"routine_steps",
where={"routine_id": routine_id},
@@ -636,8 +661,7 @@ def register(app):
continue
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
result.append(
{
entry = {
"routine_id": r["id"],
"routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""),
@@ -645,13 +669,89 @@ def register(app):
"time": sched.get("time"),
"remind": sched.get("remind", True),
"total_duration_minutes": total_duration,
"frequency": sched.get("frequency", "weekly"),
}
)
if sched.get("frequency") == "every_n_days":
entry["interval_days"] = sched.get("interval_days")
entry["start_date"] = str(sched.get("start_date")) if sched.get("start_date") else None
result.append(entry)
return flask.jsonify(result), 200
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) # At least 1 minute
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 _ranges_overlap(start1, dur1, start2, dur2):
"""Check if two time ranges overlap (in minutes since midnight)."""
end1 = start1 + dur1
end2 = start2 + dur2
return start1 < end2 and start2 < end1
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None, new_routine_id=None):
"""Check if the proposed schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple.
"""
if not new_days or not new_time:
return False, None
new_start = _time_str_to_minutes(new_time)
# Get duration of the routine being scheduled
if new_routine_id:
new_dur = _get_routine_duration_minutes(new_routine_id)
else:
new_dur = 1
# Check conflicts with other routines
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines:
if r["id"] == exclude_routine_id:
continue
other_sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if not other_sched or not other_sched.get("time"):
continue
other_days = other_sched.get("days", [])
if isinstance(other_days, str):
other_days = json.loads(other_days)
if not any(d in other_days for d in new_days):
continue
other_start = _time_str_to_minutes(other_sched["time"])
other_dur = _get_routine_duration_minutes(r["id"])
if _ranges_overlap(new_start, new_dur, other_start, other_dur):
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with medications
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
# If med has no specific days, it runs every day
if med_days and not any(d in med_days for d in new_days):
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
# Medication takes ~0 minutes, but check if it falls within routine window
if _ranges_overlap(new_start, new_dur, med_start, 1):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
def api_setRoutineSchedule(routine_id):
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}"""
"""Set when this routine should run.
Body: {days, time, remind, frequency?, interval_days?, start_date?}
frequency: 'weekly' (default, uses days) or 'every_n_days' (uses interval_days + start_date)
"""
user_uuid = _auth(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
@@ -663,12 +763,29 @@ def register(app):
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
frequency = data.get("frequency", "weekly")
# Check for schedule conflicts (only for weekly — interval conflicts checked at reminder time)
if frequency == "weekly":
new_days = data.get("days", [])
new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
schedule_data = {
"routine_id": routine_id,
"days": json.dumps(data.get("days", [])),
"time": data.get("time"),
"remind": data.get("remind", True),
"frequency": frequency,
"interval_days": data.get("interval_days"),
"start_date": data.get("start_date"),
}
if existing:
result = postgres.update(

298
api/routes/snitch.py Normal file
View File

@@ -0,0 +1,298 @@
"""
api/routes/snitch.py - API endpoints for snitch system
"""
import uuid
import flask
import jwt
import os
from datetime import datetime
import core.postgres as postgres
import core.snitch as snitch_core
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def register(app):
@app.route("/api/snitch/settings", methods=["GET"])
def get_snitch_settings():
"""Get user's snitch settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
settings = snitch_core.get_snitch_settings(user_uuid)
if not settings:
# Return defaults
return flask.jsonify(
{
"snitch_enabled": False,
"trigger_after_nags": 4,
"trigger_after_missed_doses": 1,
"max_snitches_per_day": 2,
"require_consent": True,
"consent_given": False,
"snitch_cooldown_hours": 4,
}
), 200
return flask.jsonify(
{
"snitch_enabled": settings.get("snitch_enabled", False),
"trigger_after_nags": settings.get("trigger_after_nags", 4),
"trigger_after_missed_doses": settings.get(
"trigger_after_missed_doses", 1
),
"max_snitches_per_day": settings.get("max_snitches_per_day", 2),
"require_consent": settings.get("require_consent", True),
"consent_given": settings.get("consent_given", False),
"snitch_cooldown_hours": settings.get("snitch_cooldown_hours", 4),
}
), 200
@app.route("/api/snitch/settings", methods=["PUT"])
def update_snitch_settings():
"""Update user's snitch settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "No data provided"}), 400
# Only update fields explicitly provided in the request — never overwrite with defaults
allowed_fields = [
"snitch_enabled", "trigger_after_nags", "trigger_after_missed_doses",
"max_snitches_per_day", "require_consent", "consent_given", "snitch_cooldown_hours",
]
update_data = {field: data[field] for field in allowed_fields if field in data}
if not update_data:
return flask.jsonify({"success": True}), 200
update_data["updated_at"] = datetime.utcnow()
existing = snitch_core.get_snitch_settings(user_uuid)
if existing:
postgres.update("snitch_settings", update_data, {"user_uuid": user_uuid})
else:
update_data["id"] = str(uuid.uuid4())
update_data["user_uuid"] = user_uuid
update_data["created_at"] = datetime.utcnow()
postgres.insert("snitch_settings", update_data)
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/consent", methods=["POST"])
def give_consent():
"""Give or revoke consent for snitching."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
consent_given = data.get("consent_given", False)
snitch_core.update_consent(user_uuid, consent_given)
return flask.jsonify({"success": True, "consent_given": consent_given}), 200
@app.route("/api/snitch/contacts", methods=["GET"])
def get_snitch_contacts():
"""Get user's snitch contacts."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=False)
return flask.jsonify(
[
{
"id": c.get("id"),
"contact_name": c.get("contact_name"),
"contact_type": c.get("contact_type"),
"contact_value": c.get("contact_value"),
"priority": c.get("priority", 1),
"notify_all": c.get("notify_all", False),
"is_active": c.get("is_active", True),
}
for c in contacts
]
), 200
@app.route("/api/snitch/contacts", methods=["POST"])
def add_snitch_contact():
"""Add a new snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
# Validate required fields
required = ["contact_name", "contact_type", "contact_value"]
for field in required:
if not data.get(field):
return flask.jsonify({"error": f"Missing required field: {field}"}), 400
# Validate contact_type
if data["contact_type"] not in ["discord", "email", "sms"]:
return flask.jsonify(
{"error": "contact_type must be discord, email, or sms"}
), 400
contact_data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"contact_name": data["contact_name"],
"contact_type": data["contact_type"],
"contact_value": data["contact_value"],
"priority": data.get("priority", 1),
"notify_all": data.get("notify_all", False),
"is_active": data.get("is_active", True),
"created_at": datetime.utcnow(),
}
result = postgres.insert("snitch_contacts", contact_data)
return flask.jsonify(
{"success": True, "contact_id": result.get("id") if result else None}
), 201
@app.route("/api/snitch/contacts/<contact_id>", methods=["PUT"])
def update_snitch_contact(contact_id):
"""Update a snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
# Check contact exists and belongs to user
contacts = postgres.select(
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
)
if not contacts:
return flask.jsonify({"error": "Contact not found"}), 404
update_data = {}
if "contact_name" in data:
update_data["contact_name"] = data["contact_name"]
if "contact_type" in data:
if data["contact_type"] not in ["discord", "email", "sms"]:
return flask.jsonify({"error": "Invalid contact_type"}), 400
update_data["contact_type"] = data["contact_type"]
if "contact_value" in data:
update_data["contact_value"] = data["contact_value"]
if "priority" in data:
update_data["priority"] = data["priority"]
if "notify_all" in data:
update_data["notify_all"] = data["notify_all"]
if "is_active" in data:
update_data["is_active"] = data["is_active"]
if update_data:
postgres.update("snitch_contacts", update_data, {"id": contact_id})
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/contacts/<contact_id>", methods=["DELETE"])
def delete_snitch_contact(contact_id):
"""Delete a snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
# Check contact exists and belongs to user
contacts = postgres.select(
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
)
if not contacts:
return flask.jsonify({"error": "Contact not found"}), 404
postgres.delete("snitch_contacts", {"id": contact_id})
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/history", methods=["GET"])
def get_snitch_history():
"""Get user's snitch history."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
days = flask.request.args.get("days", 7, type=int)
history = snitch_core.get_snitch_history(user_uuid, days)
return flask.jsonify(
[
{
"id": h.get("id"),
"contact_id": h.get("contact_id"),
"medication_id": h.get("medication_id"),
"trigger_reason": h.get("trigger_reason"),
"snitch_count_today": h.get("snitch_count_today"),
"sent_at": h.get("sent_at").isoformat()
if h.get("sent_at")
else None,
"delivered": h.get("delivered"),
}
for h in history
]
), 200
@app.route("/api/snitch/test", methods=["POST"])
def test_snitch():
"""Test snitch functionality (sends to first contact only)."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
# Get first active contact
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=True)
if not contacts:
return flask.jsonify({"error": "No active contacts configured"}), 400
# Send test message
contact = contacts[0]
test_message = f"🧪 This is a test snitch notification for {contact.get('contact_name')}. If you're receiving this, the snitch system is working!"
# Insert into snitch_log so the bot will pick it up and send it
log_data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"contact_id": contact.get("id"),
"medication_id": None, # Test snitch, no actual medication
"trigger_reason": "test",
"snitch_count_today": 1,
"message_content": test_message,
"sent_at": datetime.utcnow(),
"delivered": False, # Bot will pick this up and send it
}
postgres.insert("snitch_log", log_data)
return flask.jsonify(
{
"success": True,
"message": f"✅ Test snitch sent to {contact.get('contact_name')}! Check their Discord DMs in the next 30 seconds.",
}
), 200

109
api/routes/tasks.py Normal file
View File

@@ -0,0 +1,109 @@
"""
api/routes/tasks.py - One-off scheduled task CRUD
"""
import uuid
import flask
import jwt
import os
from datetime import datetime
import core.postgres as postgres
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def register(app):
@app.route("/api/tasks", methods=["GET"])
def get_tasks():
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
status_filter = flask.request.args.get("status", "pending")
if status_filter == "all":
tasks = postgres.select(
"tasks",
where={"user_uuid": user_uuid},
order_by="scheduled_datetime ASC",
)
else:
tasks = postgres.select(
"tasks",
where={"user_uuid": user_uuid, "status": status_filter},
order_by="scheduled_datetime ASC",
)
# Serialize datetimes for JSON
for t in tasks:
for key in ("scheduled_datetime", "created_at", "updated_at"):
if key in t and hasattr(t[key], "isoformat"):
t[key] = t[key].isoformat()
return flask.jsonify(tasks), 200
@app.route("/api/tasks", methods=["POST"])
def create_task():
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
title = data.get("title", "").strip()
scheduled_datetime = data.get("scheduled_datetime", "").strip()
if not title:
return flask.jsonify({"error": "title is required"}), 400
if not scheduled_datetime:
return flask.jsonify({"error": "scheduled_datetime is required"}), 400
task_id = str(uuid.uuid4())
task = {
"id": task_id,
"user_uuid": user_uuid,
"title": title,
"description": data.get("description") or None,
"scheduled_datetime": scheduled_datetime,
"reminder_minutes_before": int(data.get("reminder_minutes_before", 15)),
"status": "pending",
}
postgres.insert("tasks", task)
return flask.jsonify(task), 201
@app.route("/api/tasks/<task_id>", methods=["PATCH"])
def update_task(task_id):
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
task = postgres.select_one("tasks", {"id": task_id, "user_uuid": user_uuid})
if not task:
return flask.jsonify({"error": "not found"}), 404
data = flask.request.get_json() or {}
updates = {}
for field in ["title", "description", "scheduled_datetime", "reminder_minutes_before", "status"]:
if field in data:
updates[field] = data[field]
updates["updated_at"] = datetime.utcnow().isoformat()
postgres.update("tasks", updates, {"id": task_id})
return flask.jsonify({**{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in task.items()}, **updates}), 200
@app.route("/api/tasks/<task_id>", methods=["DELETE"])
def delete_task(task_id):
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
task = postgres.select_one("tasks", {"id": task_id, "user_uuid": user_uuid})
if not task:
return flask.jsonify({"error": "not found"}), 404
postgres.delete("tasks", {"id": task_id})
return flask.jsonify({"success": True}), 200

View File

@@ -26,6 +26,7 @@ import ai.parser as ai_parser
import bot.commands.routines # noqa: F401 - registers handler
import bot.commands.medications # noqa: F401 - registers handler
import bot.commands.knowledge # noqa: F401 - registers handler
import bot.commands.tasks # noqa: F401 - registers handler
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
API_URL = os.getenv("API_URL", "http://app:5000")
@@ -34,10 +35,12 @@ user_sessions = {}
login_state = {}
message_history = {}
user_cache = {}
CACHE_FILE = "/app/user_cache.pkl"
CACHE_FILE = os.getenv("BOT_CACHE_FILE", "/app/cache/user_cache.pkl")
intents = discord.Intents.default()
intents.message_content = True
intents.presences = True
intents.members = True
client = discord.Client(intents=intents)
@@ -114,21 +117,27 @@ class JurySystem:
print(f"Error loading DBT knowledge base: {e}")
raise
async def query(self, query_text):
"""Query the DBT knowledge base"""
try:
# Get embedding
def _retrieve_sync(self, query_text, top_k=5):
"""Embed query and search vector store. Returns list of chunk dicts."""
response = self.client.embeddings.create(
model="qwen/qwen3-embedding-8b", input=query_text
)
query_emb = response.data[0].embedding
return self.vector_store.search(query_emb, top_k=top_k)
# Search
context_chunks = self.vector_store.search(query_emb, top_k=5)
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):
"""Query the DBT knowledge base (legacy path, kept for compatibility)."""
try:
context_chunks = await self.retrieve(query_text)
if not context_chunks:
return "I couldn't find relevant DBT information for that query."
# Generate answer
context_text = "\n\n---\n\n".join(
[chunk["metadata"]["text"] for chunk in context_chunks]
)
@@ -138,20 +147,9 @@ Use the provided context from the DBT Skills Training Handouts to answer the use
If the answer is not in the context, say you don't know based on the provided text.
Be concise, compassionate, and practical."""
user_prompt = f"Context:\n{context_text}\n\nQuestion: {query_text}"
from ai.jury_council import generate_rag_answer
response = self.client.chat.completions.create(
model=self.config.get("models", {}).get(
"generator", "openai/gpt-4o-mini"
),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
)
return response.choices[0].message.content
return await generate_rag_answer(query_text, context_text, system_prompt)
except Exception as e:
return f"Error querying DBT knowledge base: {e}"
@@ -174,13 +172,20 @@ def decodeJwtPayload(token):
return json.loads(base64.urlsafe_b64decode(payload))
def apiRequest(method, endpoint, token=None, data=None):
def apiRequest(method, endpoint, token=None, data=None, _retried=False):
url = f"{API_URL}{endpoint}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
# Auto-refresh on 401 using refresh token
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
)
try:
return resp.json(), resp.status_code
except ValueError:
@@ -189,6 +194,34 @@ def apiRequest(method, endpoint, token=None, data=None):
return {"error": "API unavailable"}, 503
def _try_refresh_token_for_session(expired_token):
"""Find the discord user with this token and refresh it using their refresh token."""
for discord_id, session in user_sessions.items():
if session.get("token") == expired_token:
refresh_token = session.get("refresh_token")
if not refresh_token:
# Check cache for refresh token
cached = getCachedUser(discord_id)
if cached:
refresh_token = cached.get("refresh_token")
if refresh_token:
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
# Update cache
cached = getCachedUser(discord_id) or {}
cached["refresh_token"] = refresh_token
setCachedUser(discord_id, cached)
return new_token
return None
def loadCache():
try:
if os.path.exists(CACHE_FILE):
@@ -202,6 +235,7 @@ def loadCache():
def saveCache():
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
with open(CACHE_FILE, "wb") as f:
pickle.dump(user_cache, f)
except Exception as e:
@@ -227,13 +261,34 @@ def setCachedUser(discord_id, user_data):
def negotiateToken(discord_id, username, password):
cached = getCachedUser(discord_id)
# Try refresh token first (avoids sending password)
if cached and cached.get("refresh_token"):
result, status = apiRequest(
"post",
"/api/refresh",
data={"refresh_token": cached["refresh_token"]},
_retried=True,
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
cached["user_uuid"] = user_uuid
setCachedUser(discord_id, cached)
return token, user_uuid
# Fall back to password login, always request refresh token (trust_device)
login_data = {"username": username, "password": password, "trust_device": True}
if (
cached
and cached.get("username") == username
and cached.get("hashed_password")
and verifyPassword(password, cached.get("hashed_password"))
):
result, status = apiRequest(
"post", "/api/login", data={"username": username, "password": password}
"post", "/api/login", data=login_data, _retried=True
)
if status == 200 and "token" in result:
token = result["token"]
@@ -245,14 +300,13 @@ def negotiateToken(discord_id, username, password):
"hashed_password": cached["hashed_password"],
"user_uuid": user_uuid,
"username": username,
"refresh_token": result.get("refresh_token"),
},
)
return token, user_uuid
return None, None
result, status = apiRequest(
"post", "/api/login", data={"username": username, "password": password}
)
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
@@ -263,6 +317,7 @@ def negotiateToken(discord_id, username, password):
"hashed_password": hashPassword(password),
"user_uuid": user_uuid,
"username": username,
"refresh_token": result.get("refresh_token"),
},
)
return token, user_uuid
@@ -426,7 +481,7 @@ async def handleActiveSessionShortcuts(message, session, active_session):
async def handleDBTQuery(message):
"""Handle DBT-related queries using JurySystem"""
"""Handle DBT-related queries using JurySystem + jury council pipeline."""
if not jury_system:
return False
@@ -454,13 +509,70 @@ async def handleDBTQuery(message):
user_input_lower = message.content.lower()
is_dbt_query = any(keyword in user_input_lower for keyword in dbt_keywords)
if is_dbt_query:
if not is_dbt_query:
return False
from ai.jury_council import (
generate_search_questions,
run_jury_filter,
generate_rag_answer,
split_for_discord,
)
async with message.channel.typing():
response = await jury_system.query(message.content)
await message.channel.send(f"🧠 **DBT Support:**\n{response}")
# Step 1: Generate candidate questions via Qwen Nitro (fallback: qwen3-235b)
candidates, gen_error = await generate_search_questions(message.content)
if gen_error:
await message.channel.send(f"⚠️ **Question generator failed:** {gen_error}")
return True
return False
# Step 2: Jury council filters candidates → safe question JSON list
jury_result = await run_jury_filter(candidates, message.content)
breakdown = jury_result.format_breakdown()
# Always show the jury deliberation (verbose, as requested)
for chunk in split_for_discord(breakdown):
await message.channel.send(chunk)
if jury_result.has_error:
return True
if not jury_result.safe_questions:
return True
await message.channel.send(
"🔍 Searching knowledge base with approved questions..."
)
# Step 3: Multi-query retrieval — deduplicated by chunk ID
seen_ids = set()
context_chunks = []
for q in jury_result.safe_questions:
results = await jury_system.retrieve(q)
for r in results:
chunk_id = r["metadata"].get("id")
if chunk_id not in seen_ids:
seen_ids.add(chunk_id)
context_chunks.append(r["metadata"]["text"])
if not context_chunks:
await message.channel.send(
"⚠️ No relevant content found in the knowledge base."
)
return True
context = "\n\n---\n\n".join(context_chunks)
# Step 4: Generate answer with qwen3-235b
system_prompt = """You are a helpful mental health support assistant with expertise in DBT (Dialectical Behavior Therapy).
Use the provided context to answer the user's question accurately and compassionately.
If the answer is not in the context, say so — do not invent information.
Be concise, practical, and supportive."""
answer = await generate_rag_answer(message.content, context, system_prompt)
await message.channel.send(f"🧠 **Response:**\n{answer}")
return True
async def routeCommand(message):
@@ -538,11 +650,32 @@ async def routeCommand(message):
)
@client.event
async def on_ready():
print(f"Bot logged in as {client.user}")
loadCache()
backgroundLoop.start()
def _restore_sessions_from_cache():
"""Try to restore user sessions from cached refresh tokens on startup."""
restored = 0
for discord_id, cached in user_cache.items():
refresh_token = cached.get("refresh_token")
if not refresh_token:
continue
result, status = apiRequest(
"post",
"/api/refresh",
data={"refresh_token": refresh_token},
_retried=True,
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
user_sessions[discord_id] = {
"token": token,
"user_uuid": user_uuid,
"username": cached.get("username", ""),
"refresh_token": refresh_token,
}
restored += 1
if restored:
print(f"Restored {restored} user session(s) from cache")
@client.event
@@ -577,5 +710,200 @@ async def beforeBackgroundLoop():
await client.wait_until_ready()
# ==================== Discord Presence Tracking ====================
async def update_presence_tracking():
"""Track Discord presence for users with presence tracking enabled."""
print(f"[DEBUG] update_presence_tracking() called", flush=True)
try:
import core.adaptive_meds as adaptive_meds
import core.postgres as postgres
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}"
)
# 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:
user_uuid = setting.get("user_uuid")
# Get user's Discord ID from notifications table
notif_settings = postgres.select("notifications", {"user_uuid": user_uuid})
if not notif_settings:
continue
discord_user_id = notif_settings[0].get("discord_user_id")
print(f"[DEBUG] Looking for Discord user: {discord_user_id}", flush=True)
if not discord_user_id:
print(f"[DEBUG] No Discord ID for user {user_uuid}", flush=True)
continue
# Get the member from a shared guild (needed for presence data)
try:
member = None
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,
)
if member:
break
if not member:
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,
)
# 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,
)
# Update presence if changed
if is_online != was_online:
adaptive_meds.update_user_presence(
user_uuid, discord_user_id, is_online
)
# Record the event
from datetime import datetime
event_type = "online" if is_online else "offline"
adaptive_meds.record_presence_event(
user_uuid, event_type, datetime.utcnow()
)
print(
f"Presence update: User {user_uuid} is now {'online' if is_online else 'offline'}"
)
except Exception as e:
print(f"Error tracking presence for user {user_uuid}: {e}")
except Exception as e:
print(f"Error in presence tracking loop: {e}")
@tasks.loop(seconds=30)
async def presenceTrackingLoop():
"""Track Discord presence every 30 seconds."""
try:
await update_presence_tracking()
except Exception as e:
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
import traceback
traceback.print_exc()
@presenceTrackingLoop.before_loop
async def beforePresenceTrackingLoop():
await client.wait_until_ready()
@tasks.loop(seconds=30)
async def snitchCheckLoop():
"""Check for pending snitch notifications and send them."""
try:
import core.snitch as snitch_core
import core.postgres as postgres
from datetime import datetime, timedelta
# Get pending snitches from the last 5 minutes that haven't been sent
cutoff = datetime.utcnow() - timedelta(minutes=5)
pending_snitches = postgres.select("snitch_log", where={"delivered": False})
for snitch in pending_snitches:
sent_at = snitch.get("sent_at")
if not sent_at or sent_at < cutoff:
continue
contact_id = snitch.get("contact_id")
if not contact_id:
continue
# Get contact details
contacts = postgres.select("snitch_contacts", {"id": contact_id})
if not contacts:
continue
contact = contacts[0]
if contact.get("contact_type") != "discord":
continue
discord_user_id = contact.get("contact_value")
message = snitch.get("message_content", "Snitch notification")
try:
# Send Discord DM
user = await client.fetch_user(int(discord_user_id))
if user:
await user.send(message)
# Mark as delivered
postgres.update(
"snitch_log", {"delivered": True}, {"id": snitch.get("id")}
)
print(
f"Snitch sent to {contact.get('contact_name')} (Discord: {discord_user_id})"
)
except Exception as e:
print(f"Error sending snitch to {discord_user_id}: {e}")
except Exception as e:
print(f"Error in snitch check loop: {e}")
@snitchCheckLoop.before_loop
async def beforeSnitchCheckLoop():
await client.wait_until_ready()
@client.event
async def on_ready():
print(f"Bot logged in as {client.user}", flush=True)
print(f"Connected to {len(client.guilds)} guilds", flush=True)
loadCache()
_restore_sessions_from_cache()
backgroundLoop.start()
presenceTrackingLoop.start()
print(f"[DEBUG] Presence tracking loop started", flush=True)
snitchCheckLoop.start()
if __name__ == "__main__":
client.run(DISCORD_BOT_TOKEN)

View File

@@ -17,10 +17,16 @@ from ai.parser import client
# Configuration
EPUBS_DIRECTORY = os.getenv("KNOWLEDGE_EMBEDDINGS_DIR", "./bot/data")
TOP_K_CHUNKS = 5
EMBEDDING_MODEL = "sentence-transformers/all-minilm-l12-v2"
CHAT_MODEL = "deepseek/deepseek-v3.2"
EMBEDDING_EXTENSION = ".embeddings.json"
# Map embedding dimensions to the model that produced them
EMBEDDING_MODELS_BY_DIM = {
384: "sentence-transformers/all-minilm-l12-v2",
4096: "qwen/qwen3-embedding-8b",
}
DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-minilm-l12-v2"
# Cache for loaded embeddings: {file_path: (chunks, embeddings, metadata)}
_knowledge_cache: Dict[str, Tuple[List[str], List[List[float]], dict]] = {}
@@ -79,9 +85,14 @@ def load_knowledge_base(
return _knowledge_cache[file_path]
def get_query_embedding(query: str) -> List[float]:
def get_embedding_model_for_dim(dim: int) -> str:
"""Get the correct embedding model for a given dimension."""
return EMBEDDING_MODELS_BY_DIM.get(dim, DEFAULT_EMBEDDING_MODEL)
def get_query_embedding(query: str, model: str = DEFAULT_EMBEDDING_MODEL) -> List[float]:
"""Embed the user's question via OpenRouter."""
response = client.embeddings.create(model=EMBEDDING_MODEL, input=query)
response = client.embeddings.create(model=model, input=query)
return response.data[0].embedding
@@ -271,8 +282,12 @@ async def handle_knowledge(message, session, parsed):
await message.channel.send(f"🔍 Searching **{book_title}**...")
try:
# Detect embedding dimension and use matching model
emb_dim = len(embeddings[0]) if embeddings else 384
embedding_model = get_embedding_model_for_dim(emb_dim)
# Get query embedding and search
query_emb = get_query_embedding(query)
query_emb = get_query_embedding(query, model=embedding_model)
relevant_chunks, scores = search_context(
query_emb, chunks, embeddings, TOP_K_CHUNKS
)

View File

@@ -423,6 +423,106 @@ async def handle_medication(message, session, parsed):
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to log')}")
elif action == "take_all":
# Auto-mark doses due within the last hour (clearly "just taken").
# Ask about doses due 16 hours ago (today or yesterday) that aren't logged.
timezone_offset = await _get_user_timezone(message, session, token)
if timezone_offset is None:
timezone_offset = 0
now_local = datetime.now(timezone(timedelta(minutes=-timezone_offset)))
now_min = now_local.hour * 60 + now_local.minute
current_hhmm = now_local.strftime("%H:%M")
CURRENT_WINDOW_MIN = 60 # ≤ 1 hour ago → auto-mark
LOOKBACK_MIN = 6 * 60 # 16 hours ago → ask
resp, status = api_request("get", "/api/medications/today", token)
if status != 200:
await message.channel.send("Error fetching today's medications.")
return
meds_today = resp if isinstance(resp, list) else []
auto_doses = [] # (med_id, med_name, time_str) → mark silently
ask_doses = [] # (med_id, med_name, time_str) → prompt user
for item in meds_today:
med = item.get("medication", {})
if item.get("is_prn"):
continue
times = item.get("scheduled_times", [])
taken = set(item.get("taken_times", []))
skipped = set(item.get("skipped_times", []))
med_id_local = med.get("id")
med_name = med.get("name", "Unknown")
for t in times:
if t in taken or t in skipped:
continue
h, m = map(int, t.split(":"))
dose_min = h * 60 + m
# Handle doses that cross midnight (yesterday's late doses)
minutes_ago = now_min - dose_min
if minutes_ago < 0:
minutes_ago += 24 * 60
if minutes_ago > LOOKBACK_MIN:
continue # too old — ignore
if t > current_hhmm and minutes_ago > CURRENT_WINDOW_MIN:
continue # future dose that somehow wasn't caught — skip
if minutes_ago <= CURRENT_WINDOW_MIN:
auto_doses.append((med_id_local, med_name, t))
else:
ask_doses.append((med_id_local, med_name, t))
# Mark the clearly-current doses immediately
marked = []
for med_id_local, med_name, t in auto_doses:
api_request("post", f"/api/medications/{med_id_local}/take", token, {"scheduled_time": t})
marked.append(f"**{med_name}** at {t}")
if marked:
lines = "\n".join(f"{m}" for m in marked)
await message.channel.send(f"Logged as taken:\n{lines}")
# Ask about doses from 16 hours ago that weren't logged
if ask_doses:
if "pending_confirmations" not in session:
session["pending_confirmations"] = {}
session["pending_confirmations"]["med_past_due_check"] = {
"action": "take_all_past_confirm",
"interaction_type": "medication",
"needs_confirmation": False,
"doses": [[mid, name, t] for mid, name, t in ask_doses],
}
dose_lines = "\n".join(f"- **{name}** at {t}" for _, name, t in ask_doses)
await message.channel.send(
f"❓ Also found unlogged doses from the past 6 hours:\n{dose_lines}\n\n"
f"Did you take these too? Reply **yes** or **no**."
)
elif not marked:
await message.channel.send("✅ No past-due medications to log right now.")
elif action == "take_all_past_confirm":
# Handles yes-confirmation for past-due doses surfaced by take_all
doses = parsed.get("doses", [])
marked = []
for dose_info in doses:
if isinstance(dose_info, (list, tuple)) and len(dose_info) >= 3:
med_id_local, med_name, t = dose_info[0], dose_info[1], dose_info[2]
api_request(
"post", f"/api/medications/{med_id_local}/take", token,
{"scheduled_time": t}
)
marked.append(f"**{med_name}** at {t}")
if marked:
lines = "\n".join(f"{m}" for m in marked)
await message.channel.send(f"Logged as taken:\n{lines}")
else:
await message.channel.send("No doses to log.")
elif action == "skip":
med_id = parsed.get("medication_id")
name = parsed.get("name")
@@ -645,7 +745,7 @@ async def handle_medication(message, session, parsed):
else:
await message.channel.send(
f"Unknown action: {action}. Try: list, add, delete, take, skip, today, refills, snooze, or adherence."
f"Unknown action: {action}. Try: list, add, delete, take, take_all, skip, today, refills, snooze, or adherence."
)

View File

@@ -93,6 +93,56 @@ async def handle_routine(message, session, parsed):
await _create_routine_with_steps(message, token, name, description, steps)
elif action == "ai_compose":
goal = parsed.get("goal")
name = parsed.get("name", "my routine")
if not goal:
await message.channel.send(
"What's the goal for this routine? Tell me what you want to accomplish."
)
return
async with message.channel.typing():
resp, status = api_request(
"post", "/api/ai/generate-steps", token, {"goal": goal}
)
if status != 200:
await message.channel.send(
f"Couldn't generate steps: {resp.get('error', 'unknown error')}\n"
f"Try: \"create {name} routine with step1, step2, step3\""
)
return
steps = resp.get("steps", [])
if not steps:
await message.channel.send("The AI didn't return any steps. Try describing your goal differently.")
return
if "pending_confirmations" not in session:
session["pending_confirmations"] = {}
confirmation_id = f"routine_create_{name}"
session["pending_confirmations"][confirmation_id] = {
"action": "create_with_steps",
"interaction_type": "routine",
"name": name,
"description": f"AI-generated routine for: {goal}",
"steps": [s["name"] for s in steps],
"needs_confirmation": False,
}
total_min = sum(s.get("duration_minutes", 5) for s in steps)
steps_list = "\n".join(
[f"{i+1}. {s['name']} ({s.get('duration_minutes', 5)} min)" for i, s in enumerate(steps)]
)
await message.channel.send(
f"Here's what I suggest for **{name}** (~{total_min} min total):\n\n"
f"{steps_list}\n\n"
f"Reply **yes** to create this routine, or **no** to cancel."
)
elif action == "add_steps":
routine_name = parsed.get("routine_name")
steps = parsed.get("steps", [])
@@ -167,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:

242
bot/commands/tasks.py Normal file
View File

@@ -0,0 +1,242 @@
"""
Tasks command handler - bot-side hooks for one-off tasks/appointments
"""
from datetime import datetime, timedelta
from bot.command_registry import register_module
import ai.parser as ai_parser
def _resolve_datetime(dt_str, user_now):
"""Resolve a natural language date string to an ISO datetime string.
Handles: ISO strings, 'today', 'tomorrow', day names, plus 'HH:MM' time."""
if not dt_str:
return None
dt_str = dt_str.lower().strip()
# Try direct ISO parse first
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(dt_str, fmt).isoformat(timespec="minutes")
except ValueError:
pass
# Split into date word + time word
time_part = None
date_word = dt_str
# Try to extract HH:MM from the end
parts = dt_str.rsplit(" ", 1)
if len(parts) == 2:
possible_time = parts[1]
if ":" in possible_time:
try:
h, m = [int(x) for x in possible_time.split(":")]
time_part = (h, m)
date_word = parts[0]
except ValueError:
pass
else:
# Might be just a bare hour like "14"
try:
h = int(possible_time)
if 0 <= h <= 23:
time_part = (h, 0)
date_word = parts[0]
except ValueError:
pass
# Resolve the date part
target = user_now.date()
if "tomorrow" in date_word:
target = target + timedelta(days=1)
elif "today" in date_word or not date_word:
pass
else:
day_map = {
"monday": 0, "tuesday": 1, "wednesday": 2,
"thursday": 3, "friday": 4, "saturday": 5, "sunday": 6,
}
for name, num in day_map.items():
if name in date_word:
days_ahead = (num - target.weekday()) % 7
if days_ahead == 0:
days_ahead = 7 # next occurrence
target = target + timedelta(days=days_ahead)
break
if time_part:
return datetime(
target.year, target.month, target.day, time_part[0], time_part[1]
).isoformat(timespec="minutes")
return datetime(target.year, target.month, target.day, 9, 0).isoformat(timespec="minutes")
def _find_task_by_title(token, title):
"""Find a pending task by fuzzy title match. Returns task dict or None."""
resp, status = api_request("get", "/api/tasks", token)
if status != 200:
return None
tasks = resp if isinstance(resp, list) else []
title_lower = title.lower()
# Exact match first
for t in tasks:
if t.get("title", "").lower() == title_lower:
return t
# Partial match
for t in tasks:
if title_lower in t.get("title", "").lower() or t.get("title", "").lower() in title_lower:
return t
return None
def _format_datetime(dt_str):
"""Format ISO datetime string for display."""
try:
dt = datetime.fromisoformat(str(dt_str))
return dt.strftime("%a %b %-d at %-I:%M %p")
except Exception:
return str(dt_str)
async def handle_task(message, session, parsed):
action = parsed.get("action", "unknown")
token = session["token"]
# Get user's current time for datetime resolution
from core import tz as tz_mod
user_uuid = session.get("user_uuid")
try:
user_now = tz_mod.user_now_for(user_uuid)
except Exception:
user_now = datetime.utcnow()
if action == "add":
title = parsed.get("title", "").strip()
dt_str = parsed.get("datetime", "")
reminder_min = parsed.get("reminder_minutes_before", 15)
if not title:
await message.channel.send("What's the title of the task?")
return
resolved_dt = _resolve_datetime(dt_str, user_now) if dt_str else None
if not resolved_dt:
await message.channel.send(
"When is this task? Tell me the date and time (e.g. 'tomorrow at 3pm', 'friday 14:00')."
)
return
task_data = {
"title": title,
"scheduled_datetime": resolved_dt,
"reminder_minutes_before": int(reminder_min),
}
description = parsed.get("description", "")
if description:
task_data["description"] = description
resp, status = api_request("post", "/api/tasks", token, task_data)
if status == 201:
reminder_text = f" (reminder {reminder_min} min before)" if int(reminder_min) > 0 else ""
await message.channel.send(
f"✅ Added **{title}** for {_format_datetime(resolved_dt)}{reminder_text}"
)
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to create task')}")
elif action == "list":
resp, status = api_request("get", "/api/tasks", token)
if status == 200:
tasks = resp if isinstance(resp, list) else []
if not tasks:
await message.channel.send("No pending tasks. Add one with 'remind me about...'")
else:
lines = []
for t in tasks:
status_emoji = "🔔" if t.get("status") == "notified" else "📋"
lines.append(f"{status_emoji} **{t['title']}** — {_format_datetime(t.get('scheduled_datetime', ''))}")
await message.channel.send("**Your upcoming tasks:**\n" + "\n".join(lines))
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to fetch tasks')}")
elif action in ("done", "complete"):
title = parsed.get("title", "").strip()
if not title:
await message.channel.send("Which task is done?")
return
task = _find_task_by_title(token, title)
if not task:
await message.channel.send(f"Couldn't find a task matching '{title}'.")
return
resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "completed"})
if status == 200:
await message.channel.send(f"✅ Marked **{task['title']}** as done!")
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to update task')}")
elif action == "cancel":
title = parsed.get("title", "").strip()
if not title:
await message.channel.send("Which task should I cancel?")
return
task = _find_task_by_title(token, title)
if not task:
await message.channel.send(f"Couldn't find a task matching '{title}'.")
return
resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "cancelled"})
if status == 200:
await message.channel.send(f"❌ Cancelled **{task['title']}**.")
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to cancel task')}")
elif action == "delete":
title = parsed.get("title", "").strip()
if not title:
await message.channel.send("Which task should I delete?")
return
task = _find_task_by_title(token, title)
if not task:
await message.channel.send(f"Couldn't find a task matching '{title}'.")
return
resp, status = api_request("delete", f"/api/tasks/{task['id']}", token)
if status == 200:
await message.channel.send(f"🗑️ Deleted **{task['title']}**.")
else:
await message.channel.send(f"Error: {resp.get('error', 'Failed to delete task')}")
else:
await message.channel.send(
f"Unknown action: {action}. Try: add, list, done, cancel."
)
def api_request(method, endpoint, token, data=None):
import requests
import os
API_URL = os.getenv("API_URL", "http://app:5000")
url = f"{API_URL}{endpoint}"
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
try:
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
try:
return resp.json(), resp.status_code
except ValueError:
return {}, resp.status_code
except requests.RequestException:
return {"error": "API unavailable"}, 503
def validate_task_json(data):
errors = []
if not isinstance(data, dict):
return ["Response must be a JSON object"]
if "error" in data:
return []
if "action" not in data:
errors.append("Missing required field: action")
return errors
register_module("task", handle_task)
ai_parser.register_validator("task", validate_task_json)

12
bot/config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"openrouter_api_key": "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",
"embedding_file": "dbt_knowledge.json",
"models": {
"generator": "moonshotai/kimi-k2.5",
"jury_clinical": "z-ai/glm-5",
"jury_safety": "deepseek/deepseek-v3.2",
"jury_empathy": "openai/gpt-4o-2024-08-06",
"jury_hallucination": "qwen/qwen3-235b-a22b-2507"
},
"system_prompt": "You are a DBT assistant. Answer based ONLY on the provided context."
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
DISCORD_BOT_TOKEN=MTQ2NzYwMTc2ODM0NjE2MTE3Mw.G7BKQ-.kivCRj7mOl6aS5VyX4RW9hirqzm7qJ8nJOVMpE
DISCORD_BOT_TOKEN=MTQ3MDY0MjgyMDI1MDQ3MjYyMQ.Gczvus.1WuWxd72NDoLFC7BCjAixnMo5eS8wenqTIZ54I
API_URL=http://app:5000
DB_HOST=db
DB_PORT=5432
@@ -6,7 +6,7 @@ DB_NAME=app
DB_USER=app
DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw
JWT_SECRET=bf773b4562221bef4d304ae5752a68931382ea3e98fe38394a098f73e0c776e1
OPENROUTER_API_KEY=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
OPENROUTER_API_KEY=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
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
AI_CONFIG_PATH=/app/ai/ai_config.json

View File

@@ -0,0 +1,93 @@
-- Migration: Add snitch and adaptive medication tables
-- Run this if tables don't exist
-- Snitch Settings
CREATE TABLE IF NOT EXISTS snitch_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
snitch_enabled BOOLEAN DEFAULT FALSE,
trigger_after_nags INTEGER DEFAULT 4,
trigger_after_missed_doses INTEGER DEFAULT 1,
max_snitches_per_day INTEGER DEFAULT 2,
require_consent BOOLEAN DEFAULT TRUE,
consent_given BOOLEAN DEFAULT FALSE,
snitch_cooldown_hours INTEGER DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Snitch Contacts
CREATE TABLE IF NOT EXISTS snitch_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_name VARCHAR(255) NOT NULL,
contact_type VARCHAR(50) NOT NULL,
contact_value VARCHAR(255) NOT NULL,
priority INTEGER DEFAULT 1,
notify_all BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Snitch Log
CREATE TABLE IF NOT EXISTS snitch_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_id UUID REFERENCES snitch_contacts(id) ON DELETE SET NULL,
medication_id UUID REFERENCES medications(id) ON DELETE SET NULL,
trigger_reason VARCHAR(100) NOT NULL,
snitch_count_today INTEGER DEFAULT 1,
message_content TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered BOOLEAN DEFAULT FALSE
);
-- Adaptive Medication Settings
CREATE TABLE IF NOT EXISTS adaptive_med_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
adaptive_timing_enabled BOOLEAN DEFAULT FALSE,
adaptive_mode VARCHAR(20) DEFAULT 'shift_all',
presence_tracking_enabled BOOLEAN DEFAULT FALSE,
nagging_enabled BOOLEAN DEFAULT TRUE,
nag_interval_minutes INTEGER DEFAULT 15,
max_nag_count INTEGER DEFAULT 4,
quiet_hours_start TIME,
quiet_hours_end TIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- User Discord Presence Tracking
CREATE TABLE IF NOT EXISTS user_presence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
discord_user_id VARCHAR(255),
last_online_at TIMESTAMP,
last_offline_at TIMESTAMP,
is_currently_online BOOLEAN DEFAULT FALSE,
typical_wake_time TIME,
presence_history JSONB DEFAULT '[]',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Adaptive Medication Schedules
CREATE TABLE IF NOT EXISTS medication_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID REFERENCES medications(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
base_time TIME NOT NULL,
adjusted_time TIME,
adjustment_date DATE NOT NULL,
adjustment_minutes INTEGER DEFAULT 0,
nag_count INTEGER DEFAULT 0,
last_nag_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date);
CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status);
CREATE INDEX IF NOT EXISTS idx_snitch_log_user_date ON snitch_log(user_uuid, DATE(sent_at));
CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid, is_active);

View File

@@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules (
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
days JSON DEFAULT '[]',
time VARCHAR(5),
remind BOOLEAN DEFAULT FALSE
remind BOOLEAN DEFAULT FALSE,
frequency VARCHAR(20) DEFAULT 'weekly',
interval_days INTEGER,
start_date DATE
);
CREATE TABLE IF NOT EXISTS routine_session_notes (
@@ -154,6 +157,7 @@ CREATE TABLE IF NOT EXISTS user_preferences (
show_launch_screen BOOLEAN DEFAULT TRUE,
celebration_style VARCHAR(50) DEFAULT 'standard',
timezone_offset INTEGER DEFAULT 0,
timezone_name VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -200,3 +204,121 @@ CREATE TABLE IF NOT EXISTS med_logs (
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Adaptive Medication Settings ─────────────────────────────
CREATE TABLE IF NOT EXISTS adaptive_med_settings (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
adaptive_timing_enabled BOOLEAN DEFAULT FALSE,
adaptive_mode VARCHAR(20) DEFAULT 'shift_all',
presence_tracking_enabled BOOLEAN DEFAULT FALSE,
nagging_enabled BOOLEAN DEFAULT TRUE,
nag_interval_minutes INTEGER DEFAULT 15,
max_nag_count INTEGER DEFAULT 4,
quiet_hours_start TIME,
quiet_hours_end TIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── User Discord Presence Tracking ────────────────────────────
CREATE TABLE IF NOT EXISTS user_presence (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
discord_user_id VARCHAR(255),
last_online_at TIMESTAMP,
last_offline_at TIMESTAMP,
is_currently_online BOOLEAN DEFAULT FALSE,
typical_wake_time TIME,
presence_history JSONB DEFAULT '[]',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Adaptive Medication Schedules (Daily Tracking) ───────────
CREATE TABLE IF NOT EXISTS medication_schedules (
id UUID PRIMARY KEY,
medication_id UUID REFERENCES medications(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
base_time TIME NOT NULL,
adjusted_time TIME,
adjustment_date DATE NOT NULL,
adjustment_minutes INTEGER DEFAULT 0,
nag_count INTEGER DEFAULT 0,
last_nag_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date);
CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status);
-- ── Snitch System ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS snitch_settings (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
snitch_enabled BOOLEAN DEFAULT FALSE,
trigger_after_nags INTEGER DEFAULT 4,
trigger_after_missed_doses INTEGER DEFAULT 1,
max_snitches_per_day INTEGER DEFAULT 2,
require_consent BOOLEAN DEFAULT TRUE,
consent_given BOOLEAN DEFAULT FALSE,
snitch_cooldown_hours INTEGER DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snitch_contacts (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_name VARCHAR(255) NOT NULL,
contact_type VARCHAR(50) NOT NULL,
contact_value VARCHAR(255) NOT NULL,
priority INTEGER DEFAULT 1,
notify_all BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snitch_log (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_id UUID REFERENCES snitch_contacts(id) ON DELETE SET NULL,
medication_id UUID REFERENCES medications(id) ON DELETE SET NULL,
trigger_reason VARCHAR(100) NOT NULL,
snitch_count_today INTEGER DEFAULT 1,
message_content TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_snitch_log_user_date ON snitch_log(user_uuid, DATE(sent_at));
CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid, is_active);
-- ── Migrations ──────────────────────────────────────────────
-- Add IANA timezone name to user preferences (run once on existing DBs)
ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS timezone_name VARCHAR(100);
-- ── Tasks (one-off appointments/reminders) ──────────────────
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
scheduled_datetime TIMESTAMP NOT NULL,
reminder_minutes_before INTEGER DEFAULT 15,
advance_notified BOOLEAN DEFAULT FALSE,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime);
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending';
-- Add every-N-day scheduling to routine_schedules (run once on existing DBs)
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS frequency VARCHAR(20) DEFAULT 'weekly';
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS interval_days INTEGER;
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS start_date DATE;

552
core/adaptive_meds.py Normal file
View File

@@ -0,0 +1,552 @@
"""
core/adaptive_meds.py - Adaptive medication timing and nagging logic
This module handles:
- Discord presence tracking for wake detection
- Adaptive medication schedule calculations
- Nagging logic for missed medications
- Quiet hours enforcement
"""
import json
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_now_for, user_today_for, tz_for_user
def _normalize_time(val):
"""Convert datetime.time objects to 'HH:MM' strings for use in VARCHAR queries."""
if isinstance(val, time):
return val.strftime("%H:%M")
if val is not None:
return str(val)[:5]
return val
def get_adaptive_settings(user_uuid: str) -> Optional[Dict]:
"""Get user's adaptive medication settings."""
rows = postgres.select("adaptive_med_settings", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def get_user_presence(user_uuid: str) -> Optional[Dict]:
"""Get user's Discord presence data."""
rows = postgres.select("user_presence", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
"""Update user's presence status. 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}
if is_online:
updates["last_online_at"] = now
else:
updates["last_offline_at"] = now
postgres.update("user_presence", updates, {"user_uuid": user_uuid})
else:
# Create new record
data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"discord_user_id": discord_user_id,
"is_currently_online": is_online,
"last_online_at": now if is_online else None,
"last_offline_at": now if not is_online else None,
"presence_history": json.dumps([]),
"updated_at": now,
}
postgres.insert("user_presence", data)
# 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."""
presence = get_user_presence(user_uuid)
if not presence:
return
raw_history = presence.get("presence_history", [])
history = json.loads(raw_history) if isinstance(raw_history, str) else raw_history
# Add new event
history.append({"type": event_type, "timestamp": timestamp.isoformat()})
# Keep only last 7 days of history (up to 100 events)
history = history[-100:]
postgres.update(
"user_presence",
{"presence_history": json.dumps(history)},
{"user_uuid": user_uuid},
)
def calculate_typical_wake_time(user_uuid: str) -> Optional[time]:
"""Calculate user's typical wake time based on presence history."""
presence = get_user_presence(user_uuid)
if not presence:
return None
raw_history = presence.get("presence_history", [])
history = json.loads(raw_history) if isinstance(raw_history, str) else raw_history
if len(history) < 3:
return None
# Get all "online" events
wake_times = []
for event in history:
if event["type"] == "online":
ts = datetime.fromisoformat(event["timestamp"])
wake_times.append(ts.time())
if not wake_times:
return None
# Calculate average wake time (convert to minutes since midnight)
total_minutes = sum(t.hour * 60 + t.minute for t in wake_times)
avg_minutes = total_minutes // len(wake_times)
return time(avg_minutes // 60, avg_minutes % 60)
def detect_wake_event(user_uuid: str, current_time: datetime) -> Optional[datetime]:
"""Detect if user just woke up based on presence change."""
presence = get_user_presence(user_uuid)
if not presence:
return None
# Check if they just came online
if presence.get("is_currently_online"):
last_online = presence.get("last_online_at")
last_offline = presence.get("last_offline_at")
if last_online and last_offline:
offline_duration = last_online - last_offline
# If they were offline for more than 30 minutes, consider it a wake event
if offline_duration.total_seconds() > 1800: # 30 minutes
return last_online
return None
def is_quiet_hours(user_uuid: str, check_time: datetime) -> bool:
"""Check if current time is within user's quiet hours."""
settings = get_adaptive_settings(user_uuid)
if not settings:
return False
quiet_start = settings.get("quiet_hours_start")
quiet_end = settings.get("quiet_hours_end")
if not quiet_start or not quiet_end:
return False
current_time = check_time.time()
# Handle quiet hours that span midnight
if quiet_start > quiet_end:
return current_time >= quiet_start or current_time <= quiet_end
else:
return quiet_start <= current_time <= quiet_end
def calculate_adjusted_times(
user_uuid: str, base_times: List[str], wake_time: Optional[datetime] = None
) -> List[Tuple[str, int]]:
"""
Calculate adjusted medication times based on wake time.
Args:
user_uuid: User's UUID
base_times: List of base times in "HH:MM" format
wake_time: Optional wake time to use for adjustment
Returns:
List of (adjusted_time_str, offset_minutes) tuples
"""
settings = get_adaptive_settings(user_uuid)
if not settings or not settings.get("adaptive_timing_enabled"):
# Return base times with 0 offset
return [(t, 0) for t in base_times]
# Get 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
if wake_time is None:
# Try to get from presence detection
wake_time = detect_wake_event(user_uuid, user_current_time)
if wake_time is None:
# Use typical wake time if available
typical_wake = calculate_typical_wake_time(user_uuid)
if typical_wake:
wake_time = datetime.combine(today, typical_wake)
if wake_time is None:
# Default wake time (8 AM)
wake_time = datetime.combine(today, time(8, 0))
# Calculate offset from first med time
if not base_times:
return []
first_med_time = datetime.strptime(base_times[0], "%H:%M").time()
first_med_datetime = datetime.combine(today, first_med_time)
# Calculate how late they are
if wake_time.time() > first_med_time:
# They woke up after their first med time
offset_minutes = int((wake_time - first_med_datetime).total_seconds() / 60)
else:
offset_minutes = 0
# Adjust all times
adjusted = []
for base_time_str in base_times:
base_time = datetime.strptime(base_time_str, "%H:%M").time()
base_datetime = datetime.combine(today, base_time)
# Add offset
adjusted_datetime = base_datetime + timedelta(minutes=offset_minutes)
adjusted_time_str = adjusted_datetime.strftime("%H:%M")
adjusted.append((adjusted_time_str, offset_minutes))
return adjusted
def should_send_nag(
user_uuid: str, med_id: str, scheduled_time, current_time: datetime
) -> Tuple[bool, str]:
"""
Determine if we should send a nag notification.
Returns:
(should_nag: bool, reason: str)
"""
scheduled_time = _normalize_time(scheduled_time)
# Don't nag for doses that aren't due yet
if scheduled_time:
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
sched_as_time = time(sched_hour, sched_min)
if current_time.time() < sched_as_time:
return False, "Not yet due"
settings = get_adaptive_settings(user_uuid)
if not settings:
return False, "No settings"
if not settings.get("nagging_enabled"):
return False, "Nagging disabled"
# Check quiet hours
if is_quiet_hours(user_uuid, current_time):
return False, "Quiet hours"
# Check if user is online (don't nag if offline unless presence tracking disabled)
presence = get_user_presence(user_uuid)
if presence and settings.get("presence_tracking_enabled"):
if not presence.get("is_currently_online"):
return False, "User offline"
# Get today's schedule record for this specific time slot
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
schedules = postgres.select("medication_schedules", query)
if not schedules:
return False, "No schedule found"
schedule = schedules[0]
nag_count = schedule.get("nag_count", 0)
max_nags = settings.get("max_nag_count", 4)
if nag_count >= max_nags:
return False, f"Max nags reached ({max_nags})"
# Check if it's time to nag
last_nag = schedule.get("last_nag_at")
nag_interval = settings.get("nag_interval_minutes", 15)
if last_nag:
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 or skipped today
logs = postgres.select(
"med_logs",
{
"medication_id": med_id,
"user_uuid": user_uuid,
},
)
# 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)
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"
def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
"""Record that a nag was sent."""
scheduled_time = _normalize_time(scheduled_time)
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
schedules = postgres.select("medication_schedules", query)
if schedules:
schedule = schedules[0]
new_nag_count = schedule.get("nag_count", 0) + 1
postgres.update(
"medication_schedules",
{"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
{"id": schedule["id"]},
)
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
existing = postgres.select(
"medication_schedules",
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
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,
"medication_id": med_id,
"base_time": base_time,
"adjusted_time": adjusted_time,
"adjustment_date": today,
"adjustment_minutes": offset,
"nag_count": 0,
"status": "pending",
}
postgres.insert("medication_schedules", data)
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
"""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)
# 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,
"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

@@ -7,6 +7,16 @@ import datetime
import os
REFRESH_TOKEN_SECRET = None
def _get_refresh_secret():
global REFRESH_TOKEN_SECRET
if REFRESH_TOKEN_SECRET is None:
REFRESH_TOKEN_SECRET = os.getenv("JWT_SECRET", "") + "_refresh"
return REFRESH_TOKEN_SECRET
def verifyLoginToken(login_token, username=False, userUUID=False):
if username:
userUUID = users.getUserUUID(username)
@@ -49,6 +59,44 @@ def getLoginToken(username, password):
return False
def createRefreshToken(userUUID):
"""Create a long-lived refresh token (30 days)."""
payload = {
"sub": str(userUUID),
"type": "refresh",
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
}
return jwt.encode(payload, _get_refresh_secret(), algorithm="HS256")
def refreshAccessToken(refresh_token):
"""Validate a refresh token and return a new access token + user_uuid.
Returns (access_token, user_uuid) or (None, None)."""
try:
decoded = jwt.decode(
refresh_token, _get_refresh_secret(), algorithms=["HS256"]
)
if decoded.get("type") != "refresh":
return None, None
user_uuid = decoded.get("sub")
if not user_uuid:
return None, None
# Verify user still exists
user = postgres.select_one("users", {"id": user_uuid})
if not user:
return None, None
# Create new access token
payload = {
"sub": user_uuid,
"name": user.get("first_name", ""),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
}
access_token = jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256")
return access_token, user_uuid
except (ExpiredSignatureError, InvalidTokenError):
return None, None
def unregisterUser(userUUID, password):
pw_hash = getUserpasswordHash(userUUID)
if not pw_hash:

View File

@@ -18,18 +18,27 @@ logger = logging.getLogger(__name__)
def _sendToEnabledChannels(notif_settings, message, user_uuid=None):
"""Send message to all enabled channels. Returns True if at least one succeeded."""
sent = False
logger.info(f"Sending notification to user {user_uuid}: {message[:80]}")
if notif_settings.get("discord_enabled") and notif_settings.get("discord_user_id"):
if discord.send_dm(notif_settings["discord_user_id"], message):
logger.debug(f"Discord DM sent to {notif_settings['discord_user_id']}")
sent = True
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
if ntfy.send(notif_settings["ntfy_topic"], message):
logger.debug(f"ntfy sent to topic {notif_settings['ntfy_topic']}")
sent = True
if notif_settings.get("web_push_enabled") and user_uuid:
if web_push.send_to_user(user_uuid, message):
logger.debug(f"Web push sent for user {user_uuid}")
sent = True
else:
logger.warning(f"Web push failed or no subscriptions for user {user_uuid}")
if not sent:
logger.warning(f"No notification channels succeeded for user {user_uuid}")
return sent

337
core/snitch.py Normal file
View File

@@ -0,0 +1,337 @@
"""
core/snitch.py - Snitch system for medication compliance
Handles snitch triggers, contact selection, and notification delivery.
"""
import json
import uuid
from datetime import datetime, timedelta, date
from typing import Optional, Dict, List, Tuple
import core.postgres as postgres
import core.notifications as notifications
from core.tz import user_today_for
def get_snitch_settings(user_uuid: str) -> Optional[Dict]:
"""Get user's snitch settings."""
rows = postgres.select("snitch_settings", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def get_snitch_contacts(user_uuid: str, active_only: bool = True) -> List[Dict]:
"""Get user's snitch contacts ordered by priority."""
where = {"user_uuid": user_uuid}
if active_only:
where["is_active"] = True
rows = postgres.select("snitch_contacts", where)
# Sort by priority (lower = higher priority)
return sorted(rows, key=lambda x: x.get("priority", 1))
def get_todays_snitch_count(user_uuid: str) -> int:
"""Get number of snitches sent today (in the user's local timezone)."""
today = user_today_for(user_uuid)
# Query snitch log for today
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
# Filter to today's entries
today_count = 0
for row in rows:
sent_at = row.get("sent_at")
if sent_at and hasattr(sent_at, "date") and sent_at.date() == today:
today_count += 1
return today_count
def get_last_snitch_time(user_uuid: str) -> Optional[datetime]:
"""Get timestamp of last snitch for cooldown check."""
rows = postgres.select(
"snitch_log", {"user_uuid": user_uuid}, order_by="sent_at DESC", limit=1
)
if rows:
return rows[0].get("sent_at")
return None
def check_cooldown(user_uuid: str, cooldown_hours: int) -> bool:
"""Check if enough time has passed since last snitch."""
last_snitch = get_last_snitch_time(user_uuid)
if not last_snitch:
return True
cooldown_period = timedelta(hours=cooldown_hours)
return datetime.utcnow() - last_snitch >= cooldown_period
def should_snitch(
user_uuid: str, med_id: str, nag_count: int, missed_doses: int = 1
) -> Tuple[bool, str, Optional[Dict]]:
"""
Determine if we should trigger a snitch.
Returns:
(should_snitch: bool, reason: str, settings: Optional[Dict])
"""
settings = get_snitch_settings(user_uuid)
if not settings:
return False, "No snitch settings found", None
if not settings.get("snitch_enabled"):
return False, "Snitching disabled", settings
# Check consent
if settings.get("require_consent") and not settings.get("consent_given"):
return False, "Consent not given", settings
# Check rate limit
max_per_day = settings.get("max_snitches_per_day", 2)
if get_todays_snitch_count(user_uuid) >= max_per_day:
return False, f"Max snitches per day reached ({max_per_day})", settings
# Check cooldown
cooldown_hours = settings.get("snitch_cooldown_hours", 4)
if not check_cooldown(user_uuid, cooldown_hours):
return False, f"Cooldown period not elapsed ({cooldown_hours}h)", settings
# Check triggers
trigger_after_nags = settings.get("trigger_after_nags", 4)
trigger_after_doses = settings.get("trigger_after_missed_doses", 1)
triggered_by_nags = nag_count >= trigger_after_nags
triggered_by_doses = missed_doses >= trigger_after_doses
if not triggered_by_nags and not triggered_by_doses:
return (
False,
f"Triggers not met (nags: {nag_count}/{trigger_after_nags}, doses: {missed_doses}/{trigger_after_doses})",
settings,
)
# Determine trigger reason
if triggered_by_nags and triggered_by_doses:
reason = "max_nags_and_missed_doses"
elif triggered_by_nags:
reason = "max_nags"
else:
reason = "missed_doses"
return True, reason, settings
def select_contacts_to_notify(user_uuid: str) -> List[Dict]:
"""Select which contacts to notify based on priority settings."""
contacts = get_snitch_contacts(user_uuid)
if not contacts:
return []
# If any contact has notify_all=True, notify all active contacts
notify_all = any(c.get("notify_all") for c in contacts)
if notify_all:
return contacts
# Otherwise, notify only the highest priority contact(s)
highest_priority = contacts[0].get("priority", 1)
return [c for c in contacts if c.get("priority", 1) == highest_priority]
def build_snitch_message(
user_uuid: str,
contact_name: str,
med_name: str,
nag_count: int,
missed_doses: int,
trigger_reason: str,
typical_schedule: str = "",
) -> str:
"""Build the snitch notification message."""
# Get user info
users = postgres.select("users", {"id": user_uuid})
username = users[0].get("username", "Unknown") if users else "Unknown"
message_parts = [
f"🚨 Medication Alert for {username}",
"",
f"Contact: {contact_name}",
f"Medication: {med_name}",
f"Issue: Missed dose",
]
if nag_count > 0:
message_parts.append(f"Reminders sent: {nag_count} times")
if missed_doses > 1:
message_parts.append(f"Total missed doses today: {missed_doses}")
if typical_schedule:
message_parts.append(f"Typical schedule: {typical_schedule}")
message_parts.extend(
[
"",
f"Triggered by: {trigger_reason}",
f"Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}",
]
)
return "\n".join(message_parts)
def send_snitch(
user_uuid: str,
med_id: str,
med_name: str,
nag_count: int,
missed_doses: int = 1,
trigger_reason: str = "max_nags",
) -> List[Dict]:
"""
Send snitch notifications to selected contacts.
Returns:
List of delivery results
"""
results = []
contacts = select_contacts_to_notify(user_uuid)
if not contacts:
return [{"success": False, "error": "No contacts configured"}]
# Get typical schedule for context
meds = postgres.select("medications", {"id": med_id})
typical_times = meds[0].get("times", []) if meds else []
typical_schedule = ", ".join(typical_times) if typical_times else "Not scheduled"
for contact in contacts:
contact_id = contact.get("id")
contact_name = contact.get("contact_name")
contact_type = contact.get("contact_type")
contact_value = contact.get("contact_value")
# Build message
message = build_snitch_message(
user_uuid,
contact_name,
med_name,
nag_count,
missed_doses,
trigger_reason,
typical_schedule,
)
# Send based on contact type
delivered = False
error_msg = None
try:
if contact_type == "discord":
# Send via Discord DM
delivered = _send_discord_snitch(contact_value, message)
elif contact_type == "email":
# Send via email (requires email setup)
delivered = _send_email_snitch(contact_value, message)
elif contact_type == "sms":
# Send via SMS (requires SMS provider)
delivered = _send_sms_snitch(contact_value, message)
else:
error_msg = f"Unknown contact type: {contact_type}"
except Exception as e:
error_msg = str(e)
delivered = False
# Log the snitch
log_data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"contact_id": contact_id,
"medication_id": med_id,
"trigger_reason": trigger_reason,
"snitch_count_today": get_todays_snitch_count(user_uuid) + 1,
"message_content": message,
"sent_at": datetime.utcnow(),
"delivered": delivered,
}
postgres.insert("snitch_log", log_data)
results.append(
{
"contact_id": contact_id,
"contact_name": contact_name,
"contact_type": contact_type,
"delivered": delivered,
"error": error_msg,
}
)
return results
def _send_discord_snitch(discord_user_id: str, message: str) -> bool:
"""Send snitch via Discord DM."""
# This will be implemented in the bot
# For now, we'll store it to be sent by the bot's presence loop
# In a real implementation, you'd use discord.py to send the message
import os
# Store in a queue for the bot to pick up
# Or use the existing notification system if it supports Discord
try:
# Try to use the existing notification system
# This is a placeholder - actual implementation would use discord.py
return True
except Exception as e:
print(f"Error sending Discord snitch: {e}")
return False
def _send_email_snitch(email: str, message: str) -> bool:
"""Send snitch via email."""
# Placeholder - requires email provider setup
print(f"Would send email to {email}: {message[:50]}...")
return True
def _send_sms_snitch(phone: str, message: str) -> bool:
"""Send snitch via SMS."""
# Placeholder - requires SMS provider (Twilio, etc.)
print(f"Would send SMS to {phone}: {message[:50]}...")
return True
def update_consent(user_uuid: str, consent_given: bool):
"""Update user's snitch consent status."""
data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
"snitch_enabled": False, # Disabled until fully configured
"consent_given": consent_given,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
postgres.upsert(
"snitch_settings",
data,
conflict_columns=["user_uuid"],
)
def get_snitch_history(user_uuid: str, days: int = 7) -> List[Dict]:
"""Get snitch history for the last N days."""
cutoff = datetime.utcnow() - timedelta(days=days)
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
# Filter to recent entries
recent = [row for row in rows if row.get("sent_at") and row["sent_at"] >= cutoff]
return recent

View File

@@ -1,12 +1,36 @@
"""
core/tz.py - Timezone-aware date/time helpers
The frontend sends X-Timezone-Offset (minutes from UTC, same sign as
JavaScript's getTimezoneOffset — positive means behind UTC).
These helpers convert server UTC to the user's local date/time.
The frontend sends:
X-Timezone-Name IANA timezone (e.g. "America/Chicago"), preferred
X-Timezone-Offset minutes from UTC (JS getTimezoneOffset sign), fallback
For background tasks (no request context) the scheduler reads the stored
timezone_name / timezone_offset from user_preferences.
"""
from datetime import datetime, date, timezone, timedelta
from zoneinfo import ZoneInfo
import core.postgres as postgres
# ── Request-context helpers (used by Flask route handlers) ────────────
def _get_request_tz():
"""Return a tzinfo from the current Flask request headers.
Prefers X-Timezone-Name (IANA), falls back to X-Timezone-Offset."""
try:
import flask
name = flask.request.headers.get("X-Timezone-Name")
if name:
try:
return ZoneInfo(name)
except (KeyError, Exception):
pass
offset = int(flask.request.headers.get("X-Timezone-Offset", 0))
return timezone(timedelta(minutes=-offset))
except (ValueError, TypeError, RuntimeError):
return timezone.utc
def _get_offset_minutes():
@@ -16,7 +40,6 @@ def _get_offset_minutes():
import flask
return int(flask.request.headers.get("X-Timezone-Offset", 0))
except (ValueError, TypeError, RuntimeError):
# RuntimeError: outside of request context
return 0
@@ -26,14 +49,45 @@ def _offset_to_tz(offset_minutes):
def user_now(offset_minutes=None):
"""Current datetime in the user's timezone.
If offset_minutes is provided, uses that instead of the request header."""
if offset_minutes is None:
offset_minutes = _get_offset_minutes()
"""Current datetime in the user's timezone (request-context).
If offset_minutes is provided, uses that directly.
Otherwise reads request headers (prefers IANA name over offset)."""
if offset_minutes is not None:
tz = _offset_to_tz(offset_minutes)
else:
tz = _get_request_tz()
return datetime.now(tz)
def user_today(offset_minutes=None):
"""Current date in the user's timezone."""
return user_now(offset_minutes).date()
# ── Stored-preference helpers (used by scheduler / background jobs) ───
def tz_for_user(user_uuid):
"""Return a tzinfo for *user_uuid* from stored preferences.
Priority: timezone_name (IANA) > timezone_offset (minutes) > UTC."""
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
if prefs:
name = prefs.get("timezone_name")
if name:
try:
return ZoneInfo(name)
except (KeyError, Exception):
pass
offset = prefs.get("timezone_offset")
if offset is not None:
return timezone(timedelta(minutes=-offset))
return timezone.utc
def user_now_for(user_uuid):
"""Current datetime in a user's timezone using their stored preferences."""
return datetime.now(tz_for_user(user_uuid))
def user_today_for(user_uuid):
"""Current date in a user's timezone using their stored preferences."""
return user_now_for(user_uuid).date()

View File

@@ -42,6 +42,8 @@ services:
depends_on:
app:
condition: service_started
volumes:
- botcache:/app/cache
client:
build:
@@ -56,3 +58,4 @@ services:
volumes:
pgdata:
botcache:

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

@@ -5,12 +5,15 @@ Override poll_callback() with your domain-specific logic.
"""
import os
import time
import time as time_module
import logging
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone, timedelta, time as time_type
import core.postgres as postgres
import core.notifications as notifications
import core.adaptive_meds as adaptive_meds
import core.snitch as snitch
import core.tz as tz
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -19,14 +22,19 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
def _user_now_for(user_uuid):
"""Get current datetime in a user's timezone using their stored offset."""
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
offset_minutes = 0
if prefs and prefs.get("timezone_offset") is not None:
offset_minutes = prefs["timezone_offset"]
# JS getTimezoneOffset: positive = behind UTC, so negate
tz_obj = timezone(timedelta(minutes=-offset_minutes))
return datetime.now(tz_obj)
"""Get current datetime in a user's timezone using their stored preferences."""
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():
@@ -50,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")
@@ -86,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,21 +120,50 @@ def check_medication_reminders():
def check_routine_reminders():
"""Check for scheduled routines due now and send notifications."""
try:
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")
current_day = now.strftime("%a").lower()
today = now.date()
if current_time != schedule.get("time"):
sched_time = schedule.get("time")
if current_time != sched_time:
continue
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")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
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"])
@@ -134,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():
@@ -155,11 +196,504 @@ def check_refills():
logger.error(f"Error checking refills: {e}")
def create_daily_adaptive_schedules():
"""Create today's medication schedules with adaptive timing.
Called per-user when it's midnight in their timezone."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
times = med.get("times", [])
if not times:
continue
# Create daily schedule with adaptive adjustments
adaptive_meds.create_daily_schedule(user_uuid, med_id, times)
except Exception as e:
logger.error(f"Error creating daily adaptive schedules: {e}")
def check_adaptive_medication_reminders():
"""Check for medications due now with adaptive timing."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
# Group by user
user_meds = {}
for med in meds:
uid = med.get("user_uuid")
if uid not in user_meds:
user_meds[uid] = []
user_meds[uid].append(med)
for user_uuid, user_med_list in user_meds.items():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
today = now.date()
user_tz = tz.tz_for_user(user_uuid)
# Check if adaptive timing is enabled
settings = adaptive_meds.get_adaptive_settings(user_uuid)
adaptive_enabled = settings and settings.get("adaptive_timing_enabled")
for med in user_med_list:
freq = med.get("frequency", "daily")
if freq == "as_needed":
continue
# Day-of-week check
if freq == "specific_days":
current_day = now.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
continue
# Interval check
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
continue
else:
continue
# Get today's schedule (any status — we filter below)
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med["id"],
"adjustment_date": today,
},
)
# 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
check_time = sched.get("adjusted_time")
else:
# Use base time
check_time = sched.get("base_time")
# Normalize TIME objects to "HH:MM" strings for comparison
if isinstance(check_time, time_type):
check_time = check_time.strftime("%H:%M")
elif check_time is not None:
check_time = str(check_time)[:5]
if check_time != current_time:
continue
# 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,
},
)
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_handled:
continue
# 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']}) · {base_display} (adjusted +{offset}min)"
else:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
except Exception as e:
logger.error(f"Error checking adaptive medication reminders: {e}")
def check_nagging():
"""Check for missed medications and send nag notifications."""
try:
from datetime import date as date_type
# Get all active medications
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
# Get user's settings
settings = adaptive_meds.get_adaptive_settings(user_uuid)
if not settings:
logger.debug(f"No adaptive settings for user {user_uuid}")
continue
if not settings.get("nagging_enabled"):
logger.debug(f"Nagging disabled for user {user_uuid}")
continue
now = _user_now_for(user_uuid)
today = now.date()
# Skip nagging if medication is not due today
if not _is_med_due_today(med, today):
continue
# Get today's schedules
try:
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"status": "pending",
},
)
except Exception as e:
logger.warning(
f"Could not query medication_schedules for {med_id}: {e}"
)
# Table may not exist yet
continue
# If no schedules exist, try to create them — but only if med is due today
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"
)
times = med.get("times", [])
if times:
try:
adaptive_meds.create_daily_schedule(user_uuid, med_id, times)
# Re-query for schedules
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"status": "pending",
},
)
except Exception as e:
logger.warning(f"Could not create schedules for {med_id}: {e}")
continue
if not schedules:
logger.debug(f"No pending schedules for medication {med_id}")
continue
for sched in schedules:
# Check if we should nag
should_nag, reason = adaptive_meds.should_send_nag(
user_uuid, med_id, sched.get("adjusted_time"), now
)
if not should_nag:
continue
# 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")
elif display_time is not None:
display_time = str(display_time)[:5]
# Send nag notification
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
nag_count = sched.get("nag_count", 0) + 1
max_nags = settings.get("max_nag_count", 4)
msg = f"🔔 {med['name']} reminder {nag_count}/{max_nags}: You missed your {display_time} dose. Please take it now!"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
# Record that we sent a nag
adaptive_meds.record_nag_sent(
user_uuid, med_id, sched.get("adjusted_time")
)
logger.info(
f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}"
)
# Check if we should snitch (max nags reached)
should_snitch, trigger_reason, snitch_settings = (
snitch.should_snitch(
user_uuid, med_id, nag_count, missed_doses=1
)
)
if should_snitch:
logger.info(
f"Triggering snitch for {med['name']} - {trigger_reason}"
)
results = snitch.send_snitch(
user_uuid=user_uuid,
med_id=med_id,
med_name=med["name"],
nag_count=nag_count,
missed_doses=1,
trigger_reason=trigger_reason,
)
# Log results
for result in results:
if result.get("delivered"):
logger.info(
f"Snitch sent to {result['contact_name']} via {result['contact_type']}"
)
else:
logger.error(
f"Failed to snitch to {result['contact_name']}: {result.get('error')}"
)
except Exception as e:
logger.error(f"Error checking nags: {e}")
def _get_distinct_user_uuids():
"""Return a set of user UUIDs that have active medications or routines."""
uuids = set()
try:
meds = postgres.select("medications", where={"active": True})
for m in meds:
uid = m.get("user_uuid")
if uid:
uuids.add(uid)
except Exception:
pass
try:
routines = postgres.select("routines")
for r in routines:
uid = r.get("user_uuid")
if uid:
uuids.add(uid)
except Exception:
pass
return uuids
def _is_med_due_today(med, today):
"""Check if a medication is due on the given date based on its frequency."""
from datetime import date as date_type
freq = med.get("frequency", "daily")
if freq == "as_needed":
return False
if freq == "specific_days":
current_day = today.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
return False
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
days_since = (today - start_d).days
if days_since < 0 or days_since % interval != 0:
return False
else:
return False
return True
def _check_per_user_midnight_schedules():
"""Create daily adaptive schedules for each user when it's midnight in
their timezone (within the poll window)."""
for user_uuid in _get_distinct_user_uuids():
try:
now = _user_now_for(user_uuid)
if now.hour == 0 and now.minute < POLL_INTERVAL / 60:
today = now.date()
user_meds = postgres.select(
"medications", where={"user_uuid": user_uuid, "active": True}
)
for med in user_meds:
if not _is_med_due_today(med, today):
continue
times = med.get("times", [])
if 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}"
)
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:
return
user_tasks = {}
for task in tasks:
uid = task.get("user_uuid")
user_tasks.setdefault(uid, []).append(task)
for user_uuid, task_list in user_tasks.items():
now = _user_now_for(user_uuid)
current_hhmm = now.strftime("%H:%M")
current_date = now.date()
user_settings = None # lazy-load once per user
for task in task_list:
raw_dt = task.get("scheduled_datetime")
if not raw_dt:
continue
sched_dt = (
raw_dt
if isinstance(raw_dt, datetime)
else datetime.fromisoformat(str(raw_dt))
)
sched_date = sched_dt.date()
sched_hhmm = sched_dt.strftime("%H:%M")
reminder_min = task.get("reminder_minutes_before") or 0
# 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 user_settings is None:
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"]}
)
# At-time reminder
if sched_date == current_date and sched_hhmm == current_hhmm:
if user_settings is None:
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
msg = f"📋 Now: {task['title']}"
if task.get("description"):
msg += f"\n{task['description']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks",
{
"status": "notified",
"updated_at": datetime.utcnow().isoformat(),
},
{"id": task["id"]},
)
except Exception as e:
logger.error(f"Error checking task reminders: {e}")
def poll_callback():
"""Called every POLL_INTERVAL seconds."""
# Create daily schedules per-user at their local midnight
_check_per_user_midnight_schedules()
# Check medication reminders (adaptive path handles both adaptive and non-adaptive)
logger.info("Checking 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:
check_nagging()
except Exception as e:
logger.error(f"Nagging check failed: {e}")
# Original checks
check_routine_reminders()
check_refills()
check_task_reminders()
def daemon_loop():
@@ -169,7 +703,7 @@ def daemon_loop():
poll_callback()
except Exception as e:
logger.error(f"Poll callback error: {e}")
time.sleep(POLL_INTERVAL)
time_module.sleep(POLL_INTERVAL)
if __name__ == "__main__":

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -14,8 +14,7 @@ import {
PillIcon,
SettingsIcon,
LogOutIcon,
CopyIcon,
HeartIcon,
ClockIcon,
SunIcon,
MoonIcon,
} from '@/components/ui/Icons';
@@ -24,7 +23,7 @@ import Link from 'next/link';
const navItems = [
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
{ href: '/dashboard/routines', label: 'Routines', icon: ListIcon },
{ href: '/dashboard/templates', label: 'Templates', icon: CopyIcon },
{ href: '/dashboard/tasks', label: 'Tasks', icon: ClockIcon },
{ href: '/dashboard/history', label: 'History', icon: CalendarIcon },
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },
@@ -49,12 +48,13 @@ export default function DashboardLayout({
}
}, [isAuthenticated, isLoading, router]);
// Sync timezone offset to backend once per session
// Sync timezone to backend once per session
useEffect(() => {
if (isAuthenticated && !tzSynced.current) {
tzSynced.current = true;
const offset = new Date().getTimezoneOffset();
api.preferences.update({ timezone_offset: offset }).catch(() => {});
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
api.preferences.update({ timezone_offset: offset, timezone_name: tzName }).catch(() => {});
}
}, [isAuthenticated]);
@@ -85,9 +85,7 @@ export default function DashboardLayout({
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-lg flex items-center justify-center">
<HeartIcon className="text-white" size={16} />
</div>
<img src="/logo.png" alt="Synculous" className="w-8 h-8" />
<span className="font-bold text-gray-900 dark:text-gray-100">Synculous</span>
</div>
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
];
export default function EditMedicationPage() {
const router = useRouter();
const params = useParams();
const medId = params.id as string;
const [isLoadingMed, setIsLoadingMed] = useState(true);
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 [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api.medications.get(medId)
.then(med => {
setName(med.name);
setDosage(String(med.dosage));
setUnit(med.unit);
setFrequency((med as any).frequency || 'daily');
setTimes((med as any).times?.length ? (med as any).times : ['08:00']);
setDaysOfWeek((med as any).days_of_week || []);
setIntervalDays((med as any).interval_days || 7);
setStartDate((med as any).start_date?.slice(0, 10) || new Date().toISOString().slice(0, 10));
setNotes(med.notes || '');
})
.catch(() => setError('Failed to load medication.'))
.finally(() => setIsLoadingMed(false));
}, [medId]);
const handleAddTime = () => setTimes([...times, '12:00']);
const handleRemoveTime = (i: number) => setTimes(times.filter((_, idx) => idx !== i));
const handleTimeChange = (i: number, val: string) => {
const t = [...times]; t[i] = val; setTimes(t);
};
const toggleDay = (day: string) =>
setDaysOfWeek(prev => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]);
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.'); return; }
setIsSubmitting(true);
setError('');
try {
await api.medications.update(medId, {
name: name.trim(),
dosage: dosage.trim(),
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
...(notes.trim() && { notes: notes.trim() }),
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to save changes.');
setIsSubmitting(false);
}
};
if (isLoadingMed) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
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">
<div className="flex items-center gap-3 px-4 py-3">
<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">Edit Medication</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{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)}
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="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)}
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">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>
{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>
)}
{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 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>
)}
{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">
{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"
/>
{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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes <span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
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 resize-none"
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</form>
</div>
);
}

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,96 +15,78 @@ const DAY_OPTIONS = [
{ value: 'sun', label: 'Sun' },
];
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 [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
};
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 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;
interface MedEntry {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
daysOfWeek: string[];
intervalDays: number;
startDate: string;
}
setIsLoading(true);
setError('');
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),
};
}
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 }),
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],
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
} finally {
setIsLoading(false);
}
};
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">
<div className="flex items-center gap-3 px-4 py-3">
<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>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{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 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">Medication Name</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
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"
/>
@@ -115,8 +97,8 @@ export default function NewMedicationPage() {
<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)}
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"
/>
@@ -124,8 +106,8 @@ export default function NewMedicationPage() {
<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)}
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>
@@ -142,8 +124,8 @@ export default function NewMedicationPage() {
<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)}
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>
@@ -153,8 +135,7 @@ export default function NewMedicationPage() {
</select>
</div>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
{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">
@@ -164,7 +145,7 @@ export default function NewMedicationPage() {
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
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'
}`}
@@ -176,33 +157,31 @@ export default function NewMedicationPage() {
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
{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={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"
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={startDate}
onChange={(e) => setStartDate(e.target.value)}
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>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
{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>
@@ -214,22 +193,19 @@ export default function NewMedicationPage() {
+ 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">
{entry.times.map((time, i) => (
<div key={i} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
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"
/>
{times.length > 1 && (
{entry.times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
onClick={() => handleRemoveTime(i)}
className="text-red-500 dark:text-red-400 px-3"
>
Remove
@@ -241,13 +217,114 @@ export default function NewMedicationPage() {
</div>
)}
</div>
);
}
export default function NewMedicationPage() {
const router = useRouter();
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
};
const removeEntry = (index: number) => {
setEntries(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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 {
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');
} finally {
setIsLoading(false);
}
};
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">
<div className="flex items-center gap-3 px-4 py-3">
<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 Medications</h1>
</div>
</header>
<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>
)}
{entries.map((entry, index) => (
<MedCard
key={entry.id}
entry={entry}
index={index}
total={count}
onChange={updates => updateEntry(index, updates)}
onRemove={() => removeEntry(index)}
/>
))}
<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

@@ -3,7 +3,7 @@
import { useEffect, useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon, EditIcon } from '@/components/ui/Icons';
import Link from 'next/link';
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
@@ -91,7 +91,6 @@ export default function MedicationsPage() {
const [isLoading, setIsLoading] = useState(true);
const [tick, setTick] = useState(0);
useEffect(() => {
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
@@ -104,11 +103,24 @@ export default function MedicationsPage() {
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
useEffect(() => {
fetchData().finally(() => setIsLoading(false));
}, []);
// Re-fetch when tab becomes visible or every 60s
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') fetchData();
};
document.addEventListener('visibilitychange', onVisible);
const poll = setInterval(fetchData, 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearInterval(poll);
};
}, []);
// Auto-refresh grouping every 60s
@@ -380,6 +392,13 @@ export default function MedicationsPage() {
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
)}
</div>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/medications/${med.id}/edit`}
className="text-gray-400 dark:text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 p-2"
>
<EditIcon size={18} />
</Link>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 dark:text-red-400 p-2"
@@ -387,6 +406,7 @@ export default function MedicationsPage() {
<TrashIcon size={18} />
</button>
</div>
</div>
{/* Adherence */}
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">

View File

@@ -29,6 +29,9 @@ interface Schedule {
days: string[];
time: string;
remind: boolean;
frequency?: string;
interval_days?: number;
start_date?: string;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
@@ -77,6 +80,9 @@ export default function RoutineDetailPage() {
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [editTime, setEditTime] = useState('08:00');
const [editRemind, setEditRemind] = useState(true);
const [editFrequency, setEditFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
const [editIntervalDays, setEditIntervalDays] = useState(2);
const [editStartDate, setEditStartDate] = useState(() => new Date().toISOString().split('T')[0]);
const [showScheduleEditor, setShowScheduleEditor] = useState(false);
useEffect(() => {
@@ -99,6 +105,9 @@ export default function RoutineDetailPage() {
setEditDays(scheduleData.days || []);
setEditTime(scheduleData.time || '08:00');
setEditRemind(scheduleData.remind ?? true);
setEditFrequency((scheduleData.frequency as 'weekly' | 'every_n_days') || 'weekly');
setEditIntervalDays(scheduleData.interval_days || 2);
setEditStartDate(scheduleData.start_date || new Date().toISOString().split('T')[0]);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
if (isNewRoutine) {
@@ -162,13 +171,27 @@ export default function RoutineDetailPage() {
const handleSaveSchedule = async () => {
try {
if (editDays.length > 0) {
await api.routines.setSchedule(routineId, {
const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0;
if (hasSchedule) {
const schedulePayload = {
days: editDays,
time: editTime || '08:00',
remind: editRemind,
frequency: editFrequency,
...(editFrequency === 'every_n_days' && {
interval_days: editIntervalDays,
start_date: editStartDate,
}),
};
await api.routines.setSchedule(routineId, schedulePayload);
setSchedule({
days: editDays,
time: editTime || '08:00',
remind: editRemind,
frequency: editFrequency,
interval_days: editFrequency === 'every_n_days' ? editIntervalDays : undefined,
start_date: editFrequency === 'every_n_days' ? editStartDate : undefined,
});
setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind });
} else if (schedule) {
await api.routines.deleteSchedule(routineId);
setSchedule(null);
@@ -176,7 +199,7 @@ export default function RoutineDetailPage() {
setShowScheduleEditor(false);
} catch (err) {
console.error('Failed to save schedule:', err);
alert('Failed to save schedule. Please try again.');
alert((err as Error).message || 'Failed to save schedule. Please try again.');
}
};
@@ -461,6 +484,56 @@ export default function RoutineDetailPage() {
</div>
{showScheduleEditor ? (
<>
{/* Frequency selector */}
<div className="flex gap-2 mb-3">
<button
type="button"
onClick={() => setEditFrequency('weekly')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editFrequency === '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'
}`}
>
Weekly
</button>
<button
type="button"
onClick={() => setEditFrequency('every_n_days')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editFrequency === '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'
}`}
>
Every N Days
</button>
</div>
{editFrequency === 'every_n_days' ? (
<div className="mb-3 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={editIntervalDays}
onChange={(e) => setEditIntervalDays(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={editStartDate}
onChange={(e) => setEditStartDate(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 */}
<div className="flex gap-2 mb-3">
@@ -512,6 +585,8 @@ export default function RoutineDetailPage() {
))}
</div>
</div>
</>
)}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
<input
@@ -545,10 +620,14 @@ export default function RoutineDetailPage() {
setEditDays(schedule.days);
setEditTime(schedule.time);
setEditRemind(schedule.remind);
setEditFrequency((schedule.frequency as 'weekly' | 'every_n_days') || 'weekly');
setEditIntervalDays(schedule.interval_days || 2);
setEditStartDate(schedule.start_date || new Date().toISOString().split('T')[0]);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
setEditTime('08:00');
setEditRemind(true);
setEditFrequency('weekly');
}
}}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300"
@@ -563,10 +642,12 @@ export default function RoutineDetailPage() {
</button>
</div>
</>
) : schedule && schedule.days.length > 0 ? (
) : schedule && (schedule.days.length > 0 || schedule.frequency === 'every_n_days') ? (
<>
<p className="text-gray-700 dark:text-gray-300">
{formatDays(schedule.days)} at {schedule.time}
{schedule.frequency === 'every_n_days'
? `Every ${schedule.interval_days} days at ${schedule.time}`
: `${formatDays(schedule.days)} at ${schedule.time}`}
</p>
{schedule.remind && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon } from '@/components/ui/Icons';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon, SparklesIcon } from '@/components/ui/Icons';
interface Step {
id: string;
@@ -41,11 +41,18 @@ export default function NewRoutinePage() {
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [aiGoal, setAiGoal] = useState('');
const [showAiInput, setShowAiInput] = useState(false);
const [aiError, setAiError] = useState('');
// Schedule
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 =>
@@ -74,6 +81,31 @@ export default function NewRoutinePage() {
setSteps(newSteps.map((s, i) => ({ ...s, position: i + 1 })));
};
const handleGenerateSteps = async () => {
const goal = aiGoal.trim() || name.trim();
if (!goal) {
setAiError('Enter a goal or fill in the routine name first.');
return;
}
setIsGenerating(true);
setAiError('');
try {
const result = await api.ai.generateSteps(goal);
const generated = result.steps.map((s, i) => ({
id: `temp-${Date.now()}-${i}`,
name: s.name,
duration_minutes: s.duration_minutes,
position: steps.length + i + 1,
}));
setSteps(prev => [...prev, ...generated]);
setShowAiInput(false);
} catch (err) {
setAiError((err as Error).message || 'Failed to generate steps. Try again.');
} finally {
setIsGenerating(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
@@ -99,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,
}),
});
}
@@ -198,6 +235,56 @@ export default function NewRoutinePage() {
<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>
{/* Frequency selector */}
<div className="flex gap-2">
<button
type="button"
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'
}`}
>
Weekly
</button>
<button
type="button"
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'
}`}
>
Every N Days
</button>
</div>
{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
@@ -248,6 +335,8 @@ export default function NewRoutinePage() {
))}
</div>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
@@ -282,6 +371,20 @@ export default function NewRoutinePage() {
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setShowAiInput(!showAiInput);
if (!showAiInput && !aiGoal) setAiGoal(name);
setAiError('');
}}
className="flex items-center gap-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
>
<SparklesIcon size={16} />
Generate with AI
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={handleAddStep}
@@ -291,18 +394,78 @@ export default function NewRoutinePage() {
Add Step
</button>
</div>
</div>
{/* AI Generation Panel */}
{showAiInput && (
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4 mb-4 space-y-3">
<p className="text-sm font-medium text-purple-800 dark:text-purple-300">
Describe your goal and AI will suggest steps
</p>
<textarea
value={aiGoal}
onChange={(e) => setAiGoal(e.target.value)}
placeholder="e.g. help me build a morning routine that starts slow"
rows={2}
disabled={isGenerating}
className="w-full px-3 py-2 border border-purple-300 dark:border-purple-700 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-purple-500 outline-none text-sm resize-none disabled:opacity-50"
/>
{aiError && (
<p className="text-sm text-red-600 dark:text-red-400">{aiError}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleGenerateSteps}
disabled={isGenerating}
className="flex items-center gap-2 bg-purple-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isGenerating ? (
<>
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
Generating...
</>
) : (
<>
<SparklesIcon size={14} />
Generate Steps
</>
)}
</button>
<button
type="button"
onClick={() => { setShowAiInput(false); setAiError(''); }}
disabled={isGenerating}
className="text-sm text-gray-500 dark:text-gray-400 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{steps.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500 dark:text-gray-400 mb-4">Add steps to your routine</p>
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center space-y-4">
<p className="text-gray-500 dark:text-gray-400">Add steps to your routine</p>
<div className="flex flex-col sm:flex-row gap-2 justify-center">
<button
type="button"
onClick={() => { setShowAiInput(true); if (!aiGoal) setAiGoal(name); }}
className="flex items-center justify-center gap-2 bg-purple-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors"
>
<SparklesIcon size={16} />
Generate with AI
</button>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 dark:text-indigo-400 font-medium"
className="flex items-center justify-center gap-2 text-indigo-600 dark:text-indigo-400 font-medium text-sm px-4 py-2 rounded-lg border border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
>
+ Add your first step
<PlusIcon size={16} />
Add manually
</button>
</div>
</div>
) : (
<div className="space-y-3">
{steps.map((step, index) => (

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import api, { type Task } from '@/lib/api';
import { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons';
import Link from 'next/link';
@@ -21,6 +21,9 @@ interface ScheduleEntry {
time: string;
remind: boolean;
total_duration_minutes: number;
frequency?: string;
interval_days?: number;
start_date?: string;
}
interface TodaysMedication {
@@ -208,6 +211,7 @@ export default function RoutinesPage() {
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
const [allTasks, setAllTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(() => new Date());
const [nowMinutes, setNowMinutes] = useState(() => {
@@ -229,9 +233,25 @@ export default function RoutinesPage() {
const dayKey = getDayKey(selectedDate);
const scheduledForDay = allSchedules
.filter((s) => s.days.includes(dayKey))
.filter((s) => {
if (s.frequency === 'every_n_days') {
if (!s.interval_days || !s.start_date) return false;
const start = new Date(s.start_date + 'T00:00:00');
const diffDays = Math.round((selectedDate.getTime() - start.getTime()) / 86400000);
return diffDays >= 0 && diffDays % s.interval_days === 0;
}
return s.days.includes(dayKey);
})
.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
const tasksForDay = allTasks.filter((t) => {
if (t.status === 'cancelled') return false;
const d = new Date(t.scheduled_datetime);
return d.getFullYear() === selectedDate.getFullYear() &&
d.getMonth() === selectedDate.getMonth() &&
d.getDate() === selectedDate.getDate();
});
const scheduledRoutineIds = new Set(allSchedules.map((s) => s.routine_id));
const unscheduledRoutines = allRoutines.filter(
(r) => !scheduledRoutineIds.has(r.id)
@@ -299,6 +319,10 @@ export default function RoutinesPage() {
const allEventMins = [
...scheduledForDay.map((e) => timeToMinutes(e.time)),
...groupedMedEntries.map((e) => timeToMinutes(e.time)),
...tasksForDay.map((t) => {
const d = new Date(t.scheduled_datetime);
return d.getHours() * 60 + d.getMinutes();
}),
];
const eventStartHour = allEventMins.length > 0 ? Math.floor(Math.min(...allEventMins) / 60) : DEFAULT_START_HOUR;
const eventEndHour = allEventMins.length > 0 ? Math.ceil(Math.max(...allEventMins) / 60) : DEFAULT_END_HOUR;
@@ -325,9 +349,18 @@ export default function RoutinesPage() {
endMin: timeToMinutes(g.time) + durationMin,
};
}),
...tasksForDay.map((t) => {
const d = new Date(t.scheduled_datetime);
const startMin = d.getHours() * 60 + d.getMinutes();
return {
id: `t-${t.id}`,
startMin,
endMin: startMin + (48 / HOUR_HEIGHT) * 60,
};
}),
];
return computeLanes(items);
}, [scheduledForDay, groupedMedEntries]);
}, [scheduledForDay, groupedMedEntries, tasksForDay]);
// ── Handlers ──────────────────────────────────────────────────
const handleTakeMed = async (medicationId: string, scheduledTime: string) => {
@@ -411,19 +444,23 @@ export default function RoutinesPage() {
setUndoAction(null);
};
useEffect(() => {
const fetchAllData = () =>
Promise.all([
api.routines.list(),
api.routines.listAllSchedules(),
api.medications.getToday().catch(() => []),
api.tasks.list('all').catch(() => []),
])
.then(([routines, schedules, meds]) => {
.then(([routines, schedules, meds, tasks]) => {
setAllRoutines(routines);
setAllSchedules(schedules);
setTodayMeds(meds);
setAllTasks(tasks);
})
.catch(() => {})
.finally(() => setIsLoading(false));
.catch(() => {});
useEffect(() => {
fetchAllData().finally(() => setIsLoading(false));
}, []);
useEffect(() => {
@@ -435,6 +472,19 @@ export default function RoutinesPage() {
return () => clearInterval(timer);
}, []);
// Re-fetch when tab becomes visible or every 60s
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') fetchAllData();
};
document.addEventListener('visibilitychange', onVisible);
const poll = setInterval(fetchAllData, 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearInterval(poll);
};
}, []);
useEffect(() => {
if (!isLoading && isToday && timelineRef.current) {
const scrollTarget = nowTopPx - window.innerHeight / 3;
@@ -447,6 +497,18 @@ export default function RoutinesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading, isToday]);
const handleCompleteTask = async (taskId: string) => {
try {
await api.tasks.update(taskId, { status: 'completed' });
setAllTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: 'completed' } : t))
);
} catch (err) {
console.error('Failed to complete task:', err);
setError(err instanceof Error ? err.message : 'Failed to complete task');
}
};
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
@@ -776,8 +838,66 @@ export default function RoutinesPage() {
);
})}
{/* Task cards */}
{tasksForDay.map((task) => {
const d = new Date(task.scheduled_datetime);
const startMin = d.getHours() * 60 + d.getMinutes();
const topPx = minutesToTop(startMin, displayStartHour);
const isPast = task.status === 'completed';
const layout = timelineLayout.get(`t-${task.id}`) ?? {
lane: 0,
totalLanes: 1,
};
const timeStr = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
return (
<div
key={task.id}
onClick={() => router.push('/dashboard/tasks')}
style={{
top: `${topPx}px`,
height: '48px',
...laneStyle(layout.lane, layout.totalLanes),
}}
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden cursor-pointer ${
isPast
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 opacity-75'
: 'bg-purple-50 dark:bg-purple-900/30 border-purple-200 dark:border-purple-800'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg leading-none flex-shrink-0">📋</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">
{task.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{formatTime(timeStr)}
{task.description && ` · ${task.description}`}
</p>
</div>
{isPast ? (
<span className="text-green-600 flex-shrink-0">
<CheckIcon size={16} />
</span>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleCompleteTask(task.id);
}}
className="bg-green-600 text-white p-1 rounded-lg flex-shrink-0"
>
<CheckIcon size={14} />
</button>
)}
</div>
</div>
);
})}
{/* Empty day */}
{scheduledForDay.length === 0 && medEntries.length === 0 && (
{scheduledForDay.length === 0 && medEntries.length === 0 && tasksForDay.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 text-sm">
No routines or medications for this day

View File

@@ -21,6 +21,43 @@ interface NotifSettings {
ntfy_enabled: boolean;
}
interface AdaptiveMedSettings {
adaptive_timing_enabled: boolean;
adaptive_mode: string;
presence_tracking_enabled: boolean;
nagging_enabled: boolean;
nag_interval_minutes: number;
max_nag_count: number;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
}
interface PresenceStatus {
is_online: boolean;
last_online_at: string | null;
typical_wake_time: string | null;
}
interface SnitchSettings {
snitch_enabled: boolean;
trigger_after_nags: number;
trigger_after_missed_doses: number;
max_snitches_per_day: number;
require_consent: boolean;
consent_given: boolean;
snitch_cooldown_hours: number;
}
interface SnitchContact {
id: string;
contact_name: string;
contact_type: string;
contact_value: string;
priority: number;
notify_all: boolean;
is_active: boolean;
}
export default function SettingsPage() {
const [prefs, setPrefs] = useState<Preferences>({
sound_enabled: false,
@@ -34,8 +71,43 @@ export default function SettingsPage() {
ntfy_topic: '',
ntfy_enabled: false,
});
const [adaptiveMeds, setAdaptiveMeds] = useState<AdaptiveMedSettings>({
adaptive_timing_enabled: false,
adaptive_mode: 'shift_all',
presence_tracking_enabled: false,
nagging_enabled: true,
nag_interval_minutes: 15,
max_nag_count: 4,
quiet_hours_start: null,
quiet_hours_end: null,
});
const [presence, setPresence] = useState<PresenceStatus>({
is_online: false,
last_online_at: null,
typical_wake_time: null,
});
const [snitch, setSnitch] = useState<SnitchSettings>({
snitch_enabled: false,
trigger_after_nags: 4,
trigger_after_missed_doses: 1,
max_snitches_per_day: 2,
require_consent: true,
consent_given: false,
snitch_cooldown_hours: 4,
});
const [snitchContacts, setSnitchContacts] = useState<SnitchContact[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [saved, setSaved] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showSnitchHelp, setShowSnitchHelp] = useState(false);
const [showAddContact, setShowAddContact] = useState(false);
const [newContact, setNewContact] = useState({
contact_name: '',
contact_type: 'discord',
contact_value: '',
priority: 1,
notify_all: false,
});
useEffect(() => {
Promise.all([
@@ -46,11 +118,26 @@ export default function SettingsPage() {
ntfy_topic: data.ntfy_topic,
ntfy_enabled: data.ntfy_enabled,
})),
api.adaptiveMeds.getSettings().then((data: AdaptiveMedSettings) => setAdaptiveMeds(data)),
api.adaptiveMeds.getPresence().then((data: PresenceStatus) => setPresence(data)),
api.snitch.getSettings().then((data: SnitchSettings) => setSnitch(data)),
api.snitch.getContacts().then((data: SnitchContact[]) => setSnitchContacts(data)),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
// Poll for presence updates every 10 seconds
useEffect(() => {
if (!notif.discord_enabled || !adaptiveMeds.presence_tracking_enabled) return;
const interval = setInterval(() => {
api.adaptiveMeds.getPresence().then((data: PresenceStatus) => setPresence(data));
}, 10000);
return () => clearInterval(interval);
}, [notif.discord_enabled, adaptiveMeds.presence_tracking_enabled]);
const flashSaved = () => {
setSaved(true);
setTimeout(() => setSaved(false), 1500);
@@ -79,6 +166,87 @@ export default function SettingsPage() {
}
};
const updateAdaptiveMeds = async (updates: Partial<AdaptiveMedSettings>) => {
const prev = { ...adaptiveMeds };
const updated = { ...adaptiveMeds, ...updates };
setAdaptiveMeds(updated);
try {
await api.adaptiveMeds.updateSettings(updates);
flashSaved();
} catch {
setAdaptiveMeds(prev);
}
};
const updateSnitch = async (updates: Partial<SnitchSettings>) => {
const prev = { ...snitch };
const updated = { ...snitch, ...updates };
setSnitch(updated);
try {
await api.snitch.updateSettings(updates);
flashSaved();
} catch {
setSnitch(prev);
}
};
const addContact = async () => {
try {
const result = await api.snitch.addContact(newContact);
const contact: SnitchContact = {
id: result.contact_id,
...newContact,
is_active: true,
};
setSnitchContacts([...snitchContacts, contact]);
setNewContact({
contact_name: '',
contact_type: 'discord',
contact_value: '',
priority: 1,
notify_all: false,
});
setShowAddContact(false);
flashSaved();
} catch (e) {
console.error('Failed to add contact:', e);
}
};
const updateContact = async (contactId: string, updates: Partial<SnitchContact>) => {
const prev = [...snitchContacts];
const updated = snitchContacts.map(c =>
c.id === contactId ? { ...c, ...updates } : c
);
setSnitchContacts(updated);
try {
await api.snitch.updateContact(contactId, updates);
flashSaved();
} catch {
setSnitchContacts(prev);
}
};
const deleteContact = async (contactId: string) => {
const prev = [...snitchContacts];
setSnitchContacts(snitchContacts.filter(c => c.id !== contactId));
try {
await api.snitch.deleteContact(contactId);
flashSaved();
} catch {
setSnitchContacts(prev);
}
};
const testSnitch = async () => {
try {
const result = await api.snitch.test();
alert(result.message);
} catch (e) {
alert('Failed to send test snitch');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -228,19 +396,263 @@ export default function SettingsPage() {
</button>
</div>
{notif.discord_enabled && (
<div className="space-y-1">
<input
type="text"
placeholder="Your Discord user ID"
placeholder="Your Discord user ID (numbers only)"
value={notif.discord_user_id}
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
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>
</div>
{/* Adaptive Medication Settings */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Smart Medication Timing</h2>
<button
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showHelp && (
<div className="mb-4 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2"><strong>Adaptive Timing:</strong> Automatically adjusts your medication schedule based on when you wake up. If you wake up late, your morning meds get shifted too.</p>
<p className="mb-2"><strong>Discord Presence:</strong> Detects when you come online (wake up) and uses that to calculate adjustments. Requires Discord notifications to be enabled.</p>
<p><strong>Nagging:</strong> Sends you reminders every 15 minutes (configurable) up to 4 times if you miss a dose.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Enable Adaptive Timing */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable adaptive timing</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Adjust medication times based on your wake time</p>
</div>
<button
onClick={() => {
const newEnabled = !adaptiveMeds.adaptive_timing_enabled;
const updates: Partial<AdaptiveMedSettings> = { adaptive_timing_enabled: newEnabled };
if (newEnabled) {
updates.adaptive_mode = adaptiveMeds.adaptive_mode;
}
updateAdaptiveMeds(updates);
}}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.adaptive_timing_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.adaptive_timing_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.adaptive_timing_enabled && (
<div className="mt-3 space-y-3">
{/* Adaptive Mode Selection */}
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Adjustment mode</p>
<div className="space-y-2">
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_all' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Shift all medications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Delay all doses by the same amount</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_all' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_partial' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Partial shift</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Shift morning meds only, keep afternoon/evening fixed</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_partial' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
</div>
</div>
</div>
)}
</div>
{/* Presence Tracking */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Discord presence tracking</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Detect when you wake up via Discord</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ presence_tracking_enabled: !adaptiveMeds.presence_tracking_enabled })}
disabled={!notif.discord_enabled}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.presence_tracking_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!notif.discord_enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.presence_tracking_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{!notif.discord_enabled && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable Discord notifications above to use presence tracking
</p>
)}
{notif.discord_enabled && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${presence.is_online ? 'bg-green-500' : 'bg-gray-400'}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{presence.is_online ? 'Online' : 'Offline'}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{presence.last_online_at ? `Last seen: ${new Date(presence.last_online_at).toLocaleString()}` : 'Never seen online'}
</span>
</div>
{presence.typical_wake_time && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Typical wake time: <span className="font-medium text-gray-700 dark:text-gray-300">{presence.typical_wake_time}</span>
</p>
)}
</div>
)}
</div>
{/* Nagging Settings */}
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable nagging</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Send reminders for missed doses</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ nagging_enabled: !adaptiveMeds.nagging_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.nagging_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.nagging_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.nagging_enabled && (
<>
{/* Nag Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reminder interval (minutes)
</label>
<input
type="number"
min="5"
max="60"
value={adaptiveMeds.nag_interval_minutes}
onChange={(e) => updateAdaptiveMeds({ nag_interval_minutes: parseInt(e.target.value) || 15 })}
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 bg-white dark:bg-gray-700"
/>
</div>
{/* Max Nag Count */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maximum reminders per dose
</label>
<input
type="number"
min="1"
max="10"
value={adaptiveMeds.max_nag_count}
onChange={(e) => updateAdaptiveMeds({ max_nag_count: parseInt(e.target.value) || 4 })}
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 bg-white dark:bg-gray-700"
/>
</div>
</>
)}
</div>
{/* Quiet Hours */}
<div className="p-4 space-y-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Quiet hours</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Don&apos;t send notifications during these hours</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_start || ''}
onChange={(e) => updateAdaptiveMeds({ quiet_hours_start: e.target.value || null })}
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 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_end || ''}
onChange={(e) => updateAdaptiveMeds({ quiet_hours_end: e.target.value || null })}
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 bg-white dark:bg-gray-700"
/>
</div>
</div>
</div>
</div>
</div>
{/* Celebration Style */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Celebration Style</h2>
@@ -272,6 +684,272 @@ export default function SettingsPage() {
))}
</div>
</div>
{/* Snitch System */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Snitch System</h2>
<button
onClick={() => setShowSnitchHelp(!showSnitchHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showSnitchHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showSnitchHelp && (
<div className="mb-4 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2"><strong>The Snitch:</strong> When you miss medications repeatedly, the system can notify someone you trust (a &quot;snitch&quot;) to help keep you accountable.</p>
<p className="mb-2"><strong>Consent:</strong> You must give consent to enable this feature. You can revoke consent at any time.</p>
<p className="mb-2"><strong>Triggers:</strong> Configure after how many nags or missed doses the snitch activates.</p>
<p><strong>Privacy:</strong> Only you can see and manage your snitch contacts. They only receive alerts when triggers are met.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Consent */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable snitch system</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Allow trusted contacts to be notified about missed medications</p>
</div>
<button
onClick={() => {
if (!snitch.consent_given) {
alert('Please give consent below first');
return;
}
updateSnitch({ snitch_enabled: !snitch.snitch_enabled });
}}
disabled={!snitch.consent_given}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.snitch_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!snitch.consent_given ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.snitch_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Consent Toggle */}
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">I consent to snitch notifications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">I understand and agree that trusted contacts may be notified</p>
</div>
<button
onClick={() => updateSnitch({ consent_given: !snitch.consent_given })}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.consent_given ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.consent_given ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{snitch.snitch_enabled && (
<>
{/* Trigger Settings */}
<div className="p-4 space-y-4">
<p className="font-medium text-gray-900 dark:text-gray-100">Trigger Settings</p>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after nags
</label>
<input
type="number"
min="1"
max="20"
value={snitch.trigger_after_nags}
onChange={(e) => updateSnitch({ trigger_after_nags: parseInt(e.target.value) || 4 })}
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 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after missed doses
</label>
<input
type="number"
min="1"
max="10"
value={snitch.trigger_after_missed_doses}
onChange={(e) => updateSnitch({ trigger_after_missed_doses: parseInt(e.target.value) || 1 })}
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 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max snitches per day
</label>
<input
type="number"
min="1"
max="10"
value={snitch.max_snitches_per_day}
onChange={(e) => updateSnitch({ max_snitches_per_day: parseInt(e.target.value) || 2 })}
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 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cooldown between snitches (hours)
</label>
<input
type="number"
min="1"
max="24"
value={snitch.snitch_cooldown_hours}
onChange={(e) => updateSnitch({ snitch_cooldown_hours: parseInt(e.target.value) || 4 })}
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 bg-white dark:bg-gray-700"
/>
</div>
</div>
{/* Contacts */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Snitch Contacts</p>
<button
onClick={() => setShowAddContact(!showAddContact)}
className="text-sm px-3 py-1 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600"
>
+ Add Contact
</button>
</div>
{/* Add Contact Form */}
{showAddContact && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg space-y-3">
<input
type="text"
placeholder="Contact name"
value={newContact.contact_name}
onChange={(e) => setNewContact({ ...newContact, contact_name: e.target.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"
/>
<select
value={newContact.contact_type}
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>
<option value="email">Email</option>
<option value="sms">SMS</option>
</select>
<input
type="text"
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID (numbers only)' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_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">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={newContact.notify_all}
onChange={(e) => setNewContact({ ...newContact, notify_all: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Always notify this contact</span>
</label>
<div className="flex gap-2">
<button
onClick={() => setShowAddContact(false)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={addContact}
disabled={!newContact.contact_name || !newContact.contact_value}
className="px-3 py-1 text-sm bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
)}
{/* Contact List */}
<div className="space-y-2">
{snitchContacts.map((contact) => (
<div key={contact.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">{contact.contact_name}</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded-full text-gray-600 dark:text-gray-400">
{contact.contact_type}
</span>
{contact.notify_all && (
<span className="text-xs px-2 py-0.5 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 rounded-full">
Always notify
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{contact.contact_value}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateContact(contact.id, { is_active: !contact.is_active })}
className={`text-xs px-2 py-1 rounded ${
contact.is_active
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'
: 'bg-gray-200 dark:bg-gray-600 text-gray-500'
}`}
>
{contact.is_active ? 'Active' : 'Inactive'}
</button>
<button
onClick={() => deleteContact(contact.id)}
className="text-red-500 hover:text-red-600 p-1"
>
🗑
</button>
</div>
</div>
))}
{snitchContacts.length === 0 && (
<p className="text-center text-gray-500 dark:text-gray-400 py-4">No contacts added yet</p>
)}
</div>
{/* Test Button */}
{snitchContacts.length > 0 && (
<button
onClick={testSnitch}
className="mt-4 w-full py-2 text-sm border-2 border-indigo-500 text-indigo-500 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
>
🧪 Test Snitch (sends to first contact only)
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
const REMINDER_OPTIONS = [
{ label: 'No reminder', value: 0 },
{ label: '5 minutes before', value: 5 },
{ label: '10 minutes before', value: 10 },
{ label: '15 minutes before', value: 15 },
{ label: '30 minutes before', value: 30 },
{ label: '1 hour before', value: 60 },
];
function localDatetimeDefault(): string {
const now = new Date();
now.setMinutes(now.getMinutes() + 60, 0, 0);
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
export default function NewTaskPage() {
const router = useRouter();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [scheduledDatetime, setScheduledDatetime] = useState(localDatetimeDefault);
const [reminderMinutes, setReminderMinutes] = useState(15);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError('Title is required.');
return;
}
if (!scheduledDatetime) {
setError('Date and time are required.');
return;
}
setIsSubmitting(true);
setError('');
try {
await api.tasks.create({
title: title.trim(),
description: description.trim() || undefined,
scheduled_datetime: scheduledDatetime,
reminder_minutes_before: reminderMinutes,
});
router.push('/dashboard/tasks');
} catch (err) {
console.error('Failed to create task:', err);
setError('Failed to create task. Please try again.');
setIsSubmitting(false);
}
};
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">
<div className="flex items-center gap-3 px-4 py-3">
<Link href="/dashboard/tasks" className="p-1 -ml-1 text-gray-500 dark:text-gray-400">
<ArrowLeftIcon size={20} />
</Link>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">New Task</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-5 max-w-lg mx-auto">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Doctor appointment"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description <span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Add details..."
rows={2}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm resize-none"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Date &amp; Time <span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
value={scheduledDatetime}
onChange={e => setScheduledDatetime(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Advance Reminder
</label>
<select
value={reminderMinutes}
onChange={e => setReminderMinutes(Number(e.target.value))}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
>
{REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
You&apos;ll also get a notification exactly at the scheduled time.
</p>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white font-semibold py-3 rounded-xl transition-colors"
>
{isSubmitting ? 'Saving…' : 'Add Task'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api, { Task } from '@/lib/api';
import { PlusIcon, CheckIcon, XIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
function formatDateTime(dtStr: string): string {
try {
const dt = new Date(dtStr);
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const timeStr = dt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (dt.toDateString() === now.toDateString()) return `Today at ${timeStr}`;
if (dt.toDateString() === tomorrow.toDateString()) return `Tomorrow at ${timeStr}`;
return dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + ` at ${timeStr}`;
} catch {
return dtStr;
}
}
function isPast(dtStr: string): boolean {
try {
return new Date(dtStr) < new Date();
} catch {
return false;
}
}
export default function TasksPage() {
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCompleted, setShowCompleted] = useState(false);
const loadTasks = async (status: string) => {
try {
const data = await api.tasks.list(status);
setTasks(data);
} catch (err) {
console.error('Failed to load tasks:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadTasks(showCompleted ? 'all' : 'pending');
}, [showCompleted]);
// Re-fetch when tab becomes visible or every 60s
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') loadTasks(showCompleted ? 'all' : 'pending');
};
document.addEventListener('visibilitychange', onVisible);
const poll = setInterval(() => loadTasks(showCompleted ? 'all' : 'pending'), 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearInterval(poll);
};
}, [showCompleted]);
const handleMarkDone = async (task: Task) => {
try {
await api.tasks.update(task.id, { status: 'completed' });
setTasks(prev => prev.filter(t => t.id !== task.id));
} catch (err) {
console.error('Failed to update task:', err);
}
};
const handleCancel = async (task: Task) => {
try {
await api.tasks.update(task.id, { status: 'cancelled' });
setTasks(prev => prev.filter(t => t.id !== task.id));
} catch (err) {
console.error('Failed to cancel task:', err);
}
};
const handleDelete = async (task: Task) => {
if (!confirm(`Delete "${task.title}"?`)) return;
try {
await api.tasks.delete(task.id);
setTasks(prev => prev.filter(t => t.id !== task.id));
} catch (err) {
console.error('Failed to delete task:', err);
}
};
const activeTasks = tasks.filter(t => t.status === 'pending' || t.status === 'notified');
const doneTasks = tasks.filter(t => t.status === 'completed' || t.status === 'cancelled');
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">
<div className="flex items-center justify-between px-4 py-3">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Tasks</h1>
<Link
href="/dashboard/tasks/new"
className="flex items-center gap-1 text-indigo-600 dark:text-indigo-400 font-medium text-sm"
>
<PlusIcon size={18} />
Add Task
</Link>
</div>
</header>
<div className="p-4 space-y-4">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : activeTasks.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
<ClockIcon size={40} className="mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400 mb-4">No upcoming tasks</p>
<Link
href="/dashboard/tasks/new"
className="inline-flex items-center gap-2 bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
<PlusIcon size={16} />
Add your first task
</Link>
</div>
) : (
<div className="space-y-3">
{activeTasks.map(task => (
<div
key={task.id}
className={`bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border-l-4 ${
task.status === 'notified'
? 'border-amber-400'
: isPast(task.scheduled_datetime)
? 'border-red-400'
: 'border-indigo-400'
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">{task.title}</h3>
{task.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-1.5">
<ClockIcon size={13} className="text-gray-400 flex-shrink-0" />
<span className={`text-sm ${isPast(task.scheduled_datetime) && task.status === 'pending' ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}`}>
{formatDateTime(task.scheduled_datetime)}
</span>
{task.status === 'notified' && (
<span className="text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400 px-1.5 py-0.5 rounded-full">
Notified
</span>
)}
{task.reminder_minutes_before > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{task.reminder_minutes_before}m reminder
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleMarkDone(task)}
className="p-2 text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg transition-colors"
title="Mark done"
>
<CheckIcon size={18} />
</button>
<button
onClick={() => handleCancel(task)}
className="p-2 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Cancel"
>
<XIcon size={18} />
</button>
<button
onClick={() => handleDelete(task)}
className="p-2 text-red-400 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Delete"
>
<TrashIcon size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Show completed toggle */}
{!isLoading && (
<button
onClick={() => setShowCompleted(!showCompleted)}
className="w-full text-sm text-gray-500 dark:text-gray-400 py-2 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{showCompleted ? 'Hide completed' : `Show completed${doneTasks.length > 0 ? ` (${doneTasks.length})` : ''}`}
</button>
)}
{/* Completed tasks */}
{showCompleted && doneTasks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 px-1">Completed / Cancelled</h2>
{doneTasks.map(task => (
<div key={task.id} className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm opacity-60 flex items-center justify-between">
<div>
<p className="text-gray-700 dark:text-gray-300 line-through">{task.title}</p>
<p className="text-xs text-gray-400 dark:text-gray-500">{formatDateTime(task.scheduled_datetime)}</p>
</div>
<button
onClick={() => handleDelete(task)}
className="p-1.5 text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<TrashIcon size={16} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 909 B

View File

@@ -3,12 +3,13 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import { HeartIcon } from '@/components/ui/Icons';
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [trustDevice, setTrustDevice] = useState(true);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, register } = useAuth();
@@ -21,10 +22,10 @@ export default function LoginPage() {
try {
if (isLogin) {
await login(username, password);
await login(username, password, trustDevice);
} else {
await register(username, password);
await login(username, password);
await login(username, password, trustDevice);
}
router.push('/');
} catch (err) {
@@ -38,9 +39,7 @@ export default function LoginPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md p-8">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-2xl flex items-center justify-center mb-4">
<HeartIcon className="text-white" size={32} />
</div>
<img src="/logo.png" alt="Synculous" className="w-16 h-16 mb-4" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Synculous</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{isLogin ? 'Welcome back!' : 'Create your account'}
@@ -82,6 +81,18 @@ export default function LoginPage() {
/>
</div>
{isLogin && (
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-indigo-500 focus:ring-indigo-500"
/>
This is a trusted device
</label>
)}
<button
type="submit"
disabled={isLoading}

View File

@@ -9,7 +9,7 @@ interface AuthContextType {
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
login: (username: string, password: string, trustDevice?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
@@ -54,8 +54,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
refreshUser();
}, [refreshUser]);
const login = async (username: string, password: string) => {
const result = await api.auth.login(username, password);
const login = async (username: string, password: string, trustDevice = false) => {
const result = await api.auth.login(username, password, trustDevice);
const storedToken = api.auth.getToken();
setToken(storedToken);

View File

@@ -65,7 +65,7 @@ export default function PushNotificationToggle() {
const { public_key } = await api.notifications.getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource,
});
const subJson = sub.toJSON();

View File

@@ -1,5 +1,18 @@
const API_URL = '';
export interface Task {
id: string;
user_uuid: string;
title: string;
description?: string;
scheduled_datetime: string;
reminder_minutes_before: number;
advance_notified: boolean;
status: 'pending' | 'notified' | 'completed' | 'cancelled';
created_at: string;
updated_at: string;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
@@ -11,16 +24,63 @@ function setToken(token: string): void {
function clearToken(): void {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
}
function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token');
}
function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token);
}
let refreshPromise: Promise<boolean> | null = null;
async function tryRefreshToken(): Promise<boolean> {
// Deduplicate concurrent refresh attempts
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const resp = await fetch(`${API_URL}/api/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (resp.ok) {
const data = await resp.json();
if (data.token) {
setToken(data.token);
return true;
}
}
// Refresh token is invalid/expired - clear everything
clearToken();
return false;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
options: RequestInit = {},
_retried = false,
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
'X-Timezone-Name': Intl.DateTimeFormat().resolvedOptions().timeZone,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
@@ -30,6 +90,14 @@ async function request<T>(
headers,
});
// Auto-refresh on 401
if (response.status === 401 && !_retried) {
const refreshed = await tryRefreshToken();
if (refreshed) {
return request<T>(endpoint, options, true);
}
}
if (!response.ok) {
const body = await response.text();
let errorMsg = 'Request failed';
@@ -48,12 +116,15 @@ async function request<T>(
export const api = {
// Auth
auth: {
login: async (username: string, password: string) => {
const result = await request<{ token: string }>('/api/login', {
login: async (username: string, password: string, trustDevice = false) => {
const result = await request<{ token: string; refresh_token?: string }>('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, trust_device: trustDevice }),
});
setToken(result.token);
if (result.refresh_token) {
setRefreshToken(result.refresh_token);
}
return result;
},
@@ -252,12 +323,15 @@ export const api = {
days: string[];
time: string;
remind: boolean;
frequency?: string;
interval_days?: number;
start_date?: string;
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
},
setSchedule: async (
routineId: string,
data: { days: string[]; time: string; remind?: boolean }
data: { days: string[]; time: string; remind?: boolean; frequency?: string; interval_days?: number; start_date?: string }
) => {
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
method: 'PUT',
@@ -281,6 +355,9 @@ export const api = {
time: string;
remind: boolean;
total_duration_minutes: number;
frequency?: string;
interval_days?: number;
start_date?: string;
}>>('/api/routines/schedules', { method: 'GET' });
},
@@ -636,6 +713,7 @@ export const api = {
show_launch_screen?: boolean;
celebration_style?: string;
timezone_offset?: number;
timezone_name?: string;
}) => {
return request<Record<string, unknown>>('/api/preferences', {
method: 'PUT',
@@ -689,6 +767,159 @@ export const api = {
},
},
// Adaptive Medications
adaptiveMeds: {
getSettings: async () => {
return request<{
adaptive_timing_enabled: boolean;
adaptive_mode: string;
presence_tracking_enabled: boolean;
nagging_enabled: boolean;
nag_interval_minutes: number;
max_nag_count: number;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
}>('/api/adaptive-meds/settings', { method: 'GET' });
},
updateSettings: async (data: {
adaptive_timing_enabled?: boolean;
adaptive_mode?: string;
presence_tracking_enabled?: boolean;
nagging_enabled?: boolean;
nag_interval_minutes?: number;
max_nag_count?: number;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
}) => {
return request<{ success: boolean }>('/api/adaptive-meds/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
},
getPresence: async () => {
return request<{
is_online: boolean;
last_online_at: string | null;
typical_wake_time: string | null;
}>('/api/adaptive-meds/presence', { method: 'GET' });
},
getSchedule: async () => {
return request<Array<{
medication_id: string;
medication_name: string;
base_time: string;
adjusted_time: string;
adjustment_minutes: number;
status: string;
nag_count: number;
}>>('/api/adaptive-meds/schedule', { method: 'GET' });
},
},
// Snitch System
snitch: {
getSettings: async () => {
return request<{
snitch_enabled: boolean;
trigger_after_nags: number;
trigger_after_missed_doses: number;
max_snitches_per_day: number;
require_consent: boolean;
consent_given: boolean;
snitch_cooldown_hours: number;
}>('/api/snitch/settings', { method: 'GET' });
},
updateSettings: async (data: {
snitch_enabled?: boolean;
trigger_after_nags?: number;
trigger_after_missed_doses?: number;
max_snitches_per_day?: number;
require_consent?: boolean;
consent_given?: boolean;
snitch_cooldown_hours?: number;
}) => {
return request<{ success: boolean }>('/api/snitch/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
},
giveConsent: async (consent_given: boolean) => {
return request<{ success: boolean; consent_given: boolean }>('/api/snitch/consent', {
method: 'POST',
body: JSON.stringify({ consent_given }),
});
},
getContacts: async () => {
return request<Array<{
id: string;
contact_name: string;
contact_type: string;
contact_value: string;
priority: number;
notify_all: boolean;
is_active: boolean;
}>>('/api/snitch/contacts', { method: 'GET' });
},
addContact: async (data: {
contact_name: string;
contact_type: string;
contact_value: string;
priority?: number;
notify_all?: boolean;
is_active?: boolean;
}) => {
return request<{ success: boolean; contact_id: string }>('/api/snitch/contacts', {
method: 'POST',
body: JSON.stringify(data),
});
},
updateContact: async (contactId: string, data: {
contact_name?: string;
contact_type?: string;
contact_value?: string;
priority?: number;
notify_all?: boolean;
is_active?: boolean;
}) => {
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
deleteContact: async (contactId: string) => {
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
method: 'DELETE',
});
},
getHistory: async (days?: number) => {
return request<Array<{
id: string;
contact_id: string;
medication_id: string;
trigger_reason: string;
snitch_count_today: number;
sent_at: string;
delivered: boolean;
}>>(`/api/snitch/history?days=${days || 7}`, { method: 'GET' });
},
test: async () => {
return request<{ success: boolean; message: string }>('/api/snitch/test', {
method: 'POST',
});
},
},
// Medications
medications: {
list: async () => {
@@ -839,6 +1070,25 @@ export const api = {
});
},
},
tasks: {
list: (status = 'pending') =>
request<Task[]>(`/api/tasks?status=${status}`, { method: 'GET' }),
create: (data: { title: string; description?: string; scheduled_datetime: string; reminder_minutes_before?: number }) =>
request<Task>('/api/tasks', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: Partial<Pick<Task, 'title' | 'description' | 'scheduled_datetime' | 'reminder_minutes_before' | 'status'>>) =>
request<Task>(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string) =>
request<{ success: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }),
},
ai: {
generateSteps: (goal: string) =>
request<{ steps: { name: string; duration_minutes: number }[] }>(
'/api/ai/generate-steps',
{ method: 'POST', body: JSON.stringify({ goal }) }
),
},
};
export default api;