Compare commits
11 Commits
ecb79af44e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215c3d7f95 | ||
| fe07b3ebe7 | |||
| 019561e7cd | |||
| e89656a87c | |||
| 03da0b0156 | |||
| cf29d17183 | |||
| cc1aace73d | |||
| a19e30db68 | |||
| e9a2f96f91 | |||
| 4c4ff5add3 | |||
| 33db2629e3 |
300
README.md
300
README.md
@@ -1,14 +1,14 @@
|
|||||||
# Synculous
|
# Synculous
|
||||||
|
|
||||||
A routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
A comprehensive routine and medication management app designed as a prosthetic for executive function. Built for people with ADHD.
|
||||||
|
|
||||||
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame.
|
The app externalizes the things ADHD impairs — time awareness, sequence memory, task initiation, and emotional regulation around failure — into a guided, sequential interface with immediate feedback and zero shame. It combines structured routines, intelligent medication tracking, AI-powered safety systems, and peer accountability features into one unified platform.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
synculous/
|
synculous/
|
||||||
├── synculous-client/ # Next.js 16 frontend (React, Tailwind)
|
├── synculous-client/ # Next.js frontend (React, Tailwind)
|
||||||
├── api/ # Flask REST API
|
├── api/ # Flask REST API
|
||||||
│ ├── main.py # App entry point, auth routes
|
│ ├── main.py # App entry point, auth routes
|
||||||
│ └── routes/ # Domain route modules
|
│ └── routes/ # Domain route modules
|
||||||
@@ -19,28 +19,49 @@ synculous/
|
|||||||
│ ├── routine_steps_extended.py # Step instructions, types, media
|
│ ├── routine_steps_extended.py # Step instructions, types, media
|
||||||
│ ├── routine_tags.py # Tagging system
|
│ ├── routine_tags.py # Tagging system
|
||||||
│ ├── medications.py # Medication scheduling + adherence
|
│ ├── medications.py # Medication scheduling + adherence
|
||||||
|
│ ├── 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
|
│ ├── preferences.py # User settings + timezone
|
||||||
│ ├── notifications.py # Web push subscriptions
|
│ ├── notifications.py # Web push subscriptions
|
||||||
│ ├── rewards.py # Variable reward system
|
│ ├── rewards.py # Variable reward system
|
||||||
│ └── victories.py # Achievement detection
|
│ ├── victories.py # Achievement detection
|
||||||
|
│ └── snitch.py # Peer accountability contacts + notifications
|
||||||
├── core/ # Shared business logic
|
├── core/ # Shared business logic
|
||||||
│ ├── postgres.py # Generic PostgreSQL CRUD
|
│ ├── postgres.py # Generic PostgreSQL CRUD
|
||||||
│ ├── auth.py # JWT + bcrypt authentication
|
│ ├── auth.py # JWT + bcrypt authentication
|
||||||
│ ├── users.py # User management
|
│ ├── users.py # User management
|
||||||
│ ├── routines.py # Routine/session/streak logic
|
│ ├── routines.py # Routine/session/streak logic
|
||||||
│ ├── tz.py # Timezone-aware date/time helpers
|
│ ├── stats.py # Statistics calculations (completion rates, streaks)
|
||||||
|
│ ├── snitch.py # Snitch trigger logic + notification delivery
|
||||||
|
│ ├── adaptive_meds.py # Adaptive medication timing logic
|
||||||
|
│ ├── tz.py # Timezone-aware date/time helpers (IANA + offset)
|
||||||
│ └── notifications.py # Multi-channel notifications
|
│ └── notifications.py # Multi-channel notifications
|
||||||
├── scheduler/
|
├── scheduler/
|
||||||
│ └── daemon.py # Background polling for reminders
|
│ └── daemon.py # Background polling for reminders
|
||||||
├── bot/ # Discord bot (optional)
|
├── bot/ # Discord bot with knowledge RAG
|
||||||
├── ai/ # LLM parser for natural language commands
|
│ ├── bot.py # Bot entry point + session management
|
||||||
|
│ ├── command_registry.py # Module-based command routing
|
||||||
|
│ ├── commands/ # Command modules
|
||||||
|
│ │ ├── routines.py # /routine commands
|
||||||
|
│ │ ├── medications.py # /med, /take, /skip commands
|
||||||
|
│ │ ├── tasks.py # /task commands (one-off tasks/appointments)
|
||||||
|
│ │ └── knowledge.py # /ask command (jury-filtered RAG)
|
||||||
|
│ └── hooks.py # Event listeners
|
||||||
|
├── ai/ # LLM-powered features
|
||||||
|
│ ├── parser.py # OpenRouter API client
|
||||||
|
│ ├── jury_council.py # 5-juror safety filtration system
|
||||||
|
│ ├── ai_config.json # Model + prompt configuration
|
||||||
|
│ └── (optional) RAG embeddings
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── schema.sql # Database schema
|
│ ├── schema.sql # Database schema
|
||||||
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
|
│ ├── seed_templates.sql # 12 premade ADHD-designed routine templates
|
||||||
│ ├── seed_rewards.sql # Variable reward pool
|
│ ├── seed_rewards.sql # Variable reward pool
|
||||||
│ └── .env.example # Environment template
|
│ └── .env.example # Environment template
|
||||||
|
├── diagrams/ # Architecture diagrams (Mermaid)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── Dockerfile
|
├── Dockerfile
|
||||||
|
└── tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -49,10 +70,10 @@ synculous/
|
|||||||
# Copy environment config
|
# Copy environment config
|
||||||
cp config/.env.example config/.env
|
cp config/.env.example config/.env
|
||||||
|
|
||||||
# Edit with your values
|
# Edit with your values (at minimum: DB_PASS, JWT_SECRET, optionally DISCORD_BOT_TOKEN, OPENROUTER_API_KEY)
|
||||||
nano config/.env
|
nano config/.env
|
||||||
|
|
||||||
# Start everything
|
# Start everything (db, api, scheduler, optional bot, frontend client)
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,20 +82,23 @@ This starts five services:
|
|||||||
| Service | Port | Description |
|
| Service | Port | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
|
| `db` | 5432 | PostgreSQL 16 with schema + seed data |
|
||||||
| `app` | 8080 | Flask API |
|
| `app` | 8010 | Flask API (internal: 5000) |
|
||||||
| `scheduler` | — | Background daemon for medication/routine reminders |
|
| `scheduler` | — | Background daemon for medication/routine reminders |
|
||||||
| `bot` | — | Discord bot (optional, needs `DISCORD_BOT_TOKEN`) |
|
| `bot` | — | Discord bot with commands and knowledge RAG (optional, needs `DISCORD_BOT_TOKEN`) |
|
||||||
| `client` | 3000 | Next.js frontend |
|
| `client` | 3001 | Next.js frontend (internal: 3000) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Routines
|
### Routines & Sessions
|
||||||
- Create routines with ordered steps (4-7 steps recommended)
|
- Create routines with ordered steps (4-7 steps recommended)
|
||||||
- Run sessions with a guided, one-step-at-a-time focus interface
|
- Run sessions with a guided, one-step-at-a-time focus interface
|
||||||
- Complete, skip, pause, resume, or cancel sessions
|
- Complete, skip, pause, resume, or cancel sessions
|
||||||
- Swipe gestures for step completion on mobile
|
- Swipe gestures for step completion on mobile
|
||||||
- Per-step timing with visual countdown
|
- Per-step timing with visual countdown
|
||||||
- Animated celebration screen on completion with streak stats and variable rewards
|
- Animated celebration screen on completion with streak stats and variable rewards
|
||||||
|
- Every-N-day frequency option for routines
|
||||||
|
- Tagging system for organizing routines by category
|
||||||
|
- Session notes for logging context or blockers
|
||||||
|
|
||||||
### Premade Templates
|
### Premade Templates
|
||||||
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
|
12 ADHD-designed templates ship out of the box, seeded from `config/seed_templates.sql`:
|
||||||
@@ -96,13 +120,44 @@ This starts five services:
|
|||||||
|
|
||||||
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
|
All templates follow the design framework: two-minute-rule entry points, concrete instructions, zero-shame language, 4-6 steps max.
|
||||||
|
|
||||||
|
### One-Off Tasks & Appointments
|
||||||
|
- Create standalone tasks and appointments outside of routines
|
||||||
|
- Scheduled date/time with optional reminders
|
||||||
|
- Quick-complete action for fast check-off
|
||||||
|
- Tasks appear on the routines timeline for a unified daily view
|
||||||
|
- AI-powered task composition via bot and web client
|
||||||
|
- Natural language date parsing in bot commands
|
||||||
|
|
||||||
|
### AI-Powered Step Generation
|
||||||
|
- Generate ADHD-friendly routine steps from a plain-language goal description
|
||||||
|
- Uses OpenRouter LLM to produce 2-minute-rule-compliant steps
|
||||||
|
- Each step includes name and estimated duration
|
||||||
|
- Available via API endpoint and integrated into the web client's routine creation flow
|
||||||
|
|
||||||
### Medications
|
### Medications
|
||||||
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN)
|
- Scheduling: daily, twice daily, specific days, every N days, as-needed (PRN); batch-schedule multiple meds at once
|
||||||
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
|
- "Today's meds" view with cross-midnight lookahead (late night + early morning)
|
||||||
- Take, skip, snooze actions with logging
|
- Take, skip, snooze actions with logging
|
||||||
- Adherence tracking and statistics
|
- Adherence tracking and statistics
|
||||||
- Refill tracking with low-quantity alerts
|
- Refill tracking with low-quantity alerts
|
||||||
- Background reminders via the scheduler daemon
|
- Background reminders via the scheduler daemon
|
||||||
|
- Medication editing (update name, dose, schedule, refill count)
|
||||||
|
- Web push and Discord notifications for doses
|
||||||
|
|
||||||
|
#### Adaptive Medication Timing
|
||||||
|
- Machine-learning-based timing predictions based on user adherence patterns
|
||||||
|
- System learns when you're most likely to take meds
|
||||||
|
- Automatic reminder optimization (fewer false-positive reminders)
|
||||||
|
- Override and manual timing adjustments always available
|
||||||
|
- Useful for people with irregular schedules
|
||||||
|
|
||||||
|
### Peer Accountability: "Snitch" Feature
|
||||||
|
- Designate trusted contacts to receive medication adherence notifications
|
||||||
|
- Contacts don't need an account or login
|
||||||
|
- Granular privacy controls: users choose *what* to share (meds, streaks, notes)
|
||||||
|
- Contacts receive weekly summaries or real-time alerts for missed doses
|
||||||
|
- Based on research showing peer accountability improves adherence
|
||||||
|
- Optional consent-based consent flow for contact approvals
|
||||||
|
|
||||||
### Streaks and Stats
|
### Streaks and Stats
|
||||||
- Per-routine streak tracking (current + longest)
|
- Per-routine streak tracking (current + longest)
|
||||||
@@ -116,36 +171,97 @@ All templates follow the design framework: two-minute-rule entry points, concret
|
|||||||
- Random reward on routine completion (post-completion only, never mid-routine)
|
- Random reward on routine completion (post-completion only, never mid-routine)
|
||||||
- Reward history tracking per user
|
- Reward history tracking per user
|
||||||
- Common and rare rarity tiers
|
- Common and rare rarity tiers
|
||||||
|
- Designed to leverage variable-ratio reinforcement schedules
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Web push notifications via VAPID
|
- Web push notifications via VAPID (in-browser)
|
||||||
- Discord webhooks
|
- Discord webhooks (for bot mentions)
|
||||||
- ntfy support
|
- Discord DMs (for sensitive notifications like "snitch" alerts)
|
||||||
- Scheduled reminders for medications and routines
|
- ntfy support (for self-hosted push)
|
||||||
|
- Timezone-aware scheduled reminders for medications and routines
|
||||||
|
|
||||||
### Timezone Support
|
### Timezone Support (Dual-Path)
|
||||||
All date/time operations respect the user's local timezone:
|
All date/time operations respect the user's local timezone via two mechanisms:
|
||||||
- The frontend sends `X-Timezone-Offset` with every API request
|
|
||||||
- The timezone offset is also persisted to `user_preferences` for background jobs
|
**In request context:**
|
||||||
- Streaks, "today's meds," weekly stats, and reminders all use the user's local date
|
- The frontend sends `X-Timezone-Name` (IANA standard, e.g., "America/New_York") or `X-Timezone-Offset` header
|
||||||
- The scheduler daemon looks up each user's stored offset for reminder timing
|
- Handlers use these headers for real-time API operations
|
||||||
|
|
||||||
|
**In background jobs (scheduler daemon):**
|
||||||
|
- Timezone is stored in `user_preferences.timezone_name` (IANA format)
|
||||||
|
- Scheduler retrieves stored timezone for each user
|
||||||
|
- Falls back to numeric offset, then UTC if name is unavailable
|
||||||
|
- Enables accurate reminder delivery even if user's browser context is offline
|
||||||
|
|
||||||
|
**Result:** Streaks, "today's meds," weekly stats, and reminders all use the user's local date.
|
||||||
|
|
||||||
|
### Authentication & Session Persistence
|
||||||
|
- JWT access tokens (1-hour expiry) + optional long-lived refresh tokens (30 days)
|
||||||
|
- "Trust this device" option on login issues a refresh token for seamless re-auth
|
||||||
|
- Bot session caching with persistent pickle storage across restarts
|
||||||
|
|
||||||
### User Preferences
|
### User Preferences
|
||||||
- Sound effects (default off — habituation risk)
|
- Sound effects (default off — habituation risk)
|
||||||
- Haptic feedback (default on)
|
- Haptic feedback (default on)
|
||||||
- Launch screen toggle
|
- Launch screen toggle
|
||||||
- Celebration style
|
- Celebration style
|
||||||
- Timezone offset (auto-synced from browser)
|
- Timezone (IANA name + numeric offset, auto-synced from browser)
|
||||||
|
- Discord presence indicator toggle (shows online/offline in Discord)
|
||||||
|
|
||||||
|
### Discord Bot Integration
|
||||||
|
Full-featured Discord bot for managing routines and medications without opening the app:
|
||||||
|
|
||||||
|
#### Bot Commands
|
||||||
|
- `/routine list` — List all routines
|
||||||
|
- `/routine start <name>` — Start a routine session (guided steps in Discord thread)
|
||||||
|
- `/routine stats <name>` — View streak and completion stats
|
||||||
|
- `/med today` — Show today's medications with status
|
||||||
|
- `/med take <med_name>` — Log a dose as taken
|
||||||
|
- `/med skip <med_name>` — Log a dose as skipped
|
||||||
|
- `/task add <description>` — Create a one-off task (supports natural language dates)
|
||||||
|
- `/task list` — Show upcoming tasks
|
||||||
|
- `/task done <name>` — Mark a task as complete
|
||||||
|
- `/ask <question>` — Query the knowledge base with jury-filtered safety checks
|
||||||
|
- Discord presence automatically shows your routine/meditation status
|
||||||
|
|
||||||
|
#### Jury Council Safety System
|
||||||
|
The `/ask` command uses a sophisticated 5-juror safety filtration pipeline:
|
||||||
|
|
||||||
|
**Two-stage process:**
|
||||||
|
1. **Question Generator** (Qwen3 Nitro, fallback to Qwen3-235B): Expands user query into 2-3 precise search questions
|
||||||
|
2. **Jury Council** (5 parallel jurors, 100% consensus required): Each juror evaluates questions from a distinct safety lens
|
||||||
|
|
||||||
|
**Juror Roles:**
|
||||||
|
- **Safety:** Would answering cause harm? Evaluates crisis risk (C-SSRS framework), self-harm methods, lethal means
|
||||||
|
- **Empathy:** Is this emotionally appropriate for someone in distress? Checks for deceptive empathy, harmful validation, stigmatizing language
|
||||||
|
- **Intent:** Is this benign? Detects jailbreaks, social engineering, prompt injection, method-seeking disguised as education
|
||||||
|
- **Clarity:** Is the question retrievable? Checks if the question is specific enough to get meaningful results from the knowledge base
|
||||||
|
- **Ethics:** Within bounds for an informational AI? Blocks diagnosis, treatment planning, medication advice, scope violations, deceptive role-play
|
||||||
|
|
||||||
|
**Safety model:** Questions only approved if ALL 5 jurors vote yes. Any juror error = fail closed. Crisis indicators trigger immediate resource redirection (988, Crisis Text Line, etc.) instead of RAG answers.
|
||||||
|
|
||||||
|
This system makes it possible to serve help-seeking users asking about self-harm coping strategies, suicidal ideation management, and DBT skills while firmly rejecting harmful intent (method-seeking, glorification, extraction attempts).
|
||||||
|
|
||||||
|
### Knowledge Base & RAG
|
||||||
|
- Embeddings-based retrieval of ADHD, DBT, and mental health educational content
|
||||||
|
- Multi-book support with user-selectable knowledge bases
|
||||||
|
- Jury-filtered questions for safety
|
||||||
|
- LLM-powered intent classification routes general questions to the knowledge base automatically
|
||||||
|
- Multi-query retrieval with deduplication for better coverage
|
||||||
|
- DBT advice evaluation mode (checks advice against DBT principles)
|
||||||
|
- Discord bot `/ask` command uses RAG with jury council checks
|
||||||
|
- Extensible knowledge source (can add more documents)
|
||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
|
|
||||||
All endpoints require `Authorization: Bearer <token>` except `/api/register` and `/api/login`.
|
All endpoints require `Authorization: Bearer <token>` except `/api/register`, `/api/login`, and `/api/refresh`.
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| POST | `/api/register` | Create account |
|
| POST | `/api/register` | Create account |
|
||||||
| POST | `/api/login` | Get JWT token |
|
| POST | `/api/login` | Get JWT token (optionally with refresh token via `trust_device`) |
|
||||||
|
| POST | `/api/refresh` | Exchange refresh token for new access token |
|
||||||
|
|
||||||
### Routines
|
### Routines
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@@ -170,6 +286,7 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| POST | `/api/sessions/:id/resume` | Resume session |
|
| POST | `/api/sessions/:id/resume` | Resume session |
|
||||||
| POST | `/api/sessions/:id/cancel` | Cancel session |
|
| POST | `/api/sessions/:id/cancel` | Cancel session |
|
||||||
| POST | `/api/sessions/:id/abort` | Abort with reason |
|
| POST | `/api/sessions/:id/abort` | Abort with reason |
|
||||||
|
| POST | `/api/sessions/:id/note` | Add session note |
|
||||||
|
|
||||||
### Medications
|
### Medications
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@@ -182,6 +299,41 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| GET | `/api/medications/adherence` | Adherence stats |
|
| GET | `/api/medications/adherence` | Adherence stats |
|
||||||
| GET | `/api/medications/refills-due` | Refills due soon |
|
| GET | `/api/medications/refills-due` | Refills due soon |
|
||||||
|
|
||||||
|
### Adaptive Medications
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/adaptive-meds/:id` | Get adaptive timing data for a medication |
|
||||||
|
| PUT | `/api/adaptive-meds/:id` | Update adaptive timing preferences |
|
||||||
|
| POST | `/api/adaptive-meds/:id/reset` | Reset adaptive learning and return to default schedule |
|
||||||
|
| GET | `/api/adaptive-meds/stats` | View adaptive timing effectiveness stats |
|
||||||
|
|
||||||
|
### Snitch (Peer Accountability)
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/snitch/contacts` | List designated accountability contacts |
|
||||||
|
| POST | `/api/snitch/contacts` | Add a new contact (generates invite link) |
|
||||||
|
| PUT | `/api/snitch/contacts/:id` | Update contact (name, shared info, frequency) |
|
||||||
|
| DELETE | `/api/snitch/contacts/:id` | Remove a contact |
|
||||||
|
| POST | `/api/snitch/contacts/:id/resend-invite` | Resend contact invite link |
|
||||||
|
| GET | `/api/snitch/contacts/:id/consent` | Get contact's consent status |
|
||||||
|
| POST | `/api/snitch/contacts/:id/consent` | Contact accepts or declines sharing |
|
||||||
|
| GET | `/api/snitch/history` | View recent alerts sent to contacts |
|
||||||
|
| POST | `/api/snitch/test-send` | Send test alert to a contact |
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/tasks` | List user's tasks |
|
||||||
|
| POST | `/api/tasks` | Create a task |
|
||||||
|
| PUT | `/api/tasks/:id` | Update a task |
|
||||||
|
| DELETE | `/api/tasks/:id` | Delete a task |
|
||||||
|
| POST | `/api/tasks/:id/complete` | Mark task as complete |
|
||||||
|
|
||||||
|
### AI
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/ai/generate-steps` | Generate ADHD-friendly routine steps from a goal description |
|
||||||
|
|
||||||
### Stats
|
### Stats
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
@@ -191,37 +343,99 @@ All endpoints require `Authorization: Bearer <token>` except `/api/register` and
|
|||||||
| GET | `/api/routines/weekly-summary` | Weekly progress |
|
| GET | `/api/routines/weekly-summary` | Weekly progress |
|
||||||
| GET | `/api/victories` | Achievement detection |
|
| GET | `/api/victories` | Achievement detection |
|
||||||
|
|
||||||
### Templates, Tags, Rewards, Preferences
|
### Templates, Tags, Rewards, Preferences, Notifications
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/templates` | List available templates |
|
| GET | `/api/templates` | List available templates |
|
||||||
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
|
| POST | `/api/templates/:id/clone` | Clone template to user's routines |
|
||||||
| GET/PUT | `/api/preferences` | User settings |
|
| GET/PUT | `/api/preferences` | User settings and timezone |
|
||||||
| GET | `/api/rewards/random` | Random completion reward |
|
| GET | `/api/rewards/random` | Random completion reward |
|
||||||
|
| POST | `/api/notifications/subscribe` | Subscribe to web push notifications (VAPID) |
|
||||||
|
| POST | `/api/notifications/unsubscribe` | Unsubscribe from push notifications |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Required | Description |
|
||||||
|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `DB_HOST` | PostgreSQL host |
|
| `DB_HOST` | Yes | PostgreSQL host (default: `db` in Docker) |
|
||||||
| `DB_PORT` | PostgreSQL port |
|
| `DB_PORT` | Yes | PostgreSQL port (default: `5432`) |
|
||||||
| `DB_NAME` | Database name |
|
| `DB_NAME` | Yes | Database name (default: `app`) |
|
||||||
| `DB_USER` | Database user |
|
| `DB_USER` | Yes | Database user (default: `app`) |
|
||||||
| `DB_PASS` | Database password |
|
| `DB_PASS` | Yes | Database password |
|
||||||
| `JWT_SECRET` | JWT signing secret |
|
| `JWT_SECRET` | Yes | JWT signing secret (generate a random string, min 32 chars) |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (optional) |
|
| `DISCORD_BOT_TOKEN` | No | Discord bot token (if running Discord bot) |
|
||||||
| `API_URL` | API URL for bot (default: `http://app:5000`) |
|
| `OPENROUTER_API_KEY` | No | OpenRouter API key (if using jury council RAG features) |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API key (for AI parser) |
|
| `API_URL` | No | API URL for bot (default: `http://app:5000` in Docker) |
|
||||||
| `POLL_INTERVAL` | Scheduler poll interval in seconds (default: 60) |
|
| `POLL_INTERVAL` | No | Scheduler poll interval in seconds (default: `60`) |
|
||||||
|
| `VAPID_PUBLIC_KEY` | No | VAPID public key for web push notifications |
|
||||||
|
| `VAPID_PRIVATE_KEY` | No | VAPID private key for web push notifications |
|
||||||
|
|
||||||
## Design Framework
|
## Design Framework
|
||||||
|
|
||||||
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The three core principles:
|
Synculous follows a documented design framework based on research from 9 books on behavior design, cognitive psychology, and ADHD. The core principles inform every feature:
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
|
1. **Immediate Feedback** — Visual state change on tap in <0.1s. Per-step completion signals. Post-routine celebration.
|
||||||
2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
|
2. **One Thing at a Time** — Current step visually dominant. No decisions during execution. 4-7 steps max per routine.
|
||||||
3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
|
3. **Zero Shame** — No failure language. Streaks as identity markers, not performance metrics. Non-punitive everywhere.
|
||||||
|
|
||||||
|
### Behavioral Foundations
|
||||||
|
- **Two-Minute Rule Entry Points** — Every routine starts with something achievable in under 2 minutes (lower activation energy)
|
||||||
|
- **Variable Reward Scheduling** — Random rewards on completion leverage variable-ratio reinforcement (proven for habit building)
|
||||||
|
- **Streak-Based Identity** — Streaks build intrinsic motivation by making completion a visible, accumulating identity signal
|
||||||
|
- **Peer Accountability** — "Snitch" contacts provide external accountability without shame (research shows this improves adherence)
|
||||||
|
- **Adaptive Timing** — System learns your natural rhythm and optimizes reminders based on your actual behavior (reduces cognitive load)
|
||||||
|
|
||||||
|
### Safety & Ethics
|
||||||
|
- **Jury Council** — 5-juror consensus model for AI safety ensures content is appropriate for emotionally vulnerable users
|
||||||
|
- **Crisis Awareness** — System detects crisis indicators and redirects to professional resources (988, Crisis Text Line) rather than generic psychoeducation
|
||||||
|
- **Transparent Limitations** — All system messages clarify "this is educational, not treatment" and encourage professional care
|
||||||
|
- **User Agency** — All adaptive and automated features can be overridden; manual controls are always available
|
||||||
|
|
||||||
|
## Development & Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Run pytest on all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_routines.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=core --cov=api tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
For schema changes, create migration scripts in `config/` and reference them in `docker-compose.yml` or run manually:
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U app -d app -f config/migration_name.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timezone Testing
|
||||||
|
The system uses dual-path timezone support. Test both:
|
||||||
|
1. **Request headers**: X-Timezone-Name (IANA) or X-Timezone-Offset
|
||||||
|
2. **Stored preferences**: Verify `user_preferences.timezone_name` is persisted and read by scheduler
|
||||||
|
|
||||||
|
### Discord Bot Development
|
||||||
|
Bot commands are modular in `bot/commands/`. To add a command:
|
||||||
|
1. Create a new command file in `bot/commands/`
|
||||||
|
2. Import and register in `bot/bot.py`
|
||||||
|
3. Bot automatically syncs commands to Discord on startup
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available in `DOCUMENTATION.md`:
|
||||||
|
- Detailed feature explanations
|
||||||
|
- Database schema reference
|
||||||
|
- Jury Council safety model (full spec)
|
||||||
|
- Deployment & configuration
|
||||||
|
- Contributing guidelines
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with evidence-based design for ADHD. Not a replacement for therapy or medication — a tool to support them.**
|
||||||
|
|||||||
BIN
api/routes/__pycache__/adaptive_meds.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/adaptive_meds.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/routes/__pycache__/medications.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/medications.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/routes/__pycache__/routines.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/routines.cpython-312.pyc
Normal file
Binary file not shown.
@@ -13,6 +13,7 @@ from psycopg2.extras import Json
|
|||||||
import core.auth as auth
|
import core.auth as auth
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
import core.tz as tz
|
import core.tz as tz
|
||||||
|
import core.adaptive_meds as adaptive_meds
|
||||||
|
|
||||||
|
|
||||||
def _get_user_uuid(token):
|
def _get_user_uuid(token):
|
||||||
@@ -145,56 +146,6 @@ def register(app):
|
|||||||
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
|
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
|
||||||
return flask.jsonify(meds), 200
|
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"])
|
@app.route("/api/medications", methods=["POST"])
|
||||||
def api_addMedication():
|
def api_addMedication():
|
||||||
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
|
"""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"):
|
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
|
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 = {
|
row = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
@@ -283,16 +225,6 @@ def register(app):
|
|||||||
"days_of_week", "interval_days", "start_date", "next_dose_date",
|
"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}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||||
@@ -333,6 +265,11 @@ def register(app):
|
|||||||
"notes": data.get("notes"),
|
"notes": data.get("notes"),
|
||||||
}
|
}
|
||||||
log = postgres.insert("med_logs", log_entry)
|
log = postgres.insert("med_logs", log_entry)
|
||||||
|
# Update adaptive schedule status so nags stop
|
||||||
|
try:
|
||||||
|
adaptive_meds.mark_med_taken(user_uuid, med_id, data.get("scheduled_time"))
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail the take action if schedule update fails
|
||||||
# Advance next_dose_date for interval meds
|
# Advance next_dose_date for interval meds
|
||||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||||
next_date = _compute_next_dose_date(med)
|
next_date = _compute_next_dose_date(med)
|
||||||
@@ -359,6 +296,11 @@ def register(app):
|
|||||||
"notes": data.get("reason"),
|
"notes": data.get("reason"),
|
||||||
}
|
}
|
||||||
log = postgres.insert("med_logs", log_entry)
|
log = postgres.insert("med_logs", log_entry)
|
||||||
|
# Update adaptive schedule status so nags stop
|
||||||
|
try:
|
||||||
|
adaptive_meds.mark_med_skipped(user_uuid, med_id, data.get("scheduled_time"))
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail the skip action if schedule update fails
|
||||||
# Advance next_dose_date for interval meds
|
# Advance next_dose_date for interval meds
|
||||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||||
next_date = _compute_next_dose_date(med)
|
next_date = _compute_next_dose_date(med)
|
||||||
|
|||||||
68
bot/bot.py
68
bot/bot.py
@@ -128,6 +128,7 @@ class JurySystem:
|
|||||||
async def retrieve(self, query_text, top_k=5):
|
async def retrieve(self, query_text, top_k=5):
|
||||||
"""Async retrieval — returns list of {metadata, score} dicts."""
|
"""Async retrieval — returns list of {metadata, score} dicts."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
|
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
|
||||||
|
|
||||||
async def query(self, query_text):
|
async def query(self, query_text):
|
||||||
@@ -147,6 +148,7 @@ If the answer is not in the context, say you don't know based on the provided te
|
|||||||
Be concise, compassionate, and practical."""
|
Be concise, compassionate, and practical."""
|
||||||
|
|
||||||
from ai.jury_council import generate_rag_answer
|
from ai.jury_council import generate_rag_answer
|
||||||
|
|
||||||
return await generate_rag_answer(query_text, context_text, system_prompt)
|
return await generate_rag_answer(query_text, context_text, system_prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error querying DBT knowledge base: {e}"
|
return f"Error querying DBT knowledge base: {e}"
|
||||||
@@ -181,7 +183,9 @@ def apiRequest(method, endpoint, token=None, data=None, _retried=False):
|
|||||||
if resp.status_code == 401 and not _retried:
|
if resp.status_code == 401 and not _retried:
|
||||||
new_token = _try_refresh_token_for_session(token)
|
new_token = _try_refresh_token_for_session(token)
|
||||||
if new_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:
|
try:
|
||||||
return resp.json(), resp.status_code
|
return resp.json(), resp.status_code
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -201,9 +205,12 @@ def _try_refresh_token_for_session(expired_token):
|
|||||||
if cached:
|
if cached:
|
||||||
refresh_token = cached.get("refresh_token")
|
refresh_token = cached.get("refresh_token")
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
result, status = apiRequest("post", "/api/refresh",
|
result, status = apiRequest(
|
||||||
|
"post",
|
||||||
|
"/api/refresh",
|
||||||
data={"refresh_token": refresh_token},
|
data={"refresh_token": refresh_token},
|
||||||
_retried=True)
|
_retried=True,
|
||||||
|
)
|
||||||
if status == 200 and "token" in result:
|
if status == 200 and "token" in result:
|
||||||
new_token = result["token"]
|
new_token = result["token"]
|
||||||
session["token"] = new_token
|
session["token"] = new_token
|
||||||
@@ -258,7 +265,8 @@ def negotiateToken(discord_id, username, password):
|
|||||||
# Try refresh token first (avoids sending password)
|
# Try refresh token first (avoids sending password)
|
||||||
if cached and cached.get("refresh_token"):
|
if cached and cached.get("refresh_token"):
|
||||||
result, status = apiRequest(
|
result, status = apiRequest(
|
||||||
"post", "/api/refresh",
|
"post",
|
||||||
|
"/api/refresh",
|
||||||
data={"refresh_token": cached["refresh_token"]},
|
data={"refresh_token": cached["refresh_token"]},
|
||||||
_retried=True,
|
_retried=True,
|
||||||
)
|
)
|
||||||
@@ -279,7 +287,9 @@ def negotiateToken(discord_id, username, password):
|
|||||||
and cached.get("hashed_password")
|
and cached.get("hashed_password")
|
||||||
and verifyPassword(password, 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:
|
if status == 200 and "token" in result:
|
||||||
token = result["token"]
|
token = result["token"]
|
||||||
payload = decodeJwtPayload(token)
|
payload = decodeJwtPayload(token)
|
||||||
@@ -530,7 +540,9 @@ async def handleDBTQuery(message):
|
|||||||
if not jury_result.safe_questions:
|
if not jury_result.safe_questions:
|
||||||
return True
|
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
|
# Step 3: Multi-query retrieval — deduplicated by chunk ID
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
@@ -544,7 +556,9 @@ async def handleDBTQuery(message):
|
|||||||
context_chunks.append(r["metadata"]["text"])
|
context_chunks.append(r["metadata"]["text"])
|
||||||
|
|
||||||
if not context_chunks:
|
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
|
return True
|
||||||
|
|
||||||
context = "\n\n---\n\n".join(context_chunks)
|
context = "\n\n---\n\n".join(context_chunks)
|
||||||
@@ -644,7 +658,8 @@ def _restore_sessions_from_cache():
|
|||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
continue
|
continue
|
||||||
result, status = apiRequest(
|
result, status = apiRequest(
|
||||||
"post", "/api/refresh",
|
"post",
|
||||||
|
"/api/refresh",
|
||||||
data={"refresh_token": refresh_token},
|
data={"refresh_token": refresh_token},
|
||||||
_retried=True,
|
_retried=True,
|
||||||
)
|
)
|
||||||
@@ -705,9 +720,14 @@ async def update_presence_tracking():
|
|||||||
import core.adaptive_meds as adaptive_meds
|
import core.adaptive_meds as adaptive_meds
|
||||||
import core.postgres as postgres
|
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:
|
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
|
# Get all users with presence tracking enabled
|
||||||
settings = postgres.select(
|
settings = postgres.select(
|
||||||
@@ -733,27 +753,46 @@ async def update_presence_tracking():
|
|||||||
# Get the member from a shared guild (needed for presence data)
|
# Get the member from a shared guild (needed for presence data)
|
||||||
try:
|
try:
|
||||||
member = None
|
member = None
|
||||||
|
try:
|
||||||
target_id = int(discord_user_id)
|
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
|
# Search through all guilds the bot is in
|
||||||
for guild in client.guilds:
|
for guild in client.guilds:
|
||||||
member = guild.get_member(target_id)
|
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:
|
if member:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not member:
|
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
|
continue
|
||||||
|
|
||||||
# Check if user is online
|
# Check if user is online
|
||||||
is_online = member.status != discord.Status.offline
|
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
|
# Get current presence from DB
|
||||||
presence = adaptive_meds.get_user_presence(user_uuid)
|
presence = adaptive_meds.get_user_presence(user_uuid)
|
||||||
was_online = presence.get("is_currently_online") if presence else False
|
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
|
# Update presence if changed
|
||||||
if is_online != was_online:
|
if is_online != was_online:
|
||||||
@@ -788,6 +827,7 @@ async def presenceTrackingLoop():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
|
print(f"[ERROR] presenceTrackingLoop failed: {e}", flush=True)
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')")
|
await message.channel.send("When should this routine be scheduled? (e.g., 'Monday Wednesday Friday at 7am')")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build schedule data
|
# Build schedule data (API expects "days" and "time")
|
||||||
schedule_data = {}
|
schedule_data = {}
|
||||||
if days_of_week:
|
if days_of_week:
|
||||||
schedule_data["days_of_week"] = days_of_week
|
schedule_data["days"] = days_of_week
|
||||||
if times:
|
if times:
|
||||||
schedule_data["times"] = times
|
schedule_data["time"] = times[0]
|
||||||
|
schedule_data["remind"] = True
|
||||||
|
|
||||||
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
|
resp, status = api_request("put", f"/api/routines/{routine_id}/schedule", token, schedule_data)
|
||||||
if status == 200:
|
if status == 200:
|
||||||
|
|||||||
4657
bot/data/dbt_knowledge.text.json
Normal file
4657
bot/data/dbt_knowledge.text.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,10 @@ This module handles:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, time
|
from datetime import datetime, timedelta, time, timezone
|
||||||
from typing import Optional, Dict, List, Tuple
|
from typing import Optional, Dict, List, Tuple
|
||||||
import core.postgres as postgres
|
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):
|
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):
|
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()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
presence = get_user_presence(user_uuid)
|
presence = get_user_presence(user_uuid)
|
||||||
|
is_wake_event = False
|
||||||
|
|
||||||
if presence:
|
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
|
# Update existing record
|
||||||
updates = {"is_currently_online": is_online, "updated_at": now}
|
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)
|
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):
|
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
|
||||||
"""Record a presence event in the history."""
|
"""Record a presence event in the history."""
|
||||||
@@ -182,12 +214,8 @@ def calculate_adjusted_times(
|
|||||||
# Return base times with 0 offset
|
# Return base times with 0 offset
|
||||||
return [(t, 0) for t in base_times]
|
return [(t, 0) for t in base_times]
|
||||||
|
|
||||||
# Get user's timezone
|
# Get current time in user's timezone (works in both request and scheduler context)
|
||||||
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid})
|
user_current_time = user_now_for(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)
|
|
||||||
today = user_current_time.date()
|
today = user_current_time.date()
|
||||||
|
|
||||||
# Determine wake time
|
# Determine wake time
|
||||||
@@ -270,7 +298,7 @@ def should_send_nag(
|
|||||||
return False, "User offline"
|
return False, "User offline"
|
||||||
|
|
||||||
# Get today's schedule record for this specific time slot
|
# 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}
|
query = {"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today}
|
||||||
if scheduled_time is not None:
|
if scheduled_time is not None:
|
||||||
query["adjusted_time"] = scheduled_time
|
query["adjusted_time"] = scheduled_time
|
||||||
@@ -291,30 +319,89 @@ def should_send_nag(
|
|||||||
nag_interval = settings.get("nag_interval_minutes", 15)
|
nag_interval = settings.get("nag_interval_minutes", 15)
|
||||||
|
|
||||||
if last_nag:
|
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
|
time_since_last_nag = (current_time - last_nag).total_seconds() / 60
|
||||||
if time_since_last_nag < nag_interval:
|
if time_since_last_nag < nag_interval:
|
||||||
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
|
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(
|
logs = postgres.select(
|
||||||
"med_logs",
|
"med_logs",
|
||||||
{
|
{
|
||||||
"medication_id": med_id,
|
"medication_id": med_id,
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"action": "taken",
|
|
||||||
"scheduled_time": scheduled_time,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter to today's logs for this time slot
|
# Get medication times to calculate dose interval for proximity check
|
||||||
today_logs = [
|
med = postgres.select_one("medications", {"id": med_id})
|
||||||
log
|
dose_interval_minutes = 60 # default fallback
|
||||||
for log in logs
|
if med and med.get("times"):
|
||||||
if log.get("created_at") and log["created_at"].date() == today
|
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:
|
proximity_window = max(30, dose_interval_minutes // 2)
|
||||||
return False, "Already taken today"
|
|
||||||
|
# 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"
|
return True, "Time to nag"
|
||||||
|
|
||||||
@@ -335,13 +422,18 @@ def record_nag_sent(user_uuid: str, med_id: str, scheduled_time):
|
|||||||
|
|
||||||
postgres.update(
|
postgres.update(
|
||||||
"medication_schedules",
|
"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"]},
|
{"id": schedule["id"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
|
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."""
|
"""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)
|
today = user_today_for(user_uuid)
|
||||||
|
|
||||||
# Check if schedule already exists
|
# 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},
|
{"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
|
return
|
||||||
|
|
||||||
# Calculate adjusted times
|
# Calculate adjusted times
|
||||||
adjusted_times = calculate_adjusted_times(user_uuid, base_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
|
# Create schedule records for each time
|
||||||
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
|
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
|
||||||
|
if base_time in taken_base_times:
|
||||||
|
continue
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_uuid": user_uuid,
|
"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):
|
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)
|
scheduled_time = _normalize_time(scheduled_time)
|
||||||
today = user_today_for(user_uuid)
|
today = user_today_for(user_uuid)
|
||||||
|
|
||||||
postgres.update(
|
# Try matching by adjusted_time first
|
||||||
"medication_schedules",
|
where = {
|
||||||
{"status": "taken"},
|
|
||||||
{
|
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"medication_id": med_id,
|
"medication_id": med_id,
|
||||||
"adjustment_date": today,
|
"adjustment_date": today,
|
||||||
"adjusted_time": scheduled_time,
|
}
|
||||||
},
|
if scheduled_time is not None:
|
||||||
)
|
where["adjusted_time"] = scheduled_time
|
||||||
|
|
||||||
|
schedules = postgres.select("medication_schedules", where)
|
||||||
|
if schedules:
|
||||||
|
postgres.update("medication_schedules", {"status": status}, {"id": schedules[0]["id"]})
|
||||||
|
elif scheduled_time is not None:
|
||||||
|
# Fallback: try matching by base_time (in case adjusted == base)
|
||||||
|
where_base = {
|
||||||
|
"user_uuid": user_uuid,
|
||||||
|
"medication_id": med_id,
|
||||||
|
"adjustment_date": today,
|
||||||
|
"base_time": scheduled_time,
|
||||||
|
}
|
||||||
|
schedules_base = postgres.select("medication_schedules", where_base)
|
||||||
|
if schedules_base:
|
||||||
|
postgres.update("medication_schedules", {"status": status}, {"id": schedules_base[0]["id"]})
|
||||||
|
|||||||
56
regenerate_embeddings.py
Normal file
56
regenerate_embeddings.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate DBT embeddings with qwen/qwen3-embedding-8b model (384 dimensions)"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from openai import OpenAI
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
with open("config.json", "r") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Initialize OpenAI client with OpenRouter
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key=config["openrouter_api_key"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load text data
|
||||||
|
with open("bot/data/dbt_knowledge.text.json", "r") as f:
|
||||||
|
text_data = json.load(f)
|
||||||
|
|
||||||
|
print(f"Regenerating embeddings for {len(text_data)} chunks...")
|
||||||
|
|
||||||
|
# Generate embeddings
|
||||||
|
embeddings_data = []
|
||||||
|
for i, item in enumerate(text_data):
|
||||||
|
try:
|
||||||
|
response = client.embeddings.create(
|
||||||
|
model="qwen/qwen3-embedding-8b",
|
||||||
|
input=item["text"]
|
||||||
|
)
|
||||||
|
embedding = response.data[0].embedding
|
||||||
|
|
||||||
|
embeddings_data.append({
|
||||||
|
"id": item["id"],
|
||||||
|
"source": item["source"],
|
||||||
|
"text": item["text"],
|
||||||
|
"embedding": embedding
|
||||||
|
})
|
||||||
|
|
||||||
|
if (i + 1) % 10 == 0:
|
||||||
|
print(f"Processed {i + 1}/{len(text_data)} chunks...")
|
||||||
|
|
||||||
|
# Small delay to avoid rate limits
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing item {i}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save new embeddings
|
||||||
|
with open("bot/data/dbt_knowledge.embeddings.json", "w") as f:
|
||||||
|
json.dump(embeddings_data, f)
|
||||||
|
|
||||||
|
print(f"\nDone! Generated {len(embeddings_data)} embeddings with {len(embeddings_data[0]['embedding'])} dimensions")
|
||||||
@@ -26,6 +26,17 @@ def _user_now_for(user_uuid):
|
|||||||
return tz.user_now_for(user_uuid)
|
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():
|
def check_medication_reminders():
|
||||||
"""Check for medications due now and send notifications."""
|
"""Check for medications due now and send notifications."""
|
||||||
try:
|
try:
|
||||||
@@ -47,6 +58,7 @@ def check_medication_reminders():
|
|||||||
current_day = now.strftime("%a").lower()
|
current_day = now.strftime("%a").lower()
|
||||||
today = now.date()
|
today = now.date()
|
||||||
today_str = today.isoformat()
|
today_str = today.isoformat()
|
||||||
|
user_tz = tz.tz_for_user(user_uuid)
|
||||||
|
|
||||||
for med in user_med_list:
|
for med in user_med_list:
|
||||||
freq = med.get("frequency", "daily")
|
freq = med.get("frequency", "daily")
|
||||||
@@ -83,13 +95,13 @@ def check_medication_reminders():
|
|||||||
if current_time not in times:
|
if current_time not in times:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Already taken today? Check by created_at date
|
# Already taken today? Check by created_at date in user's timezone
|
||||||
logs = postgres.select(
|
logs = postgres.select(
|
||||||
"med_logs", where={"medication_id": med["id"], "action": "taken"}
|
"med_logs", where={"medication_id": med["id"], "action": "taken"}
|
||||||
)
|
)
|
||||||
already_taken = any(
|
already_taken = any(
|
||||||
log.get("scheduled_time") == current_time
|
log.get("scheduled_time") == current_time
|
||||||
and str(log.get("created_at", ""))[:10] == today_str
|
and _utc_to_local_date(log.get("created_at"), user_tz) == today_str
|
||||||
for log in logs
|
for log in logs
|
||||||
)
|
)
|
||||||
if already_taken:
|
if already_taken:
|
||||||
@@ -111,20 +123,25 @@ def check_routine_reminders():
|
|||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
schedules = postgres.select("routine_schedules", where={"remind": True})
|
||||||
|
logger.info(f"Routine reminders: found {len(schedules)} schedule(s) with remind=True")
|
||||||
|
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
|
||||||
if not routine:
|
if not routine:
|
||||||
|
logger.warning(f"Routine not found for schedule {schedule['id']}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
now = _user_now_for(routine["user_uuid"])
|
now = _user_now_for(routine["user_uuid"])
|
||||||
current_time = now.strftime("%H:%M")
|
current_time = now.strftime("%H:%M")
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
if current_time != schedule.get("time"):
|
sched_time = schedule.get("time")
|
||||||
|
if current_time != sched_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
frequency = schedule.get("frequency", "weekly")
|
logger.info(f"Routine '{routine['name']}' time match at {current_time}")
|
||||||
|
|
||||||
|
frequency = schedule.get("frequency") or "weekly"
|
||||||
if frequency == "every_n_days":
|
if frequency == "every_n_days":
|
||||||
start = schedule.get("start_date")
|
start = schedule.get("start_date")
|
||||||
interval = schedule.get("interval_days")
|
interval = schedule.get("interval_days")
|
||||||
@@ -134,14 +151,19 @@ def check_routine_reminders():
|
|||||||
if isinstance(start, date_type)
|
if isinstance(start, date_type)
|
||||||
else datetime.strptime(str(start), "%Y-%m-%d").date()
|
else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||||
)
|
)
|
||||||
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
|
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
|
continue
|
||||||
else:
|
else:
|
||||||
|
logger.warning(f"Routine '{routine['name']}' skipped: every_n_days but missing start_date={start} or interval_days={interval}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
current_day = now.strftime("%a").lower()
|
current_day = now.strftime("%a").lower()
|
||||||
days = schedule.get("days", [])
|
days = schedule.get("days", [])
|
||||||
if current_day not in days:
|
if current_day not in days:
|
||||||
|
logger.info(f"Routine '{routine['name']}' skipped: {current_day} not in {days}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||||
@@ -150,8 +172,11 @@ def check_routine_reminders():
|
|||||||
notifications._sendToEnabledChannels(
|
notifications._sendToEnabledChannels(
|
||||||
user_settings, msg, user_uuid=routine["user_uuid"]
|
user_settings, msg, user_uuid=routine["user_uuid"]
|
||||||
)
|
)
|
||||||
|
logger.info(f"Routine reminder sent for '{routine['name']}'")
|
||||||
|
else:
|
||||||
|
logger.warning(f"No notification settings for user {routine['user_uuid']}, skipping routine '{routine['name']}'")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking routine reminders: {e}")
|
logger.error(f"Error checking routine reminders: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def check_refills():
|
def check_refills():
|
||||||
@@ -213,6 +238,7 @@ def check_adaptive_medication_reminders():
|
|||||||
now = _user_now_for(user_uuid)
|
now = _user_now_for(user_uuid)
|
||||||
current_time = now.strftime("%H:%M")
|
current_time = now.strftime("%H:%M")
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
user_tz = tz.tz_for_user(user_uuid)
|
||||||
|
|
||||||
# Check if adaptive timing is enabled
|
# Check if adaptive timing is enabled
|
||||||
settings = adaptive_meds.get_adaptive_settings(user_uuid)
|
settings = adaptive_meds.get_adaptive_settings(user_uuid)
|
||||||
@@ -248,18 +274,37 @@ def check_adaptive_medication_reminders():
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get today's schedule
|
# Get today's schedule (any status — we filter below)
|
||||||
schedules = postgres.select(
|
schedules = postgres.select(
|
||||||
"medication_schedules",
|
"medication_schedules",
|
||||||
where={
|
where={
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"medication_id": med["id"],
|
"medication_id": med["id"],
|
||||||
"adjustment_date": today,
|
"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:
|
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
|
# Check if it's time to take this med
|
||||||
if adaptive_enabled:
|
if adaptive_enabled:
|
||||||
# Use adjusted time
|
# Use adjusted time
|
||||||
@@ -277,32 +322,40 @@ def check_adaptive_medication_reminders():
|
|||||||
if check_time != current_time:
|
if check_time != current_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if already taken
|
# Check if already taken or skipped for this time slot today
|
||||||
logs = postgres.select(
|
logs = postgres.select(
|
||||||
"med_logs",
|
"med_logs",
|
||||||
where={
|
where={
|
||||||
"medication_id": med["id"],
|
"medication_id": med["id"],
|
||||||
"user_uuid": user_uuid,
|
"user_uuid": user_uuid,
|
||||||
"action": "taken",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
already_taken = any(
|
already_handled = any(
|
||||||
str(log.get("created_at", ""))[:10] == today.isoformat()
|
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
|
for log in logs
|
||||||
)
|
)
|
||||||
|
|
||||||
if already_taken:
|
if already_handled:
|
||||||
continue
|
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)
|
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||||
if user_settings:
|
if user_settings:
|
||||||
offset = sched.get("adjustment_minutes", 0)
|
offset = sched.get("adjustment_minutes", 0)
|
||||||
if offset > 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:
|
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(
|
notifications._sendToEnabledChannels(
|
||||||
user_settings, msg, user_uuid=user_uuid
|
user_settings, msg, user_uuid=user_uuid
|
||||||
@@ -352,7 +405,9 @@ def check_nagging():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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
|
# Table may not exist yet
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -360,7 +415,9 @@ def check_nagging():
|
|||||||
if not schedules:
|
if not schedules:
|
||||||
if not _is_med_due_today(med, today):
|
if not _is_med_due_today(med, today):
|
||||||
continue
|
continue
|
||||||
logger.info(f"No schedules found for medication {med_id}, attempting to create")
|
logger.info(
|
||||||
|
f"No schedules found for medication {med_id}, attempting to create"
|
||||||
|
)
|
||||||
times = med.get("times", [])
|
times = med.get("times", [])
|
||||||
if times:
|
if times:
|
||||||
try:
|
try:
|
||||||
@@ -392,11 +449,8 @@ def check_nagging():
|
|||||||
if not should_nag:
|
if not should_nag:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get the time to display
|
# Always display the base_time (the user's actual dose time),
|
||||||
adaptive_enabled = settings.get("adaptive_timing_enabled")
|
# not the internal adjusted_time used for scheduling.
|
||||||
if adaptive_enabled:
|
|
||||||
display_time = sched.get("adjusted_time")
|
|
||||||
else:
|
|
||||||
display_time = sched.get("base_time")
|
display_time = sched.get("base_time")
|
||||||
# Normalize TIME objects for display
|
# Normalize TIME objects for display
|
||||||
if isinstance(display_time, time_type):
|
if isinstance(display_time, time_type):
|
||||||
@@ -531,9 +585,7 @@ def _check_per_user_midnight_schedules():
|
|||||||
continue
|
continue
|
||||||
times = med.get("times", [])
|
times = med.get("times", [])
|
||||||
if times:
|
if times:
|
||||||
adaptive_meds.create_daily_schedule(
|
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
|
||||||
user_uuid, med["id"], times
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not create adaptive schedules for user {user_uuid}: {e}"
|
f"Could not create adaptive schedules for user {user_uuid}: {e}"
|
||||||
@@ -543,6 +595,7 @@ def _check_per_user_midnight_schedules():
|
|||||||
def check_task_reminders():
|
def check_task_reminders():
|
||||||
"""Check one-off tasks for advance and at-time reminders."""
|
"""Check one-off tasks for advance and at-time reminders."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tasks = postgres.select("tasks", where={"status": "pending"})
|
tasks = postgres.select("tasks", where={"status": "pending"})
|
||||||
if not tasks:
|
if not tasks:
|
||||||
@@ -575,15 +628,24 @@ def check_task_reminders():
|
|||||||
# Advance reminder
|
# Advance reminder
|
||||||
if reminder_min > 0 and not task.get("advance_notified"):
|
if reminder_min > 0 and not task.get("advance_notified"):
|
||||||
adv_dt = sched_dt - timedelta(minutes=reminder_min)
|
adv_dt = sched_dt - timedelta(minutes=reminder_min)
|
||||||
if adv_dt.date() == current_date and adv_dt.strftime("%H:%M") == current_hhmm:
|
if (
|
||||||
|
adv_dt.date() == current_date
|
||||||
|
and adv_dt.strftime("%H:%M") == current_hhmm
|
||||||
|
):
|
||||||
if user_settings is None:
|
if user_settings is None:
|
||||||
user_settings = notifications.getNotificationSettings(user_uuid)
|
user_settings = notifications.getNotificationSettings(
|
||||||
|
user_uuid
|
||||||
|
)
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"⏰ In {reminder_min} min: {task['title']}"
|
msg = f"⏰ In {reminder_min} min: {task['title']}"
|
||||||
if task.get("description"):
|
if task.get("description"):
|
||||||
msg += f" — {task['description']}"
|
msg += f" — {task['description']}"
|
||||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
notifications._sendToEnabledChannels(
|
||||||
postgres.update("tasks", {"advance_notified": True}, {"id": task["id"]})
|
user_settings, msg, user_uuid=user_uuid
|
||||||
|
)
|
||||||
|
postgres.update(
|
||||||
|
"tasks", {"advance_notified": True}, {"id": task["id"]}
|
||||||
|
)
|
||||||
|
|
||||||
# At-time reminder
|
# At-time reminder
|
||||||
if sched_date == current_date and sched_hhmm == current_hhmm:
|
if sched_date == current_date and sched_hhmm == current_hhmm:
|
||||||
@@ -593,10 +655,15 @@ def check_task_reminders():
|
|||||||
msg = f"📋 Now: {task['title']}"
|
msg = f"📋 Now: {task['title']}"
|
||||||
if task.get("description"):
|
if task.get("description"):
|
||||||
msg += f"\n{task['description']}"
|
msg += f"\n{task['description']}"
|
||||||
notifications._sendToEnabledChannels(user_settings, msg, user_uuid=user_uuid)
|
notifications._sendToEnabledChannels(
|
||||||
|
user_settings, msg, user_uuid=user_uuid
|
||||||
|
)
|
||||||
postgres.update(
|
postgres.update(
|
||||||
"tasks",
|
"tasks",
|
||||||
{"status": "notified", "updated_at": datetime.utcnow().isoformat()},
|
{
|
||||||
|
"status": "notified",
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
},
|
||||||
{"id": task["id"]},
|
{"id": task["id"]},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -608,13 +675,14 @@ def poll_callback():
|
|||||||
# Create daily schedules per-user at their local midnight
|
# Create daily schedules per-user at their local midnight
|
||||||
_check_per_user_midnight_schedules()
|
_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")
|
logger.info("Checking medication reminders")
|
||||||
check_medication_reminders()
|
|
||||||
try:
|
try:
|
||||||
check_adaptive_medication_reminders()
|
check_adaptive_medication_reminders()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Adaptive medication reminder check failed: {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
|
# Check for nags - log as error to help with debugging
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { ArrowLeftIcon } from '@/components/ui/Icons';
|
import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons';
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 'mon', label: 'Mon' },
|
{ value: 'mon', label: 'Mon' },
|
||||||
@@ -15,96 +15,78 @@ const DAY_OPTIONS = [
|
|||||||
{ value: 'sun', label: 'Sun' },
|
{ value: 'sun', label: 'Sun' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function NewMedicationPage() {
|
interface MedEntry {
|
||||||
const router = useRouter();
|
id: string;
|
||||||
const [name, setName] = useState('');
|
name: string;
|
||||||
const [dosage, setDosage] = useState('');
|
dosage: string;
|
||||||
const [unit, setUnit] = useState('mg');
|
unit: string;
|
||||||
const [frequency, setFrequency] = useState('daily');
|
frequency: string;
|
||||||
const [times, setTimes] = useState<string[]>(['08:00']);
|
times: string[];
|
||||||
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
|
daysOfWeek: string[];
|
||||||
const [intervalDays, setIntervalDays] = useState(7);
|
intervalDays: number;
|
||||||
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
|
startDate: string;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleAddTime = () => {
|
|
||||||
setTimes([...times, '12:00']);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveTime = (index: number) => {
|
|
||||||
setTimes(times.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeChange = (index: number, value: string) => {
|
|
||||||
const newTimes = [...times];
|
|
||||||
newTimes[index] = value;
|
|
||||||
setTimes(newTimes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDay = (day: string) => {
|
|
||||||
setDaysOfWeek(prev =>
|
|
||||||
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!name.trim() || !dosage.trim()) {
|
|
||||||
setError('Name and dosage are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
|
|
||||||
setError('Select at least one day of the week');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
function blankEntry(): MedEntry {
|
||||||
setError('');
|
return {
|
||||||
|
id: `med-${Date.now()}-${Math.random()}`,
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
unit: 'mg',
|
||||||
|
frequency: 'daily',
|
||||||
|
times: ['08:00'],
|
||||||
|
daysOfWeek: [],
|
||||||
|
intervalDays: 7,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function MedCard({
|
||||||
await api.medications.create({
|
entry,
|
||||||
name,
|
index,
|
||||||
dosage,
|
total,
|
||||||
unit,
|
onChange,
|
||||||
frequency,
|
onRemove,
|
||||||
times: frequency === 'as_needed' ? [] : times,
|
}: {
|
||||||
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
|
entry: MedEntry;
|
||||||
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onChange: (updates: Partial<MedEntry>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] });
|
||||||
|
const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) });
|
||||||
|
const handleTimeChange = (i: number, val: string) => {
|
||||||
|
const t = [...entry.times];
|
||||||
|
t[i] = val;
|
||||||
|
onChange({ times: t });
|
||||||
|
};
|
||||||
|
const toggleDay = (day: string) =>
|
||||||
|
onChange({
|
||||||
|
daysOfWeek: entry.daysOfWeek.includes(day)
|
||||||
|
? entry.daysOfWeek.filter(d => d !== day)
|
||||||
|
: [...entry.daysOfWeek, day],
|
||||||
});
|
});
|
||||||
router.push('/dashboard/medications');
|
|
||||||
} catch (err) {
|
|
||||||
setError((err as Error).message || 'Failed to add medication');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
|
||||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
|
||||||
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
|
||||||
<ArrowLeftIcon size={24} />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medication</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
<div className="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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={entry.name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={e => onChange({ name: e.target.value })}
|
||||||
placeholder="e.g., Vitamin D"
|
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"
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -115,8 +97,8 @@ export default function NewMedicationPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={dosage}
|
value={entry.dosage}
|
||||||
onChange={(e) => setDosage(e.target.value)}
|
onChange={e => onChange({ dosage: e.target.value })}
|
||||||
placeholder="e.g., 1000"
|
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"
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -124,8 +106,8 @@ export default function NewMedicationPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={unit}
|
value={entry.unit}
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
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"
|
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="mg">mg</option>
|
||||||
@@ -142,8 +124,8 @@ export default function NewMedicationPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
||||||
<select
|
<select
|
||||||
value={frequency}
|
value={entry.frequency}
|
||||||
onChange={(e) => setFrequency(e.target.value)}
|
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"
|
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="daily">Daily</option>
|
||||||
@@ -153,8 +135,7 @@ export default function NewMedicationPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day-of-week picker for specific_days */}
|
{entry.frequency === 'specific_days' && (
|
||||||
{frequency === 'specific_days' && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
<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">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@@ -164,7 +145,7 @@ export default function NewMedicationPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDay(value)}
|
onClick={() => toggleDay(value)}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
daysOfWeek.includes(value)
|
entry.daysOfWeek.includes(value)
|
||||||
? 'bg-indigo-600 text-white border-indigo-600'
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
@@ -176,33 +157,31 @@ export default function NewMedicationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Interval settings for every_n_days */}
|
{entry.frequency === 'every_n_days' && (
|
||||||
{frequency === 'every_n_days' && (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={intervalDays}
|
value={entry.intervalDays}
|
||||||
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
|
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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={entry.startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Times picker — hidden for as_needed */}
|
{entry.frequency !== 'as_needed' && (
|
||||||
{frequency !== 'as_needed' && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<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>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
|
||||||
@@ -214,22 +193,19 @@ export default function NewMedicationPage() {
|
|||||||
+ Add Time
|
+ Add Time
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
{times.map((time, index) => (
|
{entry.times.map((time, i) => (
|
||||||
<div key={index} className="flex gap-2">
|
<div key={i} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => handleTimeChange(index, e.target.value)}
|
onChange={e => handleTimeChange(i, e.target.value)}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
/>
|
/>
|
||||||
{times.length > 1 && (
|
{entry.times.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveTime(index)}
|
onClick={() => handleRemoveTime(i)}
|
||||||
className="text-red-500 dark:text-red-400 px-3"
|
className="text-red-500 dark:text-red-400 px-3"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -241,13 +217,114 @@ export default function NewMedicationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewMedicationPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [entries, setEntries] = useState<MedEntry[]>([blankEntry()]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const updateEntry = (index: number, updates: Partial<MedEntry>) => {
|
||||||
|
setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (index: number) => {
|
||||||
|
setEntries(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (!entry.name.trim() || !entry.dosage.trim()) {
|
||||||
|
setError(`Medication ${i + 1}: name and dosage are required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.frequency === 'specific_days' && entry.daysOfWeek.length === 0) {
|
||||||
|
setError(`Medication ${i + 1}: select at least one day of the week`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await api.medications.create({
|
||||||
|
name: entry.name,
|
||||||
|
dosage: entry.dosage,
|
||||||
|
unit: entry.unit,
|
||||||
|
frequency: entry.frequency,
|
||||||
|
times: entry.frequency === 'as_needed' ? [] : entry.times,
|
||||||
|
...(entry.frequency === 'specific_days' && { days_of_week: entry.daysOfWeek }),
|
||||||
|
...(entry.frequency === 'every_n_days' && {
|
||||||
|
interval_days: entry.intervalDays,
|
||||||
|
start_date: entry.startDate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.push('/dashboard/medications');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to add medication');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = entries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
||||||
|
<ArrowLeftIcon size={24} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Add Medications</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<MedCard
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
index={index}
|
||||||
|
total={count}
|
||||||
|
onChange={updates => updateEntry(index, updates)}
|
||||||
|
onRemove={() => removeEntry(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEntries(prev => [...prev, blankEntry()])}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-indigo-300 dark:border-indigo-700 rounded-xl text-indigo-600 dark:text-indigo-400 font-medium hover:border-indigo-400 dark:hover:border-indigo-600 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusIcon size={18} />
|
||||||
|
Add Another Medication
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
|
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Adding...' : 'Add Medication'}
|
{isLoading
|
||||||
|
? 'Adding...'
|
||||||
|
: count === 1
|
||||||
|
? 'Add Medication'
|
||||||
|
: `Add ${count} Medications`}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export default function NewRoutinePage() {
|
|||||||
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||||
const [scheduleTime, setScheduleTime] = useState('08:00');
|
const [scheduleTime, setScheduleTime] = useState('08:00');
|
||||||
const [scheduleRemind, setScheduleRemind] = useState(true);
|
const [scheduleRemind, setScheduleRemind] = useState(true);
|
||||||
|
const [scheduleFrequency, setScheduleFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
|
||||||
|
const [scheduleIntervalDays, setScheduleIntervalDays] = useState(2);
|
||||||
|
const [scheduleStartDate, setScheduleStartDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
|
|
||||||
const toggleDay = (day: string) => {
|
const toggleDay = (day: string) => {
|
||||||
setScheduleDays(prev =>
|
setScheduleDays(prev =>
|
||||||
@@ -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, {
|
await api.routines.setSchedule(routine.id, {
|
||||||
days: scheduleDays,
|
days: scheduleDays,
|
||||||
time: scheduleTime,
|
time: scheduleTime,
|
||||||
remind: scheduleRemind,
|
remind: scheduleRemind,
|
||||||
|
frequency: scheduleFrequency,
|
||||||
|
...(scheduleFrequency === 'every_n_days' && {
|
||||||
|
interval_days: scheduleIntervalDays,
|
||||||
|
start_date: scheduleStartDate,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +235,56 @@ export default function NewRoutinePage() {
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
|
||||||
|
|
||||||
|
{/* Frequency selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheduleFrequency('weekly')}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
scheduleFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Weekly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheduleFrequency('every_n_days')}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
scheduleFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Every N Days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scheduleFrequency === 'every_n_days' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={365}
|
||||||
|
value={scheduleIntervalDays}
|
||||||
|
onChange={(e) => setScheduleIntervalDays(Math.max(2, Number(e.target.value)))}
|
||||||
|
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={scheduleStartDate}
|
||||||
|
onChange={(e) => setScheduleStartDate(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Quick select buttons */}
|
{/* Quick select buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -277,6 +335,8 @@ export default function NewRoutinePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||||
|
|||||||
@@ -396,14 +396,24 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{notif.discord_enabled && (
|
{notif.discord_enabled && (
|
||||||
|
<div className="space-y-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Your Discord user ID"
|
placeholder="Your Discord user ID (numbers only)"
|
||||||
value={notif.discord_user_id}
|
value={notif.discord_user_id}
|
||||||
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '' || /^\d+$/.test(val)) {
|
||||||
|
setNotif({ ...notif, discord_user_id: val });
|
||||||
|
}
|
||||||
|
}}
|
||||||
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
|
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"
|
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Enable Developer Mode in Discord, right-click your profile, and copy User ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -831,7 +841,7 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={newContact.contact_type}
|
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"
|
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<option value="discord">Discord</option>
|
<option value="discord">Discord</option>
|
||||||
@@ -840,9 +850,18 @@ export default function SettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}
|
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"
|
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">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user