From 571d94bf7e67e11b2ead54f8548327bc8d75fd4a Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 31 Jan 2026 23:49:11 +0100 Subject: [PATCH] feat: Add package type support and per-intake takenBy (#89) ## Package Type Feature - Add 'blister' and 'bottle' package types for medications - Bottle type uses totalPills for capacity and looseTablets for current stock - Blister type continues to use packCount/blistersPerPack/pillsPerBlister - Add doseUnit field for flexible dosing (mg, ml, IU, etc.) - Full UI support in medication form and detail modal ## Per-Intake TakenBy - Move takenBy from medication level to individual intakes - Each intake schedule can now be assigned to a different person - Update scheduler-utils to handle per-intake takenBy - Update SharedSchedule to filter by per-intake takenBy - Backward compatible with existing medication data ## UI Improvements - Add PasswordInput component with show/hide toggle - Centralize stockThresholds in AppContext for consistent status display - Fix SharedSchedule sync issues with per-intake takenBy - Improve mobile editing experience ## Technical - Add migrations 0004 and 0005 for schema changes - Update all relevant tests (1064 tests passing) - Maintain backward compatibility with ALTER migrations --- backend/drizzle/0004_add_package_type.sql | 3 + backend/drizzle/0005_add_intakes_json.sql | 3 + backend/drizzle/meta/0005_snapshot.json | 886 ++++++++++++++++++ backend/drizzle/meta/_journal.json | 14 + backend/src/db/client.ts | 7 + backend/src/db/schema.ts | 13 +- backend/src/routes/export.ts | 60 +- backend/src/routes/medications.ts | 212 ++++- backend/src/routes/share.ts | 76 +- .../src/services/intake-reminder-scheduler.ts | 125 ++- backend/src/test/e2e-routes.test.ts | 50 +- backend/src/test/integration.test.ts | 50 +- backend/src/test/services.test.ts | 81 +- backend/src/utils/scheduler-utils.ts | 151 ++- frontend/src/App.tsx | 7 +- frontend/src/components/Auth.tsx | 19 +- frontend/src/components/MedDetailModal.tsx | 79 +- frontend/src/components/MobileEditModal.tsx | 210 +++-- frontend/src/components/PasswordInput.tsx | 74 ++ frontend/src/components/SharedSchedule.tsx | 314 +++---- frontend/src/components/index.ts | 1 + frontend/src/context/AppContext.tsx | 23 +- frontend/src/hooks/useMedicationForm.ts | 70 +- frontend/src/i18n/de.json | 27 +- frontend/src/i18n/en.json | 27 +- frontend/src/pages/DashboardPage.tsx | 392 +++----- frontend/src/pages/MedicationsPage.tsx | 232 +++-- frontend/src/pages/SchedulePage.tsx | 24 +- frontend/src/styles.css | 130 ++- .../test/components/MedDetailModal.test.tsx | 2 + .../test/components/MobileEditModal.test.tsx | 75 +- .../test/components/PasswordInput.test.tsx | 89 ++ .../test/components/UserFilterModal.test.tsx | 2 + .../src/test/pages/DashboardPage.test.tsx | 11 +- .../src/test/pages/MedicationsPage.test.tsx | 129 ++- frontend/src/types/index.ts | 83 +- frontend/src/utils/schedule.ts | 135 ++- 37 files changed, 2896 insertions(+), 990 deletions(-) create mode 100644 backend/drizzle/0004_add_package_type.sql create mode 100644 backend/drizzle/0005_add_intakes_json.sql create mode 100644 backend/drizzle/meta/0005_snapshot.json create mode 100644 frontend/src/components/PasswordInput.tsx create mode 100644 frontend/src/test/components/PasswordInput.test.tsx diff --git a/backend/drizzle/0004_add_package_type.sql b/backend/drizzle/0004_add_package_type.sql new file mode 100644 index 0000000..b371c30 --- /dev/null +++ b/backend/drizzle/0004_add_package_type.sql @@ -0,0 +1,3 @@ +-- Add package type support (blister vs bottle) +ALTER TABLE medications ADD COLUMN package_type TEXT DEFAULT 'blister' NOT NULL; +ALTER TABLE medications ADD COLUMN total_pills INTEGER; diff --git a/backend/drizzle/0005_add_intakes_json.sql b/backend/drizzle/0005_add_intakes_json.sql new file mode 100644 index 0000000..7391fdf --- /dev/null +++ b/backend/drizzle/0005_add_intakes_json.sql @@ -0,0 +1,3 @@ +-- Add dose_unit column and intakes JSON array for per-intake takenBy support +ALTER TABLE `medications` ADD `dose_unit` text(20) DEFAULT 'mg';--> statement-breakpoint +ALTER TABLE `medications` ADD `intakes_json` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..4541901 --- /dev/null +++ b/backend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,886 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0", + "prevId": "4f1d8273-1e60-4da1-9bfc-bd51c2784836", + "tables": { + "dose_tracking": { + "name": "dose_tracking", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dose_id": { + "name": "dose_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taken_at": { + "name": "taken_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s','now'))" + }, + "marked_by": { + "name": "marked_by", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed": { + "name": "dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "dose_tracking_user_id_users_id_fk": { + "name": "dose_tracking_user_id_users_id_fk", + "tableFrom": "dose_tracking", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "medications": { + "name": "medications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generic_name": { + "name": "generic_name", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "taken_by_json": { + "name": "taken_by_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "package_type": { + "name": "package_type", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blister'" + }, + "pack_count": { + "name": "pack_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "blisters_per_pack": { + "name": "blisters_per_pack", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "pills_per_blister": { + "name": "pills_per_blister", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "total_pills": { + "name": "total_pills", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loose_tablets": { + "name": "loose_tablets", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stock_adjustment": { + "name": "stock_adjustment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_stock_correction_at": { + "name": "last_stock_correction_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pill_weight_mg": { + "name": "pill_weight_mg", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dose_unit": { + "name": "dose_unit", + "type": "text(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'mg'" + }, + "usage_json": { + "name": "usage_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "every_json": { + "name": "every_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "start_json": { + "name": "start_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "intakes_json": { + "name": "intakes_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intake_reminders_enabled": { + "name": "intake_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "dismissed_until": { + "name": "dismissed_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_user_id_users_id_fk": { + "name": "medications_user_id_users_id_fk", + "tableFrom": "medications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refill_history": { + "name": "refill_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packs_added": { + "name": "packs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "loose_pills_added": { + "name": "loose_pills_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "refill_date": { + "name": "refill_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s','now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "refill_history_medication_id_medications_id_fk": { + "name": "refill_history_medication_id_medications_id_fk", + "tableFrom": "refill_history", + "tableTo": "medications", + "columnsFrom": [ + "medication_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refill_history_user_id_users_id_fk": { + "name": "refill_history_user_id_users_id_fk", + "tableFrom": "refill_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refresh_tokens": { + "name": "refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "refresh_tokens_token_id_unique": { + "name": "refresh_tokens_token_id_unique", + "columns": [ + "token_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_tokens": { + "name": "share_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taken_by": { + "name": "taken_by", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_days": { + "name": "schedule_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "share_tokens_token_unique": { + "name": "share_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "share_tokens_user_id_users_id_fk": { + "name": "share_tokens_user_id_users_id_fk", + "tableFrom": "share_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_enabled": { + "name": "email_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notification_email": { + "name": "notification_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_stock_reminders": { + "name": "email_stock_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "email_intake_reminders": { + "name": "email_intake_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "shoutrrr_enabled": { + "name": "shoutrrr_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shoutrrr_url": { + "name": "shoutrrr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shoutrrr_stock_reminders": { + "name": "shoutrrr_stock_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "shoutrrr_intake_reminders": { + "name": "shoutrrr_intake_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "reminder_days_before": { + "name": "reminder_days_before", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "repeat_daily_reminders": { + "name": "repeat_daily_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "skip_reminders_for_taken_doses": { + "name": "skip_reminders_for_taken_doses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "repeat_reminders_enabled": { + "name": "repeat_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_repeat_interval_minutes": { + "name": "reminder_repeat_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "max_nagging_reminders": { + "name": "max_nagging_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "low_stock_days": { + "name": "low_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "normal_stock_days": { + "name": "normal_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "high_stock_days": { + "name": "high_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 180 + }, + "expiry_warning_days": { + "name": "expiry_warning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "stock_calculation_mode": { + "name": "stock_calculation_mode", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'automatic'" + }, + "last_auto_email_sent": { + "name": "last_auto_email_sent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_notification_type": { + "name": "last_notification_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_notification_channel": { + "name": "last_notification_channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_med_name": { + "name": "last_reminder_med_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_taken_by": { + "name": "last_reminder_taken_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index da46ef4..eafffa4 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1769354512857, "tag": "0003_add_reminder_info_columns", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1769886564000, + "tag": "0004_add_package_type", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1769893708813, + "tag": "0005_add_intakes_json", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 4f1e6f8..9252001 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -92,6 +92,13 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo // Added for more detailed reminder info display `ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`, `ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`, + // Added for package type support (blister vs bottle) + `ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`, + `ALTER TABLE medications ADD COLUMN total_pills integer`, + // Added for dose unit selection (mg, g, mcg, ml, IU, etc.) + `ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`, + // Added for intake-level takenBy: unified intakes structure + `ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`, ]; for (const sql of alterMigrations) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ea981b7..285cc92 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -28,16 +28,21 @@ export const medications = sqliteTable("medications", { name: text("name", { length: 100 }).notNull(), genericName: text("generic_name", { length: 100 }), takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names + packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle' packCount: integer("pack_count").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), // TRUE loose pills (user-entered) + totalPills: integer("total_pills"), // For bottle type: total capacity of the container + looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count pillWeightMg: integer("pill_weight_mg"), - usageJson: text("usage_json").notNull().default("[]"), - everyJson: text("every_json").notNull().default("[]"), - startJson: text("start_json").notNull().default("[]"), + doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.) + usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead + everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead + startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead + // New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}] + intakesJson: text("intakes_json").notNull().default("[]"), imageUrl: text("image_url"), expiryDate: text("expiry_date"), notes: text("notes"), diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index ea0f217..d4bb296 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -9,7 +9,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { parseTakenByJson } from "../utils/scheduler-utils.js"; +import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(process.cwd(), "data/images"); @@ -27,6 +27,7 @@ const scheduleSchema = z.object({ every: z.number().int().min(1), start: z.string(), // ISO datetime string remind: z.boolean().optional().default(false), + takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field) }); const inventorySchema = z.object({ @@ -44,6 +45,7 @@ const medicationExportSchema = z.object({ takenBy: z.array(z.string()).default([]), inventory: inventorySchema, pillWeightMg: z.number().int().nullable().optional(), + doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"), schedules: z.array(scheduleSchema).default([]), expiryDate: z.string().nullable().optional(), notes: z.string().nullable().optional(), @@ -126,28 +128,24 @@ async function getUserId(request: any, reply: any): Promise { return authUser.id; } -// Parse blisters from DB format to export format -function parseBlistersForExport( +// Parse intakes from DB format to export format (with per-intake takenBy) +function parseIntakesForExport( row: typeof medications.$inferSelect -): Array<{ usage: number; every: number; start: string; remind: boolean }> { - try { - const usage = JSON.parse(row.usageJson || "[]") as number[]; - const every = JSON.parse(row.everyJson || "[]") as number[]; - const start = JSON.parse(row.startJson || "[]") as string[]; - const len = Math.min(usage.length, every.length, start.length); - const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = []; - for (let i = 0; i < len; i++) { - schedules.push({ - usage: usage[i], - every: every[i], - start: start[i], - remind: row.intakeRemindersEnabled ?? false, - }); - } - return schedules; - } catch { - return []; - } +): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> { + // Use the new parseIntakesJson which falls back to legacy format + const intakes = parseIntakesJson( + row.intakesJson, + { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, + row.intakeRemindersEnabled ?? false + ); + + return intakes.map((intake) => ({ + usage: intake.usage, + every: intake.every, + start: intake.start, + remind: intake.intakeRemindersEnabled, + takenBy: intake.takenBy, // Per-intake takenBy + })); } // Read image file and convert to base64 data URL @@ -279,7 +277,8 @@ export async function exportRoutes(app: FastifyInstance) { stockAdjustment: med.stockAdjustment ?? 0, }, pillWeightMg: med.pillWeightMg, - schedules: parseBlistersForExport(med), + doseUnit: med.doseUnit ?? "mg", + schedules: parseIntakesForExport(med), expiryDate: med.expiryDate, notes: med.notes, intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, @@ -463,12 +462,23 @@ export async function exportRoutes(app: FastifyInstance) { const exportIdToNewId = new Map(); for (const med of importData.medications) { - // Convert schedules back to JSON arrays + // Convert schedules to both legacy and new formats const usageJson = JSON.stringify(med.schedules.map((s) => s.usage)); const everyJson = JSON.stringify(med.schedules.map((s) => s.every)); const startJson = JSON.stringify(med.schedules.map((s) => s.start)); const takenByJson = JSON.stringify(med.takenBy); + // Build intakesJson array (new unified format with per-intake takenBy) + const intakesJson = JSON.stringify( + med.schedules.map((s) => ({ + usage: s.usage, + every: s.every, + start: s.start, + takenBy: s.takenBy || null, + intakeRemindersEnabled: s.remind ?? false, + })) + ); + // Check if any schedule has remind enabled const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled; @@ -486,6 +496,8 @@ export async function exportRoutes(app: FastifyInstance) { stockAdjustment: med.inventory.stockAdjustment ?? 0, lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null, pillWeightMg: med.pillWeightMg || null, + doseUnit: med.doseUnit ?? "mg", + intakesJson, usageJson, everyJson, startJson, diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index baf4105..f255801 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -9,30 +9,50 @@ import { doseTracking, medications } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { parseBlisters, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js"; +import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(process.cwd(), "data/images"); +// New intake schema with per-intake takenBy +const intakeSchema = z.object({ + usage: z.number().nonnegative(), + every: z.number().int().min(1), + start: z.string().datetime({ local: true }), + takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake + intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting +}); + +// Legacy blister schema (for backward compatibility during transition) const blisterSchema = z.object({ usage: z.number().nonnegative(), every: z.number().int().min(1), start: z.string().datetime({ local: true }), }); -const medicationSchema = z.object({ - name: z.string().trim().min(1).max(100), - 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), - 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(), - notes: z.string().max(2000).nullable().optional(), - intakeRemindersEnabled: z.boolean().default(false), - blisters: z.array(blisterSchema).min(1).max(12), -}); +const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister"); +const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"); + +const medicationSchema = z + .object({ + name: z.string().trim().min(1).max(100), + genericName: z.string().trim().max(100).nullable().optional(), + takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback) + packageType: packageTypeSchema, + packCount: z.number().int().min(0).default(1), + blistersPerPack: z.number().int().min(1).default(1), + pillsPerBlister: z.number().int().min(1).default(1), + totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity + looseTablets: z.number().int().min(0).default(0), + pillWeightMg: z.number().nonnegative().nullable().optional(), + doseUnit: doseUnitSchema, + expiryDate: z.string().nullable().optional(), + notes: z.string().max(2000).nullable().optional(), + intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat) + // Accept either new intakes format or legacy blisters format + intakes: z.array(intakeSchema).min(1).max(12).optional(), + blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format + }) + .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" }); export async function medicationRoutes(app: FastifyInstance) { // All medication routes require auth @@ -58,26 +78,40 @@ export async function medicationRoutes(app: FastifyInstance) { app.get("/medications", async (request, reply) => { const userId = await getUserId(request, reply); const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); - return rows.map((row) => ({ - id: row.id, - name: row.name, - genericName: row.genericName, - takenBy: parseTakenByJson(row.takenByJson), - packCount: row.packCount ?? 1, - blistersPerPack: row.blistersPerPack ?? 1, - pillsPerBlister: row.pillsPerBlister ?? 1, - looseTablets: row.looseTablets ?? 0, - stockAdjustment: row.stockAdjustment ?? 0, - lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, - pillWeightMg: row.pillWeightMg, - blisters: parseBlisters(row), - imageUrl: row.imageUrl, - expiryDate: row.expiryDate, - notes: row.notes, - intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, - dismissedUntil: row.dismissedUntil ?? null, - updatedAt: row.updatedAt, - })); + return rows.map((row) => { + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesJson( + row.intakesJson, + { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, + row.intakeRemindersEnabled ?? false + ); + + return { + id: row.id, + name: row.name, + genericName: row.genericName, + takenBy: parseTakenByJson(row.takenByJson), + packageType: row.packageType ?? "blister", + packCount: row.packCount ?? 1, + blistersPerPack: row.blistersPerPack ?? 1, + pillsPerBlister: row.pillsPerBlister ?? 1, + totalPills: row.totalPills ?? null, + looseTablets: row.looseTablets ?? 0, + stockAdjustment: row.stockAdjustment ?? 0, + lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, + pillWeightMg: row.pillWeightMg, + doseUnit: row.doseUnit ?? "mg", + intakes, // New unified format with per-intake takenBy + // Legacy blisters format (for backward compat with frontend during transition) + blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), + imageUrl: row.imageUrl, + expiryDate: row.expiryDate, + notes: row.notes, + intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, + dismissedUntil: row.dismissedUntil ?? null, + updatedAt: row.updatedAt, + }; + }); }); app.post("/medications", async (req, reply) => { @@ -89,19 +123,50 @@ export async function medicationRoutes(app: FastifyInstance) { name, genericName, takenBy, + packageType, packCount, blistersPerPack, pillsPerBlister, + totalPills, looseTablets, pillWeightMg, + doseUnit, expiryDate, notes, intakeRemindersEnabled, - blisters, + intakes: inputIntakes, + blisters: inputBlisters, } = 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)); + + // Convert to unified intakes format + let intakes: Intake[]; + if (inputIntakes) { + // New format with per-intake takenBy + intakes = inputIntakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + takenBy: i.takenBy || null, + intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, + })); + } else if (inputBlisters) { + // Legacy format - convert to new format + intakes = inputBlisters.map((b) => ({ + usage: b.usage, + every: b.every, + start: b.start, + takenBy: null, // No per-intake takenBy from legacy + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + })); + } else { + return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); + } + + // Store both formats for backward compatibility + const intakesJson = JSON.stringify(intakes); + const usageJson = JSON.stringify(intakes.map((s) => s.usage)); + const everyJson = JSON.stringify(intakes.map((s) => s.every)); + const startJson = JSON.stringify(intakes.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); const [inserted] = await db @@ -111,14 +176,18 @@ export async function medicationRoutes(app: FastifyInstance) { name, genericName: genericName || null, takenByJson, + packageType: packageType ?? "blister", packCount, blistersPerPack, pillsPerBlister, + totalPills: totalPills || null, looseTablets, pillWeightMg: pillWeightMg || null, + doseUnit: doseUnit ?? "mg", expiryDate: expiryDate || null, notes: notes || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, + intakesJson, usageJson, everyJson, startJson, @@ -130,14 +199,18 @@ export async function medicationRoutes(app: FastifyInstance) { name: inserted.name, genericName: inserted.genericName, takenBy: parseTakenByJson(inserted.takenByJson), + packageType: inserted.packageType ?? "blister", packCount: inserted.packCount, blistersPerPack: inserted.blistersPerPack, pillsPerBlister: inserted.pillsPerBlister, + totalPills: inserted.totalPills ?? null, looseTablets: inserted.looseTablets, stockAdjustment: inserted.stockAdjustment ?? 0, lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: inserted.pillWeightMg, - blisters, + doseUnit: inserted.doseUnit ?? "mg", + intakes, + blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), imageUrl: inserted.imageUrl, expiryDate: inserted.expiryDate, notes: inserted.notes, @@ -165,19 +238,50 @@ export async function medicationRoutes(app: FastifyInstance) { name, genericName, takenBy, + packageType, packCount, blistersPerPack, pillsPerBlister, + totalPills, looseTablets, pillWeightMg, + doseUnit, expiryDate, notes, intakeRemindersEnabled, - blisters, + intakes: inputIntakes, + blisters: inputBlisters, } = 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)); + + // Convert to unified intakes format + let intakes: Intake[]; + if (inputIntakes) { + // New format with per-intake takenBy + intakes = inputIntakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + takenBy: i.takenBy || null, + intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, + })); + } else if (inputBlisters) { + // Legacy format - convert to new format + intakes = inputBlisters.map((b) => ({ + usage: b.usage, + every: b.every, + start: b.start, + takenBy: null, // No per-intake takenBy from legacy + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + })); + } else { + return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); + } + + // Store both formats for backward compatibility + const intakesJson = JSON.stringify(intakes); + const usageJson = JSON.stringify(intakes.map((s) => s.usage)); + const everyJson = JSON.stringify(intakes.map((s) => s.every)); + const startJson = JSON.stringify(intakes.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); const result = await db @@ -186,14 +290,18 @@ export async function medicationRoutes(app: FastifyInstance) { name, genericName: genericName || null, takenByJson, + packageType: packageType ?? "blister", packCount, blistersPerPack, pillsPerBlister, + totalPills: totalPills || null, looseTablets, pillWeightMg: pillWeightMg || null, + doseUnit: doseUnit ?? "mg", expiryDate: expiryDate || null, notes: notes || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, + intakesJson, usageJson, everyJson, startJson, @@ -206,7 +314,7 @@ export async function medicationRoutes(app: FastifyInstance) { // Clean up dose tracking entries that are before the earliest start date // This ensures consistency when the user changes the start date - const earliestStart = Math.min(...blisters.map((b) => parseLocalDateTime(b.start).getTime())); + const earliestStart = Math.min(...intakes.map((b) => parseLocalDateTime(b.start).getTime())); if (!Number.isNaN(earliestStart)) { // Get all dose tracking entries for this medication and filter out invalid ones const allDoses = await db @@ -235,14 +343,18 @@ export async function medicationRoutes(app: FastifyInstance) { name: result[0].name, genericName: result[0].genericName, takenBy: parseTakenByJson(result[0].takenByJson), + packageType: result[0].packageType ?? "blister", packCount: result[0].packCount, blistersPerPack: result[0].blistersPerPack, pillsPerBlister: result[0].pillsPerBlister, + totalPills: result[0].totalPills ?? null, looseTablets: result[0].looseTablets, stockAdjustment: result[0].stockAdjustment ?? 0, lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: result[0].pillWeightMg, - blisters, + doseUnit: result[0].doseUnit ?? "mg", + intakes, + blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), imageUrl: result[0].imageUrl, expiryDate: result[0].expiryDate, notes: result[0].notes, @@ -398,7 +510,13 @@ export async function medicationRoutes(app: FastifyInstance) { const now = new Date(); const payload = rows.map((row) => { - const blisters = parseBlisters(row); + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesJson( + row.intakesJson, + { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, + row.intakeRemindersEnabled ?? false + ); + const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); const usageTotal = calculateUsageInRange(blisters, start, end); const pillsPerBlister = row.pillsPerBlister ?? 1; const packCount = row.packCount ?? 1; diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 402b040..0565859 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -7,7 +7,12 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { parseTakenByJson } from "../utils/scheduler-utils.js"; +import { + getAllTakenByForMedication, + parseIntakesJson, + parseTakenByJson, + personTakesMedication, +} from "../utils/scheduler-utils.js"; // Share token validity: 1 year in milliseconds const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000; @@ -78,27 +83,32 @@ export async function shareRoutes(app: FastifyInstance) { // Use SQLite JSON function to check if takenBy is in the array const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId)); - // Filter medications where takenByJson array contains the share.takenBy value + // Filter medications where takenBy matches either medication-level OR any intake-level takenBy const meds = allMeds.filter((med) => { const takenByArray = parseTakenByJson(med.takenByJson); - return takenByArray.includes(share.takenBy); + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + return personTakesMedication(share.takenBy, takenByArray, intakes); }); // Parse blisters and build schedule data const medicationsWithBlisters = meds.map((med) => { - let blisters: { usage: number; every: number; start: string }[] = []; - try { - const usageArr = JSON.parse(med.usageJson || "[]"); - const everyArr = JSON.parse(med.everyJson || "[]"); - const startArr = JSON.parse(med.startJson || "[]"); - blisters = usageArr.map((usage: number, i: number) => ({ - usage, - every: everyArr[i] ?? 1, - start: startArr[i] ?? new Date().toISOString(), - })); - } catch { - blisters = []; - } + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + + // Convert to legacy blisters format for backward compat + const blisters = intakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + })); // Parse takenBy JSON array const takenByArray = parseTakenByJson(med.takenByJson); @@ -110,6 +120,7 @@ export async function shareRoutes(app: FastifyInstance) { name: med.name, genericName: med.genericName, pillWeightMg: med.pillWeightMg, + doseUnit: med.doseUnit ?? "mg", imageUrl: med.imageUrl, totalPills, packCount: med.packCount, @@ -117,7 +128,8 @@ export async function shareRoutes(app: FastifyInstance) { looseTablets: med.looseTablets, pillsPerBlister: med.pillsPerBlister, takenBy: takenByArray, - blisters, + intakes, // New unified format with per-intake takenBy + blisters, // Legacy format for backward compat dismissedUntil: med.dismissedUntil, updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations }; @@ -153,11 +165,16 @@ export async function shareRoutes(app: FastifyInstance) { const { takenBy, scheduleDays } = parsed.data; - // Check if user has medications for this takenBy (search in JSON array) + // Check if user has medications for this takenBy (search in both medication-level and intake-level) const allMeds = await db.select().from(medications).where(eq(medications.userId, userId)); const medsForPerson = allMeds.filter((med) => { const takenByArray = parseTakenByJson(med.takenByJson); - return takenByArray.includes(takenBy); + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + return personTakesMedication(takenBy, takenByArray, intakes); }); if (medsForPerson.length === 0) { @@ -196,17 +213,30 @@ export async function shareRoutes(app: FastifyInstance) { app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => { const userId = await getUserId(request, reply); - // Get all unique takenBy values for this user (from JSON arrays) + // Get all unique takenBy values for this user (from both medication-level and intake-level) const meds = await db - .select({ takenByJson: medications.takenByJson }) + .select({ + takenByJson: medications.takenByJson, + intakesJson: medications.intakesJson, + usageJson: medications.usageJson, + everyJson: medications.everyJson, + startJson: medications.startJson, + intakeRemindersEnabled: medications.intakeRemindersEnabled, + }) .from(medications) .where(eq(medications.userId, userId)); - // Collect all unique person names from all takenByJson arrays + // Collect all unique person names from medication-level AND intake-level takenBy const allPeople = new Set(); for (const med of meds) { const takenByArray = parseTakenByJson(med.takenByJson); - for (const person of takenByArray) { + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + const allForMed = getAllTakenByForMedication(takenByArray, intakes); + for (const person of allForMed) { if (person) allPeople.add(person); } } diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 44e898a..ad14e25 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -8,15 +8,15 @@ import { getDateLocale, getTranslations, type Language, t } from "../i18n/transl import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; // Import shared utilities import { - type Blister, cleanOldIntakeReminders, createDefaultIntakeReminderState, getTimezone, getTodaysIntakes, getUpcomingIntakes, + type Intake, type IntakeReminderState, - parseBlisters, parseIntakeReminderState, + parseIntakesJson, parseTakenByJson, type UpcomingIntake, } from "../utils/scheduler-utils.js"; @@ -75,11 +75,10 @@ async function sendIntakeReminderEmail( return pillText; }; - // Helper to format medication name with takenBy (array of names) + // Helper to format medication name with takenBy (single person or null) const formatMedName = (intake: UpcomingIntake): string => { - if (intake.takenBy.length > 0) { - const namesStr = intake.takenBy.join(", "); - return `${intake.medName} ${t(tr.intakeReminder.takenBy, { name: namesStr })}`; + if (intake.takenBy) { + return `${intake.medName} ${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}`; } return intake.medName; }; @@ -172,7 +171,7 @@ ${description} ${intakes .map((i) => { - const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; + const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : ""; return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`; }) .join("\n")} @@ -291,62 +290,92 @@ async function checkAndSendIntakeRemindersForUser( // Find intakes: upcoming ones in reminder window + past ones for repeat reminders for (const med of medsWithReminders) { - const blisters = parseBlisters(med); - const takenByArray = parseTakenByJson(med.takenByJson); + // Parse intakes using new format (with per-intake takenBy), falling back to legacy + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + // Medication-level takenBy (for fallback/display purposes) + const medicationTakenBy = parseTakenByJson(med.takenByJson); logger.info( - `[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters` + `[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes` ); - // Process each blister separately to track blisterIndex - blisters.forEach((blister, blisterIndex) => { + // Filter intakes that have reminders enabled (per-intake setting or medication-level) + const intakesWithReminders = intakes.filter((intake, idx) => { + const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled; + if (!hasReminder) { + logger.info(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`); + } + return hasReminder; + }); + + // Process each intake separately to track blisterIndex + intakesWithReminders.forEach((intake, blisterIndex) => { + const actualIndex = intakes.indexOf(intake); // Get the actual index in original array logger.info( - `[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}` + `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}` ); // Always get upcoming intakes (15 min before) for first reminders const upcomingIntakes = getUpcomingIntakes( med.name, - [blister], + [intake], REMINDER_MINUTES_BEFORE, - takenByArray, + medicationTakenBy, med.pillWeightMg, locale, - tz + tz, + undefined, // nowOverride + med.id, + med.doseUnit ?? "mg" ); logger.info( - `[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)` + `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)` ); // Add upcoming intakes for first reminders allUpcoming.push( - ...upcomingIntakes.map((intake) => ({ - ...intake, + ...upcomingIntakes.map((upcomingIntake) => ({ + ...upcomingIntake, medicationId: med.id, - blisterIndex, + blisterIndex: actualIndex, })) ); // If repeat reminders enabled, also check for missed intakes (past the intake time) if (settings.repeatRemindersEnabled) { - const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz); - logger.info( - `[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}` + const allTodaysIntakes = getTodaysIntakes( + med.name, + [intake], + medicationTakenBy, + med.pillWeightMg, + locale, + tz, + med.id, + med.doseUnit ?? "mg" ); - const missedIntakes = allTodaysIntakes.filter((intake) => intake.intakeTime.getTime() < now.getTime()); logger.info( - `[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)` + `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}` + ); + const missedIntakes = allTodaysIntakes.filter( + (todayIntake) => todayIntake.intakeTime.getTime() < now.getTime() + ); + logger.info( + `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)` ); // Add missed intakes for repeat reminders (only if not already in upcoming list) const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime())); allUpcoming.push( ...missedIntakes - .filter((intake) => !upcomingTimes.has(intake.intakeTime.getTime())) - .map((intake) => ({ - ...intake, + .filter((missed) => !upcomingTimes.has(missed.intakeTime.getTime())) + .map((missed) => ({ + ...missed, medicationId: med.id, - blisterIndex, + blisterIndex: actualIndex, })) ); } @@ -438,20 +467,31 @@ async function checkAndSendIntakeRemindersForUser( // Filter out reminders for doses that were already taken remindersToSend = remindersToSend.filter((intake) => { - const timestamp = intake.intakeTime.getTime(); + // Convert to date-only timestamp (midnight) to match frontend dose ID format + const intakeDate = intake.intakeTime; + const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime(); // Check both with and without person suffix - if (intake.takenBy.length > 0) { - // For multi-person medications, check if any person has taken it - const anyTaken = intake.takenBy.some((person) => { - const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`; - return takenDoseIds.has(doseId); - }); - return !anyTaken; // Skip if any person has taken it + if (intake.takenBy) { + // For person-specific intake, check if that person has taken it + const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`; + const isTaken = takenDoseIds.has(doseId); + if (isTaken) { + logger.info( + `[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken` + ); + } + return !isTaken; } else { - // For non-person-specific medications - const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`; - return !takenDoseIds.has(doseId); + // For non-person-specific intakes + const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`; + const isTaken = takenDoseIds.has(doseId); + if (isTaken) { + logger.info( + `[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken` + ); + } + return !isTaken; } }); @@ -541,8 +581,7 @@ async function checkAndSendIntakeRemindersForUser( const message = remindersToSend .map((i) => { - const takenByStr = - i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; + const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : ""; let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`; if (i.pillWeightMg) { const totalMg = i.usage * i.pillWeightMg; @@ -621,7 +660,7 @@ async function checkAndSendIntakeRemindersForUser( // Get the first reminder's medication name and taken by for display const firstReminder = remindersToSend[0]; const medName = firstReminder?.medName; - const takenBy = firstReminder?.takenBy?.length > 0 ? firstReminder.takenBy.join(", ") : undefined; + const takenBy = firstReminder?.takenBy || undefined; await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy); } } diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 7fbbe41..94ac976 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -76,29 +76,33 @@ async function createSchema(client: Client) { updated_at integer NOT NULL DEFAULT (strftime('%s','now')) )`, `CREATE TABLE IF NOT EXISTS medications ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - name text NOT NULL, - generic_name text, - taken_by_json text NOT NULL DEFAULT '[]', - pack_count 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, - stock_adjustment integer NOT NULL DEFAULT 0, - last_stock_correction_at integer, - pill_weight_mg integer, - usage_json text NOT NULL DEFAULT '[]', - every_json text NOT NULL DEFAULT '[]', - start_json text NOT NULL DEFAULT '[]', - image_url text, - expiry_date text, - notes text, - intake_reminders_enabled integer NOT NULL DEFAULT 0, - dismissed_until text, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + package_type text NOT NULL DEFAULT 'blister', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + total_pills integer, + loose_tablets integer NOT NULL DEFAULT 0, + stock_adjustment integer NOT NULL DEFAULT 0, + last_stock_correction_at integer, + pill_weight_mg integer, + dose_unit text DEFAULT 'mg', + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + intakes_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + dismissed_until text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, `CREATE TABLE IF NOT EXISTS user_settings ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL UNIQUE, diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 2af8a70..2494d15 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -71,29 +71,33 @@ async function createSchema(client: Client) { updated_at integer NOT NULL DEFAULT (strftime('%s','now')) )`, `CREATE TABLE IF NOT EXISTS medications ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - name text NOT NULL, - generic_name text, - taken_by_json text NOT NULL DEFAULT '[]', - pack_count 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, - stock_adjustment integer NOT NULL DEFAULT 0, - last_stock_correction_at integer, - pill_weight_mg integer, - usage_json text NOT NULL DEFAULT '[]', - every_json text NOT NULL DEFAULT '[]', - start_json text NOT NULL DEFAULT '[]', - image_url text, - expiry_date text, - notes text, - intake_reminders_enabled integer NOT NULL DEFAULT 0, - dismissed_until text, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by_json text NOT NULL DEFAULT '[]', + package_type text NOT NULL DEFAULT 'blister', + pack_count integer NOT NULL DEFAULT 1, + blisters_per_pack integer NOT NULL DEFAULT 1, + pills_per_blister integer NOT NULL DEFAULT 1, + total_pills integer, + loose_tablets integer NOT NULL DEFAULT 0, + stock_adjustment integer NOT NULL DEFAULT 0, + last_stock_correction_at integer, + pill_weight_mg integer, + dose_unit text DEFAULT 'mg', + usage_json text NOT NULL DEFAULT '[]', + every_json text NOT NULL DEFAULT '[]', + start_json text NOT NULL DEFAULT '[]', + intakes_json text NOT NULL DEFAULT '[]', + image_url text, + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + dismissed_until text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, `CREATE TABLE IF NOT EXISTS user_settings ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL UNIQUE, diff --git a/backend/src/test/services.test.ts b/backend/src/test/services.test.ts index be5222d..c6e0f56 100644 --- a/backend/src/test/services.test.ts +++ b/backend/src/test/services.test.ts @@ -16,12 +16,24 @@ import { getTodayInTimezone, getTodaysIntakes, getUpcomingIntakes, + type Intake, parseBlisters, parseIntakeReminderState, parseReminderState, parseTakenByJson, } from "../utils/scheduler-utils.js"; +// Helper to convert Blister to Intake for tests +function blisterToIntake(blister: Blister, takenBy: string | null = null, intakeRemindersEnabled = false): Intake { + return { + usage: blister.usage, + every: blister.every, + start: blister.start, + takenBy, + intakeRemindersEnabled, + }; +} + describe("Scheduler Utils - Timezone Functions", () => { let originalTz: string | undefined; @@ -333,45 +345,45 @@ describe("Scheduler Utils - Upcoming Intakes", () => { describe("getUpcomingIntakes", () => { it("should return empty array when no intakes in window", () => { // With parseLocalDateTime, times are treated as local - use same format for consistency - const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }]; + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })]; // Set "now" to a time far from any scheduled intake (12:00 local) const now = new Date(2025, 0, 1, 12, 0, 0).getTime(); - const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); expect(result).toEqual([]); }); it("should find intake within reminder window", () => { // Schedule intake at 08:00 local, check at 07:45 local (15 minutes before) - const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00" }]; + const intakes: Intake[] = [blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:00:00" }, "Alice")]; const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); - const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now); + const result = getUpcomingIntakes("TestMed", intakes, 15, [], 500, "en-US", "UTC", now); expect(result).toHaveLength(1); expect(result[0].medName).toBe("TestMed"); expect(result[0].usage).toBe(2); - expect(result[0].takenBy).toEqual(["Alice"]); + expect(result[0].takenBy).toBe("Alice"); expect(result[0].pillWeightMg).toBe(500); }); it("should skip blisters with zero interval", () => { - const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00" }]; + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })]; const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); - const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); expect(result).toEqual([]); }); it("should handle multiple blisters", () => { // Two intakes at 08:00 and 08:01 local - const blisters: Blister[] = [ - { usage: 1, every: 1, start: "2025-01-01T08:00:00" }, - { usage: 2, every: 1, start: "2025-01-01T08:01:00" }, + const intakes: Intake[] = [ + blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }), + blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }), ]; const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); - const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); // Both should be found as they're within the window expect(result.length).toBeGreaterThanOrEqual(1); @@ -382,10 +394,10 @@ describe("Scheduler Utils - Upcoming Intakes", () => { it("should return all intakes for today", () => { // Daily medication at 08:00 starting yesterday // With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time - const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }]; + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" })]; // Get intakes for today (today's intake should be at 08:00 local) - const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC"); + const result = getTodaysIntakes("TestMed", intakes, [], null, "en-US", "UTC"); expect(result.length).toBeGreaterThanOrEqual(1); const intake = result.find((i) => i.intakeTime.getHours() === 8); @@ -399,20 +411,23 @@ describe("Scheduler Utils - Upcoming Intakes", () => { const todayMidnight = new Date(); todayMidnight.setUTCHours(0, 1, 0, 0); - const blisters: Blister[] = [ - { - usage: 2, - every: 1, - start: todayMidnight.toISOString(), - }, + const intakes: Intake[] = [ + blisterToIntake( + { + usage: 2, + every: 1, + start: todayMidnight.toISOString(), + }, + "Bob" + ), ]; - const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC"); + const result = getTodaysIntakes("PastMed", intakes, [], 250, "en-US", "UTC"); expect(result).toHaveLength(1); expect(result[0].medName).toBe("PastMed"); expect(result[0].usage).toBe(2); - expect(result[0].takenBy).toEqual(["Bob"]); + expect(result[0].takenBy).toBe("Bob"); expect(result[0].pillWeightMg).toBe(250); }); @@ -424,12 +439,12 @@ describe("Scheduler Utils - Upcoming Intakes", () => { const evening = new Date(today); evening.setUTCHours(20, 0, 0, 0); - const blisters: Blister[] = [ - { usage: 1, every: 1, start: morning.toISOString() }, - { usage: 1, every: 1, start: evening.toISOString() }, + const intakes: Intake[] = [ + blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }), + blisterToIntake({ usage: 1, every: 1, start: evening.toISOString() }), ]; - const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC"); + const result = getTodaysIntakes("MultiMed", intakes, [], null, "en-US", "UTC"); expect(result.length).toBeGreaterThanOrEqual(2); }); @@ -439,16 +454,16 @@ describe("Scheduler Utils - Upcoming Intakes", () => { const lastWeek = new Date(); lastWeek.setDate(lastWeek.getDate() - 7); - const blisters: Blister[] = [ - { + const intakes: Intake[] = [ + blisterToIntake({ usage: 1, every: 7, start: lastWeek.toISOString(), - }, + }), ]; // If today is not the same day of week, should return empty - const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC"); + const result = getTodaysIntakes("WeeklyMed", intakes, [], null, "en-US", "UTC"); // This test might return 0 or 1 depending on the day expect(Array.isArray(result)).toBe(true); @@ -458,15 +473,15 @@ describe("Scheduler Utils - Upcoming Intakes", () => { // With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time // The intakeTimeStr is then formatted for the target timezone (Europe/Berlin) // So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time - const blisters: Blister[] = [ - { + const intakes: Intake[] = [ + blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time - }, + }), ]; - const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin"); + const result = getTodaysIntakes("TzMed", intakes, [], null, "de-DE", "Europe/Berlin"); expect(Array.isArray(result)).toBe(true); if (result.length > 0) { diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index 296942d..217e0e2 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -5,8 +5,18 @@ import { getDateLocale, type Language } from "../i18n/translations.js"; +// Legacy type - individual blister schedule (DEPRECATED: use Intake instead) export type Blister = { usage: number; every: number; start: string }; +// New unified intake type with per-intake takenBy +export type Intake = { + usage: number; + every: number; + start: string; + takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy) + intakeRemindersEnabled: boolean; +}; + // ============================================================================= // Timezone utilities // ============================================================================= @@ -147,7 +157,7 @@ export function parseLocalDateTime(isoString: string): Date { ); } -/** Parse blister schedules from JSON columns */ +/** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */ export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { try { const usage = JSON.parse(row.usageJson) as number[]; @@ -164,6 +174,59 @@ export function parseBlisters(row: { usageJson: string; everyJson: string; start } } +/** + * Parse intakes from the new unified intakesJson format. + * Falls back to legacy parallel arrays if intakesJson is empty. + * @param intakesJson - The new unified JSON string + * @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback + * @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy) + */ +export function parseIntakesJson( + intakesJson: string | null | undefined, + legacyRow?: { usageJson: string; everyJson: string; startJson: string }, + medicationIntakeRemindersEnabled?: boolean +): Intake[] { + // Try new format first + if (intakesJson) { + try { + const parsed = JSON.parse(intakesJson); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.map((intake: any) => ({ + usage: typeof intake.usage === "number" ? intake.usage : 0, + every: typeof intake.every === "number" ? intake.every : 1, + start: typeof intake.start === "string" ? intake.start : new Date().toISOString(), + takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null, + intakeRemindersEnabled: + typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false, + })); + } + } catch { + // Fall through to legacy parsing + } + } + + // Fallback to legacy parallel arrays + if (legacyRow) { + const blisters = parseBlisters(legacyRow); + return blisters.map((b) => ({ + usage: b.usage, + every: b.every, + start: b.start, + takenBy: null, // Legacy format has no per-intake takenBy + intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false, + })); + } + + return []; +} + +/** + * Convert intakes to legacy blister format (for backward compatibility) + */ +export function intakesToBlisters(intakes: Intake[]): Blister[] { + return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); +} + /** Parse takenByJson to array of strings */ export function parseTakenByJson(takenByJson: string | null | undefined): string[] { if (!takenByJson) return []; @@ -175,6 +238,28 @@ export function parseTakenByJson(takenByJson: string | null | undefined): string } } +/** + * Get all unique takenBy values from both medication-level and intake-level. + * Used for filtering and sharing functionality. + */ +export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] { + const allPeople = new Set(medicationTakenBy); + for (const intake of intakes) { + if (intake.takenBy) { + allPeople.add(intake.takenBy); + } + } + return Array.from(allPeople); +} + +/** + * Check if a person takes this medication (either via medication-level or intake-level takenBy). + */ +export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean { + if (medicationTakenBy.includes(person)) return true; + return intakes.some((intake) => intake.takenBy === person); +} + // ============================================================================= // Stock calculation utilities // ============================================================================= @@ -209,24 +294,30 @@ export function calculateDepletionInfo( export type UpcomingIntake = { medName: string; + medicationId?: number; + blisterIndex?: number; usage: number; intakeTime: Date; intakeTimeStr: string; - takenBy: string[]; + takenBy: string | null; // Single person for this intake (null = no specific person) pillWeightMg: number | null; + doseUnit?: string; }; /** * Get all intakes for today (past and future) - used for repeat reminders. * Returns all intakes scheduled for today in user's timezone. + * Now uses per-intake takenBy instead of medication-level. */ export function getTodaysIntakes( medName: string, - blisters: Blister[], - takenBy: string[], + intakes: Intake[], + medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, - tz?: string + tz?: string, + medicationId?: number, + doseUnit?: string ): UpcomingIntake[] { const timezone = tz ?? getTimezone(); const now = new Date(); @@ -238,14 +329,19 @@ export function getTodaysIntakes( const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone })); todayEnd.setHours(23, 59, 59, 999); - const intakes: UpcomingIntake[] = []; + const result: UpcomingIntake[] = []; - for (const blister of blisters) { - const startTime = parseLocalDateTime(blister.start).getTime(); - const intervalMs = blister.every * 24 * 60 * 60 * 1000; + for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { + const intake = intakes[blisterIdx]; + const startTime = parseLocalDateTime(intake.start).getTime(); + const intervalMs = intake.every * 24 * 60 * 60 * 1000; if (intervalMs <= 0) continue; + // Determine takenBy for this intake + // If intake has its own takenBy, use it; otherwise null (no specific person) + const effectiveTakenBy = intake.takenBy || null; + // Find all occurrences that fall within today let currentTime = startTime; @@ -260,39 +356,45 @@ export function getTodaysIntakes( while (currentTime <= todayEnd.getTime()) { if (currentTime >= todayStart.getTime()) { const intakeDate = new Date(currentTime); - intakes.push({ + result.push({ medName, - usage: blister.usage, + medicationId, + blisterIndex: blisterIdx, + usage: intake.usage, intakeTime: intakeDate, intakeTimeStr: intakeDate.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", timeZone: timezone, }), - takenBy, + takenBy: effectiveTakenBy, pillWeightMg, + doseUnit, }); } currentTime += intervalMs; } } - return intakes; + return result; } /** * Get upcoming intakes that fall within the reminder window. * Returns intakes that should be notified about right now. + * Now uses per-intake takenBy instead of medication-level. */ export function getUpcomingIntakes( medName: string, - blisters: Blister[], + intakes: Intake[], minutesBefore: number, - takenBy: string[], + medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, tz?: string, - nowOverride?: number + nowOverride?: number, + medicationId?: number, + doseUnit?: string ): UpcomingIntake[] { const now = nowOverride ?? Date.now(); const timezone = tz ?? getTimezone(); @@ -303,12 +405,16 @@ export function getUpcomingIntakes( const upcoming: UpcomingIntake[] = []; - for (const blister of blisters) { - const startTime = parseLocalDateTime(blister.start).getTime(); - const intervalMs = blister.every * 24 * 60 * 60 * 1000; + for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { + const intake = intakes[blisterIdx]; + const startTime = parseLocalDateTime(intake.start).getTime(); + const intervalMs = intake.every * 24 * 60 * 60 * 1000; if (intervalMs <= 0) continue; + // Determine takenBy for this intake + const effectiveTakenBy = intake.takenBy || null; + // Find the next scheduled intake time (could be today or in the future) let nextTime = startTime; @@ -339,15 +445,18 @@ export function getUpcomingIntakes( const intakeDate = new Date(nextTime); upcoming.push({ medName, - usage: blister.usage, + medicationId, + blisterIndex: blisterIdx, + usage: intake.usage, intakeTime: intakeDate, intakeTimeStr: intakeDate.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", timeZone: timezone, }), - takenBy, + takenBy: effectiveTakenBy, pillWeightMg, + doseUnit, }); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51fda41..c73b335 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -185,6 +185,9 @@ function AppContent() { const [showProfile, setShowProfile] = useState(false); const [showAbout, setShowAbout] = useState(false); + // Get centralized stockThresholds from context + const { stockThresholds } = ctx; + // Close modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -417,7 +420,7 @@ function AppContent() { diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 3810cba..bb3d991 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -1,6 +1,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmModal } from "./ConfirmModal"; +import { PasswordInput } from "./PasswordInput"; // ============================================================================= // Types (no roles - all users are equal) @@ -402,9 +403,8 @@ export function LoginForm({
- setPassword(e.target.value)} required @@ -522,9 +522,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
- setPassword(e.target.value)} required @@ -536,9 +535,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
- setConfirmPassword(e.target.value)} required @@ -722,9 +720,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
- setCurrentPassword(e.target.value)} autoComplete="current-password" @@ -734,9 +731,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
- setNewPassword(e.target.value)} autoComplete="new-password" @@ -747,9 +743,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
- setConfirmPassword(e.target.value)} autoComplete="new-password" diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index c5c813e..37e27e1 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -171,25 +171,30 @@ export function MedDetailModal({

{t("modal.stockInfo")}

-
- {t("table.fullBlisters")} - {formatFullBlisters(stock.fullBlisters, t)} -
-
- {t("table.openBlister")} - - {formatOpenBlisterAndLoose( - stock.openBlisterPills, - stock.loosePills, - selectedMed.pillsPerBlister ?? 1, - t - )} - -
-
+ {selectedMed.packageType === "blister" && ( + <> +
+ {t("table.fullBlisters")} + {formatFullBlisters(stock.fullBlisters, t)} +
+
+ {t("table.openBlister")} + + {formatOpenBlisterAndLoose( + stock.openBlisterPills, + stock.loosePills, + selectedMed.pillsPerBlister ?? 1, + t + )} + +
+ + )} +
{t("modal.currentStock")} - {currentStock} / {packageSize} + {currentStock} /{" "} + {selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
@@ -199,22 +204,33 @@ export function MedDetailModal({

{t("modal.packageDetails")}

-
- {t("modal.packs")} - {selectedMed.packCount} -
-
- {t("modal.blistersPerPack")} - {selectedMed.blistersPerPack} -
-
- {t("modal.pillsPerBlister")} - {selectedMed.pillsPerBlister} -
+ {selectedMed.packageType === "blister" ? ( + <> +
+ {t("modal.packs")} + {selectedMed.packCount} +
+
+ {t("modal.blistersPerPack")} + {selectedMed.blistersPerPack} +
+
+ {t("modal.pillsPerBlister")} + {selectedMed.pillsPerBlister} +
+ + ) : ( +
+ {t("form.totalCapacity")} + {selectedMed.totalPills ?? "โ€”"} +
+ )} {selectedMed.pillWeightMg && (
{t("modal.pillWeight")} - {selectedMed.pillWeightMg} mg + + {selectedMed.pillWeightMg} {selectedMed.doseUnit ?? "mg"} +
)} {selectedMed.expiryDate && ( @@ -253,7 +269,8 @@ export function MedDetailModal({
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")} - {selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`} + {selectedMed.pillWeightMg && + ` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} {t("form.blisters.every")} {blister.every}{" "} diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 7ef4b40..716e2c9 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -3,7 +3,8 @@ * Handles new medication creation and editing existing medications */ import { useTranslation } from "react-i18next"; -import type { FieldErrors, FormBlister, FormState, Medication } from "../types"; +import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; +import { DOSE_UNITS } from "../types"; import { deriveTotal } from "../utils"; // Field limits for validation @@ -31,10 +32,14 @@ export interface MobileEditModalProps { onAddTakenByPerson: (person: string) => void; onRemoveTakenByPerson: (person: string) => void; onTakenByKeyDown: (e: React.KeyboardEvent) => void; - // Blister helpers + // Blister helpers (legacy) onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void; onAddBlister: () => void; onRemoveBlister: (idx: number) => void; + // Intake helpers (new - with per-intake takenBy) + onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void; + onAddIntake: (takenBy?: string) => void; + onRemoveIntake: (idx: number) => void; // Value change handler for numeric fields onHandleValueChange: (field: K, value: string) => void; // Refill state (for edit mode) @@ -56,6 +61,10 @@ export interface MobileEditModalProps { /** Calculate total pills from form state */ function deriveTotalFromForm(form: FormState) { + if (form.packageType === "bottle") { + // For bottle type, looseTablets is the current stock + return Number(form.looseTablets) || 0; + } const packCount = Number(form.packCount) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0; const pillsPerBlister = Number(form.pillsPerBlister) || 1; @@ -82,6 +91,9 @@ export function MobileEditModal({ onSetBlisterValue, onAddBlister, onRemoveBlister, + onSetIntakeValue, + onAddIntake, + onRemoveIntake, onHandleValueChange, refillPacks, onRefillPacksChange, @@ -180,57 +192,106 @@ export function MobileEditModal({
{fieldErrors.takenBy && {fieldErrors.takenBy}} - - - -
{item.doses.map((dose) => { - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; // Check: medication-level dismissedUntil, per-dose dismissed flag, and previous schedule const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName); const isFromPreviousSchedule = isDoseFromPreviousSchedule(dose.id, dose.medName); - const allDone = people.every((person) => { - const doseId = getDoseId(dose.id, person); - return ( - takenDoses.has(doseId) || - dismissedDoses.has(doseId) || - isMedLevelDismissed || - isFromPreviousSchedule - ); - }); + const isTaken = takenDoses.has(dose.id); + const isPerDoseDismissed = dismissedDoses.has(dose.id); + const isDone = + isTaken || isPerDoseDismissed || isMedLevelDismissed || isFromPreviousSchedule; return ( -
+
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isPerDoseDismissed = dismissedDoses.has(doseId); - const isDone = - isTaken || - isPerDoseDismissed || - isMedLevelDismissed || - isFromPreviousSchedule; - return ( -
- {person && {person}} - {isDone ? ( - isTaken ? ( - - ) : ( - // Dismissed - show checkmark but no undo - - โœ“ - - ) - ) : ( - - )} -
- ); - })} +
+ {dose.takenBy && {dose.takenBy}} + {isDone ? ( + isTaken ? ( + + ) : ( + // Dismissed - show checkmark but no undo + + โœ“ + + ) + ) : ( + + )} +
); @@ -839,11 +815,7 @@ export function SharedSchedule() { {todayDay && (() => { const day = todayDay; - const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ) - ); + const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const worstStatus = getDayStockStatus(day.meds); @@ -898,9 +870,7 @@ export function SharedSchedule() { } } - const itemDoseIds = item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ); + const itemDoseIds = item.doses.map((d) => d.id); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
{item.doses.map((dose) => { - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; - const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); + const isTaken = takenDoses.has(dose.id); + const isOverdue = dose.when < Date.now() && !isTaken; return ( -
+
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isOverdue = dose.when < Date.now() && !isTaken; - return ( -
+ {dose.takenBy && {dose.takenBy}} + {isTaken ? ( + - ) : ( - - )} -
- ); - })} + โ†ฉ + + ) : ( + + )} +
); @@ -1000,11 +963,7 @@ export function SharedSchedule() { {showFutureDays && futureDays.map((day) => { // Check if all doses in this day are taken (auto-collapse) - const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ) - ); + const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -1062,9 +1021,7 @@ export function SharedSchedule() { } } - const itemDoseIds = item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ); + const itemDoseIds = item.doses.map((d) => d.id); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
{item.doses.map((dose) => { - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; - const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); + const isTaken = takenDoses.has(dose.id); // Only disable doses on future DAYS, not later today const doseDate = new Date(dose.when); doseDate.setHours(0, 0, 0, 0); const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); const isFutureDose = doseDate.getTime() > todayMidnight.getTime(); + const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose; return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose; - return ( -
+ {dose.takenBy && {dose.takenBy}} + {isTaken ? ( + - ) : ( - - )} -
- ); - })} + โ†ฉ + + ) : ( + + )} +
); diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 8dbfd04..393af3b 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,6 +13,7 @@ export type { MedicationAvatarProps } from "./MedicationAvatar"; export { MedicationAvatar } from "./MedicationAvatar"; export type { MobileEditModalProps } from "./MobileEditModal"; export { MobileEditModal } from "./MobileEditModal"; +export { PasswordInput } from "./PasswordInput"; export { default as ProfileModal } from "./ProfileModal"; export type { ShareDialogProps } from "./ShareDialog"; export { ShareDialog } from "./ShareDialog"; diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 4a51fc3..c346c77 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import { useTranslation } from "react-i18next"; import { useAuth } from "../components/Auth"; import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks"; -import type { Coverage, Medication, ScheduleEvent } from "../types"; +import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types"; import { getSystemLocale } from "../utils/formatters"; import { buildSchedulePreview, calculateCoverage } from "../utils/schedule"; @@ -134,6 +134,7 @@ export interface AppContextValue { coverage: { all: Coverage[]; low: Coverage[] }; coverageByMed: Record; depletionByMed: Record; + stockThresholds: StockThresholds; existingPeople: string[]; groupedSchedule: GroupedDay[]; pastDays: GroupedDay[]; @@ -296,6 +297,24 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); + // Centralized stock thresholds for consistent status display across all components + const stockThresholds: StockThresholds = useMemo( + () => ({ + lowStockDays: settingsHook.settings.lowStockDays, + normalStockDays: settingsHook.settings.normalStockDays, + highStockDays: settingsHook.settings.highStockDays, + criticalStockDays: settingsHook.settings.reminderDaysBefore, // Critical uses the reminder threshold + expiryWarningDays: settingsHook.settings.expiryWarningDays, + }), + [ + settingsHook.settings.lowStockDays, + settingsHook.settings.normalStockDays, + settingsHook.settings.highStockDays, + settingsHook.settings.reminderDaysBefore, + settingsHook.settings.expiryWarningDays, + ] + ); + const existingPeople = useMemo(() => { const allPeople = medications.meds.flatMap((m) => m.takenBy || []); return [...new Set(allPeople)].filter(Boolean).sort(); @@ -798,6 +817,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { coverage, coverageByMed, depletionByMed, + stockThresholds, existingPeople, groupedSchedule, pastDays, @@ -861,6 +881,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { coverage, coverageByMed, depletionByMed, + stockThresholds, existingPeople, groupedSchedule, pastDays, diff --git a/frontend/src/hooks/useMedicationForm.ts b/frontend/src/hooks/useMedicationForm.ts index a9c7a17..f0a1cca 100644 --- a/frontend/src/hooks/useMedicationForm.ts +++ b/frontend/src/hooks/useMedicationForm.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { FieldErrors, FormBlister, FormState, Medication } from "../types"; +import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; import { FIELD_LIMITS } from "../types"; import { toDateValue, toTimeValue } from "../utils/formatters"; @@ -14,19 +14,38 @@ export const defaultBlister = (): FormBlister => { }; }; +/** + * Create a new intake with optional per-intake takenBy + */ +export const defaultIntake = (takenBy: string = ""): FormIntake => { + const now = new Date(); + return { + usage: "1", + every: "1", + startDate: toDateValue(now), + startTime: toTimeValue(now), + takenBy, // Per-intake user assignment (empty string = null/everyone) + intakeRemindersEnabled: false, + }; +}; + export const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], + packageType: "blister", packCount: "1", blistersPerPack: "1", pillsPerBlister: "1", + totalPills: "", looseTablets: "0", pillWeightMg: "", + doseUnit: "mg", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()], + intakes: [defaultIntake()], }); export interface UseMedicationFormReturn { @@ -53,6 +72,10 @@ export interface UseMedicationFormReturn { setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void; addBlister: () => void; removeBlister: (idx: number) => void; + // Intake management with per-intake takenBy + setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void; + addIntake: (takenBy?: string) => void; + removeIntake: (idx: number) => void; startEdit: (med: Medication, openEditModal: () => void) => void; resetForm: () => void; handleValueChange: (key: K, value: string) => void; @@ -134,19 +157,60 @@ export function useMedicationForm(): UseMedicationFormReturn { setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) })); }, []); + // Intake management with per-intake takenBy + const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => { + setForm((prev) => { + const next = [...prev.intakes]; + next[idx] = { ...next[idx], [field]: value }; + return { ...prev, intakes: next }; + }); + }, []); + + const addIntake = useCallback((takenBy: string = "") => { + setForm((prev) => ({ ...prev, intakes: [...prev.intakes, defaultIntake(takenBy)] })); + }, []); + + const removeIntake = useCallback((idx: number) => { + setForm((prev) => ({ ...prev, intakes: prev.intakes.filter((_, i) => i !== idx) })); + }, []); + const startEdit = useCallback((med: Medication, openEditModal: () => void) => { setEditingId(med.id); setTakenByInput(""); // Clear tag input when starting edit setFormSaved(true); // Existing medication is already saved + + // Parse intakes - prefer new format, fallback to legacy blisters + const intakesFromApi = + med.intakes && med.intakes.length > 0 + ? med.intakes.map((i) => ({ + usage: String(i.usage), + every: String(i.every), + startDate: toDateValue(i.start), + startTime: toTimeValue(i.start), + takenBy: i.takenBy ?? "", // Convert null to empty string for form + intakeRemindersEnabled: i.intakeRemindersEnabled, + })) + : med.blisters.map((s) => ({ + usage: String(s.usage), + every: String(s.every), + startDate: toDateValue(s.start), + startTime: toTimeValue(s.start), + takenBy: "", // Legacy blisters have no per-intake takenBy + intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, + })); + const editForm: FormState = { name: med.name, genericName: med.genericName ?? "", takenBy: med.takenBy || [], // Already an array from API + packageType: med.packageType ?? "blister", packCount: String(med.packCount), blistersPerPack: String(med.blistersPerPack), pillsPerBlister: String(med.pillsPerBlister), + totalPills: med.totalPills ? String(med.totalPills) : "", looseTablets: String(med.looseTablets), pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", + doseUnit: med.doseUnit ?? "mg", expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", notes: med.notes ?? "", intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, @@ -156,6 +220,7 @@ export function useMedicationForm(): UseMedicationFormReturn { startDate: toDateValue(s.start), startTime: toTimeValue(s.start), })), + intakes: intakesFromApi, }; setForm(editForm); setOriginalForm(editForm); @@ -234,6 +299,9 @@ export function useMedicationForm(): UseMedicationFormReturn { setBlisterValue, addBlister, removeBlister, + setIntakeValue, + addIntake, + removeIntake, startEdit, resetForm, handleValueChange, diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index a509753..37c6fbe 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -88,7 +88,8 @@ "lowMeds": "{{count}} Medikament knapp", "lowMeds_other": "{{count}} Medikamente knapp", "daysLeft": "{{days}} Tag รผbrig", - "daysLeft_other": "{{days}} Tage รผbrig" + "daysLeft_other": "{{days}} Tage รผbrig", + "needsRefill": "Nachfรผllen nรถtig" } }, "table": { @@ -98,11 +99,16 @@ "currentPills": "Aktuelle Tabletten", "fullBlisters": "Volle Blister", "openBlister": "Offener Blister", + "stock": "Bestand", + "stockDetails": "Details", "daysLeft": "Tage รผbrig", - "status": "Bestand", + "status": "Status", "runsOut": "Aufgebraucht", "autoRemind": "Auto-Erinnerung", - "expiry": "Ablaufdatum" + "expiry": "Ablaufdatum", + "pillsCount": "{{count}} Tabletten", + "pillsCount_one": "{{count}} Tablette", + "pillsCount_other": "{{count}} Tabletten" }, "medications": { "list": { @@ -116,7 +122,8 @@ "blisters": "Blister pro Packung", "pillsPerBlister": "Tabletten pro Blister", "loose": "Lose", - "total": "Gesamt" + "total": "Gesamt", + "stock": "Bestand" } }, "form": { @@ -126,11 +133,16 @@ "commercialName": "Handelsname", "genericName": "Wirkstoff", "takenBy": "Eingenommen von", + "packageType": "Verpackungsart", + "packageTypeBlister": "Blisterpackung", + "packageTypeBottle": "Pillendose / Behรคlter", "packs": "Packungen", "blistersPerPack": "Blister pro Packung", "pillsPerBlister": "Tabletten pro Blister", + "totalCapacity": "Gesamtkapazitรคt", + "currentPills": "Aktuelle Tabletten", "loosePills": "Lose Tabletten", - "pillWeight": "Tablettengewicht (mg)", + "pillWeight": "Dosis pro Tablette", "total": "Gesamt (Tabletten)", "expiryDate": "Ablaufdatum", "notes": "Notizen", @@ -154,7 +166,9 @@ "every": "alle", "from": "ab", "startDate": "Datum", - "startTime": "Uhrzeit" + "startTime": "Uhrzeit", + "takenByIntake": "Eingenommen von", + "takenByEveryone": "Alle" } }, "planner": { @@ -260,6 +274,7 @@ }, "status": { "outOfStock": "Leer", + "criticalStock": "Kritisch", "lowStock": "Niedrig", "normal": "Normal", "highStock": "Hoch", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f04e948..ca2bcb4 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -88,7 +88,8 @@ "lowMeds": "{{count}} medication low", "lowMeds_other": "{{count}} medications low", "daysLeft": "{{days}} day left", - "daysLeft_other": "{{days}} days left" + "daysLeft_other": "{{days}} days left", + "needsRefill": "Needs refill" } }, "table": { @@ -98,11 +99,16 @@ "currentPills": "Current pills", "fullBlisters": "Full blisters", "openBlister": "Open blister", + "stock": "Stock", + "stockDetails": "Details", "daysLeft": "Days left", - "status": "Stock", + "status": "Status", "runsOut": "Runs out", "autoRemind": "Auto-remind", - "expiry": "Expiry" + "expiry": "Expiry", + "pillsCount": "{{count}} pills", + "pillsCount_one": "{{count}} pill", + "pillsCount_other": "{{count}} pills" }, "medications": { "list": { @@ -116,7 +122,8 @@ "blisters": "Blisters per pack", "pillsPerBlister": "Pills per blister", "loose": "Loose", - "total": "Total" + "total": "Total", + "stock": "Stock" } }, "form": { @@ -126,11 +133,16 @@ "commercialName": "Commercial Name", "genericName": "Generic Name", "takenBy": "Taken by", + "packageType": "Package Type", + "packageTypeBlister": "Blister Pack", + "packageTypeBottle": "Pill Bottle / Container", "packs": "Packs", "blistersPerPack": "Blisters per pack", "pillsPerBlister": "Pills per blister", + "totalCapacity": "Total Capacity", + "currentPills": "Current Pills", "loosePills": "Loose pills", - "pillWeight": "Pill weight (mg)", + "pillWeight": "Dose per pill", "total": "Total (pills)", "expiryDate": "Expiry Date", "notes": "Notes", @@ -154,7 +166,9 @@ "every": "every", "from": "from", "startDate": "Date", - "startTime": "Time" + "startTime": "Time", + "takenByIntake": "Taken by", + "takenByEveryone": "Everyone" } }, "planner": { @@ -260,6 +274,7 @@ }, "status": { "outOfStock": "Empty", + "criticalStock": "Critical", "lowStock": "Low", "normal": "Normal", "highStock": "High", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 3b0468c..fdb75bd 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,28 +1,16 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmModal, MedicationAvatar } from "../components"; import { useAuth } from "../components/Auth"; import { useAppContext } from "../context"; import type { Coverage } from "../types"; import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters"; +import { getStockStatus } from "../utils/schedule"; // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { return userId ? `user_${userId}_${key}` : key; } -// Helper function to get stock status -function getStockStatus( - daysLeft: number | null, - medsLeft: number, - settings: { lowStockDays: number; normalStockDays: number; highStockDays: number } -) { - if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" }; - if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" }; - if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" }; - return { className: "success", label: "status.normal" }; -} - // Helper function to calculate blister stock function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) { const fullBlisters = Math.floor(totalPills / pillsPerBlister); @@ -57,19 +45,6 @@ function getMedTotal(med: { return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); } -// Get next reminder date for a medication -function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string { - if (!row.depletionDate) return "-"; - const depletionDate = new Date(row.depletionDate); - const reminderDate = new Date(depletionDate); - reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore); - - const now = new Date(); - if (reminderDate <= now) return "-"; - - return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" }); -} - // Notification bell SVG icon (no emoji) function NotificationBellIcon() { return ( @@ -112,7 +87,7 @@ function getReminderStatusData( const lowCount = allCoverage.filter((c) => { if (c.medsLeft <= 0) return false; if (c.daysLeft === null) return false; - return c.daysLeft < lowStockDays && c.daysLeft > 3; + return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore; }).length; // Determine status @@ -134,13 +109,16 @@ function getReminderStatusData( }; } - // Collect all low stock medications (critical + low) - const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = []; + // Collect all low stock medications (critical + low), deduplicated by name + const lowStockMap = new Map(); // Add critical meds (from lowCoverage - these are โ‰ค3 days) for (const c of lowCoverage) { if (c.daysLeft !== null) { - lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true }); + const existing = lowStockMap.get(c.name); + if (!existing || c.daysLeft < existing.daysLeft) { + lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true }); + } } } @@ -148,13 +126,16 @@ function getReminderStatusData( for (const c of allCoverage) { if (c.medsLeft <= 0) continue; if (c.daysLeft === null) continue; - if (c.daysLeft < lowStockDays && c.daysLeft > 3) { - lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false }); + if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) { + const existing = lowStockMap.get(c.name); + if (!existing || c.daysLeft < existing.daysLeft) { + lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false }); + } } } - // Sort by days left (most urgent first) - lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft); + // Convert to array and sort by days left (most urgent first) + const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft); // Parse last sent info let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null; @@ -213,39 +194,9 @@ export function DashboardPage() { openUserFilter, openShareDialog, openScheduleLightbox, + stockThresholds, } = useAppContext(); - // Local state for reminder email - const [sendingReminderEmail, setSendingReminderEmail] = useState(false); - const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null); - - async function sendReminderEmail() { - if (!settings.notificationEmail || coverage.low.length === 0) return; - setSendingReminderEmail(true); - setReminderEmailResult(null); - - try { - const res = await fetch("/api/reminder/send-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - email: settings.notificationEmail, - lowStock: coverage.low, - }), - }); - const data = await res.json(); - if (res.ok) { - setReminderEmailResult({ success: true, message: data.message || "Email sent!" }); - } else { - setReminderEmailResult({ success: false, message: data.error || "Failed to send" }); - } - } catch { - setReminderEmailResult({ success: false, message: "Network error" }); - } - setSendingReminderEmail(false); - } - // Get structured reminder data const reminderData = getReminderStatusData( settings.reminderDaysBefore, @@ -279,23 +230,46 @@ export function DashboardPage() { {t("dashboard.reminders.active")} - - {reminderData.status.className === "success" && "โœ“ "} - {reminderData.status.text} - + {reminderData.lowStockMeds.length === 0 && ( + + {reminderData.status.className === "success" && "โœ“ "} + {reminderData.status.text} + + )}
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && ( -
- {reminderData.lowStockMeds.map((med) => ( -
- {med.name} - - {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })} - -
- ))} +
+ {t("dashboard.reminders.needsRefill")}: + + {reminderData.lowStockMeds.map((med, idx) => { + const medication = meds.find((m) => m.name === med.name); + const cov = coverage.all.find((c) => c.name === med.name); + const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null; + const textClass = + status?.className === "danger" + ? "danger-text" + : status?.className === "warning" + ? "warning-text" + : ""; + return ( + + {idx > 0 && ", "} + medication && openMedDetail(medication)} + > + {med.name} + + + {" "} + {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })} + + + ); + })} +
)} {intakeRemindersEnabled && reminderData.lastSent && ( @@ -306,9 +280,9 @@ export function DashboardPage() { {reminderData.lastSent.medName} )} {reminderData.lastSent.takenBy && ( - ({reminderData.lastSent.takenBy}) + ({reminderData.lastSent.takenBy}) )} - {reminderData.lastSent.date} + {reminderData.lastSent.date}
)} @@ -328,149 +302,53 @@ export function DashboardPage() { return

{t("dashboard.reorder.noMeds")}

; } - // Count medications with "Low" stock status (based on lowStockDays setting) - const lowStockMeds = coverage.all.filter((c) => { - if (c.medsLeft <= 0) return true; // out of stock - if (c.daysLeft === null) return false; // no schedule - return c.daysLeft < settings.lowStockDays; - }); - const lowStockCount = lowStockMeds.length; - const lowStockNames = lowStockMeds.map((c) => c.name).join(", "); - - if (coverage.low.length === 0) { - // No critical meds (โ‰ค3 days) - if (lowStockCount === 0) { - // All good - everything is Normal or High - return

{t("dashboard.reorder.allGood")}

; - } else { - // Some meds are Low but not critical - render with clickable med names - return ( -

- {t("dashboard.reorder.lowWarningPrefix")}{" "} - {lowStockMeds.map((c, idx) => { - const med = meds.find((m) => m.name === c.name); - return ( - - {idx > 0 && ", "} - med && openMedDetail(med)}> - {c.name} - - - ); - })}{" "} - {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })} -

