Refactor medication model to use blisters and pills instead of strips and tabs

- Updated medication schema to replace stripsPerPack and tabsPerStrip with blistersPerPack and pillsPerBlister.
- Adjusted medication routes to handle new blister and pill structure, including calculations for total pills.
- Modified frontend components to reflect changes in medication data structure and ensure compatibility with new backend logic.
- Updated reminder scheduler and share routes to utilize the new medication model.
- Enhanced Docker configuration for better permissions handling during development.
This commit is contained in:
Daniel Volz
2025-12-29 19:18:14 +01:00
parent dc0e364830
commit 666306b416
26 changed files with 169 additions and 492 deletions
+8 -138
View File
@@ -159,9 +159,8 @@ Each blister defines a recurring intake:
### Key Medication Fields
```typescript
{
name, genericName, takenBy, // Identity
packCount, stripsPerPack, tabsPerStrip, looseTablets, // Inventory
count, strips, stripSize, // Derived/legacy
name, genericName, takenByJson, // Identity (takenByJson is JSON array)
packCount, blistersPerPack, pillsPerBlister, looseTablets, // Inventory
pillWeightMg, // For mg display
usageJson, everyJson, startJson, // Intake schedules as JSON arrays
imageUrl, expiryDate, notes, // Optional metadata
@@ -207,141 +206,14 @@ 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 (ABSOLUTELY CRITICAL) ⚠️⚠️⚠️
## Database Schema Changes
**THIS IS NON-NEGOTIABLE: ALL database changes MUST work for EXISTING production databases!**
When adding new database columns:
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(...)}
```
1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition
2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS`
3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
## File Locations
@@ -349,8 +221,6 @@ meds.filter(m => (m.takenBy || []).includes(name))
|---------|----------|
| 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` |
-1
View File
@@ -1543,7 +1543,6 @@
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz",
"integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@libsql/core": "^0.10.0",
"@libsql/hrana-client": "^0.6.2",
+4 -37
View File
@@ -65,8 +65,9 @@ async function runMigrations() {
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
email text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
@@ -77,19 +78,15 @@ async function runMigrations() {
user_id integer NOT NULL,
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,
strips_per_pack integer NOT NULL DEFAULT 1,
tabs_per_strip integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
strip_size integer NOT NULL DEFAULT 1,
image_url text,
expiry_date text,
notes text,
@@ -161,36 +158,6 @@ async function runMigrations() {
}
console.log(`[DB] Tables verified/created`);
// Then run column migrations for existing databases
const migrations = [
{ name: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" },
{ name: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" },
{ name: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" },
{ name: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" },
{ 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) {
try {
await client.execute(migration.sql);
console.log(`[DB] Migration applied: ${migration.name}`);
} catch (e: any) {
// Ignore "duplicate column" errors - column already exists
if (!e.message?.includes("duplicate column")) {
console.error(`[DB] Migration error (${migration.name}):`, e.message);
}
}
}
// If auth is disabled, ensure a default user exists (ID=1)
const authEnabled = process.env.AUTH_ENABLED === "true";
if (!authEnabled) {
+2 -52
View File
@@ -33,19 +33,15 @@ async function main() {
user_id integer NOT NULL,
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,
strips_per_pack integer NOT NULL DEFAULT 1,
tabs_per_strip integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
strip_size integer NOT NULL DEFAULT 1,
image_url text,
expiry_date text,
notes text,
@@ -119,52 +115,6 @@ 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);
}
@@ -1,2 +0,0 @@
-- Migration 0003: Add image_url column for medication photos
ALTER TABLE medications ADD COLUMN image_url TEXT;
@@ -1,2 +0,0 @@
-- Migration 0004: Add expiry_date column for medication expiration tracking
ALTER TABLE medications ADD COLUMN expiry_date TEXT;
@@ -1,2 +0,0 @@
-- Add notes column for medication instructions
ALTER TABLE medications ADD COLUMN notes TEXT;
@@ -1,2 +0,0 @@
-- Add generic_name column for medication active ingredient
ALTER TABLE medications ADD COLUMN generic_name TEXT;
@@ -1,2 +0,0 @@
-- Add intake_reminders_enabled column to medications table
ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0;
@@ -1,2 +0,0 @@
-- Add pill weight column (in mg)
ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER;
@@ -1,2 +0,0 @@
-- Add taken_by column for family member tracking
ALTER TABLE medications ADD COLUMN taken_by TEXT;
@@ -1,28 +0,0 @@
-- Add user_id to medications (for existing databases)
-- First, add the column as nullable
ALTER TABLE medications ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
-- Create user_settings table for per-user notification settings
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
@@ -1,11 +0,0 @@
-- Dose tracking table for syncing taken doses between users and share links
CREATE TABLE IF NOT EXISTS dose_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dose_id TEXT NOT NULL,
taken_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
marked_by TEXT
);
-- Index for fast lookups by user and dose
CREATE INDEX IF NOT EXISTS idx_dose_tracking_user_dose ON dose_tracking(user_id, dose_id);
@@ -1,2 +0,0 @@
-- Add avatar URL column to users table
ALTER TABLE users ADD COLUMN avatar_url TEXT;
@@ -1,2 +0,0 @@
-- Add OIDC subject column for SSO user identification
ALTER TABLE users ADD COLUMN oidc_subject TEXT;
@@ -1,4 +0,0 @@
-- Add stock calculation mode setting
-- "automatic" = stock decreases based on schedule from start date
-- "manual" = stock only decreases when doses are marked as taken
ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic';
@@ -1,3 +0,0 @@
-- Add expiration date to share tokens (default 1 year from creation)
-- NULL means no expiration (for backwards compatibility with existing tokens)
ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER;
@@ -1,17 +0,0 @@
-- Convert taken_by from single string to JSON array
-- This allows multiple people to share the same medication
-- Add new column for JSON array
ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]';
-- Migrate existing data: convert single string to JSON array
-- If taken_by is not null/empty, convert to ["value"], otherwise keep as []
UPDATE medications
SET taken_by_json = CASE
WHEN taken_by IS NOT NULL AND taken_by != ''
THEN json_array(taken_by)
ELSE '[]'
END;
-- Note: We keep the old taken_by column for backwards compatibility during migration
-- It can be dropped in a future migration once all code uses taken_by_json
@@ -1,21 +0,0 @@
{
"entries": [
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false },
{ "idx": 1, "version": 1, "when": 1734700000, "tag": "0001_add_strips", "breakpoint": false },
{ "idx": 2, "version": 1, "when": 1734800000, "tag": "0002_pack_inventory", "breakpoint": false },
{ "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false },
{ "idx": 4, "version": 1, "when": 1735000000, "tag": "0004_add_expiry_date", "breakpoint": false },
{ "idx": 5, "version": 1, "when": 1735100000, "tag": "0005_add_notes", "breakpoint": false },
{ "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false },
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false },
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false },
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false },
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false },
{ "idx": 16, "version": 1, "when": 1735400002, "tag": "0016_taken_by_json_array", "breakpoint": false }
]
}
+2 -6
View File
@@ -25,19 +25,15 @@ export const medications = sqliteTable("medications", {
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenBy: text("taken_by", { length: 100 }), // Deprecated: use takenByJson
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
count: integer("count").notNull().default(0),
strips: integer("strips").notNull().default(0),
packCount: integer("pack_count").notNull().default(1),
stripsPerPack: integer("strips_per_pack").notNull().default(1),
tabsPerStrip: integer("tabs_per_strip").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
stripSize: integer("strip_size").notNull().default(1),
imageUrl: text("image_url"),
expiryDate: text("expiry_date"),
notes: text("notes"),
+21 -51
View File
@@ -23,8 +23,8 @@ const medicationSchema = z.object({
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
packCount: z.number().int().min(0).default(1),
stripsPerPack: z.number().int().min(1).default(1),
tabsPerStrip: z.number().int().min(1).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().int().min(1).nullable().optional(),
expiryDate: z.string().nullable().optional(),
@@ -92,12 +92,9 @@ export async function medicationRoutes(app: FastifyInstance) {
name: row.name,
genericName: row.genericName,
takenBy: parseTakenByJson(row.takenByJson),
count: row.count,
strips: row.strips,
stripSize: row.stripSize,
packCount: row.packCount ?? 1,
stripsPerPack: row.stripsPerPack ?? row.strips ?? 1,
tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1,
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
looseTablets: row.looseTablets ?? 0,
pillWeightMg: row.pillWeightMg,
blisters: parseBlisters(row),
@@ -114,28 +111,22 @@ export async function medicationRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const [inserted] = await db
.insert(medications)
.values({
userId,
name,
genericName: genericName || null,
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
takenByJson,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
blistersPerPack,
pillsPerBlister,
looseTablets,
pillWeightMg: pillWeightMg || null,
expiryDate: expiryDate || null,
@@ -152,12 +143,9 @@ export async function medicationRoutes(app: FastifyInstance) {
name: inserted.name,
genericName: inserted.genericName,
takenBy: parseTakenByJson(inserted.takenByJson),
count: inserted.count,
strips: inserted.strips,
stripSize: inserted.stripSize,
packCount: inserted.packCount,
stripsPerPack: inserted.stripsPerPack,
tabsPerStrip: inserted.tabsPerStrip,
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
looseTablets: inserted.looseTablets,
pillWeightMg: inserted.pillWeightMg,
blisters,
@@ -181,27 +169,21 @@ export async function medicationRoutes(app: FastifyInstance) {
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const result = await db
.update(medications)
.set({
name,
genericName: genericName || null,
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
takenByJson,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
blistersPerPack,
pillsPerBlister,
looseTablets,
pillWeightMg: pillWeightMg || null,
expiryDate: expiryDate || null,
@@ -222,12 +204,9 @@ export async function medicationRoutes(app: FastifyInstance) {
name: result[0].name,
genericName: result[0].genericName,
takenBy: parseTakenByJson(result[0].takenByJson),
count: result[0].count,
strips: result[0].strips,
stripSize: result[0].stripSize,
packCount: result[0].packCount,
stripsPerPack: result[0].stripsPerPack,
tabsPerStrip: result[0].tabsPerStrip,
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
looseTablets: result[0].looseTablets,
pillWeightMg: result[0].pillWeightMg,
blisters,
@@ -329,11 +308,11 @@ export async function medicationRoutes(app: FastifyInstance) {
const payload = rows.map((row) => {
const blisters = parseBlisters(row);
const usageTotal = calculateUsageInRange(blisters, start, end);
const tabsPerStrip = row.tabsPerStrip ?? row.stripSize ?? 1;
const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 1;
const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1;
const blistersPerPack = row.blistersPerPack ?? 1;
const looseTablets = row.looseTablets ?? 0;
const originalTotalPills = packCount * stripsPerPack * tabsPerStrip + looseTablets;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
// Calculate consumption up to now (same logic as frontend)
let consumedUntilNow = 0;
@@ -347,7 +326,7 @@ export async function medicationRoutes(app: FastifyInstance) {
});
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0;
const stripsNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
// Calculate current stock using realistic consumption order (loose first, then blisters)
const consumed = originalTotalPills - currentPills;
@@ -357,8 +336,8 @@ export async function medicationRoutes(app: FastifyInstance) {
const originalBlisterPills = originalTotalPills - looseTablets;
const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed);
const fullBlisters = tabsPerStrip > 0 ? Math.floor(blisterPillsRemaining / tabsPerStrip) : 0;
const openBlisterPills = tabsPerStrip > 0 ? blisterPillsRemaining % tabsPerStrip : 0;
const fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0;
const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0;
const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose
const enough = currentPills >= usageTotal;
@@ -367,7 +346,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationName: row.name,
totalPills: currentPills,
plannerUsage: usageTotal,
stripSize: tabsPerStrip,
stripSize: pillsPerBlister,
stripsNeeded,
fullBlisters,
loosePills,
@@ -390,13 +369,4 @@ function calculateUsageInRange(blisters: Array<{ usage: number; every: number; s
}
});
return Number(total.toFixed(2));
}
function deriveTotalTablets(packCount: number, stripsPerPack: number, tabsPerStrip: number, looseTablets: number) {
const packs = packCount || 0;
const strips = stripsPerPack || 0;
const tabs = tabsPerStrip || 1;
const loose = looseTablets || 0;
const packed = packs * strips * tabs;
return packed + loose;
}
-6
View File
@@ -254,12 +254,6 @@ async function findOrCreateOIDCUser(
// User already has a DIFFERENT OIDC subject - create new user with suffix
username = `${username}_sso`;
console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`);
} else if (existingByUsername.authProvider === "oidc" && !existingByUsername.oidcSubject) {
// Legacy OIDC user without subject - update it
await db.update(users)
.set({ oidcSubject: oidcSubject })
.where(eq(users.id, existingByUsername.id));
return { id: existingByUsername.id, username: existingByUsername.username };
}
}
+3 -2
View File
@@ -107,14 +107,15 @@ export async function shareRoutes(app: FastifyInstance) {
blisters = [];
}
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
imageUrl: med.imageUrl,
count: med.count,
tabsPerStrip: med.tabsPerStrip,
totalPills,
pillsPerBlister: med.pillsPerBlister,
blisters,
};
});
+3 -2
View File
@@ -237,13 +237,14 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
for (const row of rows) {
const blisters = parseBlisters(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, blisters }, language);
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
lowStock.push({
name: row.name,
medsLeft: row.count,
medsLeft: totalPills,
daysLeft,
depletionDate,
});
+2 -2
View File
@@ -2,7 +2,7 @@ services:
backend-dev:
image: node:22-slim
working_dir: /app
command: sh -c "npm install && npm run dev"
command: sh -c "chown -R node:node /app/node_modules /app/data && su node -c 'npm install && npm run dev'"
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
@@ -23,7 +23,7 @@ services:
frontend-dev:
image: node:22-slim
working_dir: /app
command: sh -c "npm install && npm run dev -- --host --port 5173"
command: sh -c "chown -R node:node /app/node_modules && su node -c 'npm install && npm run dev -- --host --port 5173'"
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
+124 -93
View File
@@ -13,14 +13,11 @@ type Medication = {
id: number;
name: string;
genericName?: string | null;
takenBy: string[]; // Changed from string | null to array
count: number;
strips: number;
stripSize: number;
packCount?: number;
stripsPerPack?: number;
tabsPerStrip?: number;
looseTablets?: number;
takenBy: string[];
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
pillWeightMg?: number | null;
blisters: Blister[];
imageUrl?: string | null;
@@ -49,8 +46,8 @@ type FormState = {
genericName: string;
takenBy: string[]; // Changed from string to array
packCount: string;
stripsPerPack: string;
tabsPerStrip: string;
blistersPerPack: string;
pillsPerBlister: string;
looseTablets: string;
pillWeightMg: string;
expiryDate: string;
@@ -69,7 +66,7 @@ const defaultBlister = (): FormBlister => {
};
};
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", blistersPerPack: "1", pillsPerBlister: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
// Field validation limits (must match backend)
const FIELD_LIMITS = {
@@ -389,6 +386,25 @@ function AppContent() {
}
}, [user?.id]);
// Get dose ID with optional person suffix
function getDoseId(baseDoseId: string, person: string | null): string {
return person ? `${baseDoseId}-${person}` : baseDoseId;
}
// Count taken doses for a day/item
function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
for (const person of people) {
total++;
if (takenDoses.has(getDoseId(d.id, person))) taken++;
}
}
return { total, taken };
}
async function markDoseTaken(doseId: string) {
// Optimistic update
setTakenDoses((prev) => {
@@ -802,10 +818,10 @@ function AppContent() {
name: med.name,
genericName: med.genericName ?? "",
takenBy: med.takenBy || [], // Already an array from API
packCount: String(med.packCount ?? 1),
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
looseTablets: String(med.looseTablets ?? 0),
packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
@@ -874,8 +890,8 @@ function AppContent() {
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.filter(name => name.trim()), // Send array, filter empty strings
packCount: Number(form.packCount) || 0,
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
blistersPerPack: Math.max(1, Number(form.blistersPerPack) || 1),
pillsPerBlister: Math.max(1, Number(form.pillsPerBlister) || 1),
looseTablets: Math.max(0, Number(form.looseTablets) || 0),
pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null,
expiryDate: form.expiryDate || null,
@@ -1196,9 +1212,9 @@ function AppContent() {
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.tabsPerStrip ?? 1,
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med?.count ?? Math.round(row.medsLeft)
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
@@ -1216,7 +1232,7 @@ function AppContent() {
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.days')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
@@ -1265,9 +1281,9 @@ function AppContent() {
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.tabsPerStrip ?? 1,
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med?.count ?? Math.round(row.medsLeft)
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
@@ -1287,7 +1303,7 @@ function AppContent() {
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.daysLeft')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.expiry')} className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
@@ -1398,16 +1414,15 @@ function AppContent() {
<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;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isEmpty}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isEmpty}></button>
)}
</div>
);
@@ -1512,16 +1527,15 @@ function AppContent() {
<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;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);
@@ -1559,12 +1573,12 @@ function AppContent() {
<div className="med-name">{med.name}</div>
</div>
<div className="med-details">
<span>{t('medications.details.packs')}: <strong>{med.packCount ?? 1}</strong></span>
<span>{t('medications.details.blisters')}: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
<span>{t('medications.details.loose')}: <strong>{med.looseTablets ?? 0}</strong></span>
<span>{t('medications.details.packs')}: <strong>{med.packCount}</strong></span>
<span>{t('medications.details.blisters')}: <strong>{med.blistersPerPack}</strong></span>
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
</div>
<div className="med-total">{t('medications.details.total')}: {med.count} {t('common.pills')}</div>
<div className="med-total">{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}</div>
</div>
<div className="med-actions">
<button className="secondary" onClick={() => startEdit(med)}>{t('common.edit')}</button>
@@ -1646,11 +1660,11 @@ function AppContent() {
</label>
<label>
{t('form.blistersPerPack')}
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
<input type="number" min="1" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
</label>
<label>
{t('form.pillsPerBlister')}
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
</label>
<label>
{t('form.loosePills')}
@@ -2283,16 +2297,15 @@ function AppContent() {
<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;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
@@ -2353,17 +2366,16 @@ function AppContent() {
<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;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
@@ -2411,15 +2423,15 @@ function AppContent() {
<h3>{t('modal.stockInfo')}</h3>
{(() => {
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : selectedMed.count;
const totalStock = (selectedMed.packCount ?? 1) * (selectedMed.stripsPerPack ?? 1) * (selectedMed.tabsPerStrip ?? 1) + (selectedMed.looseTablets ?? 0);
const totalStock = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : totalStock;
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
currentStock,
selectedMed.tabsPerStrip ?? 1,
selectedMed.looseTablets ?? 0,
selectedMed.count
selectedMed.pillsPerBlister,
selectedMed.looseTablets,
totalStock
);
return (
<div className="med-detail-grid">
@@ -2429,7 +2441,7 @@ function AppContent() {
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('table.openBlister')}</span>
<span className={`med-detail-value ${textClass}`}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.tabsPerStrip ?? 1, t)}</span>
<span className={`med-detail-value ${textClass}`}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.pillsPerBlister ?? 1, t)}</span>
</div>
<div className="med-detail-item full-width">
<span className="med-detail-label">{t('modal.currentStock')}</span>
@@ -2445,15 +2457,15 @@ function AppContent() {
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.packs')}</span>
<span className="med-detail-value">{selectedMed.packCount ?? 0}</span>
<span className="med-detail-value">{selectedMed.packCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.blistersPerPack')}</span>
<span className="med-detail-value">{selectedMed.stripsPerPack ?? 0}</span>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.pillsPerBlister')}</span>
<span className="med-detail-value">{selectedMed.tabsPerStrip ?? 1}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
@@ -2568,7 +2580,8 @@ function AppContent() {
{meds.filter(m => (m.takenBy || []).includes(selectedUser)).map((med) => {
const medCoverage = coverage.all.find(c => c.name === med.name);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(med.count);
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(totalPills);
return (
<div
key={med.id}
@@ -2581,7 +2594,7 @@ function AppContent() {
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
</div>
<div className="user-med-stats">
<span className="user-med-pills">{currentStock}/{formatNumber(med.count)} {t('common.pills')}</span>
<span className="user-med-pills">{currentStock}/{formatNumber(totalPills)} {t('common.pills')}</span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
@@ -2742,11 +2755,11 @@ function AppContent() {
</label>
<label>
{t('form.blistersPerPack')}
<input type="number" min="0" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
<input type="number" min="0" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
</label>
<label>
{t('form.pillsPerBlister')}
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
</label>
<label>
{t('form.loosePills')}
@@ -2854,10 +2867,10 @@ function AppContent() {
function deriveTotal(form: FormState) {
const packCount = Number(form.packCount) || 0;
const stripsPerPack = Number(form.stripsPerPack) || 0;
const tabsPerStrip = Number(form.tabsPerStrip) || 1;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * stripsPerPack * tabsPerStrip + looseTablets;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}
function toIsoString(value: string) {
@@ -3028,11 +3041,11 @@ function formatNumber(value: number | null) {
// Loose pills are consumed FIRST, then blisters are opened
function getBlisterStock(
currentPills: number,
tabsPerStrip: number,
pillsPerBlister: number,
originalLooseTablets: number,
originalTotalPills: number
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
if (tabsPerStrip <= 0 || tabsPerStrip === 1) {
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
}
@@ -3049,8 +3062,8 @@ function getBlisterStock(
const blisterPillsRemaining = originalBlisterPills - blisterPillsConsumed;
// Calculate full blisters and open blister
const fullBlisters = Math.floor(blisterPillsRemaining / tabsPerStrip);
const openBlisterPills = blisterPillsRemaining % tabsPerStrip;
const fullBlisters = Math.floor(blisterPillsRemaining / pillsPerBlister);
const openBlisterPills = blisterPillsRemaining % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: loosePillsRemaining };
}
@@ -3065,12 +3078,12 @@ function formatFullBlisters(fullBlisters: number, t: (key: string) => string): s
function formatOpenBlisterAndLoose(
openBlisterPills: number,
loosePills: number,
tabsPerStrip: number,
pillsPerBlister: number,
t: (key: string) => string
): string {
// Format open blister part
const openBlisterText = openBlisterPills > 0
? `${openBlisterPills} ${t('common.of')} ${tabsPerStrip} ${t('common.pills')}`
? `${openBlisterPills} ${t('common.of')} ${pillsPerBlister} ${t('common.pills')}`
: t('common.none');
// Format loose pills part (if any)
@@ -3136,10 +3149,11 @@ function calculateCoverage(
consumed += m.blisters[blisterIdx].usage;
}
}
});
}
});
}
const medsLeft = Math.max(0, m.count - consumed);
const totalPills = m.packCount * m.blistersPerPack * m.pillsPerBlister + m.looseTablets;
const medsLeft = Math.max(0, totalPills - consumed);
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
@@ -3359,7 +3373,7 @@ type SharedMedication = {
pillWeightMg?: number | null;
imageUrl?: string | null;
count?: number;
tabsPerStrip?: number;
pillsPerBlister?: number;
blisters: Blister[];
};
@@ -3485,6 +3499,25 @@ function SharedSchedule() {
}
}, [token]);
// Get dose ID with optional person suffix
function getDoseId(baseDoseId: string, person: string | null): string {
return person ? `${baseDoseId}-${person}` : baseDoseId;
}
// Count taken doses for a day/item
function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
for (const person of people) {
total++;
if (takenDoses.has(getDoseId(d.id, person))) taken++;
}
}
return { total, taken };
}
async function markDoseTaken(doseId: string) {
// Optimistic update
setTakenDoses((prev) => {
@@ -3668,7 +3701,7 @@ function SharedSchedule() {
}
for (const med of data.medications) {
const totalCount = med.count ?? 0;
const totalCount = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from blisters, multiplied by number of people
@@ -3886,16 +3919,15 @@ function SharedSchedule() {
</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
@@ -4011,17 +4043,16 @@ function SharedSchedule() {
</span>
<div className="dose-checks">
{people.map((person) => {
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
// Check both new format (with person) and legacy format (without person suffix)
const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id));
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return (
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<div key={doseId} 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 undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);