From 69ca8fd3ba959cb804a0f96bfe6c410827cdf9ab Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 28 Dec 2025 19:47:14 +0100 Subject: [PATCH] feat: implement per-person dose tracking and update migration process - Enhanced the database migration process to ensure compatibility with existing production databases, including detailed steps for adding/modifying columns. - Updated the client-side logic to support tracking doses taken by multiple users, including changes to the data structure and UI components. - Added new styles for per-person dose tracking to improve user experience and visual clarity. --- .github/copilot-instructions.md | 91 ++++++++++--- backend/src/db/client.ts | 6 + backend/src/db/migrate.ts | 48 +++++++ frontend/src/App.tsx | 233 ++++++++++++++++++++++---------- frontend/src/styles.css | 56 ++++++++ 5 files changed, 345 insertions(+), 89 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ef8d508..22374c6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -207,29 +207,80 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp - **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 (CRITICAL) +## ⚠️⚠️⚠️ Database Migrations (ABSOLUTELY CRITICAL) ⚠️⚠️⚠️ -**When adding/modifying database columns or tables, ALWAYS do ALL of the following:** +**THIS IS NON-NEGOTIABLE: ALL database changes MUST work for EXISTING production databases!** -1. **Update schema**: `backend/src/db/schema.ts` -2. **Create migration file**: `backend/src/db/migrations/XXXX_description.sql` - ```sql - -- Example: Adding a new column - ALTER TABLE medications ADD COLUMN new_column TEXT; - ``` -3. **Update journal**: `backend/src/db/migrations/meta/_journal.json` - ```json - { "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false } - ``` -4. **Update migrate.ts**: `backend/src/db/migrate.ts` - - This file contains `CREATE TABLE IF NOT EXISTS` statements for fresh database starts - - Add new columns to the relevant table or add new tables - - Without this, fresh installs will be missing the new columns/tables! +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. -**Why this matters**: -- Migration SQL files: Required for upgrading existing databases -- migrate.ts: Required for fresh database starts (creates tables with all columns) -- Forgetting either causes `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 ## File Locations diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 4a5e54e..f341cea 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -78,6 +78,7 @@ async function runMigrations() { name text NOT NULL, generic_name text, taken_by text, + taken_by_json text NOT NULL DEFAULT '[]', count integer NOT NULL DEFAULT 0, strips integer NOT NULL DEFAULT 0, pack_count integer NOT NULL DEFAULT 1, @@ -114,6 +115,7 @@ async function runMigrations() { high_stock_days integer NOT NULL DEFAULT 180, expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', last_auto_email_sent text, last_notification_type text, last_notification_channel text, @@ -137,6 +139,7 @@ async function runMigrations() { taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS dose_tracking ( @@ -167,10 +170,13 @@ async function runMigrations() { { name: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" }, { name: "pill_weight_mg", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg REAL" }, { name: "taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by TEXT" }, + { name: "taken_by_json", sql: "ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]'" }, { name: "users_email", sql: "ALTER TABLE users ADD COLUMN email TEXT" }, { name: "users_avatar_url", sql: "ALTER TABLE users ADD COLUMN avatar_url TEXT" }, { name: "users_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject TEXT" }, { name: "user_settings_expiry_warning_days", sql: "ALTER TABLE user_settings ADD COLUMN expiry_warning_days INTEGER NOT NULL DEFAULT 90" }, + { name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" }, + { name: "share_tokens_expires_at", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER" }, ]; for (const migration of migrations) { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index e184adb..2d9e0d0 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,5 +1,7 @@ import { createClient } from "@libsql/client"; import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); @@ -117,6 +119,52 @@ async function main() { await client.execute(stmt); } + console.log("Base tables created. Running migrations for existing databases..."); + + // Run incremental migrations for existing databases + // These ALTER TABLE statements are safe to run multiple times (they'll fail silently if column exists) + const migrations = [ + // 0003: Add image_url to medications + { name: "0003_add_image_url", sql: "ALTER TABLE medications ADD COLUMN image_url text" }, + // 0004: Add expiry_date to medications + { name: "0004_add_expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date text" }, + // 0005: Add notes to medications + { name: "0005_add_notes", sql: "ALTER TABLE medications ADD COLUMN notes text" }, + // 0006: Add generic_name to medications + { name: "0006_add_generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name text" }, + // 0007: Add intake_reminders_enabled to medications + { name: "0007_add_intake_reminders", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled integer NOT NULL DEFAULT 0" }, + // 0008: Add pill_weight_mg to medications + { name: "0008_add_pill_weight", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg integer" }, + // 0009: Add taken_by to medications + { name: "0009_add_taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by text" }, + // 0012: Add avatar_url to users + { name: "0012_add_user_avatar", sql: "ALTER TABLE users ADD COLUMN avatar_url text" }, + // 0013: Add oidc_subject to users + { name: "0013_add_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject text" }, + // 0014: Add stock_calculation_mode to user_settings + { name: "0014_add_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'" }, + // 0015: Add expires_at to share_tokens + { name: "0015_add_share_token_expiry", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at integer" }, + // 0016: Add taken_by_json to medications + { name: "0016_taken_by_json_array", sql: "ALTER TABLE medications ADD COLUMN taken_by_json text NOT NULL DEFAULT '[]'" }, + ]; + + for (const migration of migrations) { + try { + await client.execute(migration.sql); + console.log(`Migration ${migration.name}: applied`); + } catch (err: unknown) { + // Ignore "duplicate column" errors - means migration was already applied + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes("duplicate column") || errorMessage.includes("already exists")) { + console.log(`Migration ${migration.name}: already applied (skipped)`); + } else { + console.error(`Migration ${migration.name}: failed - ${errorMessage}`); + } + } + } + console.log("Database setup complete!"); process.exit(0); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 056b345..c9c310f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -535,13 +535,13 @@ function AppContent() { }; const groupedSchedule = useMemo(() => { - type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; + type DoseInfo = { id: string; timeStr: string; when: number; usage: number; takenBy: string[] }; const days = new Map }>(); schedule.events.slice(0, 2000).forEach((event) => { const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), isPast: event.isPast, meds: new Map() }; const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when }; medEntry.total += event.usage; - medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage }); + medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage, takenBy: event.takenBy || [] }); medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when); day.meds.set(event.medName, medEntry); days.set(event.dateStr, day); @@ -1327,7 +1327,7 @@ function AppContent() {
{/* Past days toggle */} {pastDays.length > 0 && (() => { - const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id))); + const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id]))); const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length; return (
{ - const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const isAutoCollapsed = true; // Past days are always auto-collapsed @@ -1376,7 +1376,10 @@ function AppContent() {
{!isCollapsed && day.meds.map((item) => { const med = meds.find(m => m.name === item.medName); - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const medCov = coverageByMed[item.medName]; + const isEmpty = medCov ? medCov.medsLeft <= 0 : false; + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -1387,16 +1390,28 @@ function AppContent() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); + // If no takenBy, show single checkbox; otherwise show one per person + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; return ( -
+
{dose.timeStr} - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && {t('dose.takenBy')} {med.takenBy.map((person, i) => ({i > 0 && ", "} setSelectedUser(person)}>{person}))}} - {isTaken ? ( - - ) : ( - - )} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + return ( +
+ {person && setSelectedUser(person)}>{person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} @@ -1410,7 +1425,7 @@ function AppContent() { {/* Current and future days */} {futureDays.map((day) => { // Check if all doses in this day are taken (auto-collapse) - const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -1460,12 +1475,14 @@ function AppContent() { const medCoverage = coverageByMed[item.medName]; const med = meds.find(m => m.name === item.medName); const depletionTime = depletionByMed[item.medName]; + const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; // Check if this dose is scheduled after medication runs out const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const status = willBeOutOfStock ? { className: "danger", label: "status.outOfStock" } : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -1479,7 +1496,6 @@ function AppContent() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); const isOverdue = dose.when < Date.now(); // Only disable doses on future DAYS, not later today const doseDate = new Date(dose.when); @@ -1487,15 +1503,28 @@ function AppContent() { const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); const isFutureDose = doseDate.getTime() > todayMidnight.getTime(); + // If no takenBy, show single checkbox; otherwise show one per person + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; return ( -
+
{dose.timeStr} - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && {t('dose.takenBy')} {med.takenBy.map((person, i) => ({i > 0 && ", "} setSelectedUser(person)}>{person}))}} - {isTaken ? ( - - ) : ( - - )} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + return ( +
+ {person && setSelectedUser(person)}>{person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} @@ -2186,7 +2215,7 @@ function AppContent() {
{/* Past days toggle */} {pastDays.length > 0 && (() => { - const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id))); + const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id]))); const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length; return (
{ - const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); @@ -2230,7 +2259,10 @@ function AppContent() {
{!isCollapsed && day.meds.map((item) => { const med = meds.find(m => m.name === item.medName); - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const medCov = coverageByMed[item.medName]; + const isEmpty = medCov ? medCov.medsLeft <= 0 : false; + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -2241,16 +2273,28 @@ function AppContent() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); + // If no takenBy, show single checkbox; otherwise show one per person + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; return ( -
+
{dose.timeStr} - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && {t('dose.takenBy')} {med.takenBy.map((person, i) => ({i > 0 && ", "} setSelectedUser(person)}>{person}))}} - {isTaken ? ( - - ) : ( - - )} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + return ( +
+ {person && setSelectedUser(person)}>{person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} @@ -2273,6 +2317,7 @@ function AppContent() {
{day.dateStr}
{day.meds.map((item) => { const medCoverage = coverageByMed[item.medName]; + const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; const med = meds.find(m => m.name === item.medName); const depletionTime = depletionByMed[item.medName]; // Check if this dose is scheduled after medication runs out @@ -2280,7 +2325,8 @@ function AppContent() { const status = willBeOutOfStock ? { className: "danger", label: "status.outOfStock" } : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -2294,17 +2340,31 @@ function AppContent() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); - const isOverdue = !isTaken && dose.when < Date.now(); + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; + const now = Date.now(); + const dayStart = new Date(day.date).setHours(0, 0, 0, 0); + const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0); return ( -
+
{dose.timeStr} - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && {t('dose.takenBy')} {med.takenBy.map((person, i) => ({i > 0 && ", "} setSelectedUser(person)}>{person}))}} - {isTaken ? ( - - ) : ( - - )} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + const isOverdue = !isTaken && dose.when < now && !isPastDay; + return ( +
+ {person && setSelectedUser(person)}>{person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} @@ -2912,7 +2972,7 @@ END:VCALENDAR`; } function buildSchedulePreview(meds: Medication[], locale: string, includePast: boolean = false) { - const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number; isPast: boolean }> = []; + const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number; isPast: boolean; takenBy: string[] }> = []; if (!Array.isArray(meds)) return { events, groups: [] }; const now = new Date(); @@ -2932,6 +2992,7 @@ function buildSchedulePreview(meds: Medication[], locale: string, includePast: b events.push({ id: `${med.id}-${idx}-${whenMs}`, medName: med.name, + takenBy: med.takenBy || [], usage: blister.usage, when: whenMs, isPast, @@ -3050,22 +3111,24 @@ function calculateCoverage( if (stockCalculationMode === "automatic") { // Automatic mode: calculate consumed based on schedule since start date + // Multiply by personCount since each person takes the medication m.blisters.forEach((s) => { const start = new Date(s.start).getTime(); if (Number.isNaN(start) || start > now) return; const period = Math.max(1, s.every) * MS_PER_DAY; const occurrences = Math.floor((now - start) / period) + 1; // include today if started - consumed += occurrences * s.usage; + consumed += occurrences * s.usage * personCount; }); } else { // Manual mode: count only doses marked as taken for this medication - // Dose IDs follow pattern: "{medicationId}-{blisterIndex}-{timestampMs}" + // Dose IDs follow pattern: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}" takenDoses.forEach((doseId) => { const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parseInt(parts[0], 10); const blisterIdx = parseInt(parts[1], 10); if (medId === m.id && m.blisters[blisterIdx]) { + // Each taken dose (regardless of person) consumes the usage amount consumed += m.blisters[blisterIdx].usage; } } @@ -3586,10 +3649,16 @@ function SharedSchedule() { const depletion: Record = {}; // Calculate total pills taken per medication from takenDoses + // Each person's taken dose counts separately toward pills consumed const takenByMed: Record = {}; for (const dose of schedule.flatMap(d => d.meds.flatMap(m => m.doses))) { - if (takenDoses.has(dose.id)) { - takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage; + // Check all person-specific dose IDs for this dose + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; + for (const person of people) { + const doseId = person ? `${dose.id}-${person}` : dose.id; + if (takenDoses.has(doseId)) { + takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage; + } } } @@ -3710,7 +3779,7 @@ function SharedSchedule() { <> {/* Past days toggle */} {pastDays.length > 0 && (() => { - const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id))); + const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id]))); const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length; return (
{ - const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); @@ -3761,6 +3830,7 @@ function SharedSchedule() { {!isCollapsed && day.meds.map((item) => { const med = data.medications.find(m => m.name === item.medName); const medCoverage = coverageByMed[item.medName]; + const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; const depletionTime = depletionByMed[item.medName]; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; @@ -3779,7 +3849,8 @@ function SharedSchedule() { } } - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -3800,19 +3871,30 @@ function SharedSchedule() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; return ( -
+
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} - {isTaken ? ( - - ) : ( - - )} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + return ( +
+ {person && {person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} @@ -3826,7 +3908,7 @@ function SharedSchedule() { {/* Current and future days */} {futureDays.map((day) => { // Check if all doses in this day are taken (auto-collapse) - const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -3866,6 +3948,7 @@ function SharedSchedule() { {!isCollapsed && day.meds.map((item) => { const med = data.medications.find(m => m.name === item.medName); const medCoverage = coverageByMed[item.medName]; + const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; const depletionTime = depletionByMed[item.medName]; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; @@ -3884,7 +3967,8 @@ function SharedSchedule() { } } - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -3905,8 +3989,7 @@ function SharedSchedule() {
{item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); - const isOverdue = dose.when < Date.now() && !isTaken; + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; // Only disable doses on future DAYS, not later today const doseDate = new Date(dose.when); doseDate.setHours(0, 0, 0, 0); @@ -3914,17 +3997,29 @@ function SharedSchedule() { todayMidnight.setHours(0, 0, 0, 0); const isFutureDose = doseDate.getTime() > todayMidnight.getTime(); return ( -
+
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} - {isTaken ? ( - - ) : ( - - )} +
+ {people.map((person) => { + const personDoseId = person ? `${dose.id}-${person}` : dose.id; + const isTaken = takenDoses.has(personDoseId); + const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose; + return ( +
+ {person && {person}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
); })} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 07e5ba4..34d5f16 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -907,6 +907,62 @@ textarea.auto-resize { color: var(--warning); } +/* Per-person dose tracking */ +.dose-checks { + display: flex; + flex-direction: column; + gap: 4px; + margin-left: auto; +} + +.dose-person { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.dose-person.taken { + background: var(--success-bg); +} + +.dose-person.overdue { + background: var(--warning-bg); +} + +.dose-person .person-name { + font-size: 0.75rem; + color: var(--text-secondary); + min-width: 60px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dose-person .person-name.clickable { + cursor: pointer; +} + +.dose-person .person-name.clickable:hover { + color: var(--primary); + text-decoration: underline; +} + +.dose-person.taken .person-name { + color: var(--success); +} + +.dose-person .dose-btn { + margin-left: 0; + width: 24px; + min-width: 24px; + height: 24px; + min-height: 24px; + font-size: 0.8rem; +} + .time-row.taken { opacity: 0.6; }