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.
This commit is contained in:
Daniel Volz
2025-12-28 19:47:14 +01:00
parent c7ac7fbf75
commit 69ca8fd3ba
5 changed files with 345 additions and 89 deletions
+71 -20
View File
@@ -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
+6
View File
@@ -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) {
+48
View File
@@ -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);
}
+164 -69
View File
@@ -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<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
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() {
<div className="timeline">
{/* 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 (
<div
@@ -1349,7 +1349,7 @@ function AppContent() {
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
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() {
</div>
{!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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -1387,16 +1390,28 @@ function AppContent() {
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
const isTaken = takenDoses.has(personDoseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
@@ -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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -1479,7 +1496,6 @@ function AppContent() {
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
<div key={dose.id} className={`dose-item ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')} disabled={isFutureDose}></button>
)}
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
const isTaken = takenDoses.has(personDoseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
@@ -2186,7 +2215,7 @@ function AppContent() {
<div className="timeline">
{/* 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 (
<div
@@ -2204,7 +2233,7 @@ function AppContent() {
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
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() {
</div>
{!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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -2241,16 +2273,28 @@ function AppContent() {
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
const isTaken = takenDoses.has(personDoseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
@@ -2273,6 +2317,7 @@ function AppContent() {
<div className="day-divider">{day.dateStr}</div>
{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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -2294,17 +2340,31 @@ function AppContent() {
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
const isTaken = takenDoses.has(personDoseId);
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
@@ -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<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
// Each person's taken dose counts separately toward pills consumed
const takenByMed: Record<string, number> = {};
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 (
<div
@@ -3732,7 +3801,7 @@ function SharedSchedule() {
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -3800,19 +3871,30 @@ function SharedSchedule() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
const isTaken = takenDoses.has(personDoseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name">{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
@@ -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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
@@ -3905,8 +3989,7 @@ function SharedSchedule() {
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
<div key={dose.id} className={`dose-item ${isFutureDose ? "future" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')} disabled={isFutureDose}></button>
)}
<div className="dose-checks">
{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 (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
{person && <span className="person-name">{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
+56
View File
@@ -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;
}