Compare commits
51 Commits
b3dab95cf9
...
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 |
346
README.md
@@ -1,46 +1,67 @@
|
|||||||
# Synculous
|
# Synculous
|
||||||
|
|
||||||
A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
||||||
|
|
||||||
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame.
|
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
synculous/
|
synculous/
|
||||||
├── synculous-client/ # Next.js 16 frontend (React, Tailwind)
|
├── synculous-client/ # Next.js frontend (React, Tailwind)
|
||||||
├── api/ # Flask REST API
|
├── api/ # Flask REST API
|
||||||
│ ├── main.py # App entry point, auth routes
|
│ ├── main.py # App entry point, auth routes
|
||||||
│ └── routes/ # Domain route modules
|
│ └── routes/ # Domain route modules
|
||||||
│ ├── routines.py # Routines CRUD + sessions
|
│ ├── routines.py # Routines CRUD + sessions
|
||||||
│ ├── routine_sessions_extended.py # Pause, resume, abort, notes
|
│ ├── routine_sessions_extended.py # Pause, resume, abort, notes
|
||||||
│ ├── routine_stats.py # Completion stats, streaks, weekly summary
|
│ ├── routine_stats.py # Completion stats, streaks, weekly summary
|
||||||
│ ├── routine_templates.py # Premade routine templates
|
│ ├── routine_templates.py # Premade routine templates
|
||||||
│ ├── routine_steps_extended.py # Step instructions, types, media
|
│ ├── routine_steps_extended.py # Step instructions, types, media
|
||||||
│ ├── routine_tags.py # Tagging system
|
│ ├── routine_tags.py # Tagging system
|
||||||
│ ├── medications.py # Medication scheduling + adherence
|
│ ├── medications.py # Medication scheduling + adherence
|
||||||
│ ├── preferences.py # User settings + timezone
|
│ ├── adaptive_meds.py # Adaptive medication timing (learning)
|
||||||
│ ├── notifications.py # Web push subscriptions
|
│ ├── tasks.py # One-off tasks/appointments CRUD
|
||||||
│ ├── rewards.py # Variable reward system
|
│ ├── ai.py # AI-powered step generation
|
||||||
│ └── victories.py # Achievement detection
|
│ ├── preferences.py # User settings + timezone
|
||||||
├── core/ # Shared business logic
|
│ ├── notifications.py # Web push subscriptions
|
||||||
│ ├── postgres.py # Generic PostgreSQL CRUD
|
│ ├── rewards.py # Variable reward system
|
||||||
│ ├── auth.py # JWT + bcrypt authentication
|
│ ├── victories.py # Achievement detection
|
||||||
│ ├── users.py # User management
|
│ └── snitch.py # Peer accountability contacts + notifications
|
||||||
│ ├── routines.py # Routine/session/streak logic
|
├── core/ # Shared business logic
|
||||||
│ ├── tz.py # Timezone-aware date/time helpers
|
│ ├── postgres.py # Generic PostgreSQL CRUD
|
||||||
│ └── notifications.py # Multi-channel notifications
|
│ ├── auth.py # JWT + bcrypt authentication
|
||||||
|
│ ├── users.py # User management
|
||||||
|
│ ├── routines.py # Routine/session/streak logic
|
||||||
|
│ ├── stats.py # Statistics calculations (completion rates, streaks)
|
||||||
|
│ ├── snitch.py # Snitch trigger logic + notification delivery
|
||||||
|
│ ├── adaptive_meds.py # Adaptive medication timing logic
|
||||||
|
│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset)
|
||||||
|
│ └── notifications.py # Multi-channel notifications
|
||||||
├── scheduler/
|
├── scheduler/
|
||||||
│ └── daemon.py # Background polling for reminders
|
│ └── daemon.py # Background polling for reminders
|
||||||
├── bot/ # Discord bot (optional)
|
├── bot/ # Discord bot with knowledge RAG
|
||||||
├── ai/ # LLM parser for natural language commands
|
│ ├── bot.py # Bot entry point + session management
|
||||||
|
│ ├── command_registry.py # Module-based command routing
|
||||||
|
│ ├── commands/ # Command modules
|
||||||
|
│ │ ├── routines.py # /routine commands
|
||||||
|
│ │ ├── medications.py # /med, /take, /skip commands
|
||||||
|
│ │ ├── tasks.py # /task commands (one-off tasks/appointments)
|
||||||
|
│ │ └── knowledge.py # /ask command (jury-filtered RAG)
|
||||||
|
│ └── hooks.py # Event listeners
|
||||||
|
├── ai/ # LLM-powered features
|
||||||
|
│ ├── parser.py # OpenRouter API client
|
||||||
|
│ ├── jury_council.py # 5-juror safety filtration system
|
||||||
|
│ ├── ai_config.json # Model + prompt configuration
|
||||||
|
│ └── (optional) RAG embeddings
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── schema.sql # Database schema
|
│ ├── schema.sql # Database schema
|
||||||
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
|
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
|
||||||
│ ├── seed_rewards.sql # Variable reward pool
|
│ ├── seed_rewards.sql # Variable reward pool
|
||||||
│ └── .env.example # Environment template
|
│ └── .env.example # Environment template
|
||||||
|
├── diagrams/ # Architecture diagrams (Mermaid)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── Dockerfile
|
├── Dockerfile
|
||||||
|
└── tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -49,10 +70,10 @@ synculous/
|
|||||||
# Copy environment config
|
# Copy environment config
|
||||||
cp config/.env.example config/.env
|
cp config/.env.example config/.env
|
||||||
|
|
||||||
# Edit with your values
|
# Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY)
|
||||||
nano config/.env
|
nano config/.env
|
||||||
|
|
||||||
# Start everything
|
# Start everything (db, api, scheduler, optional bot, frontend client)
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,20 +82,23 @@ This starts five services:
|
|||||||
| Service | Port | Description |
|
| Service | Port | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
|
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
|
||||||
| `app` | 8080 | Flask API |
|
| `app` | 8010 | Flask API (internal: 5000) |
|
||||||
| `scheduler` | — | Background daemon for medication/routine reminders |
|
| `scheduler` | — | Background daemon for medication/routine reminders |
|
||||||
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) |
|
| `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
|
||||||
| `client` | 3000 | Next.js frontend |
|
| `client` | 3001 | Next.js frontend (internal: 3000) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Routines
|
### Routines & Sessions
|
||||||
- Create routines with ordered steps (4-7 steps recommended)
|
- Create routines with ordered steps (4-7 steps recommended)
|
||||||
- Run sessions with a guided, one-step-at-a-time focus interface
|
- Run sessions with a guided, one-step-at-a-time focus interface
|
||||||
- Complete, skip, pause, resume, or cancel sessions
|
- Complete, skip, pause, resume, or cancel sessions
|
||||||
- Swipe gestures for step completion on mobile
|
- Swipe gestures for step completion on mobile
|
||||||
- Per-step timing with visual countdown
|
- Per-step timing with visual countdown
|
||||||
- Animated celebration screen on completion with streak stats and variable rewards
|
- Animated celebration screen on completion with streak stats and variable rewards
|
||||||
|
- Every-N-day frequency option for routines
|
||||||
|
- Tagging system for organizing routines by category
|
||||||
|
- Session notes for logging context or blockers
|
||||||
|
|
||||||
### Premade Templates
|
### Premade Templates
|
||||||
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
|
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
|
||||||
@@ -96,13 +120,44 @@ This starts five services:
|
|||||||
|
|
||||||
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
|
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
|
||||||
|
|
||||||
|
### One-Off Tasks & Appointments
|
||||||
|
- Create standalone tasks and appointments outside of routines
|
||||||
|
- Scheduled date/time with optional reminders
|
||||||
|
- Quick-complete action for fast check-off
|
||||||
|
- Tasks appear on the routines timeline for a unified daily view
|
||||||
|
- AI-powered task composition via bot and web client
|
||||||
|
- Natural language date parsing in bot commands
|
||||||
|
|
||||||
|
### AI-Powered Step Generation
|
||||||
|
- Generate ADHD-friendly routine steps from a plain-language goal description
|
||||||
|
- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps
|
||||||
|
- Each step includes name and estimated duration
|
||||||
|
- Available via API endpoint and integrated into the web client's routine creation flow
|
||||||
|
|
||||||
### Medications
|
### Medications
|
||||||
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN)
|
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once
|
||||||
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
|
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
|
||||||
- Take, skip, snooze actions with logging
|
- Take, skip, snooze actions with logging
|
||||||
- Adherence tracking and statistics
|
- Adherence tracking and statistics
|
||||||
- Refill tracking with low-quantity alerts
|
- Refill tracking with low-quantity alerts
|
||||||
- Background reminders via the scheduler daemon
|
- Background reminders via the scheduler daemon
|
||||||
|
- Medication editing (update name, dose, schedule, refill count)
|
||||||
|
- Web push and Discord notifications for doses
|
||||||
|
|
||||||
|
#### Adaptive Medication Timing
|
||||||
|
- Machine-learning-based timing predictions based on user adherence patterns
|
||||||
|
- System learns when you're most likely to take meds
|
||||||
|
- Automatic reminder optimization (fewer false-positive reminders)
|
||||||
|
- Override and manual timing adjustments always available
|
||||||
|
- Useful for people with irregular schedules
|
||||||
|
|
||||||
|
### Peer Accountability: "Snitch" Feature
|
||||||
|
- Designate trusted contacts to receive medication adherence notifications
|
||||||
|
- Contacts don't need an account or login
|
||||||
|
- Granular privacy controls: users choose *what* to share (meds, streaks, notes)
|
||||||
|
- Contacts receive weekly summaries or real-time alerts for missed doses
|
||||||
|
- Based on research showing peer accountability improves adherence
|
||||||
|
- Optional consent-based consent flow for contact approvals
|
||||||
|
|
||||||
### Streaks and Stats
|
### Streaks and Stats
|
||||||
- Per-routine streak tracking (current + longest)
|
- Per-routine streak tracking (current + longest)
|
||||||
@@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret
|
|||||||
- Random reward on routine completion (post-completion only, never mid-routine)
|
- Random reward on routine completion (post-completion only, never mid-routine)
|
||||||
- Reward history tracking per user
|
- Reward history tracking per user
|
||||||
- Common and rare rarity tiers
|
- Common and rare rarity tiers
|
||||||
|
- Designed to leverage variable-ratio reinforcement schedules
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Web push notifications via VAPID
|
- Web push notifications via VAPID (in-browser)
|
||||||
- Discord webhooks
|
- Discord webhooks (for bot mentions)
|
||||||
- ntfy support
|
- Discord DMs (for sensitive notifications like "snitch" alerts)
|
||||||
- Scheduled reminders for medications and routines
|
- ntfy support (for self-hosted push)
|
||||||
|
- Timezone-aware scheduled reminders for medications and routines
|
||||||
|
|
||||||
### Timezone Support
|
### Timezone Support (Dual-Path)
|
||||||
All date/time operations respect the user's local timezone:
|
All date/time operations respect the user's local timezone via two mechanisms:
|
||||||
- The frontend sends `X-Timezone-Offset` with every API request
|
|
||||||
- The timezone offset is also persisted to `user_preferences` for background jobs
|
**In request context:**
|
||||||
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date
|
- The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
|
||||||
- The scheduler daemon looks up each user's stored offset for reminder timing
|
- Handlers use these headers for real-time API operations
|
||||||
|
|
||||||
|
**In background jobs (scheduler daemon):**
|
||||||
|
- Timezone is stored in `user_preferences.timezone_name` (IANA format)
|
||||||
|
- Scheduler retrieves stored timezone for each user
|
||||||
|
- Falls back to numeric offset, then UTC if name is unavailable
|
||||||
|
- Enables accurate reminder delivery even if user's browser context is offline
|
||||||
|
|
||||||
|
**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date.
|
||||||
|
|
||||||
|
### Authentication & Session Persistence
|
||||||
|
- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days)
|
||||||
|
- "Trust this device" option on login issues a refresh token for seamless re-auth
|
||||||
|
- Bot session caching with persistent pickle storage across restarts
|
||||||
|
|
||||||
### User Preferences
|
### User Preferences
|
||||||
- Sound effects (default off — habituation risk)
|
- Sound effects (default off — habituation risk)
|
||||||
- Haptic feedback (default on)
|
- Haptic feedback (default on)
|
||||||
- Launch screen toggle
|
- Launch screen toggle
|
||||||
- Celebration style
|
- Celebration style
|
||||||
- Timezone offset (auto-synced from browser)
|
- Timezone (IANA name + numeric offset, auto-synced from browser)
|
||||||
|
- Discord presence indicator toggle (shows online/offline in Discord)
|
||||||
|
|
||||||
|
### Discord Bot Integration
|
||||||
|
Full-featured Discord bot for managing routines and medications without opening the app:
|
||||||
|
|
||||||
|
#### Bot Commands
|
||||||
|
- `/routine list` — List all routines
|
||||||
|
- `/routine start <name>` — Start a routine session (guided steps in Discord thread)
|
||||||
|
- `/routine stats <name>` — View streak and completion stats
|
||||||
|
- `/med today` — Show today's medications with status
|
||||||
|
- `/med take <med_name>` — Log a dose as taken
|
||||||
|
- `/med skip <med_name>` — Log a dose as skipped
|
||||||
|
- `/task add <description>` — Create a one-off task (supports natural language dates)
|
||||||
|
- `/task list` — Show upcoming tasks
|
||||||
|
- `/task done <name>` — Mark a task as complete
|
||||||
|
- `/ask <question>` — Query the knowledge base with jury-filtered safety checks
|
||||||
|
- Discord presence automatically shows your routine/meditation status
|
||||||
|
|
||||||
|
#### Jury Council Safety System
|
||||||
|
The `/ask` command uses a sophisticated 5-juror safety filtration pipeline:
|
||||||
|
|
||||||
|
**Two-stage process:**
|
||||||
|
1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions
|
||||||
|
2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens
|
||||||
|
|
||||||
|
**Juror Roles:**
|
||||||
|
- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means
|
||||||
|
- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language
|
||||||
|
- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education
|
||||||
|
- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base
|
||||||
|
- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play
|
||||||
|
|
||||||
|
**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers.
|
||||||
|
|
||||||
|
This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts).
|
||||||
|
|
||||||
|
### Knowledge Base & RAG
|
||||||
|
- Embeddings-based retrieval of ADHD, DBT, and mental health educational content
|
||||||
|
- Multi-book support with user-selectable knowledge bases
|
||||||
|
- Jury-filtered questions for safety
|
||||||
|
- LLM-powered intent classification routes general questions to the knowledge base automatically
|
||||||
|
- Multi-query retrieval with deduplication for better coverage
|
||||||
|
- DBT advice evaluation mode (checks advice against DBT principles)
|
||||||
|
- Discord bot `/ask` command uses RAG with jury council checks
|
||||||
|
- Extensible knowledge source (can add more documents)
|
||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
|
|
||||||
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`.
|
All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| POST | `/api/register` | Create account |
|
| POST | `/api/register` | Create account |
|
||||||
| POST | `/api/login` | Get JWT token |
|
| POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) |
|
||||||
|
| POST | `/api/refresh` | Exchange refresh token for new access token |
|
||||||
|
|
||||||
### Routines
|
### Routines
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| POST | `/api/sessions/:id/resume` | Resume session |
|
| POST | `/api/sessions/:id/resume` | Resume session |
|
||||||
| POST | `/api/sessions/:id/cancel` | Cancel session |
|
| POST | `/api/sessions/:id/cancel` | Cancel session |
|
||||||
| POST | `/api/sessions/:id/abort` | Abort with reason |
|
| POST | `/api/sessions/:id/abort` | Abort with reason |
|
||||||
|
| POST | `/api/sessions/:id/note` | Add session note |
|
||||||
|
|
||||||
### Medications
|
### Medications
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| GET | `/api/medications/adherence` | Adherence stats |
|
| GET | `/api/medications/adherence` | Adherence stats |
|
||||||
| GET | `/api/medications/refills-due` | Refills due soon |
|
| GET | `/api/medications/refills-due` | Refills due soon |
|
||||||
|
|
||||||
|
### Adaptive Medications
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication |
|
||||||
|
| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences |
|
||||||
|
| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule |
|
||||||
|
| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats |
|
||||||
|
|
||||||
|
### Snitch (Peer Accountability)
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/snitch/contacts` | List designated accountability contacts |
|
||||||
|
| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) |
|
||||||
|
| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) |
|
||||||
|
| DELETE | `/api/snitch/contacts/:id` | Remove a contact |
|
||||||
|
| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link |
|
||||||
|
| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status |
|
||||||
|
| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing |
|
||||||
|
| GET | `/api/snitch/history` | View recent alerts sent to contacts |
|
||||||
|
| POST | `/api/snitch/test-send` | Send test alert to a contact |
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/tasks` | List user's tasks |
|
||||||
|
| POST | `/api/tasks` | Create a task |
|
||||||
|
| PUT | `/api/tasks/:id` | Update a task |
|
||||||
|
| DELETE | `/api/tasks/:id` | Delete a task |
|
||||||
|
| POST | `/api/tasks/:id/complete` | Mark task as complete |
|
||||||
|
|
||||||
|
### AI
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description |
|
||||||
|
|
||||||
### Stats
|
### Stats
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| GET | `/api/routines/weekly-summary` | Weekly progress |
|
| GET | `/api/routines/weekly-summary` | Weekly progress |
|
||||||
| GET | `/api/victories` | Achievement detection |
|
| GET | `/api/victories` | Achievement detection |
|
||||||
|
|
||||||
### Templates, Tags, Rewards, Preferences
|
### Templates, Tags, Rewards, Preferences, Notifications
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/templates` | List available templates |
|
| GET | `/api/templates` | List available templates |
|
||||||
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
|
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
|
||||||
| GET/PUT | `/api/preferences` | User settings |
|
| GET/PUT | `/api/preferences` | User settings and timezone |
|
||||||
| GET | `/api/rewards/random` | Random completion reward |
|
| GET | `/api/rewards/random` | Random completion reward |
|
||||||
|
| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) |
|
||||||
|
| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Required | Description |
|
||||||
|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `DB_HOST` | PostgreSQL host |
|
| `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
|
||||||
| `DB_PORT` | PostgreSQL port |
|
| `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
|
||||||
| `DB_NAME` | Database name |
|
| `DB_NAME` | Yes | Database name (default: `app`) |
|
||||||
| `DB_USER` | Database user |
|
| `DB_USER` | Yes | Database user (default: `app`) |
|
||||||
| `DB_PASS` | Database password |
|
| `DB_PASS` | Yes | Database password |
|
||||||
| `JWT_SECRET` | JWT signing secret |
|
| `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) |
|
| `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
|
||||||
| `API_URL` | API URL for bot (default: `http://app:5000`) |
|
| `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) |
|
| `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
|
||||||
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) |
|
| `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) |
|
||||||
|
| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications |
|
||||||
|
| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications |
|
||||||
|
|
||||||
## Design Framework
|
## Design Framework
|
||||||
|
|
||||||
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles:
|
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature:
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
|
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
|
||||||
2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
|
2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
|
||||||
3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
|
3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
|
||||||
|
|
||||||
|
### Behavioral Foundations
|
||||||
|
- **Two-Minute Rule Entry Points** — Every routine starts with something achievable in under 2 minutes (lower activation energy)
|
||||||
|
- **Variable Reward Scheduling** — Random rewards on completion leverage variable-ratio reinforcement (proven for habit building)
|
||||||
|
- **Streak-Based Identity** — Streaks build intrinsic motivation by making completion a visible, accumulating identity signal
|
||||||
|
- **Peer Accountability** — "Snitch" contacts provide external accountability without shame (research shows this improves adherence)
|
||||||
|
- **Adaptive Timing** — System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load)
|
||||||
|
|
||||||
|
### Safety & Ethics
|
||||||
|
- **Jury Council** — 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users
|
||||||
|
- **Crisis Awareness** — System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation
|
||||||
|
- **Transparent Limitations** — All system messages clarify "this is educational, not treatment" and encourage professional care
|
||||||
|
- **User Agency** — All adaptive and automated features can be overridden; manual controls are always available
|
||||||
|
|
||||||
|
## Development & Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Run pytest on all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_routines.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=core --cov=api tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually:
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U app -d app -f config/migration_name.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timezone Testing
|
||||||
|
The system uses dual-path timezone support. Test both:
|
||||||
|
1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset
|
||||||
|
2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler
|
||||||
|
|
||||||
|
### Discord Bot Development
|
||||||
|
Bot commands are modular in `bot/commands/`. To add a command:
|
||||||
|
1. Create a new command file in `bot/commands/`
|
||||||
|
2. Import and register in `bot/bot.py`
|
||||||
|
3. Bot automatically syncs commands to Discord on startup
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available in `DOCUMENTATION.md`:
|
||||||
|
- Detailed feature explanations
|
||||||
|
- Database schema reference
|
||||||
|
- Jury Council safety model (full spec)
|
||||||
|
- Deployment & configuration
|
||||||
|
- Contributing guidelines
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def _call_llm_sync(system_prompt, user_prompt):
|
|||||||
return extracted
|
return extracted
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
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
|
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)
|
response_text = await _call_llm(prompt_config["system"], user_prompt)
|
||||||
if not response_text:
|
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:
|
try:
|
||||||
parsed = json.loads(response_text)
|
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.preferences as preferences_routes
|
||||||
import api.routes.rewards as rewards_routes
|
import api.routes.rewards as rewards_routes
|
||||||
import api.routes.victories as victories_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__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -37,6 +41,10 @@ ROUTE_MODULES = [
|
|||||||
preferences_routes,
|
preferences_routes,
|
||||||
rewards_routes,
|
rewards_routes,
|
||||||
victories_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
|
return flask.jsonify({"error": "username and password required"}), 400
|
||||||
token = auth.getLoginToken(username, password)
|
token = auth.getLoginToken(username, password)
|
||||||
if token:
|
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:
|
else:
|
||||||
return flask.jsonify({"error": "invalid credentials"}), 401
|
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 ────────────────────────────────────────────────────
|
# ── User Routes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -161,8 +191,13 @@ def _seed_templates_if_empty():
|
|||||||
count = postgres.count("routine_templates")
|
count = postgres.count("routine_templates")
|
||||||
if count == 0:
|
if count == 0:
|
||||||
import logging
|
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):
|
if os.path.exists(seed_path):
|
||||||
with open(seed_path, "r") as f:
|
with open(seed_path, "r") as f:
|
||||||
sql = f.read()
|
sql = f.read()
|
||||||
@@ -171,6 +206,7 @@ def _seed_templates_if_empty():
|
|||||||
logging.getLogger(__name__).info("Templates seeded successfully.")
|
logging.getLogger(__name__).info("Templates seeded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
|
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -180,8 +216,13 @@ def _seed_rewards_if_empty():
|
|||||||
count = postgres.count("reward_pool")
|
count = postgres.count("reward_pool")
|
||||||
if count == 0:
|
if count == 0:
|
||||||
import logging
|
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):
|
if os.path.exists(seed_path):
|
||||||
with open(seed_path, "r") as f:
|
with open(seed_path, "r") as f:
|
||||||
sql = f.read()
|
sql = f.read()
|
||||||
@@ -190,6 +231,7 @@ def _seed_rewards_if_empty():
|
|||||||
logging.getLogger(__name__).info("Rewards seeded successfully.")
|
logging.getLogger(__name__).info("Rewards seeded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")
|
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
|
Medications API - medication scheduling, logging, and adherence tracking
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timedelta, timezone
|
from datetime import datetime, date, timedelta, timezone
|
||||||
@@ -12,6 +13,7 @@ from psycopg2.extras import Json
|
|||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
import core.tz as tz
|
import core.tz as tz
|
||||||
|
import core.adaptive_meds as adaptive_meds
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -158,6 +160,11 @@ def register(app):
|
|||||||
if missing:
|
if missing:
|
||||||
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
|
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 = {
|
row = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
@@ -217,6 +224,7 @@ def register(app):
|
|||||||
"name", "dosage", "unit", "frequency", "times", "notes", "active",
|
"name", "dosage", "unit", "frequency", "times", "notes", "active",
|
||||||
"days_of_week", "interval_days", "start_date", "next_dose_date",
|
"days_of_week", "interval_days", "start_date", "next_dose_date",
|
||||||
]
|
]
|
||||||
|
|
||||||
updates = {k: v for k, v in data.items() if k in allowed}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||||
@@ -257,6 +265,11 @@ def register(app):
|
|||||||
"notes": data.get("notes"),
|
"notes": data.get("notes"),
|
||||||
}
|
}
|
||||||
log = postgres.insert("med_logs", log_entry)
|
log = postgres.insert("med_logs", log_entry)
|
||||||
|
# Update adaptive schedule status so nags stop
|
||||||
|
try:
|
||||||
|
adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time"))
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail the take action if schedule update fails
|
||||||
# Advance next_dose_date for interval meds
|
# Advance next_dose_date for interval meds
|
||||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||||
next_date = _compute_next_dose_date(med)
|
next_date = _compute_next_dose_date(med)
|
||||||
@@ -283,6 +296,11 @@ def register(app):
|
|||||||
"notes": data.get("reason"),
|
"notes": data.get("reason"),
|
||||||
}
|
}
|
||||||
log = postgres.insert("med_logs", log_entry)
|
log = postgres.insert("med_logs", log_entry)
|
||||||
|
# Update adaptive schedule status so nags stop
|
||||||
|
try:
|
||||||
|
adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time"))
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail the skip action if schedule update fails
|
||||||
# Advance next_dose_date for interval meds
|
# Advance next_dose_date for interval meds
|
||||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||||
next_date = _compute_next_dose_date(med)
|
next_date = _compute_next_dose_date(med)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def register(app):
|
|||||||
if not data:
|
if not data:
|
||||||
return flask.jsonify({"error": "missing body"}), 400
|
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}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return flask.jsonify({"error": "no valid fields"}), 400
|
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 os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import flask
|
import flask
|
||||||
import jwt
|
import jwt
|
||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
@@ -420,6 +420,31 @@ def register(app):
|
|||||||
return flask.jsonify(
|
return flask.jsonify(
|
||||||
{"error": "already have active session", "session_id": active["id"]}
|
{"error": "already have active session", "session_id": active["id"]}
|
||||||
), 409
|
), 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(
|
steps = postgres.select(
|
||||||
"routine_steps",
|
"routine_steps",
|
||||||
where={"routine_id": routine_id},
|
where={"routine_id": routine_id},
|
||||||
@@ -636,22 +661,97 @@ def register(app):
|
|||||||
continue
|
continue
|
||||||
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
|
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
|
||||||
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
|
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
|
||||||
result.append(
|
entry = {
|
||||||
{
|
"routine_id": r["id"],
|
||||||
"routine_id": r["id"],
|
"routine_name": r.get("name", ""),
|
||||||
"routine_name": r.get("name", ""),
|
"routine_icon": r.get("icon", ""),
|
||||||
"routine_icon": r.get("icon", ""),
|
"days": sched.get("days", []),
|
||||||
"days": sched.get("days", []),
|
"time": sched.get("time"),
|
||||||
"time": sched.get("time"),
|
"remind": sched.get("remind", True),
|
||||||
"remind": sched.get("remind", True),
|
"total_duration_minutes": total_duration,
|
||||||
"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
|
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"])
|
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
|
||||||
def api_setRoutineSchedule(routine_id):
|
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)
|
user_uuid = _auth(flask.request)
|
||||||
if not user_uuid:
|
if not user_uuid:
|
||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
@@ -663,12 +763,29 @@ def register(app):
|
|||||||
data = flask.request.get_json()
|
data = flask.request.get_json()
|
||||||
if not data:
|
if not data:
|
||||||
return flask.jsonify({"error": "missing body"}), 400
|
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})
|
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
"routine_id": routine_id,
|
"routine_id": routine_id,
|
||||||
"days": json.dumps(data.get("days", [])),
|
"days": json.dumps(data.get("days", [])),
|
||||||
"time": data.get("time"),
|
"time": data.get("time"),
|
||||||
"remind": data.get("remind", True),
|
"remind": data.get("remind", True),
|
||||||
|
"frequency": frequency,
|
||||||
|
"interval_days": data.get("interval_days"),
|
||||||
|
"start_date": data.get("start_date"),
|
||||||
}
|
}
|
||||||
if existing:
|
if existing:
|
||||||
result = postgres.update(
|
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):
|
def register(app):
|
||||||
|
|
||||||
@app.route("/api/victories", methods=["GET"])
|
@app.route("/api/victories", methods=["GET"])
|
||||||
def api_getVictories():
|
def api_getVictories():
|
||||||
"""Compute noteworthy achievements. Query: ?days=30"""
|
"""Compute noteworthy achievements. Query: ?days=30"""
|
||||||
@@ -42,11 +41,12 @@ def register(app):
|
|||||||
|
|
||||||
days = flask.request.args.get("days", 30, type=int)
|
days = flask.request.args.get("days", 30, type=int)
|
||||||
cutoff = tz.user_now() - timedelta(days=days)
|
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})
|
sessions = postgres.select("routine_sessions", {"user_uuid": user_uuid})
|
||||||
recent = [
|
recent = [
|
||||||
s for s in sessions
|
s for s in sessions if s.get("created_at") and s["created_at"] >= cutoff
|
||||||
if s.get("created_at") and s["created_at"] >= cutoff
|
|
||||||
]
|
]
|
||||||
completed = [s for s in recent if s.get("status") == "completed"]
|
completed = [s for s in recent if s.get("status") == "completed"]
|
||||||
|
|
||||||
@@ -60,28 +60,38 @@ def register(app):
|
|||||||
curr = sorted_completed[i]["created_at"]
|
curr = sorted_completed[i]["created_at"]
|
||||||
gap = (curr - prev).days
|
gap = (curr - prev).days
|
||||||
if gap >= 2:
|
if gap >= 2:
|
||||||
victories.append({
|
victories.append(
|
||||||
"type": "comeback",
|
{
|
||||||
"message": f"Came back after {gap} days — that takes real strength",
|
"type": "comeback",
|
||||||
"date": curr.isoformat() if hasattr(curr, 'isoformat') else str(curr),
|
"message": f"Came back after {gap} days — that takes real strength",
|
||||||
})
|
"date": curr.isoformat()
|
||||||
|
if hasattr(curr, "isoformat")
|
||||||
|
else str(curr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Weekend completion
|
# Weekend completion
|
||||||
for s in completed:
|
for s in completed:
|
||||||
created = s["created_at"]
|
created = s["created_at"]
|
||||||
if hasattr(created, 'weekday') and created.weekday() >= 5: # Saturday=5, Sunday=6
|
if (
|
||||||
victories.append({
|
hasattr(created, "weekday") and created.weekday() >= 5
|
||||||
"type": "weekend",
|
): # Saturday=5, Sunday=6
|
||||||
"message": "Completed a routine on the weekend",
|
victories.append(
|
||||||
"date": created.isoformat() if hasattr(created, 'isoformat') else str(created),
|
{
|
||||||
})
|
"type": "weekend",
|
||||||
|
"message": "Completed a routine on the weekend",
|
||||||
|
"date": created.isoformat()
|
||||||
|
if hasattr(created, "isoformat")
|
||||||
|
else str(created),
|
||||||
|
}
|
||||||
|
)
|
||||||
break # Only show once
|
break # Only show once
|
||||||
|
|
||||||
# Variety: 3+ different routines in a week
|
# Variety: 3+ different routines in a week
|
||||||
routine_ids_by_week = {}
|
routine_ids_by_week = {}
|
||||||
for s in completed:
|
for s in completed:
|
||||||
created = s["created_at"]
|
created = s["created_at"]
|
||||||
if hasattr(created, 'isocalendar'):
|
if hasattr(created, "isocalendar"):
|
||||||
week_key = created.isocalendar()[:2]
|
week_key = created.isocalendar()[:2]
|
||||||
if week_key not in routine_ids_by_week:
|
if week_key not in routine_ids_by_week:
|
||||||
routine_ids_by_week[week_key] = set()
|
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():
|
for week_key, routine_ids in routine_ids_by_week.items():
|
||||||
if len(routine_ids) >= 3:
|
if len(routine_ids) >= 3:
|
||||||
victories.append({
|
victories.append(
|
||||||
"type": "variety",
|
{
|
||||||
"message": f"Completed {len(routine_ids)} different routines in one week",
|
"type": "variety",
|
||||||
"date": None,
|
"message": f"Completed {len(routine_ids)} different routines in one week",
|
||||||
})
|
"date": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Full week consistency: completed every day for 7 consecutive days
|
# Full week consistency: completed every day for 7 consecutive days
|
||||||
@@ -101,25 +113,27 @@ def register(app):
|
|||||||
dates_set = set()
|
dates_set = set()
|
||||||
for s in completed:
|
for s in completed:
|
||||||
created = s["created_at"]
|
created = s["created_at"]
|
||||||
if hasattr(created, 'date'):
|
if hasattr(created, "date"):
|
||||||
dates_set.add(created.date())
|
dates_set.add(created.date())
|
||||||
|
|
||||||
sorted_dates = sorted(dates_set)
|
sorted_dates = sorted(dates_set)
|
||||||
max_streak = 1
|
max_streak = 1
|
||||||
current_streak = 1
|
current_streak = 1
|
||||||
for i in range(1, len(sorted_dates)):
|
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
|
current_streak += 1
|
||||||
max_streak = max(max_streak, current_streak)
|
max_streak = max(max_streak, current_streak)
|
||||||
else:
|
else:
|
||||||
current_streak = 1
|
current_streak = 1
|
||||||
|
|
||||||
if max_streak >= 7:
|
if max_streak >= 7:
|
||||||
victories.append({
|
victories.append(
|
||||||
"type": "consistency",
|
{
|
||||||
"message": f"Completed routines every day for {max_streak} days straight",
|
"type": "consistency",
|
||||||
"date": None,
|
"message": f"Completed routines every day for {max_streak} days straight",
|
||||||
})
|
"date": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Limit and deduplicate
|
# Limit and deduplicate
|
||||||
seen_types = set()
|
seen_types = set()
|
||||||
|
|||||||
536
bot/bot.py
@@ -6,6 +6,7 @@ Features:
|
|||||||
- Session management with JWT tokens
|
- Session management with JWT tokens
|
||||||
- AI-powered command parsing via registry
|
- AI-powered command parsing via registry
|
||||||
- Background task loop for polling
|
- Background task loop for polling
|
||||||
|
- JurySystem DBT integration for mental health support
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@@ -17,12 +18,15 @@ import base64
|
|||||||
import requests
|
import requests
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import pickle
|
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 ai.parser as ai_parser
|
||||||
import bot.commands.routines # noqa: F401 - registers handler
|
import bot.commands.routines # noqa: F401 - registers handler
|
||||||
import bot.commands.medications # noqa: F401 - registers handler
|
import bot.commands.medications # noqa: F401 - registers handler
|
||||||
import bot.commands.knowledge # 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")
|
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||||
API_URL = os.getenv("API_URL", "http://app:5000")
|
API_URL = os.getenv("API_URL", "http://app:5000")
|
||||||
@@ -31,27 +35,157 @@ user_sessions = {}
|
|||||||
login_state = {}
|
login_state = {}
|
||||||
message_history = {}
|
message_history = {}
|
||||||
user_cache = {}
|
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 = discord.Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
|
intents.presences = True
|
||||||
|
intents.members = True
|
||||||
|
|
||||||
client = discord.Client(intents=intents)
|
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):
|
def decodeJwtPayload(token):
|
||||||
payload = token.split(".")[1]
|
payload = token.split(".")[1]
|
||||||
payload += "=" * (4 - len(payload) % 4)
|
payload += "=" * (4 - len(payload) % 4)
|
||||||
return json.loads(base64.urlsafe_b64decode(payload))
|
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}"
|
url = f"{API_URL}{endpoint}"
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
try:
|
try:
|
||||||
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
|
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:
|
try:
|
||||||
return resp.json(), resp.status_code
|
return resp.json(), resp.status_code
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -60,6 +194,34 @@ def apiRequest(method, endpoint, token=None, data=None):
|
|||||||
return {"error": "API unavailable"}, 503
|
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():
|
def loadCache():
|
||||||
try:
|
try:
|
||||||
if os.path.exists(CACHE_FILE):
|
if os.path.exists(CACHE_FILE):
|
||||||
@@ -73,6 +235,7 @@ def loadCache():
|
|||||||
|
|
||||||
def saveCache():
|
def saveCache():
|
||||||
try:
|
try:
|
||||||
|
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||||
with open(CACHE_FILE, "wb") as f:
|
with open(CACHE_FILE, "wb") as f:
|
||||||
pickle.dump(user_cache, f)
|
pickle.dump(user_cache, f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -98,13 +261,34 @@ def setCachedUser(discord_id, user_data):
|
|||||||
|
|
||||||
def negotiateToken(discord_id, username, password):
|
def negotiateToken(discord_id, username, password):
|
||||||
cached = getCachedUser(discord_id)
|
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 (
|
if (
|
||||||
cached
|
cached
|
||||||
and cached.get("username") == username
|
and cached.get("username") == username
|
||||||
|
and cached.get("hashed_password")
|
||||||
and verifyPassword(password, cached.get("hashed_password"))
|
and verifyPassword(password, cached.get("hashed_password"))
|
||||||
):
|
):
|
||||||
result, status = apiRequest(
|
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:
|
if status == 200 and "token" in result:
|
||||||
token = result["token"]
|
token = result["token"]
|
||||||
@@ -116,14 +300,13 @@ def negotiateToken(discord_id, username, password):
|
|||||||
"hashed_password": cached["hashed_password"],
|
"hashed_password": cached["hashed_password"],
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"username": username,
|
"username": username,
|
||||||
|
"refresh_token": result.get("refresh_token"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return token, user_uuid
|
return token, user_uuid
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
result, status = apiRequest(
|
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
|
||||||
"post", "/api/login", data={"username": username, "password": password}
|
|
||||||
)
|
|
||||||
if status == 200 and "token" in result:
|
if status == 200 and "token" in result:
|
||||||
token = result["token"]
|
token = result["token"]
|
||||||
payload = decodeJwtPayload(token)
|
payload = decodeJwtPayload(token)
|
||||||
@@ -134,6 +317,7 @@ def negotiateToken(discord_id, username, password):
|
|||||||
"hashed_password": hashPassword(password),
|
"hashed_password": hashPassword(password),
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"username": username,
|
"username": username,
|
||||||
|
"refresh_token": result.get("refresh_token"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return token, user_uuid
|
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"
|
• "schedule workout for monday wednesday friday at 7am"
|
||||||
• "show my stats"
|
• "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:**
|
**💡 Tips:**
|
||||||
• I understand natural language, typos, and slang
|
• I understand natural language, typos, and slang
|
||||||
• If I'm unsure, I'll ask for clarification
|
• If I'm unsure, I'll ask for clarification
|
||||||
@@ -233,24 +423,19 @@ async def handleConfirmation(message, session):
|
|||||||
if "pending_confirmations" not in session:
|
if "pending_confirmations" not in session:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for any pending confirmations
|
|
||||||
pending = session["pending_confirmations"]
|
pending = session["pending_confirmations"]
|
||||||
if not pending:
|
if not pending:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get the most recent pending confirmation
|
|
||||||
confirmation_id = list(pending.keys())[-1]
|
confirmation_id = list(pending.keys())[-1]
|
||||||
confirmation_data = pending[confirmation_id]
|
confirmation_data = pending[confirmation_id]
|
||||||
|
|
||||||
if user_input in ("yes", "y", "yeah", "sure", "ok", "confirm"):
|
if user_input in ("yes", "y", "yeah", "sure", "ok", "confirm"):
|
||||||
# Execute the confirmed action
|
|
||||||
del pending[confirmation_id]
|
del pending[confirmation_id]
|
||||||
|
|
||||||
interaction_type = confirmation_data.get("interaction_type")
|
interaction_type = confirmation_data.get("interaction_type")
|
||||||
handler = get_handler(interaction_type)
|
handler = get_handler(interaction_type)
|
||||||
|
|
||||||
if handler:
|
if handler:
|
||||||
# Create a fake parsed object for the handler
|
|
||||||
fake_parsed = confirmation_data.copy()
|
fake_parsed = confirmation_data.copy()
|
||||||
fake_parsed["needs_confirmation"] = False
|
fake_parsed["needs_confirmation"] = False
|
||||||
await handler(message, session, fake_parsed)
|
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."""
|
"""Handle shortcuts like 'done', 'skip', 'next' when in active session."""
|
||||||
user_input = message.content.lower().strip()
|
user_input = message.content.lower().strip()
|
||||||
|
|
||||||
# Map common shortcuts to actions
|
|
||||||
shortcuts = {
|
shortcuts = {
|
||||||
"done": ("routine", "complete"),
|
"done": ("routine", "complete"),
|
||||||
"finished": ("routine", "complete"),
|
"finished": ("routine", "complete"),
|
||||||
@@ -296,6 +480,101 @@ async def handleActiveSessionShortcuts(message, session, active_session):
|
|||||||
return False
|
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):
|
async def routeCommand(message):
|
||||||
discord_id = message.author.id
|
discord_id = message.author.id
|
||||||
session = user_sessions[discord_id]
|
session = user_sessions[discord_id]
|
||||||
@@ -321,6 +600,11 @@ async def routeCommand(message):
|
|||||||
if shortcut_handled:
|
if shortcut_handled:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check for DBT queries
|
||||||
|
dbt_handled = await handleDBTQuery(message)
|
||||||
|
if dbt_handled:
|
||||||
|
return
|
||||||
|
|
||||||
async with message.channel.typing():
|
async with message.channel.typing():
|
||||||
history = message_history.get(discord_id, [])
|
history = message_history.get(discord_id, [])
|
||||||
|
|
||||||
@@ -366,11 +650,32 @@ async def routeCommand(message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@client.event
|
def _restore_sessions_from_cache():
|
||||||
async def on_ready():
|
"""Try to restore user sessions from cached refresh tokens on startup."""
|
||||||
print(f"Bot logged in as {client.user}")
|
restored = 0
|
||||||
loadCache()
|
for discord_id, cached in user_cache.items():
|
||||||
backgroundLoop.start()
|
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
|
@client.event
|
||||||
@@ -405,5 +710,200 @@ async def beforeBackgroundLoop():
|
|||||||
await client.wait_until_ready()
|
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__":
|
if __name__ == "__main__":
|
||||||
client.run(DISCORD_BOT_TOKEN)
|
client.run(DISCORD_BOT_TOKEN)
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ from ai.parser import client
|
|||||||
# Configuration
|
# Configuration
|
||||||
EPUBS_DIRECTORY = os.getenv("KNOWLEDGE_EMBEDDINGS_DIR", "./bot/data")
|
EPUBS_DIRECTORY = os.getenv("KNOWLEDGE_EMBEDDINGS_DIR", "./bot/data")
|
||||||
TOP_K_CHUNKS = 5
|
TOP_K_CHUNKS = 5
|
||||||
EMBEDDING_MODEL = "sentence-transformers/all-minilm-l12-v2"
|
|
||||||
CHAT_MODEL = "deepseek/deepseek-v3.2"
|
CHAT_MODEL = "deepseek/deepseek-v3.2"
|
||||||
EMBEDDING_EXTENSION = ".embeddings.json"
|
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)}
|
# Cache for loaded embeddings: {file_path: (chunks, embeddings, metadata)}
|
||||||
_knowledge_cache: Dict[str, Tuple[List[str], List[List[float]], dict]] = {}
|
_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:
|
with open(file_path, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
chunks = data.get("chunks", [])
|
# Handle both dict format {"chunks": [...], "embeddings": [...], "metadata": {...}}
|
||||||
embeddings = data.get("embeddings", [])
|
# and legacy list format where data is just the chunks
|
||||||
metadata = data.get("metadata", {})
|
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
|
# Add file_path to metadata for reference
|
||||||
metadata["_file_path"] = file_path
|
metadata["_file_path"] = file_path
|
||||||
@@ -64,9 +85,14 @@ def load_knowledge_base(
|
|||||||
return _knowledge_cache[file_path]
|
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."""
|
"""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
|
return response.data[0].embedding
|
||||||
|
|
||||||
|
|
||||||
@@ -256,8 +282,12 @@ async def handle_knowledge(message, session, parsed):
|
|||||||
await message.channel.send(f"🔍 Searching **{book_title}**...")
|
await message.channel.send(f"🔍 Searching **{book_title}**...")
|
||||||
|
|
||||||
try:
|
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
|
# 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(
|
relevant_chunks, scores = search_context(
|
||||||
query_emb, chunks, embeddings, TOP_K_CHUNKS
|
query_emb, chunks, embeddings, TOP_K_CHUNKS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -423,6 +423,106 @@ async def handle_medication(message, session, parsed):
|
|||||||
else:
|
else:
|
||||||
await message.channel.send(f"Error: {resp.get('error', 'Failed to log')}")
|
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":
|
elif action == "skip":
|
||||||
med_id = parsed.get("medication_id")
|
med_id = parsed.get("medication_id")
|
||||||
name = parsed.get("name")
|
name = parsed.get("name")
|
||||||
@@ -645,7 +745,7 @@ async def handle_medication(message, session, parsed):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await message.channel.send(
|
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)
|
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":
|
elif action == "add_steps":
|
||||||
routine_name = parsed.get("routine_name")
|
routine_name = parsed.get("routine_name")
|
||||||
steps = parsed.get("steps", [])
|
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')")
|
await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build schedule data
|
# Build schedule data (API expects "days" and "time")
|
||||||
schedule_data = {}
|
schedule_data = {}
|
||||||
if days_of_week:
|
if days_of_week:
|
||||||
schedule_data["days_of_week"] = days_of_week
|
schedule_data["days"] = days_of_week
|
||||||
if times:
|
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)
|
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
|
||||||
if status == 200:
|
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."
|
||||||
|
}
|
||||||
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
|
API_URL=http://app:5000
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
@@ -6,7 +6,7 @@ DB_NAME=app
|
|||||||
DB_USER=app
|
DB_USER=app
|
||||||
DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw
|
DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw
|
||||||
JWT_SECRET=bf773b4562221bef4d304ae5752a68931382ea3e98fe38394a098f73e0c776e1
|
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
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
AI_CONFIG_PATH=/app/ai/ai_config.json
|
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,
|
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
|
||||||
days JSON DEFAULT '[]',
|
days JSON DEFAULT '[]',
|
||||||
time VARCHAR(5),
|
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 (
|
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,
|
show_launch_screen BOOLEAN DEFAULT TRUE,
|
||||||
celebration_style VARCHAR(50) DEFAULT 'standard',
|
celebration_style VARCHAR(50) DEFAULT 'standard',
|
||||||
timezone_offset INTEGER DEFAULT 0,
|
timezone_offset INTEGER DEFAULT 0,
|
||||||
|
timezone_name VARCHAR(100),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,3 +204,121 @@ CREATE TABLE IF NOT EXISTS med_logs (
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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
|
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):
|
def verifyLoginToken(login_token, username=False, userUUID=False):
|
||||||
if username:
|
if username:
|
||||||
userUUID = users.getUserUUID(username)
|
userUUID = users.getUserUUID(username)
|
||||||
@@ -49,6 +59,44 @@ def getLoginToken(username, password):
|
|||||||
return False
|
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):
|
def unregisterUser(userUUID, password):
|
||||||
pw_hash = getUserpasswordHash(userUUID)
|
pw_hash = getUserpasswordHash(userUUID)
|
||||||
if not pw_hash:
|
if not pw_hash:
|
||||||
|
|||||||
@@ -18,18 +18,27 @@ logger = logging.getLogger(__name__)
|
|||||||
def _sendToEnabledChannels(notif_settings, message, user_uuid=None):
|
def _sendToEnabledChannels(notif_settings, message, user_uuid=None):
|
||||||
"""Send message to all enabled channels. Returns True if at least one succeeded."""
|
"""Send message to all enabled channels. Returns True if at least one succeeded."""
|
||||||
sent = False
|
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 notif_settings.get("discord_enabled") and notif_settings.get("discord_user_id"):
|
||||||
if discord.send_dm(notif_settings["discord_user_id"], message):
|
if discord.send_dm(notif_settings["discord_user_id"], message):
|
||||||
|
logger.debug(f"Discord DM sent to {notif_settings['discord_user_id']}")
|
||||||
sent = True
|
sent = True
|
||||||
|
|
||||||
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
|
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
|
||||||
if ntfy.send(notif_settings["ntfy_topic"], message):
|
if ntfy.send(notif_settings["ntfy_topic"], message):
|
||||||
|
logger.debug(f"ntfy sent to topic {notif_settings['ntfy_topic']}")
|
||||||
sent = True
|
sent = True
|
||||||
|
|
||||||
if notif_settings.get("web_push_enabled") and user_uuid:
|
if notif_settings.get("web_push_enabled") and user_uuid:
|
||||||
if web_push.send_to_user(user_uuid, message):
|
if web_push.send_to_user(user_uuid, message):
|
||||||
|
logger.debug(f"Web push sent for user {user_uuid}")
|
||||||
sent = True
|
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
|
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
|
||||||
72
core/tz.py
@@ -1,12 +1,36 @@
|
|||||||
"""
|
"""
|
||||||
core/tz.py - Timezone-aware date/time helpers
|
core/tz.py - Timezone-aware date/time helpers
|
||||||
|
|
||||||
The frontend sends X-Timezone-Offset (minutes from UTC, same sign as
|
The frontend sends:
|
||||||
JavaScript's getTimezoneOffset — positive means behind UTC).
|
X-Timezone-Name – IANA timezone (e.g. "America/Chicago"), preferred
|
||||||
These helpers convert server UTC to the user's local date/time.
|
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 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():
|
def _get_offset_minutes():
|
||||||
@@ -16,7 +40,6 @@ def _get_offset_minutes():
|
|||||||
import flask
|
import flask
|
||||||
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
||||||
except (ValueError, TypeError, RuntimeError):
|
except (ValueError, TypeError, RuntimeError):
|
||||||
# RuntimeError: outside of request context
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -26,14 +49,45 @@ def _offset_to_tz(offset_minutes):
|
|||||||
|
|
||||||
|
|
||||||
def user_now(offset_minutes=None):
|
def user_now(offset_minutes=None):
|
||||||
"""Current datetime in the user's timezone.
|
"""Current datetime in the user's timezone (request-context).
|
||||||
If offset_minutes is provided, uses that instead of the request header."""
|
If offset_minutes is provided, uses that directly.
|
||||||
if offset_minutes is None:
|
Otherwise reads request headers (prefers IANA name over offset)."""
|
||||||
offset_minutes = _get_offset_minutes()
|
if offset_minutes is not None:
|
||||||
tz = _offset_to_tz(offset_minutes)
|
tz = _offset_to_tz(offset_minutes)
|
||||||
|
else:
|
||||||
|
tz = _get_request_tz()
|
||||||
return datetime.now(tz)
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
def user_today(offset_minutes=None):
|
def user_today(offset_minutes=None):
|
||||||
"""Current date in the user's timezone."""
|
"""Current date in the user's timezone."""
|
||||||
return user_now(offset_minutes).date()
|
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:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- botcache:/app/cache
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
@@ -56,3 +58,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
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")
|
||||||
@@ -5,12 +5,15 @@ Override poll_callback() with your domain-specific logic.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time as time_module
|
||||||
import logging
|
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.postgres as postgres
|
||||||
import core.notifications as notifications
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,14 +22,19 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
|
|||||||
|
|
||||||
|
|
||||||
def _user_now_for(user_uuid):
|
def _user_now_for(user_uuid):
|
||||||
"""Get current datetime in a user's timezone using their stored offset."""
|
"""Get current datetime in a user's timezone using their stored preferences."""
|
||||||
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
return tz.user_now_for(user_uuid)
|
||||||
offset_minutes = 0
|
|
||||||
if prefs and prefs.get("timezone_offset") is not None:
|
|
||||||
offset_minutes = prefs["timezone_offset"]
|
def _utc_to_local_date(created_at, user_tz):
|
||||||
# JS getTimezoneOffset: positive = behind UTC, so negate
|
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
|
||||||
tz_obj = timezone(timedelta(minutes=-offset_minutes))
|
if created_at is None:
|
||||||
return datetime.now(tz_obj)
|
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():
|
def check_medication_reminders():
|
||||||
@@ -50,6 +58,7 @@ def check_medication_reminders():
|
|||||||
current_day = now.strftime("%a").lower()
|
current_day = now.strftime("%a").lower()
|
||||||
today = now.date()
|
today = now.date()
|
||||||
today_str = today.isoformat()
|
today_str = today.isoformat()
|
||||||
|
user_tz = tz.tz_for_user(user_uuid)
|
||||||
|
|
||||||
for med in user_med_list:
|
for med in user_med_list:
|
||||||
freq = med.get("frequency", "daily")
|
freq = med.get("frequency", "daily")
|
||||||
@@ -86,13 +95,13 @@ def check_medication_reminders():
|
|||||||
if current_time not in times:
|
if current_time not in times:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Already taken today? Check by created_at date
|
# Already taken today? Check by created_at date in user's timezone
|
||||||
logs = postgres.select(
|
logs = postgres.select(
|
||||||
"med_logs", where={"medication_id": med["id"], "action": "taken"}
|
"med_logs", where={"medication_id": med["id"], "action": "taken"}
|
||||||
)
|
)
|
||||||
already_taken = any(
|
already_taken = any(
|
||||||
log.get("scheduled_time") == current_time
|
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
|
for log in logs
|
||||||
)
|
)
|
||||||
if already_taken:
|
if already_taken:
|
||||||
@@ -111,31 +120,63 @@ def check_medication_reminders():
|
|||||||
def check_routine_reminders():
|
def check_routine_reminders():
|
||||||
"""Check for scheduled routines due now and send notifications."""
|
"""Check for scheduled routines due now and send notifications."""
|
||||||
try:
|
try:
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
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:
|
for schedule in schedules:
|
||||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
||||||
if not routine:
|
if not routine:
|
||||||
|
logger.warning(f"Routine not found for schedule {schedule['id']}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
now = _user_now_for(routine["user_uuid"])
|
now = _user_now_for(routine["user_uuid"])
|
||||||
current_time = now.strftime("%H:%M")
|
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")
|
||||||
continue
|
if current_time != sched_time:
|
||||||
days = schedule.get("days", [])
|
|
||||||
if current_day not in days:
|
|
||||||
continue
|
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"])
|
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"Time to start your routine: {routine['name']}"
|
msg = f"Time to start your routine: {routine['name']}"
|
||||||
notifications._sendToEnabledChannels(
|
notifications._sendToEnabledChannels(
|
||||||
user_settings, msg, user_uuid=routine["user_uuid"]
|
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:
|
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():
|
def check_refills():
|
||||||
@@ -155,11 +196,504 @@ def check_refills():
|
|||||||
logger.error(f"Error checking refills: {e}")
|
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():
|
def poll_callback():
|
||||||
"""Called every POLL_INTERVAL seconds."""
|
"""Called every POLL_INTERVAL seconds."""
|
||||||
check_medication_reminders()
|
# 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_routine_reminders()
|
||||||
check_refills()
|
check_refills()
|
||||||
|
check_task_reminders()
|
||||||
|
|
||||||
|
|
||||||
def daemon_loop():
|
def daemon_loop():
|
||||||
@@ -169,7 +703,7 @@ def daemon_loop():
|
|||||||
poll_callback()
|
poll_callback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Poll callback error: {e}")
|
logger.error(f"Poll callback error: {e}")
|
||||||
time.sleep(POLL_INTERVAL)
|
time_module.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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,
|
PillIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
CopyIcon,
|
ClockIcon,
|
||||||
HeartIcon,
|
|
||||||
SunIcon,
|
SunIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
} from '@/components/ui/Icons';
|
} from '@/components/ui/Icons';
|
||||||
@@ -24,7 +23,7 @@ import Link from 'next/link';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
|
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
|
||||||
{ href: '/dashboard/routines', label: 'Routines', icon: ListIcon },
|
{ 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/history', label: 'History', icon: CalendarIcon },
|
||||||
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
|
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
|
||||||
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },
|
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },
|
||||||
@@ -49,12 +48,13 @@ export default function DashboardLayout({
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, router]);
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
// Sync timezone offset to backend once per session
|
// Sync timezone to backend once per session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !tzSynced.current) {
|
if (isAuthenticated && !tzSynced.current) {
|
||||||
tzSynced.current = true;
|
tzSynced.current = true;
|
||||||
const offset = new Date().getTimezoneOffset();
|
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]);
|
}, [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">
|
<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 justify-between px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<img src="/logo.png" alt="Synculous" className="w-8 h-8" />
|
||||||
<HeartIcon className="text-white" size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Synculous</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">Synculous</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { ArrowLeftIcon } from '@/components/ui/Icons';
|
import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons';
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 'mon', label: 'Mon' },
|
{ value: 'mon', label: 'Mon' },
|
||||||
@@ -15,63 +15,258 @@ const DAY_OPTIONS = [
|
|||||||
{ value: 'sun', label: 'Sun' },
|
{ value: 'sun', label: 'Sun' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface MedEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dosage: string;
|
||||||
|
unit: string;
|
||||||
|
frequency: string;
|
||||||
|
times: string[];
|
||||||
|
daysOfWeek: string[];
|
||||||
|
intervalDays: number;
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankEntry(): MedEntry {
|
||||||
|
return {
|
||||||
|
id: `med-${Date.now()}-${Math.random()}`,
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
unit: 'mg',
|
||||||
|
frequency: 'daily',
|
||||||
|
times: ['08:00'],
|
||||||
|
daysOfWeek: [],
|
||||||
|
intervalDays: 7,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function MedCard({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
entry: MedEntry;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onChange: (updates: Partial<MedEntry>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] });
|
||||||
|
const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) });
|
||||||
|
const handleTimeChange = (i: number, val: string) => {
|
||||||
|
const t = [...entry.times];
|
||||||
|
t[i] = val;
|
||||||
|
onChange({ times: t });
|
||||||
|
};
|
||||||
|
const toggleDay = (day: string) =>
|
||||||
|
onChange({
|
||||||
|
daysOfWeek: entry.daysOfWeek.includes(day)
|
||||||
|
? entry.daysOfWeek.filter(d => d !== day)
|
||||||
|
: [...entry.daysOfWeek, day],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
Medication {index + 1}
|
||||||
|
</span>
|
||||||
|
{total > 1 && (
|
||||||
|
<button type="button" onClick={onRemove} className="text-red-500 dark:text-red-400 p-1">
|
||||||
|
<TrashIcon size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.name}
|
||||||
|
onChange={e => onChange({ name: e.target.value })}
|
||||||
|
placeholder="e.g., Vitamin D"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.dosage}
|
||||||
|
onChange={e => onChange({ dosage: e.target.value })}
|
||||||
|
placeholder="e.g., 1000"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
||||||
|
<select
|
||||||
|
value={entry.unit}
|
||||||
|
onChange={e => onChange({ unit: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="mg">mg</option>
|
||||||
|
<option value="mcg">mcg</option>
|
||||||
|
<option value="g">g</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="IU">IU</option>
|
||||||
|
<option value="tablets">tablets</option>
|
||||||
|
<option value="capsules">capsules</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
||||||
|
<select
|
||||||
|
value={entry.frequency}
|
||||||
|
onChange={e => onChange({ frequency: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="specific_days">Specific Days of Week</option>
|
||||||
|
<option value="every_n_days">Every N Days</option>
|
||||||
|
<option value="as_needed">As Needed (PRN)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.frequency === 'specific_days' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{DAY_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
entry.daysOfWeek.includes(value)
|
||||||
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.frequency === 'every_n_days' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={entry.intervalDays}
|
||||||
|
onChange={e => onChange({ intervalDays: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={entry.startDate}
|
||||||
|
onChange={e => onChange({ startDate: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.frequency !== 'as_needed' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTime}
|
||||||
|
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
|
||||||
|
>
|
||||||
|
+ Add Time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entry.times.map((time, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={e => handleTimeChange(i, e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
{entry.times.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveTime(i)}
|
||||||
|
className="text-red-500 dark:text-red-400 px-3"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewMedicationPage() {
|
export default function NewMedicationPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState('');
|
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
|
||||||
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handleAddTime = () => {
|
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
|
||||||
setTimes([...times, '12:00']);
|
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveTime = (index: number) => {
|
const removeEntry = (index: number) => {
|
||||||
setTimes(times.filter((_, i) => i !== index));
|
setEntries(prev => prev.filter((_, i) => i !== index));
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeChange = (index: number, value: string) => {
|
|
||||||
const newTimes = [...times];
|
|
||||||
newTimes[index] = value;
|
|
||||||
setTimes(newTimes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDay = (day: string) => {
|
|
||||||
setDaysOfWeek(prev =>
|
|
||||||
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim() || !dosage.trim()) {
|
|
||||||
setError('Name and dosage are required');
|
for (let i = 0; i < entries.length; i++) {
|
||||||
return;
|
const entry = entries[i];
|
||||||
}
|
if (!entry.name.trim() || !entry.dosage.trim()) {
|
||||||
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
|
setError(`Medication ${i + 1}: name and dosage are required`);
|
||||||
setError('Select at least one day of the week');
|
return;
|
||||||
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);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.medications.create({
|
for (const entry of entries) {
|
||||||
name,
|
await api.medications.create({
|
||||||
dosage,
|
name: entry.name,
|
||||||
unit,
|
dosage: entry.dosage,
|
||||||
frequency,
|
unit: entry.unit,
|
||||||
times: frequency === 'as_needed' ? [] : times,
|
frequency: entry.frequency,
|
||||||
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
|
times: entry.frequency === 'as_needed' ? [] : entry.times,
|
||||||
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
|
...(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');
|
router.push('/dashboard/medications');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message || 'Failed to add medication');
|
setError((err as Error).message || 'Failed to add medication');
|
||||||
@@ -80,6 +275,8 @@ export default function NewMedicationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const count = entries.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<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">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||||
@@ -87,167 +284,47 @@ export default function NewMedicationPage() {
|
|||||||
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
||||||
<ArrowLeftIcon size={24} />
|
<ArrowLeftIcon size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medication</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medications</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
{error && (
|
{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">
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
{entries.map((entry, index) => (
|
||||||
<div>
|
<MedCard
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
|
key={entry.id}
|
||||||
<input
|
entry={entry}
|
||||||
type="text"
|
index={index}
|
||||||
value={name}
|
total={count}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={updates => updateEntry(index, updates)}
|
||||||
placeholder="e.g., Vitamin D"
|
onRemove={() => removeEntry(index)}
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
/>
|
||||||
/>
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
onClick={() => setEntries(prev => [...prev, blankEntry()])}
|
||||||
<input
|
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"
|
||||||
type="text"
|
>
|
||||||
value={dosage}
|
<PlusIcon size={18} />
|
||||||
onChange={(e) => setDosage(e.target.value)}
|
Add Another Medication
|
||||||
placeholder="e.g., 1000"
|
</button>
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
|
||||||
<select
|
|
||||||
value={unit}
|
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
>
|
|
||||||
<option value="mg">mg</option>
|
|
||||||
<option value="mcg">mcg</option>
|
|
||||||
<option value="g">g</option>
|
|
||||||
<option value="ml">ml</option>
|
|
||||||
<option value="IU">IU</option>
|
|
||||||
<option value="tablets">tablets</option>
|
|
||||||
<option value="capsules">capsules</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
|
||||||
<select
|
|
||||||
value={frequency}
|
|
||||||
onChange={(e) => setFrequency(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="specific_days">Specific Days of Week</option>
|
|
||||||
<option value="every_n_days">Every N Days</option>
|
|
||||||
<option value="as_needed">As Needed (PRN)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day-of-week picker for specific_days */}
|
|
||||||
{frequency === 'specific_days' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{DAY_OPTIONS.map(({ value, label }) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleDay(value)}
|
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
||||||
daysOfWeek.includes(value)
|
|
||||||
? 'bg-indigo-600 text-white border-indigo-600'
|
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Interval settings for every_n_days */}
|
|
||||||
{frequency === 'every_n_days' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={intervalDays}
|
|
||||||
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Times picker — hidden for as_needed */}
|
|
||||||
{frequency !== 'as_needed' && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddTime}
|
|
||||||
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium"
|
|
||||||
>
|
|
||||||
+ Add Time
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{frequency === 'daily' && (
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">Add multiple times for 2x, 3x, or more doses per day</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{times.map((time, index) => (
|
|
||||||
<div key={index} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={time}
|
|
||||||
onChange={(e) => handleTimeChange(index, e.target.value)}
|
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
||||||
/>
|
|
||||||
{times.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveTime(index)}
|
|
||||||
className="text-red-500 dark:text-red-400 px-3"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
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 Link from 'next/link';
|
||||||
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
||||||
|
|
||||||
@@ -91,24 +91,36 @@ export default function MedicationsPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [medsData, todayData, adherenceData] = await Promise.all([
|
||||||
|
api.medications.list(),
|
||||||
|
api.medications.getToday().catch(() => []),
|
||||||
|
api.medications.getAdherence(30).catch(() => []),
|
||||||
|
]);
|
||||||
|
setMedications(medsData);
|
||||||
|
setTodayMeds(todayData);
|
||||||
|
setAdherence(adherenceData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch medications:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
fetchData().finally(() => setIsLoading(false));
|
||||||
try {
|
}, []);
|
||||||
const [medsData, todayData, adherenceData] = await Promise.all([
|
|
||||||
api.medications.list(),
|
// Re-fetch when tab becomes visible or every 60s
|
||||||
api.medications.getToday().catch(() => []),
|
useEffect(() => {
|
||||||
api.medications.getAdherence(30).catch(() => []),
|
const onVisible = () => {
|
||||||
]);
|
if (document.visibilityState === 'visible') fetchData();
|
||||||
setMedications(medsData);
|
};
|
||||||
setTodayMeds(todayData);
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
setAdherence(adherenceData);
|
const poll = setInterval(fetchData, 60_000);
|
||||||
} catch (err) {
|
return () => {
|
||||||
console.error('Failed to fetch medications:', err);
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
} finally {
|
clearInterval(poll);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
fetchData();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-refresh grouping every 60s
|
// Auto-refresh grouping every 60s
|
||||||
@@ -380,12 +392,20 @@ export default function MedicationsPage() {
|
|||||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDelete(med.id)}
|
<Link
|
||||||
className="text-red-500 dark:text-red-400 p-2"
|
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"
|
||||||
<TrashIcon size={18} />
|
>
|
||||||
</button>
|
<EditIcon size={18} />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(med.id)}
|
||||||
|
className="text-red-500 dark:text-red-400 p-2"
|
||||||
|
>
|
||||||
|
<TrashIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adherence */}
|
{/* Adherence */}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ interface Schedule {
|
|||||||
days: string[];
|
days: string[];
|
||||||
time: string;
|
time: string;
|
||||||
remind: boolean;
|
remind: boolean;
|
||||||
|
frequency?: string;
|
||||||
|
interval_days?: number;
|
||||||
|
start_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
|
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
|
||||||
@@ -77,6 +80,9 @@ export default function RoutineDetailPage() {
|
|||||||
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||||
const [editTime, setEditTime] = useState('08:00');
|
const [editTime, setEditTime] = useState('08:00');
|
||||||
const [editRemind, setEditRemind] = useState(true);
|
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);
|
const [showScheduleEditor, setShowScheduleEditor] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,6 +105,9 @@ export default function RoutineDetailPage() {
|
|||||||
setEditDays(scheduleData.days || []);
|
setEditDays(scheduleData.days || []);
|
||||||
setEditTime(scheduleData.time || '08:00');
|
setEditTime(scheduleData.time || '08:00');
|
||||||
setEditRemind(scheduleData.remind ?? true);
|
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 {
|
} else {
|
||||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||||
if (isNewRoutine) {
|
if (isNewRoutine) {
|
||||||
@@ -162,13 +171,27 @@ export default function RoutineDetailPage() {
|
|||||||
|
|
||||||
const handleSaveSchedule = async () => {
|
const handleSaveSchedule = async () => {
|
||||||
try {
|
try {
|
||||||
if (editDays.length > 0) {
|
const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0;
|
||||||
await api.routines.setSchedule(routineId, {
|
if (hasSchedule) {
|
||||||
|
const schedulePayload = {
|
||||||
days: editDays,
|
days: editDays,
|
||||||
time: editTime || '08:00',
|
time: editTime || '08:00',
|
||||||
remind: editRemind,
|
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) {
|
} else if (schedule) {
|
||||||
await api.routines.deleteSchedule(routineId);
|
await api.routines.deleteSchedule(routineId);
|
||||||
setSchedule(null);
|
setSchedule(null);
|
||||||
@@ -176,7 +199,7 @@ export default function RoutineDetailPage() {
|
|||||||
setShowScheduleEditor(false);
|
setShowScheduleEditor(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save schedule:', 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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -462,56 +485,108 @@ export default function RoutineDetailPage() {
|
|||||||
|
|
||||||
{showScheduleEditor ? (
|
{showScheduleEditor ? (
|
||||||
<>
|
<>
|
||||||
{/* Quick select */}
|
{/* Frequency selector */}
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="flex gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
onClick={() => setEditFrequency('weekly')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Every day
|
Weekly
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
|
onClick={() => setEditFrequency('every_n_days')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Weekdays
|
Every N Days
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditDays(['sat', 'sun'])}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
|
||||||
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Weekends
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3">
|
{editFrequency === 'every_n_days' ? (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
<div className="mb-3 space-y-3">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div>
|
||||||
{DAY_OPTIONS.map((day) => (
|
<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">
|
||||||
<button
|
<button
|
||||||
key={day.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDay(day.value)}
|
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
editDays.includes(day.value)
|
editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
? 'bg-indigo-600 text-white border-indigo-600'
|
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day.label}
|
Every day
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekdays
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditDays(['sat', 'sun'])}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekends
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{DAY_OPTIONS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
editDays.includes(day.value)
|
||||||
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||||
<input
|
<input
|
||||||
@@ -545,10 +620,14 @@ export default function RoutineDetailPage() {
|
|||||||
setEditDays(schedule.days);
|
setEditDays(schedule.days);
|
||||||
setEditTime(schedule.time);
|
setEditTime(schedule.time);
|
||||||
setEditRemind(schedule.remind);
|
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 {
|
} else {
|
||||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||||
setEditTime('08:00');
|
setEditTime('08:00');
|
||||||
setEditRemind(true);
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
{schedule.remind && (
|
{schedule.remind && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p>
|
<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 { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import api from '@/lib/api';
|
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 {
|
interface Step {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,11 +41,18 @@ export default function NewRoutinePage() {
|
|||||||
const [steps, setSteps] = useState<Step[]>([]);
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [aiGoal, setAiGoal] = useState('');
|
||||||
|
const [showAiInput, setShowAiInput] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||||
const [scheduleTime, setScheduleTime] = useState('08:00');
|
const [scheduleTime, setScheduleTime] = useState('08:00');
|
||||||
const [scheduleRemind, setScheduleRemind] = useState(true);
|
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) => {
|
const toggleDay = (day: string) => {
|
||||||
setScheduleDays(prev =>
|
setScheduleDays(prev =>
|
||||||
@@ -74,6 +81,31 @@ export default function NewRoutinePage() {
|
|||||||
setSteps(newSteps.map((s, i) => ({ ...s, position: i + 1 })));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
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, {
|
await api.routines.setSchedule(routine.id, {
|
||||||
days: scheduleDays,
|
days: scheduleDays,
|
||||||
time: scheduleTime,
|
time: scheduleTime,
|
||||||
remind: scheduleRemind,
|
remind: scheduleRemind,
|
||||||
|
frequency: scheduleFrequency,
|
||||||
|
...(scheduleFrequency === 'every_n_days' && {
|
||||||
|
interval_days: scheduleIntervalDays,
|
||||||
|
start_date: scheduleStartDate,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,56 +235,108 @@ export default function NewRoutinePage() {
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
|
||||||
|
|
||||||
{/* Quick select buttons */}
|
{/* Frequency selector */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
onClick={() => setScheduleFrequency('weekly')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
scheduleFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Every day
|
Weekly
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
|
onClick={() => setScheduleFrequency('every_n_days')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
scheduleFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Weekdays
|
Every N Days
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setScheduleDays(['sat', 'sun'])}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
|
||||||
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Weekends
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{scheduleFrequency === 'every_n_days' ? (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div>
|
||||||
{DAY_OPTIONS.map((day) => (
|
<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
|
<button
|
||||||
key={day.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDay(day.value)}
|
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
scheduleDays.includes(day.value)
|
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
? 'bg-indigo-600 text-white border-indigo-600'
|
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day.label}
|
Every day
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekdays
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheduleDays(['sat', 'sun'])}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekends
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{DAY_OPTIONS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
scheduleDays.includes(day.value)
|
||||||
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||||
@@ -282,27 +371,101 @@ export default function NewRoutinePage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleAddStep}
|
type="button"
|
||||||
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex items-center gap-1"
|
onClick={() => {
|
||||||
>
|
setShowAiInput(!showAiInput);
|
||||||
<PlusIcon size={16} />
|
if (!showAiInput && !aiGoal) setAiGoal(name);
|
||||||
Add Step
|
setAiError('');
|
||||||
</button>
|
}}
|
||||||
</div>
|
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"
|
||||||
|
>
|
||||||
{steps.length === 0 ? (
|
<SparklesIcon size={16} />
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
|
Generate with AI
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-4">Add steps to your routine</p>
|
</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddStep}
|
onClick={handleAddStep}
|
||||||
className="text-indigo-600 dark:text-indigo-400 font-medium"
|
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex items-center gap-1"
|
||||||
>
|
>
|
||||||
+ Add your first step
|
<PlusIcon size={16} />
|
||||||
|
Add Step
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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="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"
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
Add manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -21,6 +21,9 @@ interface ScheduleEntry {
|
|||||||
time: string;
|
time: string;
|
||||||
remind: boolean;
|
remind: boolean;
|
||||||
total_duration_minutes: number;
|
total_duration_minutes: number;
|
||||||
|
frequency?: string;
|
||||||
|
interval_days?: number;
|
||||||
|
start_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TodaysMedication {
|
interface TodaysMedication {
|
||||||
@@ -208,6 +211,7 @@ export default function RoutinesPage() {
|
|||||||
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
|
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
|
||||||
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
|
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
|
||||||
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
||||||
|
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||||
@@ -229,9 +233,25 @@ export default function RoutinesPage() {
|
|||||||
const dayKey = getDayKey(selectedDate);
|
const dayKey = getDayKey(selectedDate);
|
||||||
|
|
||||||
const scheduledForDay = allSchedules
|
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));
|
.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 scheduledRoutineIds = new Set(allSchedules.map((s) => s.routine_id));
|
||||||
const unscheduledRoutines = allRoutines.filter(
|
const unscheduledRoutines = allRoutines.filter(
|
||||||
(r) => !scheduledRoutineIds.has(r.id)
|
(r) => !scheduledRoutineIds.has(r.id)
|
||||||
@@ -299,6 +319,10 @@ export default function RoutinesPage() {
|
|||||||
const allEventMins = [
|
const allEventMins = [
|
||||||
...scheduledForDay.map((e) => timeToMinutes(e.time)),
|
...scheduledForDay.map((e) => timeToMinutes(e.time)),
|
||||||
...groupedMedEntries.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 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;
|
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,
|
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);
|
return computeLanes(items);
|
||||||
}, [scheduledForDay, groupedMedEntries]);
|
}, [scheduledForDay, groupedMedEntries, tasksForDay]);
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────
|
||||||
const handleTakeMed = async (medicationId: string, scheduledTime: string) => {
|
const handleTakeMed = async (medicationId: string, scheduledTime: string) => {
|
||||||
@@ -411,19 +444,23 @@ export default function RoutinesPage() {
|
|||||||
setUndoAction(null);
|
setUndoAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchAllData = () =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.routines.list(),
|
api.routines.list(),
|
||||||
api.routines.listAllSchedules(),
|
api.routines.listAllSchedules(),
|
||||||
api.medications.getToday().catch(() => []),
|
api.medications.getToday().catch(() => []),
|
||||||
|
api.tasks.list('all').catch(() => []),
|
||||||
])
|
])
|
||||||
.then(([routines, schedules, meds]) => {
|
.then(([routines, schedules, meds, tasks]) => {
|
||||||
setAllRoutines(routines);
|
setAllRoutines(routines);
|
||||||
setAllSchedules(schedules);
|
setAllSchedules(schedules);
|
||||||
setTodayMeds(meds);
|
setTodayMeds(meds);
|
||||||
|
setAllTasks(tasks);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllData().finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -435,6 +472,19 @@ export default function RoutinesPage() {
|
|||||||
return () => clearInterval(timer);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && isToday && timelineRef.current) {
|
if (!isLoading && isToday && timelineRef.current) {
|
||||||
const scrollTarget = nowTopPx - window.innerHeight / 3;
|
const scrollTarget = nowTopPx - window.innerHeight / 3;
|
||||||
@@ -447,6 +497,18 @@ export default function RoutinesPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isLoading, isToday]);
|
}, [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) => {
|
const handleStartRoutine = async (routineId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.sessions.start(routineId);
|
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 */}
|
{/* 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">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
No routines or medications for this day
|
No routines or medications for this day
|
||||||
|
|||||||
@@ -21,6 +21,43 @@ interface NotifSettings {
|
|||||||
ntfy_enabled: boolean;
|
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() {
|
export default function SettingsPage() {
|
||||||
const [prefs, setPrefs] = useState<Preferences>({
|
const [prefs, setPrefs] = useState<Preferences>({
|
||||||
sound_enabled: false,
|
sound_enabled: false,
|
||||||
@@ -34,8 +71,43 @@ export default function SettingsPage() {
|
|||||||
ntfy_topic: '',
|
ntfy_topic: '',
|
||||||
ntfy_enabled: false,
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [saved, setSaved] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -46,11 +118,26 @@ export default function SettingsPage() {
|
|||||||
ntfy_topic: data.ntfy_topic,
|
ntfy_topic: data.ntfy_topic,
|
||||||
ntfy_enabled: data.ntfy_enabled,
|
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(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setIsLoading(false));
|
.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 = () => {
|
const flashSaved = () => {
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 1500);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
@@ -228,19 +396,263 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{notif.discord_enabled && (
|
{notif.discord_enabled && (
|
||||||
<input
|
<div className="space-y-1">
|
||||||
type="text"
|
<input
|
||||||
placeholder="Your Discord user ID"
|
type="text"
|
||||||
value={notif.discord_user_id}
|
placeholder="Your Discord user ID (numbers only)"
|
||||||
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
|
value={notif.discord_user_id}
|
||||||
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
|
onChange={(e) => {
|
||||||
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"
|
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>
|
</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 */}
|
{/* Celebration Style */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Celebration Style</h2>
|
<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>
|
||||||
</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>
|
</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 { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/auth/AuthProvider';
|
import { useAuth } from '@/components/auth/AuthProvider';
|
||||||
import { HeartIcon } from '@/components/ui/Icons';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [trustDevice, setTrustDevice] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
@@ -21,10 +22,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
await login(username, password);
|
await login(username, password, trustDevice);
|
||||||
} else {
|
} else {
|
||||||
await register(username, password);
|
await register(username, password);
|
||||||
await login(username, password);
|
await login(username, password, trustDevice);
|
||||||
}
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (err) {
|
} 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="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="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="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">
|
<img src="/logo.png" alt="Synculous" className="w-16 h-16 mb-4" />
|
||||||
<HeartIcon className="text-white" size={32} />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Synculous</h1>
|
<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">
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{isLogin ? 'Welcome back!' : 'Create your account'}
|
{isLogin ? 'Welcome back!' : 'Create your account'}
|
||||||
@@ -82,6 +81,18 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface AuthContextType {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: 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>;
|
register: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -54,8 +54,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
refreshUser();
|
refreshUser();
|
||||||
}, [refreshUser]);
|
}, [refreshUser]);
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
const login = async (username: string, password: string, trustDevice = false) => {
|
||||||
const result = await api.auth.login(username, password);
|
const result = await api.auth.login(username, password, trustDevice);
|
||||||
const storedToken = api.auth.getToken();
|
const storedToken = api.auth.getToken();
|
||||||
setToken(storedToken);
|
setToken(storedToken);
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function PushNotificationToggle() {
|
|||||||
const { public_key } = await api.notifications.getVapidPublicKey();
|
const { public_key } = await api.notifications.getVapidPublicKey();
|
||||||
const sub = await reg.pushManager.subscribe({
|
const sub = await reg.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
|
applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subJson = sub.toJSON();
|
const subJson = sub.toJSON();
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
const API_URL = '';
|
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 {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem('token');
|
||||||
@@ -11,16 +24,63 @@ function setToken(token: string): void {
|
|||||||
|
|
||||||
function clearToken(): void {
|
function clearToken(): void {
|
||||||
localStorage.removeItem('token');
|
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>(
|
async function request<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {},
|
||||||
|
_retried = false,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
||||||
|
'X-Timezone-Name': Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
@@ -30,6 +90,14 @@ async function request<T>(
|
|||||||
headers,
|
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) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
let errorMsg = 'Request failed';
|
let errorMsg = 'Request failed';
|
||||||
@@ -48,12 +116,15 @@ async function request<T>(
|
|||||||
export const api = {
|
export const api = {
|
||||||
// Auth
|
// Auth
|
||||||
auth: {
|
auth: {
|
||||||
login: async (username: string, password: string) => {
|
login: async (username: string, password: string, trustDevice = false) => {
|
||||||
const result = await request<{ token: string }>('/api/login', {
|
const result = await request<{ token: string; refresh_token?: string }>('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password, trust_device: trustDevice }),
|
||||||
});
|
});
|
||||||
setToken(result.token);
|
setToken(result.token);
|
||||||
|
if (result.refresh_token) {
|
||||||
|
setRefreshToken(result.refresh_token);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -252,12 +323,15 @@ export const api = {
|
|||||||
days: string[];
|
days: string[];
|
||||||
time: string;
|
time: string;
|
||||||
remind: boolean;
|
remind: boolean;
|
||||||
|
frequency?: string;
|
||||||
|
interval_days?: number;
|
||||||
|
start_date?: string;
|
||||||
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
|
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
|
||||||
},
|
},
|
||||||
|
|
||||||
setSchedule: async (
|
setSchedule: async (
|
||||||
routineId: string,
|
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`, {
|
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -281,6 +355,9 @@ export const api = {
|
|||||||
time: string;
|
time: string;
|
||||||
remind: boolean;
|
remind: boolean;
|
||||||
total_duration_minutes: number;
|
total_duration_minutes: number;
|
||||||
|
frequency?: string;
|
||||||
|
interval_days?: number;
|
||||||
|
start_date?: string;
|
||||||
}>>('/api/routines/schedules', { method: 'GET' });
|
}>>('/api/routines/schedules', { method: 'GET' });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -636,6 +713,7 @@ export const api = {
|
|||||||
show_launch_screen?: boolean;
|
show_launch_screen?: boolean;
|
||||||
celebration_style?: string;
|
celebration_style?: string;
|
||||||
timezone_offset?: number;
|
timezone_offset?: number;
|
||||||
|
timezone_name?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return request<Record<string, unknown>>('/api/preferences', {
|
return request<Record<string, unknown>>('/api/preferences', {
|
||||||
method: 'PUT',
|
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
|
||||||
medications: {
|
medications: {
|
||||||
list: async () => {
|
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;
|
export default api;
|
||||||
|
|||||||