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:
@@ -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` |
|
||||
|
||||
Generated
-1
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user