- ); + // Count medications with low stock (based on lowStockDays setting), deduplicated by name + const lowStockMap = new Map(); + for (const c of coverage.all) { + if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock + if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) { + const existing = lowStockMap.get(c.name); + if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) { + lowStockMap.set(c.name, c); + } } } + const lowStockMeds = Array.from(lowStockMap.values()); + const lowStockCount = lowStockMeds.length; + if (lowStockCount === 0) { + // All good - everything is Normal or High + return

{t("dashboard.reorder.allGood")}

; + } + + // Some meds are low - show simple text with clickable names and days left return ( - <> -
-
- {t("table.name")} - {t("table.fullBlisters")} - {t("table.openBlister")} - {t("table.daysLeft")} - {t("table.status")} - {t("table.runsOut")} - {t("table.autoRemind")} -
- {coverage.low.map((row) => { - const status = getStockStatus(row.daysLeft, row.medsLeft, settings); - const med = meds.find((m) => m.name === row.name); - const textClass = - status.className === "danger" - ? "danger-text" - : status.className === "warning" - ? "warning-text" - : "success-text"; - const stock = getBlisterStock( - Math.round(row.medsLeft), - med?.pillsPerBlister ?? 1, - med?.looseTablets ?? 0, - med ? getMedTotal(med) : Math.round(row.medsLeft) - ); - return ( -
med && openMedDetail(med)}> - - - {row.name} - {med?.takenBy && - med.takenBy.length > 0 && - med.takenBy.map((person) => ( - { - e.stopPropagation(); - openUserFilter(person); - }} - > - {person} - - ))} - {(med?.intakeRemindersEnabled || med?.notes) && ( - - {med?.intakeRemindersEnabled && ( - - ๐Ÿ”” - - )} - {med?.notes && ( - - ๐Ÿ“ - - )} - - )} - - - {formatFullBlisters(stock.fullBlisters, t)} - - - {formatOpenBlisterAndLoose( - stock.openBlisterPills, - stock.loosePills, - med?.pillsPerBlister ?? 1, - t - )} - - - {formatNumber(row.daysLeft)} - - - {t(status.label)} - - {row.depletionDate ?? "-"} - - {getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))} - -
- ); - })} -
- {(settings.emailEnabled || settings.shoutrrrEnabled) && ( -
- - {reminderEmailResult && ( - - {reminderEmailResult.message} +

+ {t("dashboard.reorder.lowWarningPrefix")}{" "} + {lowStockMeds.map((c, idx) => { + const med = meds.find((m) => m.name === c.name); + const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds); + const textClass = + status.className === "danger" + ? "danger-text" + : status.className === "warning" + ? "warning-text" + : ""; + return ( + + {idx > 0 && ", "} + med && openMedDetail(med)}> + {c.name} - )} -

- )} - + + {" "} + ({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })}) + + + ); + })}{" "} + {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })} +

); })()} @@ -485,15 +363,15 @@ export function DashboardPage() {
{t("table.name")} - {t("table.fullBlisters")} - {t("table.openBlister")} + {t("table.stock")} + {t("table.stockDetails")} {t("table.daysLeft")} {t("table.runsOut")} {t("table.expiry")} {t("table.status")}
{coverage.all.map((row) => { - const status = getStockStatus(row.daysLeft, row.medsLeft, settings); + const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds); const med = meds.find((m) => m.name === row.name); const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); const textClass = @@ -544,11 +422,20 @@ export function DashboardPage() { )} - - {formatFullBlisters(stock.fullBlisters, t)} + + {med?.packageType === "bottle" + ? t("table.pillsCount", { count: Math.round(row.medsLeft) }) + : formatFullBlisters(stock.fullBlisters, t)} - - {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)} + + {med?.packageType === "bottle" + ? "-" + : formatOpenBlisterAndLoose( + stock.openBlisterPills, + stock.loosePills, + med?.pillsPerBlister ?? 1, + t + )} {formatNumber(row.daysLeft)} @@ -605,9 +492,7 @@ export function DashboardPage() { const missedCount = missedPastDoseIds.length; const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => - m.doses.flatMap((dose) => - (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id] - ) + m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id])) ) ); return ( @@ -656,9 +541,7 @@ export function DashboardPage() { {showPastDays && pastDays.map((day) => { const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ) + item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) ); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id)); @@ -703,9 +586,7 @@ export function DashboardPage() { const med = meds.find((m) => m.name === item.medName); const medCov = coverageByMed[item.medName]; const isEmpty = medCov ? medCov.medsLeft <= 0 : false; - const itemDoseIds = item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ); + const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -736,13 +617,14 @@ export function DashboardPage() {
{item.doses.map((dose) => { // If no takenBy, show single checkbox; otherwise show one per person - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; + const people = dose.takenBy ? [dose.takenBy] : [null]; return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
{people.map((person) => { @@ -795,9 +677,7 @@ export function DashboardPage() { (() => { const day = todayDay; const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ) + item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) ); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -808,7 +688,7 @@ export function DashboardPage() { const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; if (willBeOutOfStock) return "danger"; if (!medCoverage) return "success"; - const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); + const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds); return status.className; }); const worstStatus = dayStockStatuses.includes("danger") @@ -855,11 +735,9 @@ export function DashboardPage() { const status = willBeOutOfStock ? { className: "danger", label: "status.outOfStock" } : medCoverage - ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) + ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds) : null; - const itemDoseIds = item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ); + const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -891,7 +769,7 @@ export function DashboardPage() {
{item.doses.map((dose) => { const isOverdue = dose.when < Date.now(); - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; + const people = dose.takenBy ? [dose.takenBy] : [null]; const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
{people.map((person) => { @@ -954,9 +833,7 @@ export function DashboardPage() { (() => { const totalFutureDoses = futureDays.flatMap((d) => d.meds.flatMap((m) => - m.doses.flatMap((dose) => - (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id] - ) + m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id])) ) ); const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length; @@ -988,9 +865,7 @@ export function DashboardPage() { {showFutureDays && futureDays.map((day) => { const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ) + item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) ); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -1001,7 +876,7 @@ export function DashboardPage() { const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; if (willBeOutOfStock) return "danger"; if (!medCoverage) return "success"; - const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); + const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds); return status.className; }); const worstStatus = dayStockStatuses.includes("danger") @@ -1047,11 +922,9 @@ export function DashboardPage() { const status = willBeOutOfStock ? { className: "danger", label: "status.outOfStock" } : medCoverage - ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) + ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds) : null; - const itemDoseIds = item.doses.flatMap((d) => - (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] - ); + const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -1082,14 +955,15 @@ export function DashboardPage() {
{item.doses.map((dose) => { - const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; + const people = dose.takenBy ? [dose.takenBy] : [null]; const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + {med?.pillWeightMg && + ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
{people.map((person) => { diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 11125a5..3d1a09a 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next"; import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components"; import { useAppContext, useUnsavedChanges } from "../context"; import { useMedicationForm, useUnsavedChangesWarning } from "../hooks"; -import type { Medication } from "../types"; -import { FIELD_LIMITS, getPackageSize } from "../types"; +import type { DoseUnit, Medication } from "../types"; +import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types"; import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters"; export function MedicationsPage() { @@ -25,6 +25,7 @@ export function MedicationsPage() { setRefillLoose, refillSaving, submitRefill, + coverageByMed, } = useAppContext(); // Use the medication form hook @@ -47,6 +48,9 @@ export function MedicationsPage() { addBlister, removeBlister, setBlisterValue, + addIntake, + removeIntake, + setIntakeValue, resetForm, startEdit, } = useMedicationForm(); @@ -87,12 +91,17 @@ export function MedicationsPage() { // Calculate total tablets const totalTablets = useMemo(() => { + if (form.packageType === "bottle") { + // For bottle type, looseTablets is the current stock + return Number(form.looseTablets) || 0; + } + // For blister type const packCount = Number(form.packCount) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0; const pillsPerBlister = Number(form.pillsPerBlister) || 1; const looseTablets = Number(form.looseTablets) || 0; return packCount * blistersPerPack * pillsPerBlister + looseTablets; - }, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]); + }, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]); // Open mobile edit modal function openEditModal() { @@ -158,26 +167,39 @@ export function MedicationsPage() { if (saving) return; setSaving(true); - // Prepare medication data - const blisters = form.blisters.map((b) => ({ - usage: Number(b.usage) || 1, - every: Number(b.every) || 1, - start: combineDateAndTime(b.startDate, b.startTime), + // Prepare intakes data with per-intake takenBy + const intakes = form.intakes.map((intake) => ({ + usage: Number(intake.usage) || 1, + every: Number(intake.every) || 1, + start: combineDateAndTime(intake.startDate, intake.startTime), + takenBy: intake.takenBy.trim() || null, // Empty string becomes null + intakeRemindersEnabled: intake.intakeRemindersEnabled, + })); + + // Also prepare legacy blisters for backward compatibility + const blisters = intakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, })); const body = { name: form.name.trim(), genericName: form.genericName.trim() || null, takenBy: form.takenBy.length > 0 ? form.takenBy : [], + packageType: form.packageType, packCount: Number(form.packCount) || 0, blistersPerPack: Number(form.blistersPerPack) || 1, pillsPerBlister: Number(form.pillsPerBlister) || 1, + totalPills: Number(form.totalPills) || null, looseTablets: Number(form.looseTablets) || 0, pillWeightMg: Number(form.pillWeightMg) || null, + doseUnit: form.doseUnit, expiryDate: form.expiryDate || null, notes: form.notes.trim() || null, intakeRemindersEnabled: form.intakeRemindersEnabled, - blisters, + blisters, // Legacy format for backward compatibility + intakes, // New format with per-intake takenBy }; try { @@ -331,7 +353,9 @@ export function MedicationsPage() {
- {t("medications.details.total")}: {getPackageSize(med)} {t("common.pills")} + {t("medications.details.stock")}:{" "} + {coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "} + {getPackageSize(med)} {t("common.pills")}
@@ -431,50 +455,100 @@ export function MedicationsPage() { {fieldErrors.takenBy && {fieldErrors.takenBy}} + {form.packageType === "blister" ? ( + <> + + + + + + ) : ( + <> + + + + )} - - -