Files
medassist-ng/.github/copilot-instructions.md
T

15 KiB

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

# 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

{
  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

// 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:

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:

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

-- 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

{ "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:

// ✅ 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:

// ✅ 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 ?., `
Schedule builders Pass default: `takenBy: med.takenBy

Common Patterns:

// 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