Compare commits

..

16 Commits

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:21:22 -06:00
e89656a87c Fix adaptive medication timing and update README
- Fix double notifications: remove redundant check_medication_reminders()
  call, use adaptive path as primary with basic as fallback
- Fix nag firing immediately: require nag_interval minutes after scheduled
  dose time before first nag
- Fix missing schedules: create on-demand if midnight window was missed
- Fix wrong timezone: use user_now_for() instead of request-context
  user_now() in calculate_adjusted_times()
- Fix immutable schedules: recalculate pending schedules on wake event
  detection so adaptive timing actually adapts
- Fix take/skip not updating schedule: API endpoints now call
  mark_med_taken/skipped so nags stop after logging a dose
- Fix skipped doses still triggering reminders: check both taken and
  skipped in adaptive reminder and log queries
- Update README with tasks, AI step generation, auth refresh tokens,
  knowledge base improvements, and current architecture

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:26:44 -06:00
ecb79af44e Fix bugs, add auto-refresh, quick-complete tasks, and every-N-day routines
- Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13)
- Fix push notifications: pass Uint8Array directly as applicationServerKey (#6)
- Show specific conflict reason on schedule save instead of generic error (#17)
- Add inline checkmark button to complete tasks on routines timeline (#18)
- Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15)
- Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16)

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:32:07 -06:00
31 changed files with 7228 additions and 563 deletions

346
README.md
View File

@@ -1,46 +1,67 @@
# Synculous
A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame.
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform.
## Architecture
```
synculous/
├── synculous-client/ # Next.js 16 frontend (React, Tailwind)
├── api/ # Flask REST API
│ ├── main.py # App entry point, auth routes
│ └── routes/ # Domain route modules
│ ├── routines.py # Routines CRUD + sessions
├── synculous-client/ # Next.js frontend (React, Tailwind)
├── api/ # Flask REST API
│ ├── main.py # App entry point, auth routes
│ └── routes/ # Domain route modules
│ ├── routines.py # Routines CRUD + sessions
│ ├── routine_sessions_extended.py # Pause, resume, abort, notes
│ ├── routine_stats.py # Completion stats, streaks, weekly summary
│ ├── routine_templates.py # Premade routine templates
│ ├── routine_steps_extended.py # Step instructions, types, media
│ ├── routine_tags.py # Tagging system
│ ├── medications.py # Medication scheduling + adherence
│ ├── preferences.py # User settings + timezone
│ ├── notifications.py # Web push subscriptions
│ ├── rewards.py # Variable reward system
── victories.py # Achievement detection
├── core/ # Shared business logic
├── postgres.py # Generic PostgreSQL CRUD
├── auth.py # JWT + bcrypt authentication
├── users.py # User management
│ ├── routines.py # Routine/session/streak logic
│ ├── tz.py # Timezone-aware date/time helpers
── notifications.py # Multi-channel notifications
│ ├── routine_stats.py # Completion stats, streaks, weekly summary
│ ├── routine_templates.py # Premade routine templates
│ ├── routine_steps_extended.py # Step instructions, types, media
│ ├── routine_tags.py # Tagging system
│ ├── medications.py # Medication scheduling + adherence
│ ├── adaptive_meds.py # Adaptive medication timing (learning)
│ ├── tasks.py # One-off tasks/appointments CRUD
│ ├── ai.py # AI-powered step generation
── preferences.py # User settings + timezone
│ ├── notifications.py # Web push subscriptions
├── rewards.py # Variable reward system
├── victories.py # Achievement detection
└── snitch.py # Peer accountability contacts + notifications
├── core/ # Shared business logic
│ ├── postgres.py # Generic PostgreSQL CRUD
── auth.py # JWT + bcrypt authentication
│ ├── users.py # User management
│ ├── routines.py # Routine/session/streak logic
│ ├── stats.py # Statistics calculations (completion rates, streaks)
│ ├── snitch.py # Snitch trigger logic + notification delivery
│ ├── adaptive_meds.py # Adaptive medication timing logic
│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset)
│ └── notifications.py # Multi-channel notifications
├── scheduler/
│ └── daemon.py # Background polling for reminders
├── bot/ # Discord bot (optional)
├── ai/ # LLM parser for natural language commands
│ └── daemon.py # Background polling for reminders
├── bot/ # Discord bot with knowledge RAG
│ ├── bot.py # Bot entry point + session management
│ ├── command_registry.py # Module-based command routing
│ ├── commands/ # Command modules
│ │ ├── routines.py # /routine commands
│ │ ├── medications.py # /med, /take, /skip commands
│ │ ├── tasks.py # /task commands (one-off tasks/appointments)
│ │ └── knowledge.py # /ask command (jury-filtered RAG)
│ └── hooks.py # Event listeners
├── ai/ # LLM-powered features
│ ├── parser.py # OpenRouter API client
│ ├── jury_council.py # 5-juror safety filtration system
│ ├── ai_config.json # Model + prompt configuration
│ └── (optional) RAG embeddings
├── config/
│ ├── schema.sql # Database schema
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
│ ├── seed_rewards.sql # Variable reward pool
│ └── .env.example # Environment template
│ ├── schema.sql # Database schema
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
│ ├── seed_rewards.sql # Variable reward pool
│ └── .env.example # Environment template
├── diagrams/ # Architecture diagrams (Mermaid)
├── docker-compose.yml
── Dockerfile
── Dockerfile
└── tests/
```
## Quick Start
@@ -49,10 +70,10 @@ synculous/
# Copy environment config
cp config/.env.example config/.env
# Edit with your values
# Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY)
nano config/.env
# Start everything
# Start everything (db, api, scheduler, optional bot, frontend client)
docker-compose up
```
@@ -61,20 +82,23 @@ This starts five services:
| Service | Port | Description |
|---------|------|-------------|
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
| `app` | 8080 | Flask API |
| `app` | 8010 | Flask API (internal: 5000) |
| `scheduler` | — | Background daemon for medication/routine reminders |
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) |
| `client` | 3000 | Next.js frontend |
| `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
| `client` | 3001 | Next.js frontend (internal: 3000) |
## Features
### Routines
### Routines & Sessions
- Create routines with ordered steps (4-7 steps recommended)
- Run sessions with a guided, one-step-at-a-time focus interface
- Complete, skip, pause, resume, or cancel sessions
- Swipe gestures for step completion on mobile
- Per-step timing with visual countdown
- Animated celebration screen on completion with streak stats and variable rewards
- Every-N-day frequency option for routines
- Tagging system for organizing routines by category
- Session notes for logging context or blockers
### Premade Templates
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
@@ -96,13 +120,44 @@ This starts five services:
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
### One-Off Tasks & Appointments
- Create standalone tasks and appointments outside of routines
- Scheduled date/time with optional reminders
- Quick-complete action for fast check-off
- Tasks appear on the routines timeline for a unified daily view
- AI-powered task composition via bot and web client
- Natural language date parsing in bot commands
### AI-Powered Step Generation
- Generate ADHD-friendly routine steps from a plain-language goal description
- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps
- Each step includes name and estimated duration
- Available via API endpoint and integrated into the web client's routine creation flow
### Medications
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN)
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
- Take, skip, snooze actions with logging
- Adherence tracking and statistics
- Refill tracking with low-quantity alerts
- Background reminders via the scheduler daemon
- Medication editing (update name, dose, schedule, refill count)
- Web push and Discord notifications for doses
#### Adaptive Medication Timing
- Machine-learning-based timing predictions based on user adherence patterns
- System learns when you're most likely to take meds
- Automatic reminder optimization (fewer false-positive reminders)
- Override and manual timing adjustments always available
- Useful for people with irregular schedules
### Peer Accountability: "Snitch" Feature
- Designate trusted contacts to receive medication adherence notifications
- Contacts don't need an account or login
- Granular privacy controls: users choose *what* to share (meds, streaks, notes)
- Contacts receive weekly summaries or real-time alerts for missed doses
- Based on research showing peer accountability improves adherence
- Optional consent-based consent flow for contact approvals
### Streaks and Stats
- Per-routine streak tracking (current + longest)
@@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret
- Random reward on routine completion (post-completion only, never mid-routine)
- Reward history tracking per user
- Common and rare rarity tiers
- Designed to leverage variable-ratio reinforcement schedules
### Notifications
- Web push notifications via VAPID
- Discord webhooks
- ntfy support
- Scheduled reminders for medications and routines
- Web push notifications via VAPID (in-browser)
- Discord webhooks (for bot mentions)
- Discord DMs (for sensitive notifications like "snitch" alerts)
- ntfy support (for self-hosted push)
- Timezone-aware scheduled reminders for medications and routines
### Timezone Support
All date/time operations respect the user's local timezone:
- The frontend sends `X-Timezone-Offset` with every API request
- The timezone offset is also persisted to `user_preferences` for background jobs
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date
- The scheduler daemon looks up each user's stored offset for reminder timing
### Timezone Support (Dual-Path)
All date/time operations respect the user's local timezone via two mechanisms:
**In request context:**
- The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
- Handlers use these headers for real-time API operations
**In background jobs (scheduler daemon):**
- Timezone is stored in `user_preferences.timezone_name` (IANA format)
- Scheduler retrieves stored timezone for each user
- Falls back to numeric offset, then UTC if name is unavailable
- Enables accurate reminder delivery even if user's browser context is offline
**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date.
### Authentication & Session Persistence
- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days)
- "Trust this device" option on login issues a refresh token for seamless re-auth
- Bot session caching with persistent pickle storage across restarts
### User Preferences
- Sound effects (default off — habituation risk)
- Haptic feedback (default on)
- Launch screen toggle
- Celebration style
- Timezone offset (auto-synced from browser)
- Timezone (IANA name + numeric offset, auto-synced from browser)
- Discord presence indicator toggle (shows online/offline in Discord)
### Discord Bot Integration
Full-featured Discord bot for managing routines and medications without opening the app:
#### Bot Commands
- `/routine list` — List all routines
- `/routine start <name>` — Start a routine session (guided steps in Discord thread)
- `/routine stats <name>` — View streak and completion stats
- `/med today` — Show today's medications with status
- `/med take <med_name>` — Log a dose as taken
- `/med skip <med_name>` — Log a dose as skipped
- `/task add <description>` — Create a one-off task (supports natural language dates)
- `/task list` — Show upcoming tasks
- `/task done <name>` — Mark a task as complete
- `/ask <question>` — Query the knowledge base with jury-filtered safety checks
- Discord presence automatically shows your routine/meditation status
#### Jury Council Safety System
The `/ask` command uses a sophisticated 5-juror safety filtration pipeline:
**Two-stage process:**
1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions
2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens
**Juror Roles:**
- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means
- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language
- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education
- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base
- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play
**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers.
This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts).
### Knowledge Base & RAG
- Embeddings-based retrieval of ADHD, DBT, and mental health educational content
- Multi-book support with user-selectable knowledge bases
- Jury-filtered questions for safety
- LLM-powered intent classification routes general questions to the knowledge base automatically
- Multi-query retrieval with deduplication for better coverage
- DBT advice evaluation mode (checks advice against DBT principles)
- Discord bot `/ask` command uses RAG with jury council checks
- Extensible knowledge source (can add more documents)
## API Overview
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`.
All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
### Auth
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/register` | Create account |
| POST | `/api/login` | Get JWT token |
| POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) |
| POST | `/api/refresh` | Exchange refresh token for new access token |
### Routines
| Method | Endpoint | Description |
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| POST | `/api/sessions/:id/resume` | Resume session |
| POST | `/api/sessions/:id/cancel` | Cancel session |
| POST | `/api/sessions/:id/abort` | Abort with reason |
| POST | `/api/sessions/:id/note` | Add session note |
### Medications
| Method | Endpoint | Description |
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/medications/adherence` | Adherence stats |
| GET | `/api/medications/refills-due` | Refills due soon |
### Adaptive Medications
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication |
| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences |
| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule |
| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats |
### Snitch (Peer Accountability)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/snitch/contacts` | List designated accountability contacts |
| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) |
| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) |
| DELETE | `/api/snitch/contacts/:id` | Remove a contact |
| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link |
| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status |
| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing |
| GET | `/api/snitch/history` | View recent alerts sent to contacts |
| POST | `/api/snitch/test-send` | Send test alert to a contact |
### Tasks
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tasks` | List user's tasks |
| POST | `/api/tasks` | Create a task |
| PUT | `/api/tasks/:id` | Update a task |
| DELETE | `/api/tasks/:id` | Delete a task |
| POST | `/api/tasks/:id/complete` | Mark task as complete |
### AI
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description |
### Stats
| Method | Endpoint | Description |
|--------|----------|-------------|
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
| GET | `/api/routines/weekly-summary` | Weekly progress |
| GET | `/api/victories` | Achievement detection |
### Templates, Tags, Rewards, Preferences
### Templates, Tags, Rewards, Preferences, Notifications
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/templates` | List available templates |
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
| GET/PUT | `/api/preferences` | User settings |
| GET/PUT | `/api/preferences` | User settings and timezone |
| GET | `/api/rewards/random` | Random completion reward |
| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) |
| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `DB_HOST` | PostgreSQL host |
| `DB_PORT` | PostgreSQL port |
| `DB_NAME` | Database name |
| `DB_USER` | Database user |
| `DB_PASS` | Database password |
| `JWT_SECRET` | JWT signing secret |
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) |
| `API_URL` | API URL for bot (default: `http://app:5000`) |
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) |
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) |
| Variable | Required | Description |
|----------|----------|-------------|
| `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
| `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
| `DB_NAME` | Yes | Database name (default: `app`) |
| `DB_USER` | Yes | Database user (default: `app`) |
| `DB_PASS` | Yes | Database password |
| `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
| `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
| `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
| `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
| `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) |
| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications |
| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications |
## Design Framework
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles:
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature:
### Core Principles
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
2. **One Thing at a Time** Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
3. **Zero Shame** No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
### Behavioral Foundations
- **Two-Minute Rule Entry Points** Every routine starts with something achievable in under 2 minutes (lower activation energy)
- **Variable Reward Scheduling** Random rewards on completion leverage variable-ratio reinforcement (proven for habit building)
- **Streak-Based Identity** Streaks build intrinsic motivation by making completion a visible, accumulating identity signal
- **Peer Accountability** "Snitch" contacts provide external accountability without shame (research shows this improves adherence)
- **Adaptive Timing** System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load)
### Safety & Ethics
- **Jury Council** 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users
- **Crisis Awareness** System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation
- **Transparent Limitations** All system messages clarify "this is educational, not treatment" and encourage professional care
- **User Agency** All adaptive and automated features can be overridden; manual controls are always available
## Development & Testing
### Running Tests
```bash
# Run pytest on all tests
pytest
# Run specific test file
pytest tests/test_routines.py
# Run with coverage
pytest --cov=core --cov=api tests/
```
### Database Migrations
For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually:
```bash
psql -h localhost -U app -d app -f config/migration_name.sql
```
### Timezone Testing
The system uses dual-path timezone support. Test both:
1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset
2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler
### Discord Bot Development
Bot commands are modular in `bot/commands/`. To add a command:
1. Create a new command file in `bot/commands/`
2. Import and register in `bot/bot.py`
3. Bot automatically syncs commands to Discord on startup
## Documentation
Comprehensive documentation is available in `DOCUMENTATION.md`:
- Detailed feature explanations
- Database schema reference
- Jury Council safety model (full spec)
- Deployment & configuration
- Contributing guidelines
## License
MIT
---
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ import api.routes.victories as victories_routes
import api.routes.adaptive_meds as adaptive_meds_routes
import api.routes.snitch as snitch_routes
import api.routes.ai as ai_routes
import api.routes.tasks as tasks_routes
app = flask.Flask(__name__)
CORS(app)
@@ -43,6 +44,7 @@ ROUTE_MODULES = [
adaptive_meds_routes,
snitch_routes,
ai_routes,
tasks_routes,
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -13,6 +13,7 @@ from psycopg2.extras import Json
import core.auth as auth
import core.postgres as postgres
import core.tz as tz
import core.adaptive_meds as adaptive_meds
def _get_user_uuid(token):
@@ -145,56 +146,6 @@ def register(app):
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
return flask.jsonify(meds), 200
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1)
def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None):
"""Check if the proposed medication schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple.
"""
if not new_times:
return False, None
# Check conflicts with routines (duration-aware)
user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines:
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if not sched or not sched.get("time"):
continue
routine_days = sched.get("days", [])
if isinstance(routine_days, str):
routine_days = json.loads(routine_days)
if new_days and not any(d in routine_days for d in new_days):
continue
routine_start = _time_str_to_minutes(sched["time"])
routine_dur = _get_routine_duration_minutes(r["id"])
for t in new_times:
med_start = _time_str_to_minutes(t)
# Med falls within routine time range
if routine_start <= med_start < routine_start + routine_dur:
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with other medications
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
if med["id"] == exclude_med_id:
continue
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
if any(t in med_times for t in new_times):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None
@app.route("/api/medications", methods=["POST"])
def api_addMedication():
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
@@ -214,15 +165,6 @@ def register(app):
if not data.get("start_date") or not data.get("interval_days"):
return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400
# Check for schedule conflicts
new_times = data.get("times", [])
new_days = data.get("days_of_week", [])
has_conflict, conflict_msg = _check_med_schedule_conflicts(
user_uuid, new_times, new_days
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
row = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -283,16 +225,6 @@ def register(app):
"days_of_week", "interval_days", "start_date", "next_dose_date",
]
# Check for schedule conflicts if times are being updated
if "times" in data:
new_times = data.get("times", [])
new_days = data.get("days_of_week") or existing.get("days_of_week", [])
has_conflict, conflict_msg = _check_med_schedule_conflicts(
user_uuid, new_times, new_days, exclude_med_id=med_id
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
return flask.jsonify({"error": "no valid fields to update"}), 400
@@ -333,6 +265,11 @@ def register(app):
"notes": data.get("notes"),
}
log = postgres.insert("med_logs", log_entry)
# Update adaptive schedule status so nags stop
try:
adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time"))
except Exception:
pass # Don't fail the take action if schedule update fails
# Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)
@@ -359,6 +296,11 @@ def register(app):
"notes": data.get("reason"),
}
log = postgres.insert("med_logs", log_entry)
# Update adaptive schedule status so nags stop
try:
adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time"))
except Exception:
pass # Don't fail the skip action if schedule update fails
# Advance next_dose_date for interval meds
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
next_date = _compute_next_dose_date(med)

View File

@@ -661,17 +661,20 @@ 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(
{
"routine_id": r["id"],
"routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""),
"days": sched.get("days", []),
"time": sched.get("time"),
"remind": sched.get("remind", True),
"total_duration_minutes": total_duration,
}
)
entry = {
"routine_id": r["id"],
"routine_name": r.get("name", ""),
"routine_icon": r.get("icon", ""),
"days": sched.get("days", []),
"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,15 +764,18 @@ def register(app):
if not data:
return flask.jsonify({"error": "missing body"}), 400
# Check for schedule conflicts
new_days = data.get("days", [])
new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
frequency = data.get("frequency", "weekly")
# Check for schedule conflicts (only for weekly — interval conflicts checked at reminder time)
if frequency == "weekly":
new_days = data.get("days", [])
new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
schedule_data = {
@@ -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(

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

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

View File

@@ -26,6 +26,7 @@ import ai.parser as ai_parser
import bot.commands.routines # noqa: F401 - registers handler
import bot.commands.medications # noqa: F401 - registers handler
import bot.commands.knowledge # noqa: F401 - registers handler
import bot.commands.tasks # noqa: F401 - registers handler
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
API_URL = os.getenv("API_URL", "http://app:5000")
@@ -34,7 +35,7 @@ user_sessions = {}
login_state = {}
message_history = {}
user_cache = {}
CACHE_FILE = "/app/user_cache.pkl"
CACHE_FILE = os.getenv("BOT_CACHE_FILE", "/app/cache/user_cache.pkl")
intents = discord.Intents.default()
intents.message_content = True
@@ -127,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):
@@ -146,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}"
@@ -180,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:
@@ -200,9 +205,12 @@ def _try_refresh_token_for_session(expired_token):
if cached:
refresh_token = cached.get("refresh_token")
if refresh_token:
result, status = apiRequest("post", "/api/refresh",
data={"refresh_token": refresh_token},
_retried=True)
result, status = apiRequest(
"post",
"/api/refresh",
data={"refresh_token": refresh_token},
_retried=True,
)
if status == 200 and "token" in result:
new_token = result["token"]
session["token"] = new_token
@@ -227,6 +235,7 @@ def loadCache():
def saveCache():
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
with open(CACHE_FILE, "wb") as f:
pickle.dump(user_cache, f)
except Exception as e:
@@ -256,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,
)
@@ -277,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)
@@ -528,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()
@@ -542,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)
@@ -642,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,
)
@@ -661,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:
@@ -711,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(
@@ -739,27 +753,46 @@ async def update_presence_tracking():
# Get the member from a shared guild (needed for presence data)
try:
member = None
target_id = int(discord_user_id)
try:
target_id = int(discord_user_id)
except (ValueError, TypeError):
print(
f"[DEBUG] Invalid Discord ID for user {user_uuid}: {discord_user_id}",
flush=True,
)
continue
# Search through all guilds the bot is in
for guild in client.guilds:
member = guild.get_member(target_id)
print(f"[DEBUG] Checked guild {guild.name}, member: {member}", flush=True)
print(
f"[DEBUG] Checked guild {guild.name}, member: {member}",
flush=True,
)
if member:
break
if not member:
print(f"[DEBUG] User {discord_user_id} not found in any shared guild", flush=True)
print(
f"[DEBUG] User {discord_user_id} not found in any shared guild",
flush=True,
)
continue
# Check if user is online
is_online = member.status != discord.Status.offline
print(f"[DEBUG] User status: {member.status}, is_online: {is_online}", flush=True)
print(
f"[DEBUG] User status: {member.status}, is_online: {is_online}",
flush=True,
)
# Get current presence from DB
presence = adaptive_meds.get_user_presence(user_uuid)
was_online = presence.get("is_currently_online") if presence else False
print(f"[DEBUG] Previous state: {was_online}, Current: {is_online}", flush=True)
print(
f"[DEBUG] Previous state: {was_online}, Current: {is_online}",
flush=True,
)
# Update presence if changed
if is_online != was_online:
@@ -794,6 +827,7 @@ async def presenceTrackingLoop():
except Exception as e:
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
import traceback
traceback.print_exc()
@@ -864,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)

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules (
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
days JSON DEFAULT '[]',
time VARCHAR(5),
remind BOOLEAN DEFAULT FALSE
remind BOOLEAN DEFAULT FALSE,
frequency VARCHAR(20) DEFAULT 'weekly',
interval_days INTEGER,
start_date DATE
);
CREATE TABLE IF NOT EXISTS routine_session_notes (
@@ -298,3 +301,24 @@ CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid
-- ── Migrations ──────────────────────────────────────────────
-- Add IANA timezone name to user preferences (run once on existing DBs)
ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS timezone_name VARCHAR(100);
-- ── Tasks (one-off appointments/reminders) ──────────────────
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
scheduled_datetime TIMESTAMP NOT NULL,
reminder_minutes_before INTEGER DEFAULT 15,
advance_notified BOOLEAN DEFAULT FALSE,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime);
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending';
-- Add every-N-day scheduling to routine_schedules (run once on existing DBs)
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS frequency VARCHAR(20) DEFAULT 'weekly';
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS interval_days INTEGER;
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS start_date DATE;

View File

@@ -10,10 +10,10 @@ This module handles:
import json
import uuid
from datetime import datetime, timedelta, time
from datetime import datetime, timedelta, time, timezone
from typing import Optional, Dict, List, Tuple
import core.postgres as postgres
from core.tz import user_now, user_today_for
from core.tz import user_now, user_now_for, user_today_for, tz_for_user
def _normalize_time(val):
@@ -42,12 +42,24 @@ def get_user_presence(user_uuid: str) -> Optional[Dict]:
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
"""Update user's presence status."""
"""Update user's presence status. If a wake event is detected (came online
after 30+ minutes offline), recalculates today's adaptive medication schedules."""
now = datetime.utcnow()
presence = get_user_presence(user_uuid)
is_wake_event = False
if presence:
# Detect wake event before updating
if is_online and not presence.get("is_currently_online"):
last_offline = presence.get("last_offline_at")
if last_offline:
if isinstance(last_offline, datetime) and last_offline.tzinfo is None:
last_offline = last_offline.replace(tzinfo=timezone.utc)
offline_duration = (now.replace(tzinfo=timezone.utc) - last_offline).total_seconds()
if offline_duration > 1800: # 30 minutes
is_wake_event = True
# Update existing record
updates = {"is_currently_online": is_online, "updated_at": now}
@@ -71,6 +83,26 @@ def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
}
postgres.insert("user_presence", data)
# On wake event, recalculate today's adaptive schedules
if is_wake_event:
_recalculate_schedules_on_wake(user_uuid, now)
def _recalculate_schedules_on_wake(user_uuid: str, wake_time: datetime):
"""Recalculate today's pending adaptive schedules using the actual wake time."""
settings = get_adaptive_settings(user_uuid)
if not settings or not settings.get("adaptive_timing_enabled"):
return
try:
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in meds:
times = med.get("times", [])
if times:
create_daily_schedule(user_uuid, med["id"], times, recalculate=True)
except Exception:
pass # Best-effort — don't break presence tracking if this fails
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
"""Record a presence event in the history."""
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
# Return base times with 0 offset
return [(t, 0) for t in base_times]
# Get user's timezone
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid})
offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0
# Get current time in user's timezone
user_current_time = user_now(offset_minutes)
# Get current time in user's timezone (works in both request and scheduler context)
user_current_time = user_now_for(user_uuid)
today = user_current_time.date()
# Determine wake time
@@ -270,7 +298,7 @@ def should_send_nag(
return False, "User offline"
# Get today's schedule record for this specific time slot
today = current_time.date()
today = user_today_for(user_uuid)
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
if scheduled_time is not None:
query["adjusted_time"] = scheduled_time
@@ -291,30 +319,89 @@ def should_send_nag(
nag_interval = settings.get("nag_interval_minutes", 15)
if last_nag:
if isinstance(last_nag, datetime) and last_nag.tzinfo is None:
last_nag = last_nag.replace(tzinfo=timezone.utc)
time_since_last_nag = (current_time - last_nag).total_seconds() / 60
if time_since_last_nag < nag_interval:
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
else:
# First nag: require at least nag_interval minutes since the scheduled dose time
if scheduled_time:
sched_hour, sched_min = int(scheduled_time[:2]), int(scheduled_time[3:5])
sched_dt = current_time.replace(hour=sched_hour, minute=sched_min, second=0, microsecond=0)
minutes_since_dose = (current_time - sched_dt).total_seconds() / 60
if minutes_since_dose < nag_interval:
return False, f"Too soon after dose time ({int(minutes_since_dose)} < {nag_interval} min)"
# Check if this specific dose was already taken today
# Check if this specific dose was already taken or skipped today
logs = postgres.select(
"med_logs",
{
"medication_id": med_id,
"user_uuid": user_uuid,
"action": "taken",
"scheduled_time": scheduled_time,
},
)
# Filter to today's logs for this time slot
today_logs = [
log
for log in logs
if log.get("created_at") and log["created_at"].date() == today
]
# Get medication times to calculate dose interval for proximity check
med = postgres.select_one("medications", {"id": med_id})
dose_interval_minutes = 60 # default fallback
if med and med.get("times"):
times = med["times"]
if len(times) >= 2:
time_minutes = []
for t in times:
t = _normalize_time(t)
if t:
h, m = int(t[:2]), int(t[3:5])
time_minutes.append(h * 60 + m)
time_minutes.sort()
intervals = []
for i in range(1, len(time_minutes)):
intervals.append(time_minutes[i] - time_minutes[i - 1])
if intervals:
dose_interval_minutes = min(intervals)
if today_logs:
return False, "Already taken today"
proximity_window = max(30, dose_interval_minutes // 2)
# Filter to today's logs and check for this specific dose
user_tz = tz_for_user(user_uuid)
for log in logs:
action = log.get("action")
if action not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
# created_at is stored as UTC but timezone-naive; convert to user's timezone
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
created_at_local = created_at.astimezone(user_tz)
if created_at_local.date() != today:
continue
log_scheduled_time = log.get("scheduled_time")
if log_scheduled_time:
log_scheduled_time = _normalize_time(log_scheduled_time)
if log_scheduled_time == scheduled_time:
return False, f"Already {action} today"
else:
if scheduled_time:
log_hour = created_at_local.hour
log_min = created_at_local.minute
sched_hour, sched_min = (
int(scheduled_time[:2]),
int(scheduled_time[3:5]),
)
log_mins = log_hour * 60 + log_min
sched_mins = sched_hour * 60 + sched_min
diff_minutes = abs(log_mins - sched_mins)
# Handle midnight wraparound (e.g. 23:00 vs 00:42)
diff_minutes = min(diff_minutes, 1440 - diff_minutes)
if diff_minutes <= proximity_window:
return False, f"Already {action} today"
return True, "Time to nag"
@@ -335,13 +422,18 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
postgres.update(
"medication_schedules",
{"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()},
{"nag_count": new_nag_count, "last_nag_at": datetime.now(timezone.utc)},
{"id": schedule["id"]},
)
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
"""Create today's medication schedule with adaptive adjustments."""
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str], recalculate: bool = False):
"""Create today's medication schedule with adaptive adjustments.
If recalculate=True, deletes existing *pending* schedules and recreates them
with updated adaptive timing (e.g. after a wake event is detected).
Already-taken or skipped schedules are preserved.
"""
today = user_today_for(user_uuid)
# Check if schedule already exists
@@ -350,14 +442,62 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
if existing:
if existing and not recalculate:
return
if existing and recalculate:
# Only delete pending schedules — preserve taken/skipped
for sched in existing:
if sched.get("status") == "pending":
postgres.delete("medication_schedules", {"id": sched["id"]})
# Check if any pending remain to create
remaining = [s for s in existing if s.get("status") != "pending"]
completed_base_times = set()
for s in remaining:
bt = _normalize_time(s.get("base_time"))
if bt:
completed_base_times.add(bt)
# Only create schedules for times that haven't been taken/skipped
base_times = [t for t in base_times if t not in completed_base_times]
if not base_times:
return
# Calculate adjusted times
adjusted_times = calculate_adjusted_times(user_uuid, base_times)
# Check recent med logs to skip doses already taken/skipped.
# Handles cross-midnight: if adaptive offset shifts 23:00 → 00:42 today,
# but the user already took the 23:00 dose last night, don't schedule it.
# Yesterday's logs only suppress if the scheduled_time is late-night
# (21:00+), since only those could plausibly cross midnight with an offset.
user_tz = tz_for_user(user_uuid)
yesterday = today - timedelta(days=1)
recent_logs = postgres.select("med_logs", {"medication_id": med_id, "user_uuid": user_uuid})
taken_base_times = set()
for log in recent_logs:
if log.get("action") not in ("taken", "skipped"):
continue
created_at = log.get("created_at")
if not created_at:
continue
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
log_date = created_at.astimezone(user_tz).date()
if log_date == today:
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched:
taken_base_times.add(log_sched)
elif log_date == yesterday:
# Only suppress cross-midnight doses (late-night times like 21:00+)
log_sched = _normalize_time(log.get("scheduled_time"))
if log_sched and log_sched >= "21:00":
taken_base_times.add(log_sched)
# Create schedule records for each time
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
if base_time in taken_base_times:
continue
data = {
"id": str(uuid.uuid4()),
"user_uuid": user_uuid,
@@ -373,17 +513,40 @@ def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication as taken."""
"""Mark a medication schedule as taken."""
_mark_med_status(user_uuid, med_id, scheduled_time, "taken")
def mark_med_skipped(user_uuid: str, med_id: str, scheduled_time):
"""Mark a medication schedule as skipped."""
_mark_med_status(user_uuid, med_id, scheduled_time, "skipped")
def _mark_med_status(user_uuid: str, med_id: str, scheduled_time, status: str):
"""Update a medication schedule's status for today."""
scheduled_time = _normalize_time(scheduled_time)
today = user_today_for(user_uuid)
postgres.update(
"medication_schedules",
{"status": "taken"},
{
# Try matching by adjusted_time first
where = {
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
}
if scheduled_time is not None:
where["adjusted_time"] = scheduled_time
schedules = postgres.select("medication_schedules", where)
if schedules:
postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]})
elif scheduled_time is not None:
# Fallback: try matching by base_time (in case adjusted == base)
where_base = {
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"adjusted_time": scheduled_time,
},
)
"base_time": scheduled_time,
}
schedules_base = postgres.select("medication_schedules", where_base)
if schedules_base:
postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]})

View File

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

56
regenerate_embeddings.py Normal file
View File

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

View File

@@ -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,31 +120,63 @@ 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"):
continue
days = schedule.get("days", [])
if current_day not in days:
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"])
if user_settings:
msg = f"Time to start your routine: {routine['name']}"
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
@@ -317,6 +389,10 @@ def check_nagging():
now = _user_now_for(user_uuid)
today = now.date()
# Skip nagging if medication is not due today
if not _is_med_due_today(med, today):
continue
# Get today's schedules
try:
schedules = postgres.select(
@@ -329,13 +405,19 @@ 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
# If no schedules exist, try to create them
# If no schedules exist, try to create them — but only if med is due today
if not schedules:
logger.info(f"No schedules found for medication {med_id}, attempting to create")
if not _is_med_due_today(med, today):
continue
logger.info(
f"No schedules found for medication {med_id}, attempting to create"
)
times = med.get("times", [])
if times:
try:
@@ -367,12 +449,9 @@ def check_nagging():
if not should_nag:
continue
# Get the time to display
adaptive_enabled = settings.get("adaptive_timing_enabled")
if adaptive_enabled:
display_time = sched.get("adjusted_time")
else:
display_time = sched.get("base_time")
# Always display the base_time (the user's actual dose time),
# not the internal adjusted_time used for scheduling.
display_time = sched.get("base_time")
# Normalize TIME objects for display
if isinstance(display_time, time_type):
display_time = display_time.strftime("%H:%M")
@@ -506,27 +585,104 @@ 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}"
)
def check_task_reminders():
"""Check one-off tasks for advance and at-time reminders."""
from datetime import timedelta
try:
tasks = postgres.select("tasks", where={"status": "pending"})
if not tasks:
return
user_tasks = {}
for task in tasks:
uid = task.get("user_uuid")
user_tasks.setdefault(uid, []).append(task)
for user_uuid, task_list in user_tasks.items():
now = _user_now_for(user_uuid)
current_hhmm = now.strftime("%H:%M")
current_date = now.date()
user_settings = None # lazy-load once per user
for task in task_list:
raw_dt = task.get("scheduled_datetime")
if not raw_dt:
continue
sched_dt = (
raw_dt
if isinstance(raw_dt, datetime)
else datetime.fromisoformat(str(raw_dt))
)
sched_date = sched_dt.date()
sched_hhmm = sched_dt.strftime("%H:%M")
reminder_min = task.get("reminder_minutes_before") or 0
# Advance reminder
if reminder_min > 0 and not task.get("advance_notified"):
adv_dt = sched_dt - timedelta(minutes=reminder_min)
if (
adv_dt.date() == current_date
and adv_dt.strftime("%H:%M") == current_hhmm
):
if user_settings is None:
user_settings = notifications.getNotificationSettings(
user_uuid
)
if user_settings:
msg = f"⏰ In {reminder_min} min: {task['title']}"
if task.get("description"):
msg += f"{task['description']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks", {"advance_notified": True}, {"id": task["id"]}
)
# At-time reminder
if sched_date == current_date and sched_hhmm == current_hhmm:
if user_settings is None:
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
msg = f"📋 Now: {task['title']}"
if task.get("description"):
msg += f"\n{task['description']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks",
{
"status": "notified",
"updated_at": datetime.utcnow().isoformat(),
},
{"id": task["id"]},
)
except Exception as e:
logger.error(f"Error checking task reminders: {e}")
def poll_callback():
"""Called every POLL_INTERVAL seconds."""
# Create daily schedules per-user at their local midnight
_check_per_user_midnight_schedules()
# Check 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:
@@ -537,6 +693,7 @@ def poll_callback():
# Original checks
check_routine_reminders()
check_refills()
check_task_reminders()
def daemon_loop():

View File

@@ -14,8 +14,7 @@ import {
PillIcon,
SettingsIcon,
LogOutIcon,
CopyIcon,
ClockIcon,
SunIcon,
MoonIcon,
} from '@/components/ui/Icons';
@@ -24,7 +23,7 @@ import Link from 'next/link';
const navItems = [
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
{ href: '/dashboard/routines', label: 'Routines', icon: ListIcon },
{ href: '/dashboard/templates', label: 'Templates', icon: CopyIcon },
{ href: '/dashboard/tasks', label: 'Tasks', icon: ClockIcon },
{ href: '/dashboard/history', label: 'History', icon: CalendarIcon },
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },

View File

@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
];
export default function EditMedicationPage() {
const router = useRouter();
const params = useParams();
const medId = params.id as string;
const [isLoadingMed, setIsLoadingMed] = useState(true);
const [name, setName] = useState('');
const [dosage, setDosage] = useState('');
const [unit, setUnit] = useState('mg');
const [frequency, setFrequency] = useState('daily');
const [times, setTimes] = useState<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api.medications.get(medId)
.then(med => {
setName(med.name);
setDosage(String(med.dosage));
setUnit(med.unit);
setFrequency((med as any).frequency || 'daily');
setTimes((med as any).times?.length ? (med as any).times : ['08:00']);
setDaysOfWeek((med as any).days_of_week || []);
setIntervalDays((med as any).interval_days || 7);
setStartDate((med as any).start_date?.slice(0, 10) || new Date().toISOString().slice(0, 10));
setNotes(med.notes || '');
})
.catch(() => setError('Failed to load medication.'))
.finally(() => setIsLoadingMed(false));
}, [medId]);
const handleAddTime = () => setTimes([...times, '12:00']);
const handleRemoveTime = (i: number) => setTimes(times.filter((_, idx) => idx !== i));
const handleTimeChange = (i: number, val: string) => {
const t = [...times]; t[i] = val; setTimes(t);
};
const toggleDay = (day: string) =>
setDaysOfWeek(prev => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !dosage.trim()) { setError('Name and dosage are required.'); return; }
if (frequency === 'specific_days' && daysOfWeek.length === 0) { setError('Select at least one day.'); return; }
setIsSubmitting(true);
setError('');
try {
await api.medications.update(medId, {
name: name.trim(),
dosage: dosage.trim(),
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
...(notes.trim() && { notes: notes.trim() }),
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to save changes.');
setIsSubmitting(false);
}
};
if (isLoadingMed) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
<div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Edit Medication</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && (
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={e => setDosage(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
<select
value={unit}
onChange={e => setUnit(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
<select
value={frequency}
onChange={e => setFrequency(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={intervalDays}
onChange={e => setIntervalDays(parseInt(e.target.value) || 1)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
<button type="button" onClick={handleAddTime} className="text-indigo-600 dark:text-indigo-400 text-sm font-medium">
+ Add Time
</button>
</div>
<div className="space-y-2">
{times.map((time, i) => (
<div key={i} className="flex gap-2">
<input
type="time"
value={time}
onChange={e => handleTimeChange(i, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button type="button" onClick={() => handleRemoveTime(i)} className="text-red-500 dark:text-red-400 px-3">
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes <span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none resize-none"
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</form>
</div>
);
}

View File

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

View File

@@ -3,7 +3,7 @@
import { useEffect, useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon, EditIcon } from '@/components/ui/Icons';
import Link from 'next/link';
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
@@ -91,24 +91,36 @@ export default function MedicationsPage() {
const [isLoading, setIsLoading] = useState(true);
const [tick, setTick] = useState(0);
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
}
};
useEffect(() => {
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
} finally {
setIsLoading(false);
}
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);
};
fetchData();
}, []);
// Auto-refresh grouping every 60s
@@ -380,12 +392,20 @@ export default function MedicationsPage() {
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
)}
</div>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 dark:text-red-400 p-2"
>
<TrashIcon size={18} />
</button>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/medications/${med.id}/edit`}
className="text-gray-400 dark:text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 p-2"
>
<EditIcon size={18} />
</Link>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 dark:text-red-400 p-2"
>
<TrashIcon size={18} />
</button>
</div>
</div>
{/* Adherence */}

