364 lines
15 KiB
Markdown
364 lines
15 KiB
Markdown
# MedAssist-ng - AI Coding Instructions
|
|
|
|
## Architecture Overview
|
|
|
|
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
|
|
|
- **Backend**: Fastify 5 + TypeScript + SQLite (Drizzle ORM) at `backend/`
|
|
- **Frontend**: React 18 + Vite + TypeScript at `frontend/`
|
|
- **Database**: SQLite with migrations in `backend/src/db/migrations/`
|
|
- **Deployment**: Docker Compose with separate dev containers
|
|
- **i18n**: English (en) and German (de) via react-i18next
|
|
|
|
### Data Flow
|
|
```
|
|
Frontend (React) → /api/* proxy → Backend (Fastify) → SQLite
|
|
↓ (Vite rewrites /api to /)
|
|
```
|
|
|
|
The Vite proxy at `frontend/vite.config.ts` rewrites `/api/*` to `/` - so frontend calls `/api/medications` but backend route is just `/medications`.
|
|
|
|
## Development Commands
|
|
|
|
```bash
|
|
# Start dev environment (preferred)
|
|
docker compose -f docker-compose.dev.yml up
|
|
|
|
# Or run services separately:
|
|
cd backend && npm run dev # tsx watch on port 3000
|
|
cd frontend && npm run dev # Vite on port 5173
|
|
|
|
# Production
|
|
docker compose up -d
|
|
|
|
# Database migrations
|
|
cd backend && npm run migrate
|
|
```
|
|
|
|
## Key Patterns
|
|
|
|
### Backend Routes (`backend/src/routes/`)
|
|
| Route File | Endpoints |
|
|
|------------|-----------|
|
|
| `auth.ts` | `/auth/login`, `/auth/register`, `/auth/logout`, `/auth/refresh`, `/auth/me` |
|
|
| `medications.ts` | CRUD `/medications`, `/medications/:id/image` |
|
|
| `doses.ts` | `/doses/taken` - track dose intake |
|
|
| `planner.ts` | `/medications/usage` - calculate usage for date range |
|
|
| `settings.ts` | `/settings` - user settings CRUD |
|
|
| `share.ts` | `/share` - create share tokens, `/share/:token` - public access |
|
|
| `health.ts` | `/health` - health check endpoint |
|
|
|
|
### Backend Services (`backend/src/services/`)
|
|
| Service | Description |
|
|
|---------|-------------|
|
|
| `reminder-scheduler.ts` | Stock reminder emails/push notifications |
|
|
| `intake-reminder-scheduler.ts` | Intake reminder notifications |
|
|
|
|
### Frontend (`frontend/src/App.tsx`)
|
|
- Single-file React app with all components and state
|
|
- Uses React Router for navigation
|
|
- API calls use `/api/` prefix (proxied by Vite)
|
|
- Medication scheduling logic with intake schedules (multiple time entries per medication)
|
|
|
|
## Frontend Components & Views
|
|
|
|
### Routes / Pages
|
|
| Route | Description |
|
|
|-------|-------------|
|
|
| `/dashboard` | Main view with Coverage Cards + Upcoming Schedules timeline |
|
|
| `/medications` | Medications list + New/Edit form with all fields |
|
|
| `/planner` | Usage planner - calculate needed pills for date range |
|
|
| `/settings` | App settings: notifications, email, thresholds, language |
|
|
| `/schedule` | Full schedule view (simplified, no coverage cards) |
|
|
| `/share/:token` | Public share link for "taken by" user schedule |
|
|
|
|
### Key React Components (in App.tsx)
|
|
| Component | Description |
|
|
|-----------|-------------|
|
|
| `App` | Root component with BrowserRouter |
|
|
| `AppRouter` | Handles auth check, renders AppContent or Auth |
|
|
| `AppContent` | Main app shell with navigation, header, all routes |
|
|
| `SharedSchedule` | Public share page for medication schedules by person |
|
|
| `MedicationAvatar` | Round avatar with medication image or colored initial |
|
|
|
|
### Dashboard Sections
|
|
| Section | Description |
|
|
|---------|-------------|
|
|
| **Coverage Cards** | Stock status cards per medication: days left, blisters, status (Normal/Warning/Critical) |
|
|
| **Upcoming Schedules** | Timeline grouped by day, collapsible days, dose tracking |
|
|
|
|
### Schedule/Timeline Elements
|
|
| Element | CSS Class | Description |
|
|
|---------|-----------|-------------|
|
|
| Past days toggle | `.past-days-toggle` | Click to show/hide past days |
|
|
| Day container | `.day-block` | Container for one day, collapsible |
|
|
| Today highlight | `.day-block.today` | Blue border/background for current day |
|
|
| Past day | `.day-block.past` | Dashed border, reduced opacity |
|
|
| All taken | `.day-block.all-taken` | Green styling when all doses taken |
|
|
| Day header | `.day-divider` | Date header with collapse toggle arrow |
|
|
| Collapse icon | `.day-collapse-icon` | ▶/▼ arrow for expand/collapse |
|
|
| Day summary | `.day-summary` | Shows "X/Y" doses taken or "✓ All taken" |
|
|
| Medication row | `.time-row` | One medication's doses for that day |
|
|
| Dose item | `.dose-item` | Individual dose with time, amount, take/undo button |
|
|
| Dose taken | `.dose-item.taken` | Green background when dose is marked taken |
|
|
| Dose overdue | `.dose-item.overdue` | Styling for past untaken doses |
|
|
| Dose future | `.dose-item.future` | Disabled button for future days |
|
|
|
|
### Medication Form (New/Edit)
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| Commercial Name | Main medication name (required) |
|
|
| Generic Name | Scientific/generic name (optional) |
|
|
| Taken By | Person taking the medication (optional, enables filtering/sharing) |
|
|
| Packs | Number of full packs |
|
|
| Blisters per Pack | Strips/blisters in each pack |
|
|
| Pills per Blister | Tablets per strip |
|
|
| Loose Pills | Extra pills not in blisters |
|
|
| Pill Weight (mg) | Weight per pill for dose calculation display |
|
|
| Expiry Date | Medication expiration |
|
|
| Notes | Free text notes |
|
|
| Image Upload | Medication photo (preview for new, direct upload for edit) |
|
|
| **Intake Schedule** | One or more intake entries defining usage pattern |
|
|
|
|
### Intake Schedule
|
|
Each blister defines a recurring intake:
|
|
- **Usage (Pills)**: How many pills per dose
|
|
- **Every (Days)**: Interval (1 = daily, 7 = weekly)
|
|
- **Start (Date/Time)**: When the schedule starts (determines past/future doses)
|
|
- **Remind checkbox**: Enable intake reminders (🔔)
|
|
|
|
### Modals
|
|
| Modal | Trigger | Content |
|
|
|-------|---------|---------|
|
|
| Medication Detail | Click on coverage card or medication row | Full medication info, stock, schedule preview, edit/delete/ICS buttons |
|
|
| Image Lightbox | Click medication image | Full-size medication image |
|
|
| Share Dialog | "Share" button on schedules | Generate share link for specific "taken by" person |
|
|
| User Schedule Filter | Click on "taken by" badge | Filter schedule by person |
|
|
|
|
### Settings Sections
|
|
| Section | Settings |
|
|
|---------|----------|
|
|
| General | Language toggle (EN/DE) |
|
|
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
|
| Email Notifications | Enable, email address, stock/intake toggles |
|
|
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
|
|
| Reminder Settings | Days before, repeat daily |
|
|
| SMTP | Email config (read-only from .env) |
|
|
|
|
## Database Schema (`backend/src/db/schema.ts`)
|
|
|
|
| Table | Description |
|
|
|-------|-------------|
|
|
| `users` | User accounts with password hash, auth provider, timestamps |
|
|
| `medications` | Per-user medications with inventory, schedules as JSON arrays |
|
|
| `userSettings` | Per-user settings: notifications, thresholds, language |
|
|
| `refreshTokens` | JWT refresh tokens for auth rotation |
|
|
| `shareTokens` | Public share links by takenBy person |
|
|
| `doseTracking` | Tracks when doses are marked as taken |
|
|
|
|
### Key Medication Fields
|
|
```typescript
|
|
{
|
|
name, genericName, takenBy, // Identity
|
|
packCount, stripsPerPack, tabsPerStrip, looseTablets, // Inventory
|
|
count, strips, stripSize, // Derived/legacy
|
|
pillWeightMg, // For mg display
|
|
usageJson, everyJson, startJson, // Intake schedules as JSON arrays
|
|
imageUrl, expiryDate, notes, // Optional metadata
|
|
intakeRemindersEnabled // Per-med reminder toggle
|
|
}
|
|
```
|
|
|
|
### Dose ID Format
|
|
Dose IDs follow the pattern: `{medicationId}-{blisterIndex}-{timestampMs}`
|
|
Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|
|
|
## State Management (AppContent)
|
|
|
|
### Key State Variables
|
|
| State | Purpose |
|
|
|-------|---------|
|
|
| `meds` | Array of all user's medications |
|
|
| `form` | Current medication form data |
|
|
| `editingId` | ID of medication being edited (null for new) |
|
|
| `pendingImage` / `pendingImagePreview` | Image upload for new medications |
|
|
| `settings` / `savedSettings` | User settings current vs saved |
|
|
| `scheduleDays` | How many days to show (30/90/180) |
|
|
| `showPastDays` | Toggle for past days visibility |
|
|
| `takenDoses` | Set of dose IDs that are marked taken |
|
|
| `manuallyCollapsedDays` / `manuallyExpandedDays` | Day collapse state |
|
|
| `selectedMed` | Medication shown in detail modal |
|
|
| `selectedUser` | Filter schedule by "taken by" person |
|
|
|
|
### Key Computed Values (useMemo)
|
|
| Value | Purpose |
|
|
|-------|---------|
|
|
| `schedule` | All scheduled events from `buildSchedulePreview()` |
|
|
| `groupedSchedule` | Events grouped by day |
|
|
| `pastDays` / `futureDays` | Split groupedSchedule by today |
|
|
| `coverage` | Stock coverage calculations |
|
|
| `coverageByMed` / `depletionByMed` | Coverage lookups |
|
|
|
|
## Conventions
|
|
|
|
- **TypeScript**: Strict mode, ESM modules (`"type": "module"`)
|
|
- **Styling**: CSS custom properties in `frontend/src/styles.css`, dark/light theme via `data-theme`
|
|
- **API responses**: Return objects directly, Fastify serializes to JSON
|
|
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
|
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
|
|
|
## ⚠️⚠️⚠️ Database Migrations (ABSOLUTELY CRITICAL) ⚠️⚠️⚠️
|
|
|
|
**THIS IS NON-NEGOTIABLE: ALL database changes MUST work for EXISTING production databases!**
|
|
|
|
Users update their Docker containers and expect the app to work with their existing data. If migrations don't run automatically, the app crashes with `SQLITE_ERROR: no such column` errors.
|
|
|
|
### The Migration System
|
|
|
|
The app uses **auto-migrations at startup** in `backend/src/db/client.ts`. This file:
|
|
1. Creates tables if they don't exist (fresh install)
|
|
2. Runs `ALTER TABLE ADD COLUMN` for each new column (existing databases)
|
|
3. Ignores "duplicate column" errors (migration already applied)
|
|
|
|
### When adding/modifying database columns or tables, ALWAYS do ALL of the following:
|
|
|
|
#### 1. Update schema: `backend/src/db/schema.ts`
|
|
```typescript
|
|
// Add the new column to the Drizzle schema
|
|
stockCalculationMode: text("stock_calculation_mode").notNull().default("automatic"),
|
|
```
|
|
|
|
#### 2. Update client.ts TABLE CREATION: `backend/src/db/client.ts`
|
|
Find the `CREATE TABLE IF NOT EXISTS` statement and add the new column:
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
...
|
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic', -- ADD THIS LINE
|
|
...
|
|
)
|
|
```
|
|
**This is for FRESH installs** - new databases get all columns from the start.
|
|
|
|
#### 3. Update client.ts MIGRATIONS ARRAY: `backend/src/db/client.ts`
|
|
Add an entry to the `migrations` array:
|
|
```typescript
|
|
const migrations = [
|
|
...existing migrations...
|
|
{ name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" },
|
|
];
|
|
```
|
|
**This is for EXISTING databases** - the ALTER TABLE adds the column to old databases.
|
|
|
|
#### 4. Create migration SQL file (for documentation): `backend/src/db/migrations/XXXX_description.sql`
|
|
```sql
|
|
-- Add stock calculation mode setting
|
|
ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic';
|
|
```
|
|
|
|
#### 5. Update journal: `backend/src/db/migrations/meta/_journal.json`
|
|
```json
|
|
{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false }
|
|
```
|
|
|
|
#### 6. Update migrate.ts: `backend/src/db/migrate.ts`
|
|
Add the column to the `CREATE TABLE` statement AND to the `migrations` array.
|
|
|
|
### ⚠️ CRITICAL CHECKLIST - DO NOT SKIP ANY STEP:
|
|
|
|
| Step | File | Purpose | If Missing |
|
|
|------|------|---------|------------|
|
|
| 1 | `schema.ts` | Drizzle ORM knows about column | TypeScript errors |
|
|
| 2 | `client.ts` (CREATE TABLE) | Fresh installs have column | Fresh installs crash |
|
|
| 3 | `client.ts` (migrations array) | Existing DBs get column | **PRODUCTION CRASHES** |
|
|
| 4 | `migrations/*.sql` | Documentation | None (but keep for history) |
|
|
| 5 | `_journal.json` | Migration tracking | None (but keep for history) |
|
|
| 6 | `migrate.ts` | CLI migration tool | CLI tool fails |
|
|
|
|
**Step 3 is the most critical!** Without it, users who update their Docker container will get `SQLITE_ERROR: no such column` and the app will not start.
|
|
|
|
### Testing Migrations
|
|
|
|
Before pushing changes:
|
|
1. Test with fresh database: Delete `backend/data/medassist-ng.db` and restart
|
|
2. Test with existing database: Keep old DB and restart - new columns should be added automatically
|
|
|
|
## ⚠️ Defensive Coding (CRITICAL for Production)
|
|
|
|
**ALL new optional fields MUST be handled defensively in both Backend AND Frontend!**
|
|
|
|
When a user updates their app, old data in the database may not have new fields. The frontend receives this data and crashes with `TypeError: Cannot read property 'length' of undefined`.
|
|
|
|
### Rules for New Optional/Array Fields:
|
|
|
|
#### Backend (routes/*.ts):
|
|
Always provide default values when returning data:
|
|
```typescript
|
|
// ✅ CORRECT - Always return array, even if DB value is null/undefined
|
|
takenBy: parseTakenByJson(row.takenByJson), // Returns [] if null/undefined
|
|
|
|
// Parser function example:
|
|
function parseTakenByJson(value: string | null | undefined): string[] {
|
|
if (!value) return [];
|
|
try { return JSON.parse(value) || []; }
|
|
catch { return []; }
|
|
}
|
|
```
|
|
|
|
#### Frontend (App.tsx):
|
|
Always use defensive checks when accessing optional properties:
|
|
```typescript
|
|
// ✅ CORRECT - Defensive checks
|
|
med?.takenBy && med.takenBy.length > 0
|
|
(m.takenBy || []).includes(selectedUser)
|
|
(d.takenBy || []).length > 0 ? d.takenBy : [null]
|
|
const personCount = Math.max(1, m.takenBy?.length || 1);
|
|
|
|
// ❌ WRONG - Will crash if takenBy is undefined
|
|
m.takenBy.includes(selectedUser) // TypeError!
|
|
m.takenBy.length > 0 // TypeError!
|
|
```
|
|
|
|
### Checklist for New Optional Fields:
|
|
|
|
| Location | Action |
|
|
|----------|--------|
|
|
| Backend route | Return default value (`[]`, `null`, `0`, etc.) |
|
|
| Frontend type | Mark as optional: `takenBy?: string[]` |
|
|
| Frontend access | ALWAYS use `?.`, `|| []`, or null-check before `.length`, `.map()`, `.includes()` |
|
|
| Schedule builders | Pass default: `takenBy: med.takenBy || []` |
|
|
|
|
### Common Patterns:
|
|
```typescript
|
|
// Arrays - always default to []
|
|
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
|
meds.flatMap(m => m.takenBy || []);
|
|
|
|
// Optional chaining for nested access
|
|
med?.takenBy?.length > 0
|
|
|
|
// Filter with optional check
|
|
meds.filter(m => (m.takenBy || []).includes(name))
|
|
|
|
// Conditional rendering
|
|
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map(...)}
|
|
```
|
|
|
|
## File Locations
|
|
|
|
| Purpose | Location |
|
|
|---------|----------|
|
|
| Backend entry | `backend/src/index.ts` |
|
|
| Database schema | `backend/src/db/schema.ts` |
|
|
| Migrations | `backend/src/db/migrations/*.sql` |
|
|
| Migration journal | `backend/src/db/migrations/meta/_journal.json` |
|
|
| Backend routes | `backend/src/routes/*.ts` |
|
|
| Backend services | `backend/src/services/*.ts` |
|
|
| Frontend app | `frontend/src/App.tsx` |
|
|
| Frontend auth | `frontend/src/components/Auth.tsx` |
|
|
| Styles | `frontend/src/styles.css` |
|
|
| i18n English | `frontend/src/i18n/en.json` |
|
|
| i18n German | `frontend/src/i18n/de.json` |
|
|
| Docker prod | `docker-compose.yml` |
|
|
| Docker dev | `docker-compose.dev.yml` |
|
|
| Env template | `.env.example` |
|