Compare commits
13 Commits
d45929ddc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215c3d7f95 | ||
| fe07b3ebe7 | |||
| 019561e7cd | |||
| e89656a87c | |||
| 03da0b0156 | |||
| cf29d17183 | |||
| cc1aace73d | |||
| a19e30db68 | |||
| e9a2f96f91 | |||
| 4c4ff5add3 | |||
| 33db2629e3 | |||
| ecb79af44e | |||
| 24a1d18b25 |
300
README.md
300
README.md
@@ -1,14 +1,14 @@
|
||||
# Synculous
|
||||
|
||||
A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
||||
A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
||||
|
||||
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame.
|
||||
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
synculous/
|
||||
├── synculous-client/ # Next.js 16 frontend (React, Tailwind)
|
||||
├── synculous-client/ # Next.js frontend (React, Tailwind)
|
||||
├── api/ # Flask REST API
|
||||
│ ├── main.py # App entry point, auth routes
|
||||
│ └── routes/ # Domain route modules
|
||||
@@ -19,28 +19,49 @@ synculous/
|
||||
│ ├── routine_steps_extended.py # Step instructions, types, media
|
||||
│ ├── routine_tags.py # Tagging system
|
||||
│ ├── medications.py # Medication scheduling + adherence
|
||||
│ ├── adaptive_meds.py # Adaptive medication timing (learning)
|
||||
│ ├── tasks.py # One-off tasks/appointments CRUD
|
||||
│ ├── ai.py # AI-powered step generation
|
||||
│ ├── preferences.py # User settings + timezone
|
||||
│ ├── notifications.py # Web push subscriptions
|
||||
│ ├── rewards.py # Variable reward system
|
||||
│ └── victories.py # Achievement detection
|
||||
│ ├── victories.py # Achievement detection
|
||||
│ └── snitch.py # Peer accountability contacts + notifications
|
||||
├── core/ # Shared business logic
|
||||
│ ├── postgres.py # Generic PostgreSQL CRUD
|
||||
│ ├── auth.py # JWT + bcrypt authentication
|
||||
│ ├── users.py # User management
|
||||
│ ├── routines.py # Routine/session/streak logic
|
||||
│ ├── tz.py # Timezone-aware date/time helpers
|
||||
│ ├── stats.py # Statistics calculations (completion rates, streaks)
|
||||
│ ├── snitch.py # Snitch trigger logic + notification delivery
|
||||
│ ├── adaptive_meds.py # Adaptive medication timing logic
|
||||
│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset)
|
||||
│ └── notifications.py # Multi-channel notifications
|
||||
├── scheduler/
|
||||
│ └── daemon.py # Background polling for reminders
|
||||
├── bot/ # Discord bot (optional)
|
||||
├── ai/ # LLM parser for natural language commands
|
||||
├── bot/ # Discord bot with knowledge RAG
|
||||
│ ├── bot.py # Bot entry point + session management
|
||||
│ ├── command_registry.py # Module-based command routing
|
||||
│ ├── commands/ # Command modules
|
||||
│ │ ├── routines.py # /routine commands
|
||||
│ │ ├── medications.py # /med, /take, /skip commands
|
||||
│ │ ├── tasks.py # /task commands (one-off tasks/appointments)
|
||||
│ │ └── knowledge.py # /ask command (jury-filtered RAG)
|
||||
│ └── hooks.py # Event listeners
|
||||
├── ai/ # LLM-powered features
|
||||
│ ├── parser.py # OpenRouter API client
|
||||
│ ├── jury_council.py # 5-juror safety filtration system
|
||||
│ ├── ai_config.json # Model + prompt configuration
|
||||
│ └── (optional) RAG embeddings
|
||||
├── config/
|
||||
│ ├── schema.sql # Database schema
|
||||
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
|
||||
│ ├── seed_rewards.sql # Variable reward pool
|
||||
│ └── .env.example # Environment template
|
||||
├── diagrams/ # Architecture diagrams (Mermaid)
|
||||
├── docker-compose.yml
|
||||
└── Dockerfile
|
||||
├── Dockerfile
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -49,10 +70,10 @@ synculous/
|
||||
# Copy environment config
|
||||
cp config/.env.example config/.env
|
||||
|
||||
# Edit with your values
|
||||
# Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY)
|
||||
nano config/.env
|
||||
|
||||
# Start everything
|
||||
# Start everything (db, api, scheduler, optional bot, frontend client)
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
@@ -61,20 +82,23 @@ This starts five services:
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
|
||||
| `app` | 8080 | Flask API |
|
||||
| `app` | 8010 | Flask API (internal: 5000) |
|
||||
| `scheduler` | — | Background daemon for medication/routine reminders |
|
||||
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) |
|
||||
| `client` | 3000 | Next.js frontend |
|
||||
| `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
|
||||
| `client` | 3001 | Next.js frontend (internal: 3000) |
|
||||
|
||||
## Features
|
||||
|
||||
### Routines
|
||||
### Routines & Sessions
|
||||
- Create routines with ordered steps (4-7 steps recommended)
|
||||
- Run sessions with a guided, one-step-at-a-time focus interface
|
||||
- Complete, skip, pause, resume, or cancel sessions
|
||||
- Swipe gestures for step completion on mobile
|
||||
- Per-step timing with visual countdown
|
||||
- Animated celebration screen on completion with streak stats and variable rewards
|
||||
- Every-N-day frequency option for routines
|
||||
- Tagging system for organizing routines by category
|
||||
- Session notes for logging context or blockers
|
||||
|
||||
### Premade Templates
|
||||
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
|
||||
@@ -96,13 +120,44 @@ This starts five services:
|
||||
|
||||
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
|
||||
|
||||
### One-Off Tasks & Appointments
|
||||
- Create standalone tasks and appointments outside of routines
|
||||
- Scheduled date/time with optional reminders
|
||||
- Quick-complete action for fast check-off
|
||||
- Tasks appear on the routines timeline for a unified daily view
|
||||
- AI-powered task composition via bot and web client
|
||||
- Natural language date parsing in bot commands
|
||||
|
||||
### AI-Powered Step Generation
|
||||
- Generate ADHD-friendly routine steps from a plain-language goal description
|
||||
- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps
|
||||
- Each step includes name and estimated duration
|
||||
- Available via API endpoint and integrated into the web client's routine creation flow
|
||||
|
||||
### Medications
|
||||
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN)
|
||||
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once
|
||||
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
|
||||
- Take, skip, snooze actions with logging
|
||||
- Adherence tracking and statistics
|
||||
- Refill tracking with low-quantity alerts
|
||||
- Background reminders via the scheduler daemon
|
||||
- Medication editing (update name, dose, schedule, refill count)
|
||||
- Web push and Discord notifications for doses
|
||||
|
||||
#### Adaptive Medication Timing
|
||||
- Machine-learning-based timing predictions based on user adherence patterns
|
||||
- System learns when you're most likely to take meds
|
||||
- Automatic reminder optimization (fewer false-positive reminders)
|
||||
- Override and manual timing adjustments always available
|
||||
- Useful for people with irregular schedules
|
||||
|
||||
### Peer Accountability: "Snitch" Feature
|
||||
- Designate trusted contacts to receive medication adherence notifications
|
||||
- Contacts don't need an account or login
|
||||
- Granular privacy controls: users choose *what* to share (meds, streaks, notes)
|
||||
- Contacts receive weekly summaries or real-time alerts for missed doses
|
||||
- Based on research showing peer accountability improves adherence
|
||||
- Optional consent-based consent flow for contact approvals
|
||||
|
||||
### Streaks and Stats
|
||||
- Per-routine streak tracking (current + longest)
|
||||
@@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret
|
||||
- Random reward on routine completion (post-completion only, never mid-routine)
|
||||
- Reward history tracking per user
|
||||
- Common and rare rarity tiers
|
||||
- Designed to leverage variable-ratio reinforcement schedules
|
||||
|
||||
### Notifications
|
||||
- Web push notifications via VAPID
|
||||
- Discord webhooks
|
||||
- ntfy support
|
||||
- Scheduled reminders for medications and routines
|
||||
- Web push notifications via VAPID (in-browser)
|
||||
- Discord webhooks (for bot mentions)
|
||||
- Discord DMs (for sensitive notifications like "snitch" alerts)
|
||||
- ntfy support (for self-hosted push)
|
||||
- Timezone-aware scheduled reminders for medications and routines
|
||||
|
||||
### Timezone Support
|
||||
All date/time operations respect the user's local timezone:
|
||||
- The frontend sends `X-Timezone-Offset` with every API request
|
||||
- The timezone offset is also persisted to `user_preferences` for background jobs
|
||||
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date
|
||||
- The scheduler daemon looks up each user's stored offset for reminder timing
|
||||
### Timezone Support (Dual-Path)
|
||||
All date/time operations respect the user's local timezone via two mechanisms:
|
||||
|
||||
**In request context:**
|
||||
- The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
|
||||
- Handlers use these headers for real-time API operations
|
||||
|
||||
**In background jobs (scheduler daemon):**
|
||||
- Timezone is stored in `user_preferences.timezone_name` (IANA format)
|
||||
- Scheduler retrieves stored timezone for each user
|
||||
- Falls back to numeric offset, then UTC if name is unavailable
|
||||
- Enables accurate reminder delivery even if user's browser context is offline
|
||||
|
||||
**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date.
|
||||
|
||||
### Authentication & Session Persistence
|
||||
- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days)
|
||||
- "Trust this device" option on login issues a refresh token for seamless re-auth
|
||||
- Bot session caching with persistent pickle storage across restarts
|
||||
|
||||
### User Preferences
|
||||
- Sound effects (default off — habituation risk)
|
||||
- Haptic feedback (default on)
|
||||
- Launch screen toggle
|
||||
- Celebration style
|
||||
- Timezone offset (auto-synced from browser)
|
||||
- Timezone (IANA name + numeric offset, auto-synced from browser)
|
||||
- Discord presence indicator toggle (shows online/offline in Discord)
|
||||
|
||||
### Discord Bot Integration
|
||||
Full-featured Discord bot for managing routines and medications without opening the app:
|
||||
|
||||
#### Bot Commands
|
||||
- `/routine list` — List all routines
|
||||
- `/routine start <name>` — Start a routine session (guided steps in Discord thread)
|
||||
- `/routine stats <name>` — View streak and completion stats
|
||||
- `/med today` — Show today's medications with status
|
||||
- `/med take <med_name>` — Log a dose as taken
|
||||
- `/med skip <med_name>` — Log a dose as skipped
|
||||
- `/task add <description>` — Create a one-off task (supports natural language dates)
|
||||
- `/task list` — Show upcoming tasks
|
||||
- `/task done <name>` — Mark a task as complete
|
||||
- `/ask <question>` — Query the knowledge base with jury-filtered safety checks
|
||||
- Discord presence automatically shows your routine/meditation status
|
||||
|
||||
#### Jury Council Safety System
|
||||
The `/ask` command uses a sophisticated 5-juror safety filtration pipeline:
|
||||
|
||||
**Two-stage process:**
|
||||
1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions
|
||||
2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens
|
||||
|
||||
**Juror Roles:**
|
||||
- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means
|
||||
- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language
|
||||
- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education
|
||||
- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base
|
||||
- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play
|
||||
|
||||
**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers.
|
||||
|
||||
This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts).
|
||||
|
||||
### Knowledge Base & RAG
|
||||
- Embeddings-based retrieval of ADHD, DBT, and mental health educational content
|
||||
- Multi-book support with user-selectable knowledge bases
|
||||
- Jury-filtered questions for safety
|
||||
- LLM-powered intent classification routes general questions to the knowledge base automatically
|
||||
- Multi-query retrieval with deduplication for better coverage
|
||||
- DBT advice evaluation mode (checks advice against DBT principles)
|
||||
- Discord bot `/ask` command uses RAG with jury council checks
|
||||
- Extensible knowledge source (can add more documents)
|
||||
|
||||
## API Overview
|
||||
|
||||
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`.
|
||||
All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
|
||||
|
||||
### Auth
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/register` | Create account |
|
||||
| POST | `/api/login` | Get JWT token |
|
||||
| POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) |
|
||||
| POST | `/api/refresh` | Exchange refresh token for new access token |
|
||||
|
||||
### Routines
|
||||
| Method | Endpoint | Description |
|
||||
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
||||
| POST | `/api/sessions/:id/resume` | Resume session |
|
||||
| POST | `/api/sessions/:id/cancel` | Cancel session |
|
||||
| POST | `/api/sessions/:id/abort` | Abort with reason |
|
||||
| POST | `/api/sessions/:id/note` | Add session note |
|
||||
|
||||
### Medications
|
||||
| Method | Endpoint | Description |
|
||||
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
||||
| GET | `/api/medications/adherence` | Adherence stats |
|
||||
| GET | `/api/medications/refills-due` | Refills due soon |
|
||||
|
||||
### Adaptive Medications
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication |
|
||||
| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences |
|
||||
| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule |
|
||||
| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats |
|
||||
|
||||
### Snitch (Peer Accountability)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/snitch/contacts` | List designated accountability contacts |
|
||||
| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) |
|
||||
| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) |
|
||||
| DELETE | `/api/snitch/contacts/:id` | Remove a contact |
|
||||
| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link |
|
||||
| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status |
|
||||
| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing |
|
||||
| GET | `/api/snitch/history` | View recent alerts sent to contacts |
|
||||
| POST | `/api/snitch/test-send` | Send test alert to a contact |
|
||||
|
||||
### Tasks
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/tasks` | List user's tasks |
|
||||
| POST | `/api/tasks` | Create a task |
|
||||
| PUT | `/api/tasks/:id` | Update a task |
|
||||
| DELETE | `/api/tasks/:id` | Delete a task |
|
||||
| POST | `/api/tasks/:id/complete` | Mark task as complete |
|
||||
|
||||
### AI
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description |
|
||||
|
||||
### Stats
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
||||
| GET | `/api/routines/weekly-summary` | Weekly progress |
|
||||
| GET | `/api/victories` | Achievement detection |
|
||||
|
||||
### Templates, Tags, Rewards, Preferences
|
||||
### Templates, Tags, Rewards, Preferences, Notifications
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/templates` | List available templates |
|
||||
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
|
||||
| GET/PUT | `/api/preferences` | User settings |
|
||||
| GET/PUT | `/api/preferences` | User settings and timezone |
|
||||
| GET | `/api/rewards/random` | Random completion reward |
|
||||
| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) |
|
||||
| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DB_HOST` | PostgreSQL host |
|
||||
| `DB_PORT` | PostgreSQL port |
|
||||
| `DB_NAME` | Database name |
|
||||
| `DB_USER` | Database user |
|
||||
| `DB_PASS` | Database password |
|
||||
| `JWT_SECRET` | JWT signing secret |
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) |
|
||||
| `API_URL` | API URL for bot (default: `http://app:5000`) |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) |
|
||||
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) |
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
|
||||
| `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
|
||||
| `DB_NAME` | Yes | Database name (default: `app`) |
|
||||
| `DB_USER` | Yes | Database user (default: `app`) |
|
||||
| `DB_PASS` | Yes | Database password |
|
||||
| `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
|
||||
| `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
|
||||
| `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
|
||||
| `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
|
||||
| `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) |
|
||||
| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications |
|
||||
| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications |
|
||||
|
||||
## Design Framework
|
||||
|
||||
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles:
|
||||
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature:
|
||||
|
||||
### Core Principles
|
||||
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
|
||||
2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
|
||||
3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
|
||||
|
||||
### Behavioral Foundations
|
||||
- **Two-Minute Rule Entry Points** — Every routine starts with something achievable in under 2 minutes (lower activation energy)
|
||||
- **Variable Reward Scheduling** — Random rewards on completion leverage variable-ratio reinforcement (proven for habit building)
|
||||
- **Streak-Based Identity** — Streaks build intrinsic motivation by making completion a visible, accumulating identity signal
|
||||
- **Peer Accountability** — "Snitch" contacts provide external accountability without shame (research shows this improves adherence)
|
||||
- **Adaptive Timing** — System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load)
|
||||
|
||||
### Safety & Ethics
|
||||
- **Jury Council** — 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users
|
||||
- **Crisis Awareness** — System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation
|
||||
- **Transparent Limitations** — All system messages clarify "this is educational, not treatment" and encourage professional care
|
||||
- **User Agency** — All adaptive and automated features can be overridden; manual controls are always available
|
||||
|
||||
## Development & Testing
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Run pytest on all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_routines.py
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=core --cov=api tests/
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually:
|
||||
```bash
|
||||
psql -h localhost -U app -d app -f config/migration_name.sql
|
||||
```
|
||||
|
||||
### Timezone Testing
|
||||
The system uses dual-path timezone support. Test both:
|
||||
1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset
|
||||
2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler
|
||||
|
||||
### Discord Bot Development
|
||||
Bot commands are modular in `bot/commands/`. To add a command:
|
||||
1. Create a new command file in `bot/commands/`
|
||||
2. Import and register in `bot/bot.py`
|
||||
3. Bot automatically syncs commands to Discord on startup
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in `DOCUMENTATION.md`:
|
||||
- Detailed feature explanations
|
||||
- Database schema reference
|
||||
- Jury Council safety model (full spec)
|
||||
- Deployment & configuration
|
||||
- Contributing guidelines
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**
|
||||
|
||||
BIN
api/routes/__pycache__/adaptive_meds.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/adaptive_meds.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/routes/__pycache__/medications.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/medications.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/routes/__pycache__/routines.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/routines.cpython-312.pyc
Normal file
Binary file not shown.
@@ -13,6 +13,7 @@ from psycopg2.extras import Json
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
import core.tz as tz
|
||||
import core.adaptive_meds as adaptive_meds
|
||||
|
||||
|
||||
def _get_user_uuid(token):
|
||||
@@ -145,56 +146,6 @@ def register(app):
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
|
||||
return flask.jsonify(meds), 200
|
||||
|
||||
def _time_str_to_minutes(time_str):
|
||||
"""Convert 'HH:MM' to minutes since midnight."""
|
||||
parts = time_str.split(":")
|
||||
return int(parts[0]) * 60 + int(parts[1])
|
||||
|
||||
def _get_routine_duration_minutes(routine_id):
|
||||
"""Get total duration of a routine from its steps."""
|
||||
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
|
||||
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
|
||||
return max(total, 1)
|
||||
|
||||
def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None):
|
||||
"""Check if the proposed medication schedule conflicts with existing routines or medications.
|
||||
Returns (has_conflict, conflict_message) tuple.
|
||||
"""
|
||||
if not new_times:
|
||||
return False, None
|
||||
|
||||
# Check conflicts with routines (duration-aware)
|
||||
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
|
||||
for r in user_routines:
|
||||
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
|
||||
if not sched or not sched.get("time"):
|
||||
continue
|
||||
routine_days = sched.get("days", [])
|
||||
if isinstance(routine_days, str):
|
||||
routine_days = json.loads(routine_days)
|
||||
if new_days and not any(d in routine_days for d in new_days):
|
||||
continue
|
||||
routine_start = _time_str_to_minutes(sched["time"])
|
||||
routine_dur = _get_routine_duration_minutes(r["id"])
|
||||
for t in new_times:
|
||||
med_start = _time_str_to_minutes(t)
|
||||
# Med falls within routine time range
|
||||
if routine_start <= med_start < routine_start + routine_dur:
|
||||
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
|
||||
|
||||
# Check conflicts with other medications
|
||||
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
|
||||
for med in user_meds:
|
||||
if med["id"] == exclude_med_id:
|
||||
continue
|
||||
med_times = med.get("times", [])
|
||||
if isinstance(med_times, str):
|
||||
med_times = json.loads(med_times)
|
||||
if any(t in med_times for t in new_times):
|
||||
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
|
||||
|
||||
return False, None
|
||||
|
||||
@app.route("/api/medications", methods=["POST"])
|
||||
def api_addMedication():
|
||||
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
|
||||
@@ -214,15 +165,6 @@ def register(app):
|
||||
if not data.get("start_date") or not data.get("interval_days"):
|
||||
return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400
|
||||
|
||||
# Check for schedule conflicts
|
||||
new_times = data.get("times", [])
|
||||
new_days = data.get("days_of_week", [])
|
||||
has_conflict, conflict_msg = _check_med_schedule_conflicts(
|
||||
user_uuid, new_times, new_days
|
||||
)
|
||||
if has_conflict:
|
||||
return flask.jsonify({"error": conflict_msg}), 409
|
||||
|
||||
row = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
@@ -283,16 +225,6 @@ def register(app):
|
||||
"days_of_week", "interval_days", "start_date", "next_dose_date",
|
||||
]
|
||||
|
||||
# Check for schedule conflicts if times are being updated
|
||||
if "times" in data:
|
||||
new_times = data.get("times", [])
|
||||
new_days = data.get("days_of_week") or existing.get("days_of_week", [])
|
||||
has_conflict, conflict_msg = _check_med_schedule_conflicts(
|
||||
user_uuid, new_times, new_days, exclude_med_id=med_id
|
||||
)
|
||||
if has_conflict:
|
||||
return flask.jsonify({"error": conflict_msg}), 409
|
||||
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
if not updates:
|
||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||
@@ -333,6 +265,11 @@ def register(app):
|
||||
"notes": data.get("notes"),
|
||||
}
|
||||
log = postgres.insert("med_logs", log_entry)
|
||||
# Update adaptive schedule status so nags stop
|
||||
try:
|
||||
adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time"))
|
||||
except Exception:
|
||||
pass # Don't fail the take action if schedule update fails
|
||||
# Advance next_dose_date for interval meds
|
||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||
next_date = _compute_next_dose_date(med)
|
||||
@@ -359,6 +296,11 @@ def register(app):
|
||||
"notes": data.get("reason"),
|
||||
}
|
||||
log = postgres.insert("med_logs", log_entry)
|
||||
# Update adaptive schedule status so nags stop
|
||||
try:
|
||||
adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time"))
|
||||
except Exception:
|
||||
pass # Don't fail the skip action if schedule update fails
|
||||
# Advance next_dose_date for interval meds
|
||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||
next_date = _compute_next_dose_date(med)
|
||||
|
||||
@@ -661,8 +661,7 @@ def register(app):
|
||||
continue
|
||||
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
|
||||
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
|
||||
result.append(
|
||||
{
|
||||
entry = {
|
||||
"routine_id": r["id"],
|
||||
"routine_name": r.get("name", ""),
|
||||
"routine_icon": r.get("icon", ""),
|
||||
@@ -670,8 +669,12 @@ def register(app):
|
||||
"time": sched.get("time"),
|
||||
"remind": sched.get("remind", True),
|
||||
"total_duration_minutes": total_duration,
|
||||
"frequency": sched.get("frequency", "weekly"),
|
||||
}
|
||||
)
|
||||
if sched.get("frequency") == "every_n_days":
|
||||
entry["interval_days"] = sched.get("interval_days")
|
||||
entry["start_date"] = str(sched.get("start_date")) if sched.get("start_date") else None
|
||||
result.append(entry)
|
||||
return flask.jsonify(result), 200
|
||||
|
||||
def _get_routine_duration_minutes(routine_id):
|
||||
@@ -745,7 +748,10 @@ def register(app):
|
||||
|
||||
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
|
||||
def api_setRoutineSchedule(routine_id):
|
||||
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}"""
|
||||
"""Set when this routine should run.
|
||||
Body: {days, time, remind, frequency?, interval_days?, start_date?}
|
||||
frequency: 'weekly' (default, uses days) or 'every_n_days' (uses interval_days + start_date)
|
||||
"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
@@ -758,7 +764,10 @@ def register(app):
|
||||
if not data:
|
||||
return flask.jsonify({"error": "missing body"}), 400
|
||||
|
||||
# Check for schedule conflicts
|
||||
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(
|
||||
@@ -774,6 +783,9 @@ def register(app):
|
||||
"days": json.dumps(data.get("days", [])),
|
||||
"time": data.get("time"),
|
||||
"remind": data.get("remind", True),
|
||||
"frequency": frequency,
|
||||
"interval_days": data.get("interval_days"),
|
||||
"start_date": data.get("start_date"),
|
||||
}
|
||||
if existing:
|
||||
result = postgres.update(
|
||||
|
||||
77
bot/bot.py
77
bot/bot.py
@@ -128,6 +128,7 @@ class JurySystem:
|
||||
async def retrieve(self, query_text, top_k=5):
|
||||
"""Async retrieval — returns list of {metadata, score} dicts."""
|
||||
import asyncio
|
||||
|
||||
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
|
||||
|
||||
async def query(self, query_text):
|
||||
@@ -147,6 +148,7 @@ If the answer is not in the context, say you don't know based on the provided te
|
||||
Be concise, compassionate, and practical."""
|
||||
|
||||
from ai.jury_council import generate_rag_answer
|
||||
|
||||
return await generate_rag_answer(query_text, context_text, system_prompt)
|
||||
except Exception as e:
|
||||
return f"Error querying DBT knowledge base: {e}"
|
||||
@@ -181,7 +183,9 @@ def apiRequest(method, endpoint, token=None, data=None, _retried=False):
|
||||
if resp.status_code == 401 and not _retried:
|
||||
new_token = _try_refresh_token_for_session(token)
|
||||
if new_token:
|
||||
return apiRequest(method, endpoint, token=new_token, data=data, _retried=True)
|
||||
return apiRequest(
|
||||
method, endpoint, token=new_token, data=data, _retried=True
|
||||
)
|
||||
try:
|
||||
return resp.json(), resp.status_code
|
||||
except ValueError:
|
||||
@@ -201,9 +205,12 @@ def _try_refresh_token_for_session(expired_token):
|
||||
if cached:
|
||||
refresh_token = cached.get("refresh_token")
|
||||
if refresh_token:
|
||||
result, status = apiRequest("post", "/api/refresh",
|
||||
result, status = apiRequest(
|
||||
"post",
|
||||
"/api/refresh",
|
||||
data={"refresh_token": refresh_token},
|
||||
_retried=True)
|
||||
_retried=True,
|
||||
)
|
||||
if status == 200 and "token" in result:
|
||||
new_token = result["token"]
|
||||
session["token"] = new_token
|
||||
@@ -258,7 +265,8 @@ def negotiateToken(discord_id, username, password):
|
||||
# Try refresh token first (avoids sending password)
|
||||
if cached and cached.get("refresh_token"):
|
||||
result, status = apiRequest(
|
||||
"post", "/api/refresh",
|
||||
"post",
|
||||
"/api/refresh",
|
||||
data={"refresh_token": cached["refresh_token"]},
|
||||
_retried=True,
|
||||
)
|
||||
@@ -279,7 +287,9 @@ def negotiateToken(discord_id, username, password):
|
||||
and cached.get("hashed_password")
|
||||
and verifyPassword(password, cached.get("hashed_password"))
|
||||
):
|
||||
result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
|
||||
result, status = apiRequest(
|
||||
"post", "/api/login", data=login_data, _retried=True
|
||||
)
|
||||
if status == 200 and "token" in result:
|
||||
token = result["token"]
|
||||
payload = decodeJwtPayload(token)
|
||||
@@ -530,7 +540,9 @@ async def handleDBTQuery(message):
|
||||
if not jury_result.safe_questions:
|
||||
return True
|
||||
|
||||
await message.channel.send("🔍 Searching knowledge base with approved questions...")
|
||||
await message.channel.send(
|
||||
"🔍 Searching knowledge base with approved questions..."
|
||||
)
|
||||
|
||||
# Step 3: Multi-query retrieval — deduplicated by chunk ID
|
||||
seen_ids = set()
|
||||
@@ -544,7 +556,9 @@ async def handleDBTQuery(message):
|
||||
context_chunks.append(r["metadata"]["text"])
|
||||
|
||||
if not context_chunks:
|
||||
await message.channel.send("⚠️ No relevant content found in the knowledge base.")
|
||||
await message.channel.send(
|
||||
"⚠️ No relevant content found in the knowledge base."
|
||||
)
|
||||
return True
|
||||
|
||||
context = "\n\n---\n\n".join(context_chunks)
|
||||
@@ -644,7 +658,8 @@ def _restore_sessions_from_cache():
|
||||
if not refresh_token:
|
||||
continue
|
||||
result, status = apiRequest(
|
||||
"post", "/api/refresh",
|
||||
"post",
|
||||
"/api/refresh",
|
||||
data={"refresh_token": refresh_token},
|
||||
_retried=True,
|
||||
)
|
||||
@@ -663,14 +678,6 @@ def _restore_sessions_from_cache():
|
||||
print(f"Restored {restored} user session(s) from cache")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_ready():
|
||||
print(f"Bot logged in as {client.user}")
|
||||
loadCache()
|
||||
_restore_sessions_from_cache()
|
||||
backgroundLoop.start()
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_message(message):
|
||||
if message.author == client.user:
|
||||
@@ -713,9 +720,14 @@ async def update_presence_tracking():
|
||||
import core.adaptive_meds as adaptive_meds
|
||||
import core.postgres as postgres
|
||||
|
||||
print(f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}", flush=True)
|
||||
print(
|
||||
f"[DEBUG] Running presence tracking. Guilds: {len(client.guilds)}",
|
||||
flush=True,
|
||||
)
|
||||
for guild in client.guilds:
|
||||
print(f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}")
|
||||
print(
|
||||
f"[DEBUG] Guild: {guild.name} ({guild.id}) - Members: {guild.member_count}"
|
||||
)
|
||||
|
||||
# Get all users with presence tracking enabled
|
||||
settings = postgres.select(
|
||||
@@ -741,27 +753,46 @@ async def update_presence_tracking():
|
||||
# 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)
|
||||
print(
|
||||
f"[DEBUG] Checked guild {guild.name}, member: {member}",
|
||||
flush=True,
|
||||
)
|
||||
if member:
|
||||
break
|
||||
|
||||
if not member:
|
||||
print(f"[DEBUG] User {discord_user_id} not found in any shared guild", flush=True)
|
||||
print(
|
||||
f"[DEBUG] User {discord_user_id} not found in any shared guild",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if user is online
|
||||
is_online = member.status != discord.Status.offline
|
||||
print(f"[DEBUG] User status: {member.status}, is_online: {is_online}", flush=True)
|
||||
print(
|
||||
f"[DEBUG] User status: {member.status}, is_online: {is_online}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Get current presence from DB
|
||||
presence = adaptive_meds.get_user_presence(user_uuid)
|
||||
was_online = presence.get("is_currently_online") if presence else False
|
||||
print(f"[DEBUG] Previous state: {was_online}, Current: {is_online}", flush=True)
|
||||
print(
|
||||
f"[DEBUG] Previous state: {was_online}, Current: {is_online}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Update presence if changed
|
||||
if is_online != was_online:
|
||||
@@ -796,6 +827,7 @@ async def presenceTrackingLoop():
|
||||
except Exception as e:
|
||||
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@@ -866,6 +898,7 @@ 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)
|
||||
|
||||
@@ -217,12 +217,13 @@ async def handle_routine(message, session, parsed):
|
||||
await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')")
|
||||
return
|
||||
|
||||
# Build schedule data
|
||||
# Build schedule data (API expects "days" and "time")
|
||||
schedule_data = {}
|
||||
if days_of_week:
|
||||
schedule_data["days_of_week"] = days_of_week
|
||||
schedule_data["days"] = days_of_week
|
||||
if times:
|
||||
schedule_data["times"] = times
|
||||
schedule_data["time"] = times[0]
|
||||
schedule_data["remind"] = True
|
||||
|
||||
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
|
||||
if status == 200:
|
||||
|
||||
4657
bot/data/dbt_knowledge.text.json
Normal file
4657
bot/data/dbt_knowledge.text.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules (
|
||||
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
|
||||
days JSON DEFAULT '[]',
|
||||
time VARCHAR(5),
|
||||
remind BOOLEAN DEFAULT FALSE
|
||||
remind BOOLEAN DEFAULT FALSE,
|
||||
frequency VARCHAR(20) DEFAULT 'weekly',
|
||||
interval_days INTEGER,
|
||||
start_date DATE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routine_session_notes (
|
||||
@@ -314,3 +317,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -10,10 +10,10 @@ This module handles:
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, time
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
import core.postgres as postgres
|
||||
from core.tz import user_now, user_today_for
|
||||
from core.tz import user_now, user_now_for, user_today_for, tz_for_user
|
||||
|
||||
|
||||
def _normalize_time(val):
|
||||
@@ -42,12 +42,24 @@ def get_user_presence(user_uuid: str) -> Optional[Dict]:
|
||||
|
||||
|
||||
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
|
||||
"""Update user's presence status."""
|
||||
"""Update user's presence status. If a wake event is detected (came online
|
||||
after 30+ minutes offline), recalculates today's adaptive medication schedules."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
presence = get_user_presence(user_uuid)
|
||||
is_wake_event = False
|
||||
|
||||
if presence:
|
||||
# Detect wake event before updating
|
||||
if is_online and not presence.get("is_currently_online"):
|
||||
last_offline = presence.get("last_offline_at")
|
||||
if last_offline:
|
||||
if isinstance(last_offline, datetime) and last_offline.tzinfo is None:
|
||||
last_offline = last_offline.replace(tzinfo=timezone.utc)
|
||||
offline_duration = (now.replace(tzinfo=timezone.utc) - last_offline).total_seconds()
|
||||
if offline_duration > 1800: # 30 minutes
|
||||
is_wake_event = True
|
||||
|
||||
# Update existing record
|
||||
updates = {"is_currently_online": is_online, "updated_at": now}
|
||||
|
||||
@@ -71,6 +83,26 @@ def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
|
||||
}
|
||||
postgres.insert("user_presence", data)
|
||||
|
||||
# On wake event, recalculate today's adaptive schedules
|
||||
if is_wake_event:
|
||||
_recalculate_schedules_on_wake(user_uuid, now)
|
||||
|
||||
|
||||
def _recalculate_schedules_on_wake(user_uuid: str, wake_time: datetime):
|
||||
"""Recalculate today's pending adaptive schedules using the actual wake time."""
|
||||
settings = get_adaptive_settings(user_uuid)
|
||||
if not settings or not settings.get("adaptive_timing_enabled"):
|
||||
return
|
||||
|
||||
try:
|
||||
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
|
||||
for med in meds:
|
||||
times = med.get("times", [])
|
||||
if times:
|
||||
create_daily_schedule(user_uuid, med["id"], times, recalculate=True)
|
||||
except Exception:
|
||||
pass # Best-effort — don't break presence tracking if this fails
|
||||
|
||||
|
||||
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
|
||||
"""Record a presence event in the history."""
|
||||
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
|
||||
# Return base times with 0 offset
|
||||
return [(t, 0) for t in base_times]
|
||||
|
||||
# Get user's timezone
|
||||
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid})
|
||||
offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0
|
||||
|
||||
# Get current time in user's timezone
|
||||
user_current_time = user_now(offset_minutes)
|
||||
# Get current time in user's timezone (works in both request and scheduler context)
|
||||
user_current_time = user_now_for(user_uuid)
|
||||
today = user_current_time.date()
|
||||
|
||||
# Determine wake time
|
||||
@@ -270,7 +298,7 @@ def should_send_nag(
|
||||
return False, "User offline"
|
||||
|
||||
# Get today's schedule record for this specific time slot
|
||||
today = current_time.date()
|
||||
today = user_today_for(user_uuid)
|
||||
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
|
||||
if scheduled_time is not None:
|
||||
query["adjusted_time"] = scheduled_time
|
||||
@@ -291,30 +319,89 @@ def should_send_nag(
|
||||
nag_interval = settings.get("nag_interval_minutes", 15)
|
||||
|
||||
if last_nag:
|
||||
if isinstance(last_nag, datetime) and last_nag.tzinfo is None:
|
||||
last_nag = last_nag.replace(tzinfo=timezone.utc)
|
||||
time_since_last_nag = (current_time - last_nag).total_seconds() / 60
|
||||
if time_since_last_nag < nag_interval:
|
||||
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
|
||||
else:
|
||||
# First nag: require at least nag_interval minutes since the scheduled dose time
|
||||
if scheduled_time:
|
||||
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
|
||||
sched_dt = current_time.replace(hour=sched_hour, minute=sched_min, second=0, microsecond=0)
|
||||
minutes_since_dose = (current_time - sched_dt).total_seconds() / 60
|
||||
if minutes_since_dose < nag_interval:
|
||||
return False, f"Too soon after dose time ({int(minutes_since_dose)} < {nag_interval} min)"
|
||||
|
||||
# Check if this specific dose was already taken today
|
||||
# Check if this specific dose was already taken or skipped today
|
||||
logs = postgres.select(
|
||||
"med_logs",
|
||||
{
|
||||
"medication_id": med_id,
|
||||
"user_uuid": user_uuid,
|
||||
"action": "taken",
|
||||
"scheduled_time": scheduled_time,
|
||||
},
|
||||
)
|
||||
|
||||
# Filter to today's logs for this time slot
|
||||
today_logs = [
|
||||
log
|
||||
for log in logs
|
||||
if log.get("created_at") and log["created_at"].date() == today
|
||||
]
|
||||
# Get medication times to calculate dose interval for proximity check
|
||||
med = postgres.select_one("medications", {"id": med_id})
|
||||
dose_interval_minutes = 60 # default fallback
|
||||
if med and med.get("times"):
|
||||
times = med["times"]
|
||||
if len(times) >= 2:
|
||||
time_minutes = []
|
||||
for t in times:
|
||||
t = _normalize_time(t)
|
||||
if t:
|
||||
h, m = int(t[:2]), int(t[3:5])
|
||||
time_minutes.append(h * 60 + m)
|
||||
time_minutes.sort()
|
||||
intervals = []
|
||||
for i in range(1, len(time_minutes)):
|
||||
intervals.append(time_minutes[i] - time_minutes[i - 1])
|
||||
if intervals:
|
||||
dose_interval_minutes = min(intervals)
|
||||
|
||||
if today_logs:
|
||||
return False, "Already taken today"
|
||||
proximity_window = max(30, dose_interval_minutes // 2)
|
||||
|
||||
# Filter to today's logs and check for this specific dose
|
||||
user_tz = tz_for_user(user_uuid)
|
||||
for log in logs:
|
||||
action = log.get("action")
|
||||
if action not in ("taken", "skipped"):
|
||||
continue
|
||||
|
||||
created_at = log.get("created_at")
|
||||
if not created_at:
|
||||
continue
|
||||
|
||||
# created_at is stored as UTC but timezone-naive; convert to user's timezone
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
created_at_local = created_at.astimezone(user_tz)
|
||||
|
||||
if created_at_local.date() != today:
|
||||
continue
|
||||
|
||||
log_scheduled_time = log.get("scheduled_time")
|
||||
if log_scheduled_time:
|
||||
log_scheduled_time = _normalize_time(log_scheduled_time)
|
||||
if log_scheduled_time == scheduled_time:
|
||||
return False, f"Already {action} today"
|
||||
else:
|
||||
if scheduled_time:
|
||||
log_hour = created_at_local.hour
|
||||
log_min = created_at_local.minute
|
||||
sched_hour, sched_min = (
|
||||
int(scheduled_time[:2]),
|
||||
int(scheduled_time[3:5]),
|
||||
)
|
||||
log_mins = log_hour * 60 + log_min
|
||||
sched_mins = sched_hour * 60 + sched_min
|
||||
diff_minutes = abs(log_mins - sched_mins)
|
||||
# Handle midnight wraparound (e.g. 23:00 vs 00:42)
|
||||
diff_minutes = min(diff_minutes, 1440 - diff_minutes)
|
||||
if diff_minutes <= proximity_window:
|
||||
return False, f"Already {action} today"
|
||||
|
||||
return True, "Time to nag"
|
||||
|
||||
@@ -335,13 +422,18 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
|
||||
|
||||
postgres.update(
|
||||
"medication_schedules",
|
||||
{"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()},
|
||||
{"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
|
||||
{"id": schedule["id"]},
|
||||
)
|
||||
|
||||
|
||||
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
|
||||
"""Create today's medication schedule with adaptive adjustments."""
|
||||
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str], recalculate: bool = False):
|
||||
"""Create today's medication schedule with adaptive adjustments.
|
||||
|
||||
If recalculate=True, deletes existing *pending* schedules and recreates them
|
||||
with updated adaptive timing (e.g. after a wake event is detected).
|
||||
Already-taken or skipped schedules are preserved.
|
||||
"""
|
||||
today = user_today_for(user_uuid)
|
||||
|
||||
# Check if schedule already exists
|
||||
@@ -350,14 +442,62 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
|
||||
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
|
||||
)
|
||||
|
||||
if existing:
|
||||
if existing and not recalculate:
|
||||
return
|
||||
|
||||
if existing and recalculate:
|
||||
# Only delete pending schedules — preserve taken/skipped
|
||||
for sched in existing:
|
||||
if sched.get("status") == "pending":
|
||||
postgres.delete("medication_schedules", {"id": sched["id"]})
|
||||
# Check if any pending remain to create
|
||||
remaining = [s for s in existing if s.get("status") != "pending"]
|
||||
completed_base_times = set()
|
||||
for s in remaining:
|
||||
bt = _normalize_time(s.get("base_time"))
|
||||
if bt:
|
||||
completed_base_times.add(bt)
|
||||
# Only create schedules for times that haven't been taken/skipped
|
||||
base_times = [t for t in base_times if t not in completed_base_times]
|
||||
if not base_times:
|
||||
return
|
||||
|
||||
# Calculate adjusted times
|
||||
adjusted_times = calculate_adjusted_times(user_uuid, base_times)
|
||||
|
||||
# Check recent med logs to skip doses already taken/skipped.
|
||||
# Handles cross-midnight: if adaptive offset shifts 23:00 → 00:42 today,
|
||||
# but the user already took the 23:00 dose last night, don't schedule it.
|
||||
# Yesterday's logs only suppress if the scheduled_time is late-night
|
||||
# (21:00+), since only those could plausibly cross midnight with an offset.
|
||||
user_tz = tz_for_user(user_uuid)
|
||||
yesterday = today - timedelta(days=1)
|
||||
recent_logs = postgres.select("med_logs", {"medication_id": med_id, "user_uuid": user_uuid})
|
||||
taken_base_times = set()
|
||||
for log in recent_logs:
|
||||
if log.get("action") not in ("taken", "skipped"):
|
||||
continue
|
||||
created_at = log.get("created_at")
|
||||
if not created_at:
|
||||
continue
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
log_date = created_at.astimezone(user_tz).date()
|
||||
if log_date == today:
|
||||
log_sched = _normalize_time(log.get("scheduled_time"))
|
||||
if log_sched:
|
||||
taken_base_times.add(log_sched)
|
||||
elif log_date == yesterday:
|
||||
# Only suppress cross-midnight doses (late-night times like 21:00+)
|
||||
log_sched = _normalize_time(log.get("scheduled_time"))
|
||||
if log_sched and log_sched >= "21:00":
|
||||
taken_base_times.add(log_sched)
|
||||
|
||||
# Create schedule records for each time
|
||||
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
|
||||
if base_time in taken_base_times:
|
||||
continue
|
||||
|
||||
data = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
@@ -373,17 +513,40 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
|
||||
|
||||
|
||||
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
|
||||
"""Mark a medication as taken."""
|
||||
"""Mark a medication schedule as taken."""
|
||||
_mark_med_status(user_uuid, med_id, scheduled_time, "taken")
|
||||
|
||||
|
||||
def mark_med_skipped(user_uuid: str, med_id: str, scheduled_time):
|
||||
"""Mark a medication schedule as skipped."""
|
||||
_mark_med_status(user_uuid, med_id, scheduled_time, "skipped")
|
||||
|
||||
|
||||
def _mark_med_status(user_uuid: str, med_id: str, scheduled_time, status: str):
|
||||
"""Update a medication schedule's status for today."""
|
||||
scheduled_time = _normalize_time(scheduled_time)
|
||||
today = user_today_for(user_uuid)
|
||||
|
||||
postgres.update(
|
||||
"medication_schedules",
|
||||
{"status": "taken"},
|
||||
{
|
||||
# Try matching by adjusted_time first
|
||||
where = {
|
||||
"user_uuid": user_uuid,
|
||||
"medication_id": med_id,
|
||||
"adjustment_date": today,
|
||||
"adjusted_time": scheduled_time,
|
||||
},
|
||||
)
|
||||
}
|
||||
if scheduled_time is not None:
|
||||
where["adjusted_time"] = scheduled_time
|
||||
|
||||
schedules = postgres.select("medication_schedules", where)
|
||||
if schedules:
|
||||
postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]})
|
||||
elif scheduled_time is not None:
|
||||
# Fallback: try matching by base_time (in case adjusted == base)
|
||||
where_base = {
|
||||
"user_uuid": user_uuid,
|
||||
"medication_id": med_id,
|
||||
"adjustment_date": today,
|
||||
"base_time": scheduled_time,
|
||||
}
|
||||
schedules_base = postgres.select("medication_schedules", where_base)
|
||||
if schedules_base:
|
||||
postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]})
|
||||
|
||||
56
regenerate_embeddings.py
Normal file
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")
|
||||
@@ -26,6 +26,17 @@ def _user_now_for(user_uuid):
|
||||
return tz.user_now_for(user_uuid)
|
||||
|
||||
|
||||
def _utc_to_local_date(created_at, user_tz):
|
||||
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
|
||||
if created_at is None:
|
||||
return ""
|
||||
if isinstance(created_at, datetime):
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
return created_at.astimezone(user_tz).date().isoformat()
|
||||
return str(created_at)[:10]
|
||||
|
||||
|
||||
def check_medication_reminders():
|
||||
"""Check for medications due now and send notifications."""
|
||||
try:
|
||||
@@ -47,6 +58,7 @@ def check_medication_reminders():
|
||||
current_day = now.strftime("%a").lower()
|
||||
today = now.date()
|
||||
today_str = today.isoformat()
|
||||
user_tz = tz.tz_for_user(user_uuid)
|
||||
|
||||
for med in user_med_list:
|
||||
freq = med.get("frequency", "daily")
|
||||
@@ -83,13 +95,13 @@ def check_medication_reminders():
|
||||
if current_time not in times:
|
||||
continue
|
||||
|
||||
# Already taken today? Check by created_at date
|
||||
# Already taken today? Check by created_at date in user's timezone
|
||||
logs = postgres.select(
|
||||
"med_logs", where={"medication_id": med["id"], "action": "taken"}
|
||||
)
|
||||
already_taken = any(
|
||||
log.get("scheduled_time") == current_time
|
||||
and str(log.get("created_at", ""))[:10] == today_str
|
||||
and _utc_to_local_date(log.get("created_at"), user_tz) == today_str
|
||||
for log in logs
|
||||
)
|
||||
if already_taken:
|
||||
@@ -108,21 +120,50 @@ def check_medication_reminders():
|
||||
def check_routine_reminders():
|
||||
"""Check for scheduled routines due now and send notifications."""
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
|
||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
||||
logger.info(f"Routine reminders: found {len(schedules)} schedule(s) with remind=True")
|
||||
|
||||
for schedule in schedules:
|
||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
||||
if not routine:
|
||||
logger.warning(f"Routine not found for schedule {schedule['id']}")
|
||||
continue
|
||||
|
||||
now = _user_now_for(routine["user_uuid"])
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower()
|
||||
today = now.date()
|
||||
|
||||
if current_time != schedule.get("time"):
|
||||
sched_time = schedule.get("time")
|
||||
if current_time != sched_time:
|
||||
continue
|
||||
|
||||
logger.info(f"Routine '{routine['name']}' time match at {current_time}")
|
||||
|
||||
frequency = schedule.get("frequency") or "weekly"
|
||||
if frequency == "every_n_days":
|
||||
start = schedule.get("start_date")
|
||||
interval = schedule.get("interval_days")
|
||||
if start and interval:
|
||||
start_d = (
|
||||
start
|
||||
if isinstance(start, date_type)
|
||||
else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||
)
|
||||
if (today - start_d).days < 0 or (
|
||||
today - start_d
|
||||
).days % interval != 0:
|
||||
logger.info(f"Routine '{routine['name']}' skipped: not due today (every {interval} days from {start_d})")
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Routine '{routine['name']}' skipped: every_n_days but missing start_date={start} or interval_days={interval}")
|
||||
continue
|
||||
else:
|
||||
current_day = now.strftime("%a").lower()
|
||||
days = schedule.get("days", [])
|
||||
if current_day not in days:
|
||||
logger.info(f"Routine '{routine['name']}' skipped: {current_day} not in {days}")
|
||||
continue
|
||||
|
||||
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||
@@ -131,8 +172,11 @@ def check_routine_reminders():
|
||||
notifications._sendToEnabledChannels(
|
||||
user_settings, msg, user_uuid=routine["user_uuid"]
|
||||
)
|
||||
logger.info(f"Routine reminder sent for '{routine['name']}'")
|
||||
else:
|
||||
logger.warning(f"No notification settings for user {routine['user_uuid']}, skipping routine '{routine['name']}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking routine reminders: {e}")
|
||||
logger.error(f"Error checking routine reminders: {e}", exc_info=True)
|
||||
|
||||
|
||||
def check_refills():
|
||||
@@ -194,6 +238,7 @@ def check_adaptive_medication_reminders():
|
||||
now = _user_now_for(user_uuid)
|
||||
current_time = now.strftime("%H:%M")
|
||||
today = now.date()
|
||||
user_tz = tz.tz_for_user(user_uuid)
|
||||
|
||||
# Check if adaptive timing is enabled
|
||||
settings = adaptive_meds.get_adaptive_settings(user_uuid)
|
||||
@@ -229,18 +274,37 @@ def check_adaptive_medication_reminders():
|
||||
else:
|
||||
continue
|
||||
|
||||
# Get today's schedule
|
||||
# Get today's schedule (any status — we filter below)
|
||||
schedules = postgres.select(
|
||||
"medication_schedules",
|
||||
where={
|
||||
"user_uuid": user_uuid,
|
||||
"medication_id": med["id"],
|
||||
"adjustment_date": today,
|
||||
"status": "pending",
|
||||
},
|
||||
)
|
||||
|
||||
# If no schedules exist yet, create them on demand
|
||||
if not schedules:
|
||||
times = med.get("times", [])
|
||||
if times:
|
||||
try:
|
||||
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
|
||||
schedules = postgres.select(
|
||||
"medication_schedules",
|
||||
where={
|
||||
"user_uuid": user_uuid,
|
||||
"medication_id": med["id"],
|
||||
"adjustment_date": today,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create on-demand schedule for {med['id']}: {e}")
|
||||
|
||||
for sched in schedules:
|
||||
# Skip already-taken or skipped schedules
|
||||
if sched.get("status") in ("taken", "skipped"):
|
||||
continue
|
||||
# Check if it's time to take this med
|
||||
if adaptive_enabled:
|
||||
# Use adjusted time
|
||||
@@ -258,32 +322,40 @@ def check_adaptive_medication_reminders():
|
||||
if check_time != current_time:
|
||||
continue
|
||||
|
||||
# Check if already taken
|
||||
# Check if already taken or skipped for this time slot today
|
||||
logs = postgres.select(
|
||||
"med_logs",
|
||||
where={
|
||||
"medication_id": med["id"],
|
||||
"user_uuid": user_uuid,
|
||||
"action": "taken",
|
||||
},
|
||||
)
|
||||
|
||||
already_taken = any(
|
||||
str(log.get("created_at", ""))[:10] == today.isoformat()
|
||||
already_handled = any(
|
||||
log.get("action") in ("taken", "skipped")
|
||||
and log.get("scheduled_time") == check_time
|
||||
and _utc_to_local_date(log.get("created_at"), user_tz)
|
||||
== today.isoformat()
|
||||
for log in logs
|
||||
)
|
||||
|
||||
if already_taken:
|
||||
if already_handled:
|
||||
continue
|
||||
|
||||
# Send notification
|
||||
# Send notification — display base_time to the user
|
||||
base_display = sched.get("base_time")
|
||||
if isinstance(base_display, time_type):
|
||||
base_display = base_display.strftime("%H:%M")
|
||||
elif base_display is not None:
|
||||
base_display = str(base_display)[:5]
|
||||
|
||||
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||
if user_settings:
|
||||
offset = sched.get("adjustment_minutes", 0)
|
||||
if offset > 0:
|
||||
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)"
|
||||
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display} (adjusted +{offset}min)"
|
||||
else:
|
||||
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}"
|
||||
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {base_display}"
|
||||
|
||||
notifications._sendToEnabledChannels(
|
||||
user_settings, msg, user_uuid=user_uuid
|
||||
@@ -333,7 +405,9 @@ def check_nagging():
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query medication_schedules for {med_id}: {e}")
|
||||
logger.warning(
|
||||
f"Could not query medication_schedules for {med_id}: {e}"
|
||||
)
|
||||
# Table may not exist yet
|
||||
continue
|
||||
|
||||
@@ -341,7 +415,9 @@ def check_nagging():
|
||||
if not schedules:
|
||||
if not _is_med_due_today(med, today):
|
||||
continue
|
||||
logger.info(f"No schedules found for medication {med_id}, attempting to create")
|
||||
logger.info(
|
||||
f"No schedules found for medication {med_id}, attempting to create"
|
||||
)
|
||||
times = med.get("times", [])
|
||||
if times:
|
||||
try:
|
||||
@@ -373,11 +449,8 @@ def check_nagging():
|
||||
if not should_nag:
|
||||
continue
|
||||
|
||||
# Get the time to display
|
||||
adaptive_enabled = settings.get("adaptive_timing_enabled")
|
||||
if adaptive_enabled:
|
||||
display_time = sched.get("adjusted_time")
|
||||
else:
|
||||
# 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):
|
||||
@@ -512,9 +585,7 @@ def _check_per_user_midnight_schedules():
|
||||
continue
|
||||
times = med.get("times", [])
|
||||
if times:
|
||||
adaptive_meds.create_daily_schedule(
|
||||
user_uuid, med["id"], times
|
||||
)
|
||||
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not create adaptive schedules for user {user_uuid}: {e}"
|
||||
@@ -524,6 +595,7 @@ def _check_per_user_midnight_schedules():
|
||||
def check_task_reminders():
|
||||
"""Check one-off tasks for advance and at-time reminders."""
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
tasks = postgres.select("tasks", where={"status": "pending"})
|
||||
if not tasks:
|
||||
@@ -556,15 +628,24 @@ def check_task_reminders():
|
||||
# Advance reminder
|
||||
if reminder_min > 0 and not task.get("advance_notified"):
|
||||
adv_dt = sched_dt - timedelta(minutes=reminder_min)
|
||||
if adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm:
|
||||
if (
|
||||
adv_dt.date() == current_date
|
||||
and adv_dt.strftime("%H:%M") == current_hhmm
|
||||
):
|
||||
if user_settings is None:
|
||||
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||
user_settings = notifications.getNotificationSettings(
|
||||
user_uuid
|
||||
)
|
||||
if user_settings:
|
||||
msg = f"⏰ In {reminder_min} min: {task['title']}"
|
||||
if task.get("description"):
|
||||
msg += f" — {task['description']}"
|
||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
||||
postgres.update("tasks", {"advance_notified": True}, {"id": task["id"]})
|
||||
notifications._sendToEnabledChannels(
|
||||
user_settings, msg, user_uuid=user_uuid
|
||||
)
|
||||
postgres.update(
|
||||
"tasks", {"advance_notified": True}, {"id": task["id"]}
|
||||
)
|
||||
|
||||
# At-time reminder
|
||||
if sched_date == current_date and sched_hhmm == current_hhmm:
|
||||
@@ -574,10 +655,15 @@ def check_task_reminders():
|
||||
msg = f"📋 Now: {task['title']}"
|
||||
if task.get("description"):
|
||||
msg += f"\n{task['description']}"
|
||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
||||
notifications._sendToEnabledChannels(
|
||||
user_settings, msg, user_uuid=user_uuid
|
||||
)
|
||||
postgres.update(
|
||||
"tasks",
|
||||
{"status": "notified", "updated_at": datetime.utcnow().isoformat()},
|
||||
{
|
||||
"status": "notified",
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
},
|
||||
{"id": task["id"]},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -589,13 +675,14 @@ def poll_callback():
|
||||
# Create daily schedules per-user at their local midnight
|
||||
_check_per_user_midnight_schedules()
|
||||
|
||||
# Check reminders - use both original and adaptive checks
|
||||
# Check medication reminders (adaptive path handles both adaptive and non-adaptive)
|
||||
logger.info("Checking medication reminders")
|
||||
check_medication_reminders()
|
||||
try:
|
||||
check_adaptive_medication_reminders()
|
||||
except Exception as e:
|
||||
logger.warning(f"Adaptive medication reminder check failed: {e}")
|
||||
# Fall back to basic reminders if adaptive check fails entirely
|
||||
check_medication_reminders()
|
||||
|
||||
# Check for nags - log as error to help with debugging
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { ArrowLeftIcon } from '@/components/ui/Icons';
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 'mon', label: 'Mon' },
|
||||
@@ -15,96 +15,78 @@ const DAY_OPTIONS = [
|
||||
{ value: 'sun', label: 'Sun' },
|
||||
];
|
||||
|
||||
export default function NewMedicationPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [dosage, setDosage] = useState('');
|
||||
const [unit, setUnit] = useState('mg');
|
||||
const [frequency, setFrequency] = useState('daily');
|
||||
const [times, setTimes] = useState<string[]>(['08:00']);
|
||||
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
|
||||
const [intervalDays, setIntervalDays] = useState(7);
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
interface MedEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
times: string[];
|
||||
daysOfWeek: string[];
|
||||
intervalDays: number;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
const handleAddTime = () => {
|
||||
setTimes([...times, '12:00']);
|
||||
function blankEntry(): MedEntry {
|
||||
return {
|
||||
id: `med-${Date.now()}-${Math.random()}`,
|
||||
name: '',
|
||||
dosage: '',
|
||||
unit: 'mg',
|
||||
frequency: 'daily',
|
||||
times: ['08:00'],
|
||||
daysOfWeek: [],
|
||||
intervalDays: 7,
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
const handleRemoveTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index));
|
||||
function MedCard({
|
||||
entry,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: MedEntry;
|
||||
index: number;
|
||||
total: number;
|
||||
onChange: (updates: Partial<MedEntry>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] });
|
||||
const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) });
|
||||
const handleTimeChange = (i: number, val: string) => {
|
||||
const t = [...entry.times];
|
||||
t[i] = val;
|
||||
onChange({ times: t });
|
||||
};
|
||||
|
||||
const handleTimeChange = (index: number, value: string) => {
|
||||
const newTimes = [...times];
|
||||
newTimes[index] = value;
|
||||
setTimes(newTimes);
|
||||
};
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
setDaysOfWeek(prev =>
|
||||
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !dosage.trim()) {
|
||||
setError('Name and dosage are required');
|
||||
return;
|
||||
}
|
||||
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
|
||||
setError('Select at least one day of the week');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.medications.create({
|
||||
name,
|
||||
dosage,
|
||||
unit,
|
||||
frequency,
|
||||
times: frequency === 'as_needed' ? [] : times,
|
||||
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
|
||||
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
|
||||
const toggleDay = (day: string) =>
|
||||
onChange({
|
||||
daysOfWeek: entry.daysOfWeek.includes(day)
|
||||
? entry.daysOfWeek.filter(d => d !== day)
|
||||
: [...entry.daysOfWeek, day],
|
||||
});
|
||||
router.push('/dashboard/medications');
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to add medication');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
||||
<ArrowLeftIcon size={24} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medication</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400">
|
||||
Medication {index + 1}
|
||||
</span>
|
||||
{total > 1 && (
|
||||
<button type="button" onClick={onRemove} className="text-red-500 dark:text-red-400 p-1">
|
||||
<TrashIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={entry.name}
|
||||
onChange={e => onChange({ name: e.target.value })}
|
||||
placeholder="e.g., Vitamin D"
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
@@ -115,8 +97,8 @@ export default function NewMedicationPage() {
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dosage}
|
||||
onChange={(e) => setDosage(e.target.value)}
|
||||
value={entry.dosage}
|
||||
onChange={e => onChange({ dosage: e.target.value })}
|
||||
placeholder="e.g., 1000"
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
@@ -124,8 +106,8 @@ export default function NewMedicationPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
value={entry.unit}
|
||||
onChange={e => onChange({ unit: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
>
|
||||
<option value="mg">mg</option>
|
||||
@@ -142,8 +124,8 @@ export default function NewMedicationPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
value={entry.frequency}
|
||||
onChange={e => onChange({ frequency: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
@@ -153,8 +135,7 @@ export default function NewMedicationPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week picker for specific_days */}
|
||||
{frequency === 'specific_days' && (
|
||||
{entry.frequency === 'specific_days' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -164,7 +145,7 @@ export default function NewMedicationPage() {
|
||||
type="button"
|
||||
onClick={() => toggleDay(value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
daysOfWeek.includes(value)
|
||||
entry.daysOfWeek.includes(value)
|
||||
? 'bg-indigo-600 text-white border-indigo-600'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
@@ -176,33 +157,31 @@ export default function NewMedicationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interval settings for every_n_days */}
|
||||
{frequency === 'every_n_days' && (
|
||||
{entry.frequency === 'every_n_days' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
value={entry.intervalDays}
|
||||
onChange={e => onChange({ intervalDays: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
value={entry.startDate}
|
||||
onChange={e => onChange({ startDate: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Times picker — hidden for as_needed */}
|
||||
{frequency !== 'as_needed' && (
|
||||
{entry.frequency !== 'as_needed' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
|
||||
@@ -214,22 +193,19 @@ export default function NewMedicationPage() {
|
||||
+ Add Time
|
||||
</button>
|
||||
</div>
|
||||
{frequency === 'daily' && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">Add multiple times for 2x, 3x, or more doses per day</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{times.map((time, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
{entry.times.map((time, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => handleTimeChange(index, e.target.value)}
|
||||
onChange={e => handleTimeChange(i, e.target.value)}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
{times.length > 1 && (
|
||||
{entry.times.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTime(index)}
|
||||
onClick={() => handleRemoveTime(i)}
|
||||
className="text-red-500 dark:text-red-400 px-3"
|
||||
>
|
||||
Remove
|
||||
@@ -241,13 +217,114 @@ export default function NewMedicationPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewMedicationPage() {
|
||||
const router = useRouter();
|
||||
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
|
||||
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry.name.trim() || !entry.dosage.trim()) {
|
||||
setError(`Medication ${i + 1}: name and dosage are required`);
|
||||
return;
|
||||
}
|
||||
if (entry.frequency === 'specific_days' && entry.daysOfWeek.length === 0) {
|
||||
setError(`Medication ${i + 1}: select at least one day of the week`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
await api.medications.create({
|
||||
name: entry.name,
|
||||
dosage: entry.dosage,
|
||||
unit: entry.unit,
|
||||
frequency: entry.frequency,
|
||||
times: entry.frequency === 'as_needed' ? [] : entry.times,
|
||||
...(entry.frequency === 'specific_days' && { days_of_week: entry.daysOfWeek }),
|
||||
...(entry.frequency === 'every_n_days' && {
|
||||
interval_days: entry.intervalDays,
|
||||
start_date: entry.startDate,
|
||||
}),
|
||||
});
|
||||
}
|
||||
router.push('/dashboard/medications');
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to add medication');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const count = entries.length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
||||
<ArrowLeftIcon size={24} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medications</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map((entry, index) => (
|
||||
<MedCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={count}
|
||||
onChange={updates => updateEntry(index, updates)}
|
||||
onRemove={() => removeEntry(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntries(prev => [...prev, blankEntry()])}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-indigo-300 dark:border-indigo-700 rounded-xl text-indigo-600 dark:text-indigo-400 font-medium hover:border-indigo-400 dark:hover:border-indigo-600 transition-colors"
|
||||
>
|
||||
<PlusIcon size={18} />
|
||||
Add Another Medication
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add Medication'}
|
||||
{isLoading
|
||||
? 'Adding...'
|
||||
: count === 1
|
||||
? 'Add Medication'
|
||||
: `Add ${count} Medications`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,6 @@ export default function MedicationsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [medsData, todayData, adherenceData] = await Promise.all([
|
||||
@@ -104,11 +103,24 @@ export default function MedicationsPage() {
|
||||
setAdherence(adherenceData);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch medications:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Re-fetch when tab becomes visible or every 60s
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') fetchData();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
const poll = setInterval(fetchData, 60_000);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
clearInterval(poll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-refresh grouping every 60s
|
||||
|
||||
@@ -29,6 +29,9 @@ interface Schedule {
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
|
||||
@@ -77,6 +80,9 @@ export default function RoutineDetailPage() {
|
||||
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
const [editTime, setEditTime] = useState('08:00');
|
||||
const [editRemind, setEditRemind] = useState(true);
|
||||
const [editFrequency, setEditFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
|
||||
const [editIntervalDays, setEditIntervalDays] = useState(2);
|
||||
const [editStartDate, setEditStartDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
const [showScheduleEditor, setShowScheduleEditor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,6 +105,9 @@ export default function RoutineDetailPage() {
|
||||
setEditDays(scheduleData.days || []);
|
||||
setEditTime(scheduleData.time || '08:00');
|
||||
setEditRemind(scheduleData.remind ?? true);
|
||||
setEditFrequency((scheduleData.frequency as 'weekly' | 'every_n_days') || 'weekly');
|
||||
setEditIntervalDays(scheduleData.interval_days || 2);
|
||||
setEditStartDate(scheduleData.start_date || new Date().toISOString().split('T')[0]);
|
||||
} else {
|
||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
if (isNewRoutine) {
|
||||
@@ -162,13 +171,27 @@ export default function RoutineDetailPage() {
|
||||
|
||||
const handleSaveSchedule = async () => {
|
||||
try {
|
||||
if (editDays.length > 0) {
|
||||
await api.routines.setSchedule(routineId, {
|
||||
const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0;
|
||||
if (hasSchedule) {
|
||||
const schedulePayload = {
|
||||
days: editDays,
|
||||
time: editTime || '08:00',
|
||||
remind: editRemind,
|
||||
frequency: editFrequency,
|
||||
...(editFrequency === 'every_n_days' && {
|
||||
interval_days: editIntervalDays,
|
||||
start_date: editStartDate,
|
||||
}),
|
||||
};
|
||||
await api.routines.setSchedule(routineId, schedulePayload);
|
||||
setSchedule({
|
||||
days: editDays,
|
||||
time: editTime || '08:00',
|
||||
remind: editRemind,
|
||||
frequency: editFrequency,
|
||||
interval_days: editFrequency === 'every_n_days' ? editIntervalDays : undefined,
|
||||
start_date: editFrequency === 'every_n_days' ? editStartDate : undefined,
|
||||
});
|
||||
setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind });
|
||||
} else if (schedule) {
|
||||
await api.routines.deleteSchedule(routineId);
|
||||
setSchedule(null);
|
||||
@@ -176,7 +199,7 @@ export default function RoutineDetailPage() {
|
||||
setShowScheduleEditor(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save schedule:', err);
|
||||
alert('Failed to save schedule. Please try again.');
|
||||
alert((err as Error).message || 'Failed to save schedule. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -461,6 +484,56 @@ export default function RoutineDetailPage() {
|
||||
</div>
|
||||
|
||||
{showScheduleEditor ? (
|
||||
<>
|
||||
{/* Frequency selector */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditFrequency('weekly')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
editFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Weekly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditFrequency('every_n_days')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
editFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Every N Days
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editFrequency === 'every_n_days' ? (
|
||||
<div className="mb-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={365}
|
||||
value={editIntervalDays}
|
||||
onChange={(e) => setEditIntervalDays(Math.max(2, Number(e.target.value)))}
|
||||
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editStartDate}
|
||||
onChange={(e) => setEditStartDate(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Quick select */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
@@ -512,6 +585,8 @@ export default function RoutineDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||
<input
|
||||
@@ -545,10 +620,14 @@ export default function RoutineDetailPage() {
|
||||
setEditDays(schedule.days);
|
||||
setEditTime(schedule.time);
|
||||
setEditRemind(schedule.remind);
|
||||
setEditFrequency((schedule.frequency as 'weekly' | 'every_n_days') || 'weekly');
|
||||
setEditIntervalDays(schedule.interval_days || 2);
|
||||
setEditStartDate(schedule.start_date || new Date().toISOString().split('T')[0]);
|
||||
} else {
|
||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
setEditTime('08:00');
|
||||
setEditRemind(true);
|
||||
setEditFrequency('weekly');
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
@@ -563,10 +642,12 @@ export default function RoutineDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : schedule && schedule.days.length > 0 ? (
|
||||
) : schedule && (schedule.days.length > 0 || schedule.frequency === 'every_n_days') ? (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{formatDays(schedule.days)} at {schedule.time}
|
||||
{schedule.frequency === 'every_n_days'
|
||||
? `Every ${schedule.interval_days} days at ${schedule.time}`
|
||||
: `${formatDays(schedule.days)} at ${schedule.time}`}
|
||||
</p>
|
||||
{schedule.remind && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p>
|
||||
|
||||
@@ -50,6 +50,9 @@ export default function NewRoutinePage() {
|
||||
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
const [scheduleTime, setScheduleTime] = useState('08:00');
|
||||
const [scheduleRemind, setScheduleRemind] = useState(true);
|
||||
const [scheduleFrequency, setScheduleFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
|
||||
const [scheduleIntervalDays, setScheduleIntervalDays] = useState(2);
|
||||
const [scheduleStartDate, setScheduleStartDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
setScheduleDays(prev =>
|
||||
@@ -128,11 +131,16 @@ export default function NewRoutinePage() {
|
||||
});
|
||||
}
|
||||
|
||||
if (scheduleDays.length > 0) {
|
||||
if (scheduleFrequency === 'every_n_days' || scheduleDays.length > 0) {
|
||||
await api.routines.setSchedule(routine.id, {
|
||||
days: scheduleDays,
|
||||
time: scheduleTime,
|
||||
remind: scheduleRemind,
|
||||
frequency: scheduleFrequency,
|
||||
...(scheduleFrequency === 'every_n_days' && {
|
||||
interval_days: scheduleIntervalDays,
|
||||
start_date: scheduleStartDate,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,6 +235,56 @@ export default function NewRoutinePage() {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
|
||||
|
||||
{/* Frequency selector */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleFrequency('weekly')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
scheduleFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Weekly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleFrequency('every_n_days')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
scheduleFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Every N Days
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleFrequency === 'every_n_days' ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={365}
|
||||
value={scheduleIntervalDays}
|
||||
onChange={(e) => setScheduleIntervalDays(Math.max(2, Number(e.target.value)))}
|
||||
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduleStartDate}
|
||||
onChange={(e) => setScheduleStartDate(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Quick select buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -277,6 +335,8 @@ export default function NewRoutinePage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import api, { type Task } from '@/lib/api';
|
||||
import { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -21,6 +21,9 @@ interface ScheduleEntry {
|
||||
time: string;
|
||||
remind: boolean;
|
||||
total_duration_minutes: number;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
interface TodaysMedication {
|
||||
@@ -208,6 +211,7 @@ export default function RoutinesPage() {
|
||||
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
|
||||
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
|
||||
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
||||
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||
@@ -229,9 +233,25 @@ export default function RoutinesPage() {
|
||||
const dayKey = getDayKey(selectedDate);
|
||||
|
||||
const scheduledForDay = allSchedules
|
||||
.filter((s) => s.days.includes(dayKey))
|
||||
.filter((s) => {
|
||||
if (s.frequency === 'every_n_days') {
|
||||
if (!s.interval_days || !s.start_date) return false;
|
||||
const start = new Date(s.start_date + 'T00:00:00');
|
||||
const diffDays = Math.round((selectedDate.getTime() - start.getTime()) / 86400000);
|
||||
return diffDays >= 0 && diffDays % s.interval_days === 0;
|
||||
}
|
||||
return s.days.includes(dayKey);
|
||||
})
|
||||
.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
||||
|
||||
const tasksForDay = allTasks.filter((t) => {
|
||||
if (t.status === 'cancelled') return false;
|
||||
const d = new Date(t.scheduled_datetime);
|
||||
return d.getFullYear() === selectedDate.getFullYear() &&
|
||||
d.getMonth() === selectedDate.getMonth() &&
|
||||
d.getDate() === selectedDate.getDate();
|
||||
});
|
||||
|
||||
const scheduledRoutineIds = new Set(allSchedules.map((s) => s.routine_id));
|
||||
const unscheduledRoutines = allRoutines.filter(
|
||||
(r) => !scheduledRoutineIds.has(r.id)
|
||||
@@ -299,6 +319,10 @@ export default function RoutinesPage() {
|
||||
const allEventMins = [
|
||||
...scheduledForDay.map((e) => timeToMinutes(e.time)),
|
||||
...groupedMedEntries.map((e) => timeToMinutes(e.time)),
|
||||
...tasksForDay.map((t) => {
|
||||
const d = new Date(t.scheduled_datetime);
|
||||
return d.getHours() * 60 + d.getMinutes();
|
||||
}),
|
||||
];
|
||||
const eventStartHour = allEventMins.length > 0 ? Math.floor(Math.min(...allEventMins) / 60) : DEFAULT_START_HOUR;
|
||||
const eventEndHour = allEventMins.length > 0 ? Math.ceil(Math.max(...allEventMins) / 60) : DEFAULT_END_HOUR;
|
||||
@@ -325,9 +349,18 @@ export default function RoutinesPage() {
|
||||
endMin: timeToMinutes(g.time) + durationMin,
|
||||
};
|
||||
}),
|
||||
...tasksForDay.map((t) => {
|
||||
const d = new Date(t.scheduled_datetime);
|
||||
const startMin = d.getHours() * 60 + d.getMinutes();
|
||||
return {
|
||||
id: `t-${t.id}`,
|
||||
startMin,
|
||||
endMin: startMin + (48 / HOUR_HEIGHT) * 60,
|
||||
};
|
||||
}),
|
||||
];
|
||||
return computeLanes(items);
|
||||
}, [scheduledForDay, groupedMedEntries]);
|
||||
}, [scheduledForDay, groupedMedEntries, tasksForDay]);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────
|
||||
const handleTakeMed = async (medicationId: string, scheduledTime: string) => {
|
||||
@@ -411,19 +444,23 @@ export default function RoutinesPage() {
|
||||
setUndoAction(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = () =>
|
||||
Promise.all([
|
||||
api.routines.list(),
|
||||
api.routines.listAllSchedules(),
|
||||
api.medications.getToday().catch(() => []),
|
||||
api.tasks.list('all').catch(() => []),
|
||||
])
|
||||
.then(([routines, schedules, meds]) => {
|
||||
.then(([routines, schedules, meds, tasks]) => {
|
||||
setAllRoutines(routines);
|
||||
setAllSchedules(schedules);
|
||||
setTodayMeds(meds);
|
||||
setAllTasks(tasks);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -435,6 +472,19 @@ export default function RoutinesPage() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Re-fetch when tab becomes visible or every 60s
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') fetchAllData();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
const poll = setInterval(fetchAllData, 60_000);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
clearInterval(poll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isToday && timelineRef.current) {
|
||||
const scrollTarget = nowTopPx - window.innerHeight / 3;
|
||||
@@ -447,6 +497,18 @@ export default function RoutinesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, isToday]);
|
||||
|
||||
const handleCompleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await api.tasks.update(taskId, { status: 'completed' });
|
||||
setAllTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: 'completed' } : t))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to complete task:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to complete task');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRoutine = async (routineId: string) => {
|
||||
try {
|
||||
await api.sessions.start(routineId);
|
||||
@@ -776,8 +838,66 @@ export default function RoutinesPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Task cards */}
|
||||
{tasksForDay.map((task) => {
|
||||
const d = new Date(task.scheduled_datetime);
|
||||
const startMin = d.getHours() * 60 + d.getMinutes();
|
||||
const topPx = minutesToTop(startMin, displayStartHour);
|
||||
const isPast = task.status === 'completed';
|
||||
const layout = timelineLayout.get(`t-${task.id}`) ?? {
|
||||
lane: 0,
|
||||
totalLanes: 1,
|
||||
};
|
||||
const timeStr = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => router.push('/dashboard/tasks')}
|
||||
style={{
|
||||
top: `${topPx}px`,
|
||||
height: '48px',
|
||||
...laneStyle(layout.lane, layout.totalLanes),
|
||||
}}
|
||||
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden cursor-pointer ${
|
||||
isPast
|
||||
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 opacity-75'
|
||||
: 'bg-purple-50 dark:bg-purple-900/30 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg leading-none flex-shrink-0">📋</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{formatTime(timeStr)}
|
||||
{task.description && ` · ${task.description}`}
|
||||
</p>
|
||||
</div>
|
||||
{isPast ? (
|
||||
<span className="text-green-600 flex-shrink-0">
|
||||
<CheckIcon size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCompleteTask(task.id);
|
||||
}}
|
||||
className="bg-green-600 text-white p-1 rounded-lg flex-shrink-0"
|
||||
>
|
||||
<CheckIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty day */}
|
||||
{scheduledForDay.length === 0 && medEntries.length === 0 && (
|
||||
{scheduledForDay.length === 0 && medEntries.length === 0 && tasksForDay.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-gray-400 text-sm">
|
||||
No routines or medications for this day
|
||||
|
||||
@@ -396,14 +396,24 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
{notif.discord_enabled && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Discord user ID"
|
||||
placeholder="Your Discord user ID (numbers only)"
|
||||
value={notif.discord_user_id}
|
||||
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '' || /^\d+$/.test(val)) {
|
||||
setNotif({ ...notif, discord_user_id: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enable Developer Mode in Discord, right-click your profile, and copy User ID
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -831,7 +841,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<select
|
||||
value={newContact.contact_type}
|
||||
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value })}
|
||||
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value, contact_value: '' })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
@@ -840,9 +850,18 @@ export default function SettingsPage() {
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
|
||||
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID (numbers only)' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
|
||||
value={newContact.contact_value}
|
||||
onChange={(e) => setNewContact({ ...newContact, contact_value: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (newContact.contact_type === 'discord') {
|
||||
if (val === '' || /^\d+$/.test(val)) {
|
||||
setNewContact({ ...newContact, contact_value: val });
|
||||
}
|
||||
} else {
|
||||
setNewContact({ ...newContact, contact_value: val });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -53,6 +53,19 @@ export default function TasksPage() {
|
||||
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' });
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function PushNotificationToggle() {
|
||||
const { public_key } = await api.notifications.getVapidPublicKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource,
|
||||
});
|
||||
|
||||
const subJson = sub.toJSON();
|
||||
|
||||
@@ -323,12 +323,15 @@ export const api = {
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
|
||||
},
|
||||
|
||||
setSchedule: async (
|
||||
routineId: string,
|
||||
data: { days: string[]; time: string; remind?: boolean }
|
||||
data: { days: string[]; time: string; remind?: boolean; frequency?: string; interval_days?: number; start_date?: string }
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
|
||||
method: 'PUT',
|
||||
@@ -352,6 +355,9 @@ export const api = {
|
||||
time: string;
|
||||
remind: boolean;
|
||||
total_duration_minutes: number;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}>>('/api/routines/schedules', { method: 'GET' });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user