View File

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

View File

@@ -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,56 +235,108 @@ 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>
{/* Quick select buttons */}
{/* Frequency selector */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleFrequency('weekly')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Every day
Weekly
</button>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleFrequency('every_n_days')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
Every N Days
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
{scheduleFrequency === 'every_n_days' ? (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
<div className="flex items-center gap-2">
<input
type="number"
min={2}
max={365}
value={scheduleIntervalDays}
onChange={(e) => setScheduleIntervalDays(Math.max(2, Number(e.target.value)))}
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
<input
type="date"
value={scheduleStartDate}
onChange={(e) => setScheduleStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
) : (
<>
{/* Quick select buttons */}
<div className="flex gap-2">
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{day.label}
Every day
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{day.label}
</button>
))}
</div>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [trustDevice, setTrustDevice] = useState(false);
const [trustDevice, setTrustDevice] = useState(true);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, register } = useAuth();

View File

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

View File

@@ -1,5 +1,18 @@
const API_URL = '';
export interface Task {
id: string;
user_uuid: string;
title: string;
description?: string;
scheduled_datetime: string;
reminder_minutes_before: number;
advance_notified: boolean;
status: 'pending' | 'notified' | 'completed' | 'cancelled';
created_at: string;
updated_at: string;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
@@ -310,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',
@@ -339,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' });
},
@@ -1052,6 +1071,17 @@ export const api = {
},
},
tasks: {
list: (status = 'pending') =>
request<Task[]>(`/api/tasks?status=${status}`, { method: 'GET' }),
create: (data: { title: string; description?: string; scheduled_datetime: string; reminder_minutes_before?: number }) =>
request<Task>('/api/tasks', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: Partial<Pick<Task, 'title' | 'description' | 'scheduled_datetime' | 'reminder_minutes_before' | 'status'>>) =>
request<Task>(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string) =>
request<{ success: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }),
},
ai: {
generateSteps: (goal: string) =>
request<{ steps: { name: string; duration_minutes: number }[] }>(