Compare commits
58 Commits
f140f8f75c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215c3d7f95 | ||
| fe07b3ebe7 | |||
| 019561e7cd | |||
| e89656a87c | |||
| 03da0b0156 | |||
| cf29d17183 | |||
| cc1aace73d | |||
| a19e30db68 | |||
| e9a2f96f91 | |||
| 4c4ff5add3 | |||
| 33db2629e3 | |||
| ecb79af44e | |||
| 24a1d18b25 | |||
| d45929ddc0 | |||
| bebc609091 | |||
| 2951382c51 | |||
| 95ebae6766 | |||
| 9fb56edf74 | |||
| 288e447d3e | |||
| d2b074d39b | |||
| d4adbde3df | |||
| 6850abf7d2 | |||
| f826e511d5 | |||
| cf2b4be033 | |||
| 8ac7a5129a | |||
| 80ebecf0b1 | |||
| 0e28e1ac9d | |||
| ac27a9fb69 | |||
| d673d73530 | |||
| 123a7ce3e7 | |||
|
|
3d3b80fe96 | ||
|
|
596467628f | ||
|
|
a0126d0aba | ||
|
|
a2c7940a5c | ||
| 3aad7a4867 | |||
| 98706702da | |||
| 1d79516794 | |||
| f740fe8be2 | |||
| 6e875186b4 | |||
| 35f51e6d27 | |||
| a6ae4e13fd | |||
| 69163a37d1 | |||
| 84c6032dc9 | |||
| d4fb41ae6b | |||
| 1ed187b0dd | |||
| 2feaf0cdc0 | |||
| 09d453017c | |||
| 833800842a | |||
| e4e6ad44ac | |||
| c7be19611a | |||
| b1bb05e879 | |||
|
|
b3dab95cf9 | ||
|
|
d5737e97bf | ||
|
|
ad0faf72e1 | ||
| 832c1e1a23 | |||
| c693572069 | |||
| 028bdfa4f9 | |||
| a395f221cc |
300
README.md
@@ -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.**
|
||||
|
||||
@@ -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)
|
||||
|
||||
52
api/main.py
@@ -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}")
|
||||
|
||||
|
||||
|
||||
BIN
api/routes/__pycache__/adaptive_meds.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/medications.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/routines.cpython-312.pyc
Normal file
194
api/routes/adaptive_meds.py
Normal 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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -32,7 +32,6 @@ def _auth(request):
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
@app.route("/api/victories", methods=["GET"])
|
||||
def api_getVictories():
|
||||
"""Compute noteworthy achievements. Query: ?days=30"""
|
||||
@@ -42,11 +41,12 @@ def register(app):
|
||||
|
||||
days = flask.request.args.get("days", 30, type=int)
|
||||
cutoff = tz.user_now() - timedelta(days=days)
|
||||
# Convert to naive datetime for comparison with database timestamps
|
||||
cutoff = cutoff.replace(tzinfo=None)
|
||||
|
||||
sessions = postgres.select("routine_sessions", {"user_uuid": user_uuid})
|
||||
recent = [
|
||||
s for s in sessions
|
||||
if s.get("created_at") and s["created_at"] >= cutoff
|
||||
s for s in sessions if s.get("created_at") and s["created_at"] >= cutoff
|
||||
]
|
||||
completed = [s for s in recent if s.get("status") == "completed"]
|
||||
|
||||
@@ -60,28 +60,38 @@ def register(app):
|
||||
curr = sorted_completed[i]["created_at"]
|
||||
gap = (curr - prev).days
|
||||
if gap >= 2:
|
||||
victories.append({
|
||||
victories.append(
|
||||
{
|
||||
"type": "comeback",
|
||||
"message": f"Came back after {gap} days — that takes real strength",
|
||||
"date": curr.isoformat() if hasattr(curr, 'isoformat') else str(curr),
|
||||
})
|
||||
"date": curr.isoformat()
|
||||
if hasattr(curr, "isoformat")
|
||||
else str(curr),
|
||||
}
|
||||
)
|
||||
|
||||
# Weekend completion
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'weekday') and created.weekday() >= 5: # Saturday=5, Sunday=6
|
||||
victories.append({
|
||||
if (
|
||||
hasattr(created, "weekday") and created.weekday() >= 5
|
||||
): # Saturday=5, Sunday=6
|
||||
victories.append(
|
||||
{
|
||||
"type": "weekend",
|
||||
"message": "Completed a routine on the weekend",
|
||||
"date": created.isoformat() if hasattr(created, 'isoformat') else str(created),
|
||||
})
|
||||
"date": created.isoformat()
|
||||
if hasattr(created, "isoformat")
|
||||
else str(created),
|
||||
}
|
||||
)
|
||||
break # Only show once
|
||||
|
||||
# Variety: 3+ different routines in a week
|
||||
routine_ids_by_week = {}
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'isocalendar'):
|
||||
if hasattr(created, "isocalendar"):
|
||||
week_key = created.isocalendar()[:2]
|
||||
if week_key not in routine_ids_by_week:
|
||||
routine_ids_by_week[week_key] = set()
|
||||
@@ -89,11 +99,13 @@ def register(app):
|
||||
|
||||
for week_key, routine_ids in routine_ids_by_week.items():
|
||||
if len(routine_ids) >= 3:
|
||||
victories.append({
|
||||
victories.append(
|
||||
{
|
||||
"type": "variety",
|
||||
"message": f"Completed {len(routine_ids)} different routines in one week",
|
||||
"date": None,
|
||||
})
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# Full week consistency: completed every day for 7 consecutive days
|
||||
@@ -101,25 +113,27 @@ def register(app):
|
||||
dates_set = set()
|
||||
for s in completed:
|
||||
created = s["created_at"]
|
||||
if hasattr(created, 'date'):
|
||||
if hasattr(created, "date"):
|
||||
dates_set.add(created.date())
|
||||
|
||||
sorted_dates = sorted(dates_set)
|
||||
max_streak = 1
|
||||
current_streak = 1
|
||||
for i in range(1, len(sorted_dates)):
|
||||
if (sorted_dates[i] - sorted_dates[i-1]).days == 1:
|
||||
if (sorted_dates[i] - sorted_dates[i - 1]).days == 1:
|
||||
current_streak += 1
|
||||
max_streak = max(max_streak, current_streak)
|
||||
else:
|
||||
current_streak = 1
|
||||
|
||||
if max_streak >= 7:
|
||||
victories.append({
|
||||
victories.append(
|
||||
{
|
||||
"type": "consistency",
|
||||
"message": f"Completed routines every day for {max_streak} days straight",
|
||||
"date": None,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Limit and deduplicate
|
||||
seen_types = set()
|
||||
|
||||
BIN
bot/.DS_Store
vendored
Normal file
536
bot/bot.py
@@ -6,6 +6,7 @@ Features:
|
||||
- Session management with JWT tokens
|
||||
- AI-powered command parsing via registry
|
||||
- Background task loop for polling
|
||||
- JurySystem DBT integration for mental health support
|
||||
"""
|
||||
|
||||
import discord
|
||||
@@ -17,12 +18,15 @@ import base64
|
||||
import requests
|
||||
import bcrypt
|
||||
import pickle
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
|
||||
from bot.command_registry import get_handler, list_registered
|
||||
from bot.command_registry import get_handler, list_registered, register_module
|
||||
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")
|
||||
@@ -31,27 +35,157 @@ 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)
|
||||
|
||||
|
||||
# ==================== JurySystem Integration ====================
|
||||
|
||||
|
||||
class SimpleVectorStore:
|
||||
"""A simple in-memory vector store using NumPy."""
|
||||
|
||||
def __init__(self):
|
||||
self.vectors = []
|
||||
self.metadata = []
|
||||
|
||||
def add(self, vectors, metadatas):
|
||||
self.vectors.extend(vectors)
|
||||
self.metadata.extend(metadatas)
|
||||
|
||||
def search(self, query_vector, top_k=5):
|
||||
if not self.vectors:
|
||||
return []
|
||||
|
||||
query_vec = np.array(query_vector)
|
||||
doc_vecs = np.array(self.vectors)
|
||||
norms = np.linalg.norm(doc_vecs, axis=1)
|
||||
valid_indices = norms > 0
|
||||
scores = np.zeros(len(doc_vecs))
|
||||
dot_products = np.dot(doc_vecs, query_vec)
|
||||
scores[valid_indices] = dot_products[valid_indices] / (
|
||||
norms[valid_indices] * np.linalg.norm(query_vec)
|
||||
)
|
||||
top_indices = np.argsort(scores)[-top_k:][::-1]
|
||||
|
||||
results = []
|
||||
for idx in top_indices:
|
||||
results.append({"metadata": self.metadata[idx], "score": scores[idx]})
|
||||
return results
|
||||
|
||||
|
||||
class JurySystem:
|
||||
"""DBT Knowledge Base Query System"""
|
||||
|
||||
def __init__(self):
|
||||
config_path = os.getenv("CONFIG_PATH", "config.json")
|
||||
kb_path = os.getenv(
|
||||
"KNOWLEDGE_BASE_PATH", "bot/data/dbt_knowledge.embeddings.json"
|
||||
)
|
||||
|
||||
with open(config_path, "r") as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
self.client = OpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=self.config["openrouter_api_key"],
|
||||
)
|
||||
self.vector_store = SimpleVectorStore()
|
||||
self._load_knowledge_base(kb_path)
|
||||
|
||||
def _load_knowledge_base(self, kb_path):
|
||||
print(f"Loading DBT knowledge base from {kb_path}...")
|
||||
try:
|
||||
with open(kb_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
vectors = []
|
||||
metadata = []
|
||||
for item in data:
|
||||
vectors.append(item["embedding"])
|
||||
metadata.append(
|
||||
{"id": item["id"], "source": item["source"], "text": item["text"]}
|
||||
)
|
||||
self.vector_store.add(vectors, metadata)
|
||||
print(f"Loaded {len(vectors)} chunks into DBT vector store.")
|
||||
except Exception as e:
|
||||
print(f"Error loading DBT knowledge base: {e}")
|
||||
raise
|
||||
|
||||
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)
|
||||
|
||||
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."
|
||||
|
||||
context_text = "\n\n---\n\n".join(
|
||||
[chunk["metadata"]["text"] for chunk in context_chunks]
|
||||
)
|
||||
|
||||
system_prompt = """You are a helpful DBT (Dialectical Behavior Therapy) assistant.
|
||||
Use the provided context from the DBT Skills Training Handouts to answer the user's question.
|
||||
If the answer is not in the context, say you don't know based on the provided text.
|
||||
Be concise, compassionate, and practical."""
|
||||
|
||||
from ai.jury_council import generate_rag_answer
|
||||
|
||||
return await generate_rag_answer(query_text, context_text, system_prompt)
|
||||
except Exception as e:
|
||||
return f"Error querying DBT knowledge base: {e}"
|
||||
|
||||
|
||||
# Initialize JurySystem
|
||||
jury_system = None
|
||||
try:
|
||||
jury_system = JurySystem()
|
||||
print("✓ JurySystem (DBT) initialized successfully")
|
||||
except Exception as e:
|
||||
print(f"⚠ JurySystem initialization failed: {e}")
|
||||
|
||||
|
||||
# ==================== Original Bot Functions ====================
|
||||
|
||||
|
||||
def decodeJwtPayload(token):
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
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:
|
||||
@@ -60,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):
|
||||
@@ -73,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:
|
||||
@@ -98,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"]
|
||||
@@ -116,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)
|
||||
@@ -134,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
|
||||
@@ -205,6 +389,12 @@ Just talk to me naturally! Here are some examples:
|
||||
• "schedule workout for monday wednesday friday at 7am"
|
||||
• "show my stats"
|
||||
|
||||
**🧠 DBT Support:**
|
||||
• "how do I use distress tolerance?"
|
||||
• "explain radical acceptance"
|
||||
• "give me a DBT skill for anger"
|
||||
• "what are the TIPP skills?"
|
||||
|
||||
**💡 Tips:**
|
||||
• I understand natural language, typos, and slang
|
||||
• If I'm unsure, I'll ask for clarification
|
||||
@@ -233,24 +423,19 @@ async def handleConfirmation(message, session):
|
||||
if "pending_confirmations" not in session:
|
||||
return False
|
||||
|
||||
# Check for any pending confirmations
|
||||
pending = session["pending_confirmations"]
|
||||
if not pending:
|
||||
return False
|
||||
|
||||
# Get the most recent pending confirmation
|
||||
confirmation_id = list(pending.keys())[-1]
|
||||
confirmation_data = pending[confirmation_id]
|
||||
|
||||
if user_input in ("yes", "y", "yeah", "sure", "ok", "confirm"):
|
||||
# Execute the confirmed action
|
||||
del pending[confirmation_id]
|
||||
|
||||
interaction_type = confirmation_data.get("interaction_type")
|
||||
handler = get_handler(interaction_type)
|
||||
|
||||
if handler:
|
||||
# Create a fake parsed object for the handler
|
||||
fake_parsed = confirmation_data.copy()
|
||||
fake_parsed["needs_confirmation"] = False
|
||||
await handler(message, session, fake_parsed)
|
||||
@@ -268,7 +453,6 @@ async def handleActiveSessionShortcuts(message, session, active_session):
|
||||
"""Handle shortcuts like 'done', 'skip', 'next' when in active session."""
|
||||
user_input = message.content.lower().strip()
|
||||
|
||||
# Map common shortcuts to actions
|
||||
shortcuts = {
|
||||
"done": ("routine", "complete"),
|
||||
"finished": ("routine", "complete"),
|
||||
@@ -296,6 +480,101 @@ async def handleActiveSessionShortcuts(message, session, active_session):
|
||||
return False
|
||||
|
||||
|
||||
async def handleDBTQuery(message):
|
||||
"""Handle DBT-related queries using JurySystem + jury council pipeline."""
|
||||
if not jury_system:
|
||||
return False
|
||||
|
||||
# Keywords that indicate a DBT query
|
||||
dbt_keywords = [
|
||||
"dbt",
|
||||
"distress tolerance",
|
||||
"emotion regulation",
|
||||
"interpersonal effectiveness",
|
||||
"mindfulness",
|
||||
"radical acceptance",
|
||||
"wise mind",
|
||||
"tipp",
|
||||
"dearman",
|
||||
"check the facts",
|
||||
"opposite action",
|
||||
"cope ahead",
|
||||
"abc please",
|
||||
"stop skill",
|
||||
"pros and cons",
|
||||
"half smile",
|
||||
"willing hands",
|
||||
]
|
||||
|
||||
user_input_lower = message.content.lower()
|
||||
is_dbt_query = any(keyword in user_input_lower for keyword in dbt_keywords)
|
||||
|
||||
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():
|
||||
# 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
|
||||
|
||||
# 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):
|
||||
discord_id = message.author.id
|
||||
session = user_sessions[discord_id]
|
||||
@@ -321,6 +600,11 @@ async def routeCommand(message):
|
||||
if shortcut_handled:
|
||||
return
|
||||
|
||||
# Check for DBT queries
|
||||
dbt_handled = await handleDBTQuery(message)
|
||||
if dbt_handled:
|
||||
return
|
||||
|
||||
async with message.channel.typing():
|
||||
history = message_history.get(discord_id, [])
|
||||
|
||||
@@ -366,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
|
||||
@@ -405,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)
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -53,9 +59,24 @@ def load_knowledge_base(
|
||||
with open(file_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Handle both dict format {"chunks": [...], "embeddings": [...], "metadata": {...}}
|
||||
# and legacy list format where data is just the chunks
|
||||
if isinstance(data, dict):
|
||||
chunks = data.get("chunks", [])
|
||||
embeddings = data.get("embeddings", [])
|
||||
metadata = data.get("metadata", {})
|
||||
elif isinstance(data, list):
|
||||
# Legacy format: assume it's just chunks, or list of [chunk, embedding] pairs
|
||||
if data and isinstance(data[0], dict) and "text" in data[0]:
|
||||
# Format: [{"text": "...", "embedding": [...]}, ...]
|
||||
chunks = [item.get("text", "") for item in data]
|
||||
embeddings = [item.get("embedding", []) for item in data]
|
||||
metadata = {"format": "legacy_list_of_dicts"}
|
||||
else:
|
||||
# Unknown list format - can't process
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
# Add file_path to metadata for reference
|
||||
metadata["_file_path"] = file_path
|
||||
@@ -64,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
|
||||
|
||||
|
||||
@@ -256,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
|
||||
)
|
||||
@@ -271,9 +301,44 @@ async def handle_knowledge(message, session, parsed):
|
||||
except Exception as e:
|
||||
await message.channel.send(f"❌ Error processing query: {e}")
|
||||
|
||||
elif action == "dbt_evaluate_advice":
|
||||
advice = parsed.get("advice", "")
|
||||
if not advice:
|
||||
await message.channel.send("Please provide the advice you want to evaluate.")
|
||||
return
|
||||
|
||||
await message.channel.send("Processing your request for DBT advice evaluation. This may take a minute...")
|
||||
|
||||
system_prompt = """You are an expert in Dialectical Behavior Therapy (DBT).
|
||||
Your task is to evaluate the provided advice against DBT principles.
|
||||
Focus on whether the advice aligns with DBT skills, such as mindfulness, distress tolerance, emotion regulation, and interpersonal effectiveness.
|
||||
Provide a clear "cleared" or "not cleared" judgment, followed by a brief explanation of why, referencing specific DBT principles where applicable.
|
||||
|
||||
Example of good advice evaluation:
|
||||
CLEARED: This advice encourages mindfulness by suggesting to observe thoughts without judgment, which is a core DBT skill.
|
||||
|
||||
Example of bad advice evaluation:
|
||||
NOT CLEARED: This advice promotes suppressing emotions, which is contrary to DBT's emphasis on emotion regulation through healthy expression and understanding.
|
||||
|
||||
Evaluate the following advice:
|
||||
"""
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=CHAT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": advice},
|
||||
],
|
||||
temperature=0.2, # Slightly higher temperature for more varied explanations, but still grounded
|
||||
)
|
||||
evaluation = response.choices[0].message.content
|
||||
await message.channel.send(f"**DBT Advice Jury Says:**\n{evaluation}")
|
||||
except Exception as e:
|
||||
await message.channel.send(f"❌ Error evaluating advice: {e}")
|
||||
|
||||
else:
|
||||
await message.channel.send(
|
||||
f"Unknown knowledge action: {action}. Try: list, select, or ask a question."
|
||||
f"Unknown knowledge action: {action}. Try: list, select, query, or dbt_evaluate_advice."
|
||||
)
|
||||
|
||||
|
||||
@@ -290,6 +355,11 @@ def validate_knowledge_json(data):
|
||||
if "action" not in data:
|
||||
errors.append("Missing required field: action")
|
||||
|
||||
action = data.get("action")
|
||||
|
||||
if action == "dbt_evaluate_advice" and "advice" not in data:
|
||||
errors.append("Missing required field for dbt_evaluate_advice: advice")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""
|
||||
Medications command handler - bot-side hooks for medication management
|
||||
"""
|
||||
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from bot.command_registry import register_module
|
||||
import ai.parser as ai_parser
|
||||
import pytz
|
||||
|
||||
|
||||
def _get_nearest_scheduled_time(times, user_tz=None):
|
||||
@@ -14,7 +13,7 @@ def _get_nearest_scheduled_time(times, user_tz=None):
|
||||
|
||||
Args:
|
||||
times: List of time strings like ["08:00", "20:00"]
|
||||
user_tz: pytz timezone object for the user
|
||||
user_tz: pytz timezone object or offset in minutes
|
||||
|
||||
Returns the time as HH:MM string, or None if no times provided.
|
||||
"""
|
||||
@@ -25,6 +24,13 @@ def _get_nearest_scheduled_time(times, user_tz=None):
|
||||
if user_tz is None:
|
||||
# Default to UTC if no timezone provided
|
||||
user_tz = timezone.utc
|
||||
elif isinstance(user_tz, int):
|
||||
# If user_tz is an offset in minutes, convert to timezone object.
|
||||
# The int format is: positive = behind UTC (e.g. 480 = UTC-8).
|
||||
# Python's timezone offset is: positive = ahead of UTC.
|
||||
# So we negate the input to get the correct standard offset.
|
||||
user_tz = timezone(timedelta(minutes=-user_tz))
|
||||
|
||||
now = datetime.now(user_tz)
|
||||
now_minutes = now.hour * 60 + now.minute
|
||||
|
||||
@@ -51,10 +57,6 @@ def _get_nearest_scheduled_time(times, user_tz=None):
|
||||
|
||||
# If no time within window, use the most recent past time
|
||||
if best_time is None:
|
||||
# Get current time in user's timezone again for reference
|
||||
now = datetime.now(user_tz)
|
||||
now_minutes = now.hour * 60 + now.minute
|
||||
|
||||
# Find the most recent past time
|
||||
past_times = []
|
||||
for time_str in times:
|
||||
@@ -76,56 +78,15 @@ def _get_nearest_scheduled_time(times, user_tz=None):
|
||||
past_times.sort()
|
||||
best_time = past_times[-1][1]
|
||||
|
||||
return best_time
|
||||
if not best_time:
|
||||
for time_str in times:
|
||||
try:
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
time_minutes = hour * 60 + minute
|
||||
|
||||
# If this time was earlier today, it's a candidate
|
||||
if time_minutes <= now_minutes:
|
||||
diff = now_minutes - time_minutes
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best_time = time_str
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
# If still no time found, use the first scheduled time
|
||||
# Fallback: if still no time found, use the first scheduled time
|
||||
if not best_time and times:
|
||||
best_time = times[0]
|
||||
|
||||
return best_time
|
||||
|
||||
|
||||
async def _get_user_timezone(bot, user_id):
|
||||
"""Check if user has timezone set, ask for it if not.
|
||||
|
||||
Returns the timezone string or None if user cancels.
|
||||
"""
|
||||
# Check if user has timezone set
|
||||
user_data = await bot.api.get_user_data(user_id)
|
||||
if user_data and user_data.get('timezone'):
|
||||
return user_data['timezone']
|
||||
|
||||
# Ask user for their timezone
|
||||
await bot.send_dm(user_id, "🕐 I don't have your timezone set yet. Could you please tell me your timezone?\n\n" +
|
||||
"You can provide it in various formats:\n" +
|
||||
"- Timezone name (e.g., 'America/New_York', 'Europe/London')\n" +
|
||||
"- UTC offset (e.g., 'UTC+2', '-05:00')\n" +
|
||||
"- Common abbreviations (e.g., 'EST', 'PST')\n\n" +
|
||||
"Please reply with your timezone and I'll set it up for you!")
|
||||
|
||||
# Wait for user response (simplified - in real implementation this would be more complex)
|
||||
# For now, we'll just return None to indicate we need to handle this differently
|
||||
return None
|
||||
await bot.send_dm(user_id, "I didn't understand that timezone format. Please say something like:\n"
|
||||
'- "UTC-8" or "-8" for Pacific Time\n'
|
||||
'- "UTC+1" or "+1" for Central European Time\n'
|
||||
'- "PST", "EST", "CST", "MST" for US timezones')
|
||||
return None
|
||||
|
||||
async def _get_user_timezone(message, session, token):
|
||||
"""Get user's timezone offset. Returns offset_minutes or None if not set."""
|
||||
# Check if user has timezone set in preferences
|
||||
resp, status = api_request("get", "/api/preferences", token)
|
||||
if status == 200 and resp:
|
||||
@@ -133,7 +94,7 @@ async def _get_user_timezone(bot, user_id):
|
||||
if offset is not None:
|
||||
return offset
|
||||
|
||||
# No timezone set - need to ask user
|
||||
# No timezone set
|
||||
return None
|
||||
|
||||
|
||||
@@ -251,10 +212,74 @@ async def _get_scheduled_time_from_context(message, med_name):
|
||||
|
||||
|
||||
async def handle_medication(message, session, parsed):
|
||||
action = parsed.get("action", "unknown")
|
||||
token = session["token"]
|
||||
user_uuid = session["user_uuid"]
|
||||
|
||||
# --- PENDING CONFIRMATION HANDLER ---
|
||||
# Check if we are waiting for a response (e.g., timezone, yes/no confirmation)
|
||||
pending = session.get("pending_confirmations", {})
|
||||
|
||||
# 1. Handle Pending Timezone
|
||||
if "timezone" in pending:
|
||||
user_response = message.content.strip()
|
||||
offset = _parse_timezone(user_response)
|
||||
|
||||
if offset is not None:
|
||||
# Save to API
|
||||
resp, status = api_request(
|
||||
"put", "/api/preferences", token, {"timezone_offset": offset}
|
||||
)
|
||||
if status == 200:
|
||||
# Retrieve the stored action context
|
||||
prev_context = pending["timezone"]
|
||||
del session["pending_confirmations"]["timezone"]
|
||||
|
||||
await message.channel.send(f"✅ Timezone set successfully.")
|
||||
|
||||
# Restore the previous action so we can resume it
|
||||
# We merge the context into 'parsed' so the logic below continues correctly
|
||||
parsed = prev_context
|
||||
else:
|
||||
await message.channel.send("Error saving timezone. Please try again.")
|
||||
return
|
||||
else:
|
||||
await message.channel.send(
|
||||
"I didn't understand that timezone format. Please say something like:\n"
|
||||
'- "UTC-8" or "-8" for Pacific Time\n'
|
||||
'- "UTC+1" or "+1" for Central European Time\n'
|
||||
'- "PST", "EST", "CST", "MST" for US timezones'
|
||||
)
|
||||
return
|
||||
|
||||
# 2. Handle Generic Yes/No Confirmations (Add/Delete meds)
|
||||
# Find keys that look like confirmations (excluding timezone)
|
||||
confirm_keys = [k for k in pending if k.startswith("med_")]
|
||||
if confirm_keys:
|
||||
text = message.content.strip().lower()
|
||||
key = confirm_keys[0] # Handle one confirmation at a time
|
||||
stored_action = pending[key]
|
||||
|
||||
if text in ["yes", "y", "confirm"]:
|
||||
del session["pending_confirmations"][key]
|
||||
# Resume the action with confirmation bypassed
|
||||
parsed = stored_action
|
||||
# Force needs_confirmation to False just in case
|
||||
parsed["needs_confirmation"] = False
|
||||
|
||||
elif text in ["no", "n", "cancel"]:
|
||||
del session["pending_confirmations"][key]
|
||||
await message.channel.send("Okay, cancelled.")
|
||||
return
|
||||
else:
|
||||
# User said something else, remind them
|
||||
await message.channel.send(
|
||||
"I'm waiting for a confirmation. Please reply **yes** to proceed or **no** to cancel."
|
||||
)
|
||||
return
|
||||
|
||||
# --- MAIN ACTION HANDLER ---
|
||||
action = parsed.get("action", "unknown")
|
||||
|
||||
if action == "list":
|
||||
resp, status = api_request("get", "/api/medications", token)
|
||||
if status == 200:
|
||||
@@ -398,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 1–6 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 # 1–6 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 1–6 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")
|
||||
@@ -620,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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
1
bot/data/dbt_knowledge.embeddings.json
Normal file
4657
bot/data/dbt_knowledge.text.json
Normal file
12
config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"openrouter_api_key": "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",
|
||||
"embedding_file": "bot/data/dbt_knowledge.embeddings.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."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
93
config/migration_20250217.sql
Normal 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);
|
||||
@@ -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
@@ -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"]})
|
||||
48
core/auth.py
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
70
core/tz.py
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -10,3 +10,4 @@ pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pywebpush>=1.14.0
|
||||
numpy>=1.26.0
|
||||
pytz
|
||||
|
||||
@@ -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__":
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 22 KiB |
BIN
synculous-client/public/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
interface MedEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
times: string[];
|
||||
daysOfWeek: string[];
|
||||
intervalDays: number;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
const handleAddTime = () => {
|
||||
setTimes([...times, '12:00']);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
const handleRemoveTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index));
|
||||
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 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;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.medications.create({
|
||||
name,
|
||||
dosage,
|
||||
unit,
|
||||
frequency,
|
||||
times: frequency === 'as_needed' ? [] : times,
|
||||
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
|
||||
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
|
||||
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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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 "snitch") 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>
|
||||
);
|
||||
}
|
||||
|
||||
150
synculous-client/src/app/dashboard/tasks/new/page.tsx
Normal 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 & 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'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>
|
||||
);
|
||||
}
|
||||
230
synculous-client/src/app/dashboard/tasks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 909 B |
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||