Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9984392b76 | |||
| 571d94bf7e | |||
| ac4b8151e4 |
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@
|
|||||||
"when": 1769354512857,
|
"when": 1769354512857,
|
||||||
"tag": "0003_add_reminder_info_columns",
|
"tag": "0003_add_reminder_info_columns",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.6.5",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
// Added for more detailed reminder info display
|
// 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_med_name text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by 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) {
|
for (const sql of alterMigrations) {
|
||||||
|
|||||||
@@ -28,16 +28,21 @@ export const medications = sqliteTable("medications", {
|
|||||||
name: text("name", { length: 100 }).notNull(),
|
name: text("name", { length: 100 }).notNull(),
|
||||||
genericName: text("generic_name", { length: 100 }),
|
genericName: text("generic_name", { length: 100 }),
|
||||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
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),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||||
pillsPerBlister: integer("pills_per_blister").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
|
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
|
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"),
|
pillWeightMg: integer("pill_weight_mg"),
|
||||||
usageJson: text("usage_json").notNull().default("[]"),
|
doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
everyJson: text("every_json").notNull().default("[]"),
|
usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
||||||
startJson: text("start_json").notNull().default("[]"),
|
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"),
|
imageUrl: text("image_url"),
|
||||||
expiryDate: text("expiry_date"),
|
expiryDate: text("expiry_date"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.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");
|
const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ const scheduleSchema = z.object({
|
|||||||
every: z.number().int().min(1),
|
every: z.number().int().min(1),
|
||||||
start: z.string(), // ISO datetime string
|
start: z.string(), // ISO datetime string
|
||||||
remind: z.boolean().optional().default(false),
|
remind: z.boolean().optional().default(false),
|
||||||
|
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||||
});
|
});
|
||||||
|
|
||||||
const inventorySchema = z.object({
|
const inventorySchema = z.object({
|
||||||
@@ -44,6 +45,7 @@ const medicationExportSchema = z.object({
|
|||||||
takenBy: z.array(z.string()).default([]),
|
takenBy: z.array(z.string()).default([]),
|
||||||
inventory: inventorySchema,
|
inventory: inventorySchema,
|
||||||
pillWeightMg: z.number().int().nullable().optional(),
|
pillWeightMg: z.number().int().nullable().optional(),
|
||||||
|
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||||
schedules: z.array(scheduleSchema).default([]),
|
schedules: z.array(scheduleSchema).default([]),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
@@ -126,28 +128,24 @@ async function getUserId(request: any, reply: any): Promise<number> {
|
|||||||
return authUser.id;
|
return authUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse blisters from DB format to export format
|
// Parse intakes from DB format to export format (with per-intake takenBy)
|
||||||
function parseBlistersForExport(
|
function parseIntakesForExport(
|
||||||
row: typeof medications.$inferSelect
|
row: typeof medications.$inferSelect
|
||||||
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
|
||||||
try {
|
// Use the new parseIntakesJson which falls back to legacy format
|
||||||
const usage = JSON.parse(row.usageJson || "[]") as number[];
|
const intakes = parseIntakesJson(
|
||||||
const every = JSON.parse(row.everyJson || "[]") as number[];
|
row.intakesJson,
|
||||||
const start = JSON.parse(row.startJson || "[]") as string[];
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
const len = Math.min(usage.length, every.length, start.length);
|
row.intakeRemindersEnabled ?? false
|
||||||
const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = [];
|
);
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
schedules.push({
|
return intakes.map((intake) => ({
|
||||||
usage: usage[i],
|
usage: intake.usage,
|
||||||
every: every[i],
|
every: intake.every,
|
||||||
start: start[i],
|
start: intake.start,
|
||||||
remind: row.intakeRemindersEnabled ?? false,
|
remind: intake.intakeRemindersEnabled,
|
||||||
});
|
takenBy: intake.takenBy, // Per-intake takenBy
|
||||||
}
|
}));
|
||||||
return schedules;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read image file and convert to base64 data URL
|
// Read image file and convert to base64 data URL
|
||||||
@@ -279,7 +277,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
},
|
},
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
schedules: parseBlistersForExport(med),
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
|
schedules: parseIntakesForExport(med),
|
||||||
expiryDate: med.expiryDate,
|
expiryDate: med.expiryDate,
|
||||||
notes: med.notes,
|
notes: med.notes,
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
@@ -463,12 +462,23 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
const exportIdToNewId = new Map<string, number>();
|
const exportIdToNewId = new Map<string, number>();
|
||||||
|
|
||||||
for (const med of importData.medications) {
|
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 usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
|
||||||
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
||||||
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
||||||
const takenByJson = JSON.stringify(med.takenBy);
|
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
|
// Check if any schedule has remind enabled
|
||||||
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
|
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,
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
|
|||||||
@@ -9,30 +9,50 @@ import { doseTracking, medications } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.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");
|
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({
|
const blisterSchema = z.object({
|
||||||
usage: z.number().nonnegative(),
|
usage: z.number().nonnegative(),
|
||||||
every: z.number().int().min(1),
|
every: z.number().int().min(1),
|
||||||
start: z.string().datetime({ local: true }),
|
start: z.string().datetime({ local: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const medicationSchema = z.object({
|
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||||
name: z.string().trim().min(1).max(100),
|
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||||
genericName: z.string().trim().max(100).nullable().optional(),
|
|
||||||
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
|
const medicationSchema = z
|
||||||
packCount: z.number().int().min(0).default(1),
|
.object({
|
||||||
blistersPerPack: z.number().int().min(1).default(1),
|
name: z.string().trim().min(1).max(100),
|
||||||
pillsPerBlister: z.number().int().min(1).default(1),
|
genericName: z.string().trim().max(100).nullable().optional(),
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
||||||
pillWeightMg: z.number().int().min(1).nullable().optional(),
|
packageType: packageTypeSchema,
|
||||||
expiryDate: z.string().nullable().optional(),
|
packCount: z.number().int().min(0).default(1),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
blistersPerPack: z.number().int().min(1).default(1),
|
||||||
intakeRemindersEnabled: z.boolean().default(false),
|
pillsPerBlister: z.number().int().min(1).default(1),
|
||||||
blisters: z.array(blisterSchema).min(1).max(12),
|
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) {
|
export async function medicationRoutes(app: FastifyInstance) {
|
||||||
// All medication routes require auth
|
// All medication routes require auth
|
||||||
@@ -58,26 +78,40 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
app.get("/medications", async (request, reply) => {
|
app.get("/medications", async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => {
|
||||||
id: row.id,
|
// Parse intakes from new format, falling back to legacy
|
||||||
name: row.name,
|
const intakes = parseIntakesJson(
|
||||||
genericName: row.genericName,
|
row.intakesJson,
|
||||||
takenBy: parseTakenByJson(row.takenByJson),
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
packCount: row.packCount ?? 1,
|
row.intakeRemindersEnabled ?? false
|
||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
);
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
|
||||||
looseTablets: row.looseTablets ?? 0,
|
return {
|
||||||
stockAdjustment: row.stockAdjustment ?? 0,
|
id: row.id,
|
||||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
name: row.name,
|
||||||
pillWeightMg: row.pillWeightMg,
|
genericName: row.genericName,
|
||||||
blisters: parseBlisters(row),
|
takenBy: parseTakenByJson(row.takenByJson),
|
||||||
imageUrl: row.imageUrl,
|
packageType: row.packageType ?? "blister",
|
||||||
expiryDate: row.expiryDate,
|
packCount: row.packCount ?? 1,
|
||||||
notes: row.notes,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
dismissedUntil: row.dismissedUntil ?? null,
|
totalPills: row.totalPills ?? null,
|
||||||
updatedAt: row.updatedAt,
|
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) => {
|
app.post("/medications", async (req, reply) => {
|
||||||
@@ -89,19 +123,50 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
|
doseUnit,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
intakeRemindersEnabled,
|
intakeRemindersEnabled,
|
||||||
blisters,
|
intakes: inputIntakes,
|
||||||
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
|
||||||
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
// Convert to unified intakes format
|
||||||
const startJson = JSON.stringify(blisters.map((s) => s.start));
|
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 takenByJson = JSON.stringify(takenBy || []);
|
||||||
|
|
||||||
const [inserted] = await db
|
const [inserted] = await db
|
||||||
@@ -111,14 +176,18 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
|
packageType: packageType ?? "blister",
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
totalPills: totalPills || null,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
|
doseUnit: doseUnit ?? "mg",
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
@@ -130,14 +199,18 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: inserted.name,
|
name: inserted.name,
|
||||||
genericName: inserted.genericName,
|
genericName: inserted.genericName,
|
||||||
takenBy: parseTakenByJson(inserted.takenByJson),
|
takenBy: parseTakenByJson(inserted.takenByJson),
|
||||||
|
packageType: inserted.packageType ?? "blister",
|
||||||
packCount: inserted.packCount,
|
packCount: inserted.packCount,
|
||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
|
totalPills: inserted.totalPills ?? null,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
stockAdjustment: inserted.stockAdjustment ?? 0,
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: inserted.pillWeightMg,
|
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,
|
imageUrl: inserted.imageUrl,
|
||||||
expiryDate: inserted.expiryDate,
|
expiryDate: inserted.expiryDate,
|
||||||
notes: inserted.notes,
|
notes: inserted.notes,
|
||||||
@@ -165,19 +238,50 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
|
doseUnit,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
intakeRemindersEnabled,
|
intakeRemindersEnabled,
|
||||||
blisters,
|
intakes: inputIntakes,
|
||||||
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
|
||||||
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
// Convert to unified intakes format
|
||||||
const startJson = JSON.stringify(blisters.map((s) => s.start));
|
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 takenByJson = JSON.stringify(takenBy || []);
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -186,14 +290,18 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
|
packageType: packageType ?? "blister",
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
totalPills: totalPills || null,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
|
doseUnit: doseUnit ?? "mg",
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
@@ -206,7 +314,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Clean up dose tracking entries that are before the earliest start date
|
// Clean up dose tracking entries that are before the earliest start date
|
||||||
// This ensures consistency when the user changes the 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)) {
|
if (!Number.isNaN(earliestStart)) {
|
||||||
// Get all dose tracking entries for this medication and filter out invalid ones
|
// Get all dose tracking entries for this medication and filter out invalid ones
|
||||||
const allDoses = await db
|
const allDoses = await db
|
||||||
@@ -235,14 +343,18 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: result[0].name,
|
name: result[0].name,
|
||||||
genericName: result[0].genericName,
|
genericName: result[0].genericName,
|
||||||
takenBy: parseTakenByJson(result[0].takenByJson),
|
takenBy: parseTakenByJson(result[0].takenByJson),
|
||||||
|
packageType: result[0].packageType ?? "blister",
|
||||||
packCount: result[0].packCount,
|
packCount: result[0].packCount,
|
||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
|
totalPills: result[0].totalPills ?? null,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: result[0].pillWeightMg,
|
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,
|
imageUrl: result[0].imageUrl,
|
||||||
expiryDate: result[0].expiryDate,
|
expiryDate: result[0].expiryDate,
|
||||||
notes: result[0].notes,
|
notes: result[0].notes,
|
||||||
@@ -398,7 +510,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const payload = rows.map((row) => {
|
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 usageTotal = calculateUsageInRange(blisters, start, end);
|
||||||
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
|
|||||||
+54
-23
@@ -7,7 +7,12 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.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
|
// Share token validity: 1 year in milliseconds
|
||||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
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
|
// 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));
|
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 meds = allMeds.filter((med) => {
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
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
|
// Parse blisters and build schedule data
|
||||||
const medicationsWithBlisters = meds.map((med) => {
|
const medicationsWithBlisters = meds.map((med) => {
|
||||||
let blisters: { usage: number; every: number; start: string }[] = [];
|
// Parse intakes from new format, falling back to legacy
|
||||||
try {
|
const intakes = parseIntakesJson(
|
||||||
const usageArr = JSON.parse(med.usageJson || "[]");
|
med.intakesJson,
|
||||||
const everyArr = JSON.parse(med.everyJson || "[]");
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
const startArr = JSON.parse(med.startJson || "[]");
|
med.intakeRemindersEnabled ?? false
|
||||||
blisters = usageArr.map((usage: number, i: number) => ({
|
);
|
||||||
usage,
|
|
||||||
every: everyArr[i] ?? 1,
|
// Convert to legacy blisters format for backward compat
|
||||||
start: startArr[i] ?? new Date().toISOString(),
|
const blisters = intakes.map((i) => ({
|
||||||
}));
|
usage: i.usage,
|
||||||
} catch {
|
every: i.every,
|
||||||
blisters = [];
|
start: i.start,
|
||||||
}
|
}));
|
||||||
|
|
||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
@@ -110,6 +120,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName,
|
genericName: med.genericName,
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
imageUrl: med.imageUrl,
|
imageUrl: med.imageUrl,
|
||||||
totalPills,
|
totalPills,
|
||||||
packCount: med.packCount,
|
packCount: med.packCount,
|
||||||
@@ -117,8 +128,10 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
looseTablets: med.looseTablets,
|
looseTablets: med.looseTablets,
|
||||||
pillsPerBlister: med.pillsPerBlister,
|
pillsPerBlister: med.pillsPerBlister,
|
||||||
takenBy: takenByArray,
|
takenBy: takenByArray,
|
||||||
blisters,
|
intakes, // New unified format with per-intake takenBy
|
||||||
|
blisters, // Legacy format for backward compat
|
||||||
dismissedUntil: med.dismissedUntil,
|
dismissedUntil: med.dismissedUntil,
|
||||||
|
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,11 +165,16 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const { takenBy, scheduleDays } = parsed.data;
|
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 allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||||
const medsForPerson = allMeds.filter((med) => {
|
const medsForPerson = allMeds.filter((med) => {
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
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) {
|
if (medsForPerson.length === 0) {
|
||||||
@@ -195,17 +213,30 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
|
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
|
||||||
const userId = await getUserId(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
|
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)
|
.from(medications)
|
||||||
.where(eq(medications.userId, userId));
|
.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<string>();
|
const allPeople = new Set<string>();
|
||||||
for (const med of meds) {
|
for (const med of meds) {
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
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);
|
if (person) allPeople.add(person);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import { getDateLocale, getTranslations, type Language, t } from "../i18n/transl
|
|||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
import {
|
import {
|
||||||
type Blister,
|
|
||||||
cleanOldIntakeReminders,
|
cleanOldIntakeReminders,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
|
type Intake,
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
parseBlisters,
|
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
|
parseIntakesJson,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
type UpcomingIntake,
|
type UpcomingIntake,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
@@ -75,11 +75,10 @@ async function sendIntakeReminderEmail(
|
|||||||
return pillText;
|
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 => {
|
const formatMedName = (intake: UpcomingIntake): string => {
|
||||||
if (intake.takenBy.length > 0) {
|
if (intake.takenBy) {
|
||||||
const namesStr = intake.takenBy.join(", ");
|
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}</span>`;
|
||||||
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: namesStr })}</span>`;
|
|
||||||
}
|
}
|
||||||
return intake.medName;
|
return intake.medName;
|
||||||
};
|
};
|
||||||
@@ -172,7 +171,7 @@ ${description}
|
|||||||
|
|
||||||
${intakes
|
${intakes
|
||||||
.map((i) => {
|
.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}`;
|
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
|
||||||
})
|
})
|
||||||
.join("\n")}
|
.join("\n")}
|
||||||
@@ -291,62 +290,92 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const med of medsWithReminders) {
|
for (const med of medsWithReminders) {
|
||||||
const blisters = parseBlisters(med);
|
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
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(
|
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
|
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
||||||
blisters.forEach((blister, blisterIndex) => {
|
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(
|
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
|
// Always get upcoming intakes (15 min before) for first reminders
|
||||||
const upcomingIntakes = getUpcomingIntakes(
|
const upcomingIntakes = getUpcomingIntakes(
|
||||||
med.name,
|
med.name,
|
||||||
[blister],
|
[intake],
|
||||||
REMINDER_MINUTES_BEFORE,
|
REMINDER_MINUTES_BEFORE,
|
||||||
takenByArray,
|
medicationTakenBy,
|
||||||
med.pillWeightMg,
|
med.pillWeightMg,
|
||||||
locale,
|
locale,
|
||||||
tz
|
tz,
|
||||||
|
undefined, // nowOverride
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
);
|
);
|
||||||
logger.info(
|
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
|
// Add upcoming intakes for first reminders
|
||||||
allUpcoming.push(
|
allUpcoming.push(
|
||||||
...upcomingIntakes.map((intake) => ({
|
...upcomingIntakes.map((upcomingIntake) => ({
|
||||||
...intake,
|
...upcomingIntake,
|
||||||
medicationId: med.id,
|
medicationId: med.id,
|
||||||
blisterIndex,
|
blisterIndex: actualIndex,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||||
if (settings.repeatRemindersEnabled) {
|
if (settings.repeatRemindersEnabled) {
|
||||||
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz);
|
const allTodaysIntakes = getTodaysIntakes(
|
||||||
logger.info(
|
med.name,
|
||||||
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
|
[intake],
|
||||||
|
medicationTakenBy,
|
||||||
|
med.pillWeightMg,
|
||||||
|
locale,
|
||||||
|
tz,
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
);
|
);
|
||||||
const missedIntakes = allTodaysIntakes.filter((intake) => intake.intakeTime.getTime() < now.getTime());
|
|
||||||
logger.info(
|
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)
|
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||||
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
|
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
|
||||||
allUpcoming.push(
|
allUpcoming.push(
|
||||||
...missedIntakes
|
...missedIntakes
|
||||||
.filter((intake) => !upcomingTimes.has(intake.intakeTime.getTime()))
|
.filter((missed) => !upcomingTimes.has(missed.intakeTime.getTime()))
|
||||||
.map((intake) => ({
|
.map((missed) => ({
|
||||||
...intake,
|
...missed,
|
||||||
medicationId: med.id,
|
medicationId: med.id,
|
||||||
blisterIndex,
|
blisterIndex: actualIndex,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -438,20 +467,31 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Filter out reminders for doses that were already taken
|
// Filter out reminders for doses that were already taken
|
||||||
remindersToSend = remindersToSend.filter((intake) => {
|
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
|
// Check both with and without person suffix
|
||||||
if (intake.takenBy.length > 0) {
|
if (intake.takenBy) {
|
||||||
// For multi-person medications, check if any person has taken it
|
// For person-specific intake, check if that person has taken it
|
||||||
const anyTaken = intake.takenBy.some((person) => {
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
|
const isTaken = takenDoseIds.has(doseId);
|
||||||
return takenDoseIds.has(doseId);
|
if (isTaken) {
|
||||||
});
|
logger.info(
|
||||||
return !anyTaken; // Skip if any person has taken it
|
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return !isTaken;
|
||||||
} else {
|
} else {
|
||||||
// For non-person-specific medications
|
// For non-person-specific intakes
|
||||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
return !takenDoseIds.has(doseId);
|
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 =
|
const message =
|
||||||
remindersToSend
|
remindersToSend
|
||||||
.map((i) => {
|
.map((i) => {
|
||||||
const takenByStr =
|
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
|
||||||
i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
|
||||||
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
||||||
if (i.pillWeightMg) {
|
if (i.pillWeightMg) {
|
||||||
const totalMg = i.usage * 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
|
// Get the first reminder's medication name and taken by for display
|
||||||
const firstReminder = remindersToSend[0];
|
const firstReminder = remindersToSend[0];
|
||||||
const medName = firstReminder?.medName;
|
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);
|
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,29 +76,33 @@ async function createSchema(client: Client) {
|
|||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS medications (
|
`CREATE TABLE IF NOT EXISTS medications (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL,
|
user_id integer NOT NULL,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
total_pills integer,
|
||||||
last_stock_correction_at integer,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
pill_weight_mg integer,
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
last_stock_correction_at integer,
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
pill_weight_mg integer,
|
||||||
start_json text NOT NULL DEFAULT '[]',
|
dose_unit text DEFAULT 'mg',
|
||||||
image_url text,
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
expiry_date text,
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
notes text,
|
start_json text NOT NULL DEFAULT '[]',
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intakes_json text NOT NULL DEFAULT '[]',
|
||||||
dismissed_until text,
|
image_url text,
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
expiry_date text,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -71,29 +71,33 @@ async function createSchema(client: Client) {
|
|||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS medications (
|
`CREATE TABLE IF NOT EXISTS medications (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL,
|
user_id integer NOT NULL,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
total_pills integer,
|
||||||
last_stock_correction_at integer,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
pill_weight_mg integer,
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
last_stock_correction_at integer,
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
pill_weight_mg integer,
|
||||||
start_json text NOT NULL DEFAULT '[]',
|
dose_unit text DEFAULT 'mg',
|
||||||
image_url text,
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
expiry_date text,
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
notes text,
|
start_json text NOT NULL DEFAULT '[]',
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intakes_json text NOT NULL DEFAULT '[]',
|
||||||
dismissed_until text,
|
image_url text,
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
expiry_date text,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ import {
|
|||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
|
type Intake,
|
||||||
parseBlisters,
|
parseBlisters,
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
} from "../utils/scheduler-utils.js";
|
} 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", () => {
|
describe("Scheduler Utils - Timezone Functions", () => {
|
||||||
let originalTz: string | undefined;
|
let originalTz: string | undefined;
|
||||||
|
|
||||||
@@ -333,45 +345,45 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
describe("getUpcomingIntakes", () => {
|
describe("getUpcomingIntakes", () => {
|
||||||
it("should return empty array when no intakes in window", () => {
|
it("should return empty array when no intakes in window", () => {
|
||||||
// With parseLocalDateTime, times are treated as local - use same format for consistency
|
// 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)
|
// 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 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([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find intake within reminder window", () => {
|
it("should find intake within reminder window", () => {
|
||||||
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
|
// 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 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).toHaveLength(1);
|
||||||
expect(result[0].medName).toBe("TestMed");
|
expect(result[0].medName).toBe("TestMed");
|
||||||
expect(result[0].usage).toBe(2);
|
expect(result[0].usage).toBe(2);
|
||||||
expect(result[0].takenBy).toEqual(["Alice"]);
|
expect(result[0].takenBy).toBe("Alice");
|
||||||
expect(result[0].pillWeightMg).toBe(500);
|
expect(result[0].pillWeightMg).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should skip blisters with zero interval", () => {
|
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 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([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle multiple blisters", () => {
|
it("should handle multiple blisters", () => {
|
||||||
// Two intakes at 08:00 and 08:01 local
|
// Two intakes at 08:00 and 08:01 local
|
||||||
const blisters: Blister[] = [
|
const intakes: Intake[] = [
|
||||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00" },
|
blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }),
|
||||||
{ usage: 2, every: 1, start: "2025-01-01T08:01:00" },
|
blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }),
|
||||||
];
|
];
|
||||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
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
|
// Both should be found as they're within the window
|
||||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -382,10 +394,10 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
it("should return all intakes for today", () => {
|
it("should return all intakes for today", () => {
|
||||||
// Daily medication at 08:00 starting yesterday
|
// Daily medication at 08:00 starting yesterday
|
||||||
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
|
// 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)
|
// 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);
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
const intake = result.find((i) => i.intakeTime.getHours() === 8);
|
const intake = result.find((i) => i.intakeTime.getHours() === 8);
|
||||||
@@ -399,20 +411,23 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
const todayMidnight = new Date();
|
const todayMidnight = new Date();
|
||||||
todayMidnight.setUTCHours(0, 1, 0, 0);
|
todayMidnight.setUTCHours(0, 1, 0, 0);
|
||||||
|
|
||||||
const blisters: Blister[] = [
|
const intakes: Intake[] = [
|
||||||
{
|
blisterToIntake(
|
||||||
usage: 2,
|
{
|
||||||
every: 1,
|
usage: 2,
|
||||||
start: todayMidnight.toISOString(),
|
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).toHaveLength(1);
|
||||||
expect(result[0].medName).toBe("PastMed");
|
expect(result[0].medName).toBe("PastMed");
|
||||||
expect(result[0].usage).toBe(2);
|
expect(result[0].usage).toBe(2);
|
||||||
expect(result[0].takenBy).toEqual(["Bob"]);
|
expect(result[0].takenBy).toBe("Bob");
|
||||||
expect(result[0].pillWeightMg).toBe(250);
|
expect(result[0].pillWeightMg).toBe(250);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,12 +439,12 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
const evening = new Date(today);
|
const evening = new Date(today);
|
||||||
evening.setUTCHours(20, 0, 0, 0);
|
evening.setUTCHours(20, 0, 0, 0);
|
||||||
|
|
||||||
const blisters: Blister[] = [
|
const intakes: Intake[] = [
|
||||||
{ usage: 1, every: 1, start: morning.toISOString() },
|
blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }),
|
||||||
{ usage: 1, every: 1, start: evening.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);
|
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
@@ -439,16 +454,16 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
const lastWeek = new Date();
|
const lastWeek = new Date();
|
||||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||||
|
|
||||||
const blisters: Blister[] = [
|
const intakes: Intake[] = [
|
||||||
{
|
blisterToIntake({
|
||||||
usage: 1,
|
usage: 1,
|
||||||
every: 7,
|
every: 7,
|
||||||
start: lastWeek.toISOString(),
|
start: lastWeek.toISOString(),
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// If today is not the same day of week, should return empty
|
// 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
|
// This test might return 0 or 1 depending on the day
|
||||||
expect(Array.isArray(result)).toBe(true);
|
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
|
// 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)
|
// 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
|
// 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,
|
usage: 1,
|
||||||
every: 1,
|
every: 1,
|
||||||
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time
|
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);
|
expect(Array.isArray(result)).toBe(true);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|||||||
@@ -5,8 +5,18 @@
|
|||||||
|
|
||||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
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 };
|
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
|
// 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[] {
|
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||||
try {
|
try {
|
||||||
const usage = JSON.parse(row.usageJson) as number[];
|
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 */
|
/** Parse takenByJson to array of strings */
|
||||||
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||||
if (!takenByJson) return [];
|
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<string>(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
|
// Stock calculation utilities
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -209,24 +294,30 @@ export function calculateDepletionInfo(
|
|||||||
|
|
||||||
export type UpcomingIntake = {
|
export type UpcomingIntake = {
|
||||||
medName: string;
|
medName: string;
|
||||||
|
medicationId?: number;
|
||||||
|
blisterIndex?: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
intakeTime: Date;
|
intakeTime: Date;
|
||||||
intakeTimeStr: string;
|
intakeTimeStr: string;
|
||||||
takenBy: string[];
|
takenBy: string | null; // Single person for this intake (null = no specific person)
|
||||||
pillWeightMg: number | null;
|
pillWeightMg: number | null;
|
||||||
|
doseUnit?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all intakes for today (past and future) - used for repeat reminders.
|
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||||
* Returns all intakes scheduled for today in user's timezone.
|
* Returns all intakes scheduled for today in user's timezone.
|
||||||
|
* Now uses per-intake takenBy instead of medication-level.
|
||||||
*/
|
*/
|
||||||
export function getTodaysIntakes(
|
export function getTodaysIntakes(
|
||||||
medName: string,
|
medName: string,
|
||||||
blisters: Blister[],
|
intakes: Intake[],
|
||||||
takenBy: string[],
|
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string
|
tz?: string,
|
||||||
|
medicationId?: number,
|
||||||
|
doseUnit?: string
|
||||||
): UpcomingIntake[] {
|
): UpcomingIntake[] {
|
||||||
const timezone = tz ?? getTimezone();
|
const timezone = tz ?? getTimezone();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -238,14 +329,19 @@ export function getTodaysIntakes(
|
|||||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||||
todayEnd.setHours(23, 59, 59, 999);
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
const intakes: UpcomingIntake[] = [];
|
const result: UpcomingIntake[] = [];
|
||||||
|
|
||||||
for (const blister of blisters) {
|
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
const intake = intakes[blisterIdx];
|
||||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||||
|
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
if (intervalMs <= 0) continue;
|
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
|
// Find all occurrences that fall within today
|
||||||
let currentTime = startTime;
|
let currentTime = startTime;
|
||||||
|
|
||||||
@@ -260,39 +356,45 @@ export function getTodaysIntakes(
|
|||||||
while (currentTime <= todayEnd.getTime()) {
|
while (currentTime <= todayEnd.getTime()) {
|
||||||
if (currentTime >= todayStart.getTime()) {
|
if (currentTime >= todayStart.getTime()) {
|
||||||
const intakeDate = new Date(currentTime);
|
const intakeDate = new Date(currentTime);
|
||||||
intakes.push({
|
result.push({
|
||||||
medName,
|
medName,
|
||||||
usage: blister.usage,
|
medicationId,
|
||||||
|
blisterIndex: blisterIdx,
|
||||||
|
usage: intake.usage,
|
||||||
intakeTime: intakeDate,
|
intakeTime: intakeDate,
|
||||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
}),
|
}),
|
||||||
takenBy,
|
takenBy: effectiveTakenBy,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
|
doseUnit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
currentTime += intervalMs;
|
currentTime += intervalMs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return intakes;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get upcoming intakes that fall within the reminder window.
|
* Get upcoming intakes that fall within the reminder window.
|
||||||
* Returns intakes that should be notified about right now.
|
* Returns intakes that should be notified about right now.
|
||||||
|
* Now uses per-intake takenBy instead of medication-level.
|
||||||
*/
|
*/
|
||||||
export function getUpcomingIntakes(
|
export function getUpcomingIntakes(
|
||||||
medName: string,
|
medName: string,
|
||||||
blisters: Blister[],
|
intakes: Intake[],
|
||||||
minutesBefore: number,
|
minutesBefore: number,
|
||||||
takenBy: string[],
|
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
nowOverride?: number
|
nowOverride?: number,
|
||||||
|
medicationId?: number,
|
||||||
|
doseUnit?: string
|
||||||
): UpcomingIntake[] {
|
): UpcomingIntake[] {
|
||||||
const now = nowOverride ?? Date.now();
|
const now = nowOverride ?? Date.now();
|
||||||
const timezone = tz ?? getTimezone();
|
const timezone = tz ?? getTimezone();
|
||||||
@@ -303,12 +405,16 @@ export function getUpcomingIntakes(
|
|||||||
|
|
||||||
const upcoming: UpcomingIntake[] = [];
|
const upcoming: UpcomingIntake[] = [];
|
||||||
|
|
||||||
for (const blister of blisters) {
|
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
const intake = intakes[blisterIdx];
|
||||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||||
|
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
if (intervalMs <= 0) continue;
|
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)
|
// Find the next scheduled intake time (could be today or in the future)
|
||||||
let nextTime = startTime;
|
let nextTime = startTime;
|
||||||
|
|
||||||
@@ -339,15 +445,18 @@ export function getUpcomingIntakes(
|
|||||||
const intakeDate = new Date(nextTime);
|
const intakeDate = new Date(nextTime);
|
||||||
upcoming.push({
|
upcoming.push({
|
||||||
medName,
|
medName,
|
||||||
usage: blister.usage,
|
medicationId,
|
||||||
|
blisterIndex: blisterIdx,
|
||||||
|
usage: intake.usage,
|
||||||
intakeTime: intakeDate,
|
intakeTime: intakeDate,
|
||||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
}),
|
}),
|
||||||
takenBy,
|
takenBy: effectiveTakenBy,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
|
doseUnit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.5",
|
"version": "1.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ function AppContent() {
|
|||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
|
||||||
|
// Get centralized stockThresholds from context
|
||||||
|
const { stockThresholds } = ctx;
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
@@ -417,7 +420,7 @@ function AppContent() {
|
|||||||
<MedDetailModal
|
<MedDetailModal
|
||||||
selectedMed={selectedMed}
|
selectedMed={selectedMed}
|
||||||
coverage={coverage}
|
coverage={coverage}
|
||||||
settings={settings}
|
settings={stockThresholds}
|
||||||
showImageLightbox={showImageLightbox}
|
showImageLightbox={showImageLightbox}
|
||||||
showRefillModal={showRefillModal}
|
showRefillModal={showRefillModal}
|
||||||
showEditStockModal={showEditStockModal}
|
showEditStockModal={showEditStockModal}
|
||||||
@@ -450,7 +453,7 @@ function AppContent() {
|
|||||||
selectedUser={selectedUser}
|
selectedUser={selectedUser}
|
||||||
meds={meds}
|
meds={meds}
|
||||||
coverage={coverage}
|
coverage={coverage}
|
||||||
settings={settings}
|
settings={stockThresholds}
|
||||||
onClose={closeUserFilter}
|
onClose={closeUserFilter}
|
||||||
onOpenMedDetail={openMedDetail}
|
onOpenMedDetail={openMedDetail}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal } from "./ConfirmModal";
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
|
import { PasswordInput } from "./PasswordInput";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types (no roles - all users are equal)
|
// Types (no roles - all users are equal)
|
||||||
@@ -402,9 +403,8 @@ export function LoginForm({
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">{t("auth.password", "Password")}</label>
|
<label htmlFor="password">{t("auth.password", "Password")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -522,9 +522,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">{t("auth.password", "Password")} *</label>
|
<label htmlFor="password">{t("auth.password", "Password")} *</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -536,9 +535,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label>
|
<label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -722,9 +720,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
|
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="current-password"
|
id="current-password"
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -734,9 +731,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
|
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="new-password"
|
id="new-password"
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -747,9 +743,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
|
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
|
||||||
<input
|
<PasswordInput
|
||||||
id="confirm-new-password"
|
id="confirm-new-password"
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|||||||
@@ -171,25 +171,30 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>{t("modal.stockInfo")}</h3>
|
<h3>{t("modal.stockInfo")}</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
<div className="med-detail-item">
|
{selectedMed.packageType === "blister" && (
|
||||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
<>
|
||||||
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
<div className="med-detail-item">
|
||||||
</div>
|
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||||
<div className="med-detail-item">
|
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||||
<span className="med-detail-label">{t("table.openBlister")}</span>
|
</div>
|
||||||
<span className={`med-detail-value ${textClass}`}>
|
<div className="med-detail-item">
|
||||||
{formatOpenBlisterAndLoose(
|
<span className="med-detail-label">{t("table.openBlister")}</span>
|
||||||
stock.openBlisterPills,
|
<span className={`med-detail-value ${textClass}`}>
|
||||||
stock.loosePills,
|
{formatOpenBlisterAndLoose(
|
||||||
selectedMed.pillsPerBlister ?? 1,
|
stock.openBlisterPills,
|
||||||
t
|
stock.loosePills,
|
||||||
)}
|
selectedMed.pillsPerBlister ?? 1,
|
||||||
</span>
|
t
|
||||||
</div>
|
)}
|
||||||
<div className="med-detail-item full-width">
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
||||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
||||||
<span className={`med-detail-value ${textClass}`}>
|
<span className={`med-detail-value ${textClass}`}>
|
||||||
{currentStock} / {packageSize}
|
{currentStock} /{" "}
|
||||||
|
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,22 +204,33 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>{t("modal.packageDetails")}</h3>
|
<h3>{t("modal.packageDetails")}</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
<div className="med-detail-item">
|
{selectedMed.packageType === "blister" ? (
|
||||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
<>
|
||||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
<div className="med-detail-item">
|
||||||
</div>
|
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||||
<div className="med-detail-item">
|
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
</div>
|
||||||
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
<div className="med-detail-item">
|
||||||
</div>
|
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||||
<div className="med-detail-item">
|
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
||||||
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
</div>
|
||||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
<div className="med-detail-item">
|
||||||
</div>
|
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
||||||
|
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||||
|
<span className="med-detail-value">{selectedMed.totalPills ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedMed.pillWeightMg && (
|
{selectedMed.pillWeightMg && (
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||||
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
|
<span className="med-detail-value">
|
||||||
|
{selectedMed.pillWeightMg} {selectedMed.doseUnit ?? "mg"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedMed.expiryDate && (
|
{selectedMed.expiryDate && (
|
||||||
@@ -253,7 +269,8 @@ export function MedDetailModal({
|
|||||||
<div key={idx} className="med-schedule-item">
|
<div key={idx} className="med-schedule-item">
|
||||||
<span className="med-schedule-usage">
|
<span className="med-schedule-usage">
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
|
{selectedMed.pillWeightMg &&
|
||||||
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="med-schedule-freq">
|
<span className="med-schedule-freq">
|
||||||
{t("form.blisters.every")} {blister.every}{" "}
|
{t("form.blisters.every")} {blister.every}{" "}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Handles new medication creation and editing existing medications
|
* Handles new medication creation and editing existing medications
|
||||||
*/
|
*/
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { deriveTotal } from "../utils";
|
||||||
|
|
||||||
// Field limits for validation
|
// Field limits for validation
|
||||||
@@ -31,10 +32,14 @@ export interface MobileEditModalProps {
|
|||||||
onAddTakenByPerson: (person: string) => void;
|
onAddTakenByPerson: (person: string) => void;
|
||||||
onRemoveTakenByPerson: (person: string) => void;
|
onRemoveTakenByPerson: (person: string) => void;
|
||||||
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
// Blister helpers
|
// Blister helpers (legacy)
|
||||||
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||||
onAddBlister: () => void;
|
onAddBlister: () => void;
|
||||||
onRemoveBlister: (idx: number) => 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
|
// Value change handler for numeric fields
|
||||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
||||||
// Refill state (for edit mode)
|
// Refill state (for edit mode)
|
||||||
@@ -56,6 +61,10 @@ export interface MobileEditModalProps {
|
|||||||
|
|
||||||
/** Calculate total pills from form state */
|
/** Calculate total pills from form state */
|
||||||
function deriveTotalFromForm(form: FormState) {
|
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 packCount = Number(form.packCount) || 0;
|
||||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
@@ -82,6 +91,9 @@ export function MobileEditModal({
|
|||||||
onSetBlisterValue,
|
onSetBlisterValue,
|
||||||
onAddBlister,
|
onAddBlister,
|
||||||
onRemoveBlister,
|
onRemoveBlister,
|
||||||
|
onSetIntakeValue,
|
||||||
|
onAddIntake,
|
||||||
|
onRemoveIntake,
|
||||||
onHandleValueChange,
|
onHandleValueChange,
|
||||||
refillPacks,
|
refillPacks,
|
||||||
onRefillPacksChange,
|
onRefillPacksChange,
|
||||||
@@ -180,57 +192,106 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="full">
|
||||||
{t("form.packs")}
|
{t("form.packageType")}
|
||||||
<input
|
<select
|
||||||
type="number"
|
className="package-type-select"
|
||||||
min="0"
|
value={form.packageType}
|
||||||
value={form.packCount}
|
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
>
|
||||||
/>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
</label>
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
<label>
|
</select>
|
||||||
{t("form.blistersPerPack")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.blistersPerPack}
|
|
||||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("form.pillsPerBlister")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={form.pillsPerBlister}
|
|
||||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("form.loosePills")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
{form.packageType === "blister" ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.packs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.packCount}
|
||||||
|
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.blistersPerPack")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.blistersPerPack}
|
||||||
|
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.pillsPerBlister")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.pillsPerBlister}
|
||||||
|
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.loosePills")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.totalCapacity")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.totalPills}
|
||||||
|
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.currentPills")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="full">
|
<div className="full">
|
||||||
<p className="sub">
|
<p className="sub">
|
||||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
|
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")}
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<input
|
<div className="dose-input-group">
|
||||||
type="number"
|
<input
|
||||||
min="0"
|
type="number"
|
||||||
step="0.1"
|
min="0"
|
||||||
value={form.pillWeightMg}
|
step="0.1"
|
||||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
value={form.pillWeightMg}
|
||||||
placeholder={t("form.placeholders.weight")}
|
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||||
/>
|
placeholder={t("form.placeholders.weight")}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={form.doseUnit}
|
||||||
|
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||||
|
className="dose-unit-select"
|
||||||
|
>
|
||||||
|
{DOSE_UNITS.map((unit) => (
|
||||||
|
<option key={unit.value} value={unit.value}>
|
||||||
|
{unit.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.expiryDate")}
|
{t("form.expiryDate")}
|
||||||
@@ -327,19 +388,8 @@ export function MobileEditModal({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<fieldset className="full blister-section">
|
<fieldset className="full blister-section">
|
||||||
<legend>
|
<legend>{t("form.blisters.title")}</legend>
|
||||||
{t("form.blisters.title")}
|
{form.intakes.map((intake, idx) => (
|
||||||
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.intakeRemindersEnabled}
|
|
||||||
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span className="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span className="legend-hint">{t("form.blisters.remind")}</span>
|
|
||||||
</legend>
|
|
||||||
{form.blisters.map((b, idx) => (
|
|
||||||
<div key={idx} className="blister-row">
|
<div key={idx} className="blister-row">
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.usage")}</span>
|
<span>{t("form.blisters.usage")}</span>
|
||||||
@@ -347,8 +397,8 @@ export function MobileEditModal({
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={b.usage}
|
value={intake.usage}
|
||||||
onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
@@ -356,34 +406,54 @@ export function MobileEditModal({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={b.every}
|
value={intake.every}
|
||||||
onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact full-row">
|
<label className="compact full-row">
|
||||||
<span>{t("form.blisters.startDate")}</span>
|
<span>{t("form.blisters.startDate")}</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={b.startDate}
|
value={intake.startDate}
|
||||||
onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact time-label">
|
<label className="compact time-label">
|
||||||
<span>{t("form.blisters.startTime")}</span>
|
<span>{t("form.blisters.startTime")}</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={b.startTime}
|
value={intake.startTime}
|
||||||
onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.blisters.length > 1 && (
|
<label className="compact full-row">
|
||||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
|
<span>{t("form.blisters.takenByIntake")}</span>
|
||||||
|
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
||||||
|
<option value="">{t("form.blisters.takenByEveryone")}</option>
|
||||||
|
{existingPeople.map((person) => (
|
||||||
|
<option key={person} value={person}>
|
||||||
|
{person}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={intake.intakeRemindersEnabled}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span className="legend-hint">🔔</span>
|
||||||
|
{form.intakes.length > 1 && (
|
||||||
|
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||||
{t("common.remove")}
|
{t("common.remove")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
|
<button type="button" className="ghost add-blister" onClick={() => onAddIntake()}>
|
||||||
+ {t("form.blisters.addIntake")}
|
+ {t("form.blisters.addIntake")}
|
||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface PasswordInputProps {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
required?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
autoComplete,
|
||||||
|
minLength,
|
||||||
|
maxLength,
|
||||||
|
placeholder,
|
||||||
|
}: PasswordInputProps) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
required={required}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
minLength={minLength}
|
||||||
|
maxLength={maxLength}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-toggle-btn"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -153,21 +153,20 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// Get dose ID with optional person suffix
|
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
||||||
function getDoseId(baseDoseId: string, person: string | null): string {
|
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
||||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
function getDoseId(doseId: string, _person: string | null): string {
|
||||||
|
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
||||||
|
return doseId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count taken doses for a day/item
|
// Count taken doses for a day/item (simplified - per-intake takenBy means one person per dose)
|
||||||
function _countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
|
function _countTakenDoses(doses: Array<{ id: string; takenBy: string | null }>): { total: number; taken: number } {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let taken = 0;
|
let taken = 0;
|
||||||
for (const d of doses) {
|
for (const d of doses) {
|
||||||
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
|
total++;
|
||||||
for (const person of people) {
|
if (takenDoses.has(d.id)) taken++;
|
||||||
total++;
|
|
||||||
if (takenDoses.has(getDoseId(d.id, person))) taken++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { total, taken };
|
return { total, taken };
|
||||||
}
|
}
|
||||||
@@ -274,32 +273,42 @@ export function SharedSchedule() {
|
|||||||
usage: number;
|
usage: number;
|
||||||
timeStr: string;
|
timeStr: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string[];
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
for (const med of data.medications) {
|
for (const med of data.medications) {
|
||||||
med.blisters.forEach((blister, blisterIdx) => {
|
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
||||||
const startDate = new Date(blister.start);
|
const intakes =
|
||||||
|
med.intakes ||
|
||||||
|
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
|
||||||
|
|
||||||
|
intakes.forEach((intake, intakeIdx) => {
|
||||||
|
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
||||||
|
if (intake.takenBy !== null && intake.takenBy !== data.takenBy) return;
|
||||||
|
|
||||||
|
const startDate = new Date(intake.start);
|
||||||
if (Number.isNaN(startDate.getTime())) return;
|
if (Number.isNaN(startDate.getTime())) return;
|
||||||
|
|
||||||
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
||||||
// This ensures identical timestamps even across DST changes
|
// This ensures identical timestamps even across DST changes
|
||||||
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
|
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
|
||||||
const t = d.getTime();
|
const t = d.getTime();
|
||||||
const isPast = d < todayStart;
|
const isPast = d < todayStart;
|
||||||
// Use date-only timestamp for stable ID (immune to time changes)
|
// Use date-only timestamp for stable ID (immune to time changes)
|
||||||
// This ensures changing intake times doesn't invalidate past dose tracking
|
// This ensures changing intake times doesn't invalidate past dose tracking
|
||||||
// Must match buildSchedulePreview in schedule.ts exactly
|
// Must match buildSchedulePreview in schedule.ts exactly
|
||||||
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
const doseId = `${med.id}-${blisterIdx}-${dateOnlyMs}`;
|
// Dose ID includes person suffix if there's a per-intake takenBy
|
||||||
|
const baseDoseId = `${med.id}-${intakeIdx}-${dateOnlyMs}`;
|
||||||
|
const doseId = intake.takenBy ? `${baseDoseId}-${intake.takenBy}` : baseDoseId;
|
||||||
doses.push({
|
doses.push({
|
||||||
id: doseId,
|
id: doseId,
|
||||||
when: t,
|
when: t,
|
||||||
medName: med.name,
|
medName: med.name,
|
||||||
usage: blister.usage,
|
usage: intake.usage,
|
||||||
isPast,
|
isPast,
|
||||||
takenBy: med.takenBy || [],
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
||||||
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), {
|
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -395,6 +404,39 @@ export function SharedSchedule() {
|
|||||||
return doseDateStr <= dismissedUntilDate;
|
return doseDateStr <= dismissedUntilDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a map of medication name -> updatedAt timestamp
|
||||||
|
// Used to filter out doses from previous schedule configurations
|
||||||
|
const updatedAtByMed = useMemo(() => {
|
||||||
|
if (!data) return new Map<string, number>();
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const med of data.medications) {
|
||||||
|
if (med.updatedAt) {
|
||||||
|
const ts = typeof med.updatedAt === "number" ? med.updatedAt : new Date(med.updatedAt).getTime();
|
||||||
|
if (!Number.isNaN(ts)) {
|
||||||
|
map.set(med.name, ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Helper to check if a dose was scheduled BEFORE the medication was last updated
|
||||||
|
// If so, it's from a previous schedule configuration and shouldn't count as "missed"
|
||||||
|
// This matches the main app's isDoseFromPreviousSchedule logic in AppContext.tsx
|
||||||
|
function isDoseFromPreviousSchedule(doseId: string, medName: string): boolean {
|
||||||
|
const updatedAtTimestamp = updatedAtByMed.get(medName);
|
||||||
|
if (!updatedAtTimestamp) return false; // No updatedAt means it was never changed, all doses are valid
|
||||||
|
|
||||||
|
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) return false;
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(doseTimestamp)) return false;
|
||||||
|
|
||||||
|
// If the dose was scheduled before the medication was updated, it's from a previous schedule
|
||||||
|
return doseTimestamp < updatedAtTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate coverage for stock status colors (matches main app logic)
|
// Calculate coverage for stock status colors (matches main app logic)
|
||||||
// This needs to account for taken doses and calculate depletion time
|
// This needs to account for taken doses and calculate depletion time
|
||||||
const { coverageByMed, depletionByMed } = useMemo(() => {
|
const { coverageByMed, depletionByMed } = useMemo(() => {
|
||||||
@@ -403,16 +445,11 @@ export function SharedSchedule() {
|
|||||||
const depletion: Record<string, number | null> = {};
|
const depletion: Record<string, number | null> = {};
|
||||||
|
|
||||||
// Calculate total pills taken per medication from takenDoses
|
// Calculate total pills taken per medication from takenDoses
|
||||||
// Each person's taken dose counts separately toward pills consumed
|
// With per-intake takenBy, each dose.id is unique and already has person suffix if needed
|
||||||
const takenByMed: Record<string, number> = {};
|
const takenByMed: Record<string, number> = {};
|
||||||
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
|
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
|
||||||
// Check all person-specific dose IDs for this dose
|
if (takenDoses.has(dose.id)) {
|
||||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
|
||||||
for (const person of people) {
|
|
||||||
const doseId = person ? `${dose.id}-${person}` : dose.id;
|
|
||||||
if (takenDoses.has(doseId)) {
|
|
||||||
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,9 +457,9 @@ export function SharedSchedule() {
|
|||||||
const totalCount = getMedTotal(med);
|
const totalCount = getMedTotal(med);
|
||||||
const taken = takenByMed[med.name] || 0;
|
const taken = takenByMed[med.name] || 0;
|
||||||
const currentCount = Math.max(0, totalCount - taken);
|
const currentCount = Math.max(0, totalCount - taken);
|
||||||
// Calculate daily usage from blisters, multiplied by number of people
|
// Calculate daily usage from intakes (or blisters for legacy)
|
||||||
const personCount = Math.max(1, med.takenBy?.length || 1);
|
const intakes = med.intakes || med.blisters;
|
||||||
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount;
|
const dailyUsage = intakes.reduce((sum, b) => sum + b.usage / b.every, 0);
|
||||||
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
|
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
|
||||||
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
|
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
|
||||||
|
|
||||||
@@ -544,15 +581,10 @@ export function SharedSchedule() {
|
|||||||
{pastDays.length > 0 &&
|
{pastDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
// Count all past doses (for display)
|
// Count all past doses (for display)
|
||||||
const totalPastDoses = pastDays.flatMap((d) =>
|
// With per-intake takenBy, each dose.id is unique
|
||||||
d.meds.flatMap((m) =>
|
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||||
m.doses.flatMap((dose) => {
|
// Count missed doses (not taken AND not dismissed AND not from previous schedule)
|
||||||
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id];
|
// Check: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// Count missed doses (not taken AND not dismissed)
|
|
||||||
// Check both: per-dose dismissed flag from API AND medication-level dismissedUntil
|
|
||||||
const missedPastDoses = totalPastDoses.filter((id) => {
|
const missedPastDoses = totalPastDoses.filter((id) => {
|
||||||
if (takenDoses.has(id)) return false;
|
if (takenDoses.has(id)) return false;
|
||||||
// Check if this dose is dismissed via per-dose flag from API
|
// Check if this dose is dismissed via per-dose flag from API
|
||||||
@@ -560,14 +592,20 @@ export function SharedSchedule() {
|
|||||||
// Check if dismissed via medication-level dismissedUntil date
|
// Check if dismissed via medication-level dismissedUntil date
|
||||||
const parts = id.split("-");
|
const parts = id.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const timestamp = parseInt(parts[2], 10);
|
|
||||||
const medId = parts[0];
|
const medId = parts[0];
|
||||||
const med = data?.medications.find((m) => String(m.id) === medId);
|
const med = data?.medications.find((m) => String(m.id) === medId);
|
||||||
if (med && isDoseDismissed(timestamp, med.name)) {
|
if (med) {
|
||||||
return false; // dismissed = not missed
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (isDoseDismissed(timestamp, med.name)) {
|
||||||
|
return false; // dismissed = not missed
|
||||||
|
}
|
||||||
|
// Check if this dose is from a previous schedule configuration
|
||||||
|
if (isDoseFromPreviousSchedule(id, med.name)) {
|
||||||
|
return false; // from previous schedule = not missed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true; // not taken, not dismissed = missed
|
return true; // not taken, not dismissed, not from previous schedule = missed
|
||||||
}).length;
|
}).length;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -599,30 +637,32 @@ export function SharedSchedule() {
|
|||||||
{/* Past days (when expanded) */}
|
{/* Past days (when expanded) */}
|
||||||
{showPastDays &&
|
{showPastDays &&
|
||||||
pastDays.map((day) => {
|
pastDays.map((day) => {
|
||||||
// Helper to check if a dose ID is "done" (taken or dismissed)
|
// Helper to check if a dose ID is "done" (taken, dismissed, or from previous schedule)
|
||||||
// Checks both: per-dose dismissed flag from API AND medication-level dismissedUntil
|
// Checks: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
|
||||||
const isDoseIdDone = (doseId: string) => {
|
const isDoseIdDone = (doseId: string) => {
|
||||||
if (takenDoses.has(doseId)) return true;
|
if (takenDoses.has(doseId)) return true;
|
||||||
// Check if this dose is dismissed via per-dose flag from API
|
// Check if this dose is dismissed via per-dose flag from API
|
||||||
if (dismissedDoses.has(doseId)) return true;
|
if (dismissedDoses.has(doseId)) return true;
|
||||||
// Check if dismissed via medication-level dismissedUntil date
|
// Check if dismissed via medication-level dismissedUntil date or from previous schedule
|
||||||
const parts = doseId.split("-");
|
const parts = doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const timestamp = parseInt(parts[2], 10);
|
|
||||||
const medId = parts[0];
|
const medId = parts[0];
|
||||||
const med = data?.medications.find((m) => String(m.id) === medId);
|
const med = data?.medications.find((m) => String(m.id) === medId);
|
||||||
if (med && isDoseDismissed(timestamp, med.name)) {
|
if (med) {
|
||||||
return true;
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (isDoseDismissed(timestamp, med.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if this dose is from a previous schedule configuration
|
||||||
|
if (isDoseFromPreviousSchedule(doseId, med.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
item.doses.flatMap((d) => {
|
|
||||||
return (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
|
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
|
||||||
const doneCount = allDoseIds.filter(isDoseIdDone).length;
|
const doneCount = allDoseIds.filter(isDoseIdDone).length;
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
@@ -684,9 +724,7 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
// A dose is "done" if taken OR dismissed
|
// A dose is "done" if taken OR dismissed
|
||||||
const allDone = itemDoseIds.every(isDoseIdDone);
|
const allDone = itemDoseIds.every(isDoseIdDone);
|
||||||
|
|
||||||
@@ -715,60 +753,53 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
// Check: medication-level dismissedUntil, per-dose dismissed flag, and previous schedule
|
||||||
// Check both: medication-level dismissedUntil AND per-dose dismissed flag
|
|
||||||
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
|
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
|
||||||
const allDone = people.every((person) => {
|
const isFromPreviousSchedule = isDoseFromPreviousSchedule(dose.id, dose.medName);
|
||||||
const doseId = getDoseId(dose.id, person);
|
const isTaken = takenDoses.has(dose.id);
|
||||||
return takenDoses.has(doseId) || dismissedDoses.has(doseId) || isMedLevelDismissed;
|
const isPerDoseDismissed = dismissedDoses.has(dose.id);
|
||||||
});
|
const isDone =
|
||||||
|
isTaken || isPerDoseDismissed || isMedLevelDismissed || isFromPreviousSchedule;
|
||||||
return (
|
return (
|
||||||
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item past ${isDone ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
<div className={`dose-person ${isDone ? "taken" : ""}`}>
|
||||||
const doseId = getDoseId(dose.id, person);
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
const isTaken = takenDoses.has(doseId);
|
{isDone ? (
|
||||||
const isPerDoseDismissed = dismissedDoses.has(doseId);
|
isTaken ? (
|
||||||
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
|
<button
|
||||||
return (
|
className="dose-btn undo"
|
||||||
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
|
onClick={() => undoDoseTaken(dose.id)}
|
||||||
{person && <span className="person-name">{person}</span>}
|
title={t("common.undo")}
|
||||||
{isDone ? (
|
>
|
||||||
isTaken ? (
|
↩
|
||||||
<button
|
</button>
|
||||||
className="dose-btn undo"
|
) : (
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
// Dismissed - show checkmark but no undo
|
||||||
title={t("common.undo")}
|
<span
|
||||||
>
|
className="dose-btn dismissed"
|
||||||
↩
|
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
|
||||||
</button>
|
>
|
||||||
) : (
|
✓
|
||||||
// Dismissed - show checkmark but no undo
|
</span>
|
||||||
<span
|
)
|
||||||
className="dose-btn dismissed"
|
) : (
|
||||||
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
|
<button
|
||||||
>
|
className="dose-btn take"
|
||||||
✓
|
onClick={() => markDoseTaken(dose.id)}
|
||||||
</span>
|
disabled={isEmpty}
|
||||||
)
|
title={t("dose.markAsTaken")}
|
||||||
) : (
|
>
|
||||||
<button
|
✓
|
||||||
className="dose-btn take"
|
</button>
|
||||||
onClick={() => markDoseTaken(doseId)}
|
)}
|
||||||
disabled={isEmpty}
|
</div>
|
||||||
title={t("dose.markAsTaken")}
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -784,11 +815,7 @@ export function SharedSchedule() {
|
|||||||
{todayDay &&
|
{todayDay &&
|
||||||
(() => {
|
(() => {
|
||||||
const day = todayDay;
|
const day = todayDay;
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
item.doses.flatMap((d) =>
|
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||||
const worstStatus = getDayStockStatus(day.meds);
|
const worstStatus = getDayStockStatus(day.meds);
|
||||||
@@ -843,9 +870,7 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -872,47 +897,40 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
const isTaken = takenDoses.has(dose.id);
|
||||||
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
const isOverdue = dose.when < Date.now() && !isTaken;
|
||||||
return (
|
return (
|
||||||
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item ${isTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
<div
|
||||||
const doseId = getDoseId(dose.id, person);
|
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||||
const isTaken = takenDoses.has(doseId);
|
>
|
||||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
return (
|
{isTaken ? (
|
||||||
<div
|
<button
|
||||||
key={doseId}
|
className="dose-btn undo"
|
||||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
onClick={() => undoDoseTaken(dose.id)}
|
||||||
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
{person && <span className="person-name">{person}</span>}
|
↩
|
||||||
{isTaken ? (
|
</button>
|
||||||
<button
|
) : (
|
||||||
className="dose-btn undo"
|
<button
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
className="dose-btn take"
|
||||||
title={t("common.undo")}
|
onClick={() => markDoseTaken(dose.id)}
|
||||||
>
|
title={t("dose.markAsTaken")}
|
||||||
↩
|
disabled={isEmpty}
|
||||||
</button>
|
>
|
||||||
) : (
|
✓
|
||||||
<button
|
</button>
|
||||||
className="dose-btn take"
|
)}
|
||||||
onClick={() => markDoseTaken(doseId)}
|
</div>
|
||||||
title={t("dose.markAsTaken")}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -945,11 +963,7 @@ export function SharedSchedule() {
|
|||||||
{showFutureDays &&
|
{showFutureDays &&
|
||||||
futureDays.map((day) => {
|
futureDays.map((day) => {
|
||||||
// Check if all doses in this day are taken (auto-collapse)
|
// Check if all doses in this day are taken (auto-collapse)
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
item.doses.flatMap((d) =>
|
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||||
|
|
||||||
@@ -1007,9 +1021,7 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1036,56 +1048,49 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
const isTaken = takenDoses.has(dose.id);
|
||||||
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
|
||||||
// Only disable doses on future DAYS, not later today
|
// Only disable doses on future DAYS, not later today
|
||||||
const doseDate = new Date(dose.when);
|
const doseDate = new Date(dose.when);
|
||||||
doseDate.setHours(0, 0, 0, 0);
|
doseDate.setHours(0, 0, 0, 0);
|
||||||
const todayMidnight = new Date();
|
const todayMidnight = new Date();
|
||||||
todayMidnight.setHours(0, 0, 0, 0);
|
todayMidnight.setHours(0, 0, 0, 0);
|
||||||
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
|
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
|
||||||
|
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={dose.id}
|
key={dose.id}
|
||||||
className={`dose-item ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}
|
className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "all-taken" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
<div
|
||||||
const doseId = getDoseId(dose.id, person);
|
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||||
const isTaken = takenDoses.has(doseId);
|
>
|
||||||
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
return (
|
{isTaken ? (
|
||||||
<div
|
<button
|
||||||
key={doseId}
|
className="dose-btn undo"
|
||||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
onClick={() => undoDoseTaken(dose.id)}
|
||||||
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
{person && <span className="person-name">{person}</span>}
|
↩
|
||||||
{isTaken ? (
|
</button>
|
||||||
<button
|
) : (
|
||||||
className="dose-btn undo"
|
<button
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
className="dose-btn take"
|
||||||
title={t("common.undo")}
|
onClick={() => markDoseTaken(dose.id)}
|
||||||
>
|
title={t("dose.markAsTaken")}
|
||||||
↩
|
disabled={isFutureDose || isEmpty}
|
||||||
</button>
|
>
|
||||||
) : (
|
✓
|
||||||
<button
|
</button>
|
||||||
className="dose-btn take"
|
)}
|
||||||
onClick={() => markDoseTaken(doseId)}
|
</div>
|
||||||
title={t("dose.markAsTaken")}
|
|
||||||
disabled={isFutureDose || isEmpty}
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type { MedicationAvatarProps } from "./MedicationAvatar";
|
|||||||
export { MedicationAvatar } from "./MedicationAvatar";
|
export { MedicationAvatar } from "./MedicationAvatar";
|
||||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||||
export { MobileEditModal } from "./MobileEditModal";
|
export { MobileEditModal } from "./MobileEditModal";
|
||||||
|
export { PasswordInput } from "./PasswordInput";
|
||||||
export { default as ProfileModal } from "./ProfileModal";
|
export { default as ProfileModal } from "./ProfileModal";
|
||||||
export type { ShareDialogProps } from "./ShareDialog";
|
export type { ShareDialogProps } from "./ShareDialog";
|
||||||
export { ShareDialog } from "./ShareDialog";
|
export { ShareDialog } from "./ShareDialog";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
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 { getSystemLocale } from "../utils/formatters";
|
||||||
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
|
||||||
|
|
||||||
@@ -134,6 +134,7 @@ export interface AppContextValue {
|
|||||||
coverage: { all: Coverage[]; low: Coverage[] };
|
coverage: { all: Coverage[]; low: Coverage[] };
|
||||||
coverageByMed: Record<string, Coverage>;
|
coverageByMed: Record<string, Coverage>;
|
||||||
depletionByMed: Record<string, number | null>;
|
depletionByMed: Record<string, number | null>;
|
||||||
|
stockThresholds: StockThresholds;
|
||||||
existingPeople: string[];
|
existingPeople: string[];
|
||||||
groupedSchedule: GroupedDay[];
|
groupedSchedule: GroupedDay[];
|
||||||
pastDays: 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]);
|
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 existingPeople = useMemo(() => {
|
||||||
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
|
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
|
||||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
return [...new Set(allPeople)].filter(Boolean).sort();
|
||||||
@@ -798,6 +817,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
coverage,
|
coverage,
|
||||||
coverageByMed,
|
coverageByMed,
|
||||||
depletionByMed,
|
depletionByMed,
|
||||||
|
stockThresholds,
|
||||||
existingPeople,
|
existingPeople,
|
||||||
groupedSchedule,
|
groupedSchedule,
|
||||||
pastDays,
|
pastDays,
|
||||||
@@ -861,6 +881,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
coverage,
|
coverage,
|
||||||
coverageByMed,
|
coverageByMed,
|
||||||
depletionByMed,
|
depletionByMed,
|
||||||
|
stockThresholds,
|
||||||
existingPeople,
|
existingPeople,
|
||||||
groupedSchedule,
|
groupedSchedule,
|
||||||
pastDays,
|
pastDays,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { FIELD_LIMITS } from "../types";
|
||||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
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 => ({
|
export const defaultForm = (): FormState => ({
|
||||||
name: "",
|
name: "",
|
||||||
genericName: "",
|
genericName: "",
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
|
packageType: "blister",
|
||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "1",
|
blistersPerPack: "1",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
|
totalPills: "",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
|
doseUnit: "mg",
|
||||||
expiryDate: "",
|
expiryDate: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
intakeRemindersEnabled: false,
|
intakeRemindersEnabled: false,
|
||||||
blisters: [defaultBlister()],
|
blisters: [defaultBlister()],
|
||||||
|
intakes: [defaultIntake()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface UseMedicationFormReturn {
|
export interface UseMedicationFormReturn {
|
||||||
@@ -53,6 +72,10 @@ export interface UseMedicationFormReturn {
|
|||||||
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||||
addBlister: () => void;
|
addBlister: () => void;
|
||||||
removeBlister: (idx: number) => 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;
|
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||||
resetForm: () => void;
|
resetForm: () => void;
|
||||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||||
@@ -134,19 +157,60 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
|
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) => {
|
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
|
||||||
setEditingId(med.id);
|
setEditingId(med.id);
|
||||||
setTakenByInput(""); // Clear tag input when starting edit
|
setTakenByInput(""); // Clear tag input when starting edit
|
||||||
setFormSaved(true); // Existing medication is already saved
|
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 = {
|
const editForm: FormState = {
|
||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName ?? "",
|
genericName: med.genericName ?? "",
|
||||||
takenBy: med.takenBy || [], // Already an array from API
|
takenBy: med.takenBy || [], // Already an array from API
|
||||||
|
packageType: med.packageType ?? "blister",
|
||||||
packCount: String(med.packCount),
|
packCount: String(med.packCount),
|
||||||
blistersPerPack: String(med.blistersPerPack),
|
blistersPerPack: String(med.blistersPerPack),
|
||||||
pillsPerBlister: String(med.pillsPerBlister),
|
pillsPerBlister: String(med.pillsPerBlister),
|
||||||
|
totalPills: med.totalPills ? String(med.totalPills) : "",
|
||||||
looseTablets: String(med.looseTablets),
|
looseTablets: String(med.looseTablets),
|
||||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||||
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||||
notes: med.notes ?? "",
|
notes: med.notes ?? "",
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
@@ -156,6 +220,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
startDate: toDateValue(s.start),
|
startDate: toDateValue(s.start),
|
||||||
startTime: toTimeValue(s.start),
|
startTime: toTimeValue(s.start),
|
||||||
})),
|
})),
|
||||||
|
intakes: intakesFromApi,
|
||||||
};
|
};
|
||||||
setForm(editForm);
|
setForm(editForm);
|
||||||
setOriginalForm(editForm);
|
setOriginalForm(editForm);
|
||||||
@@ -234,6 +299,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
setBlisterValue,
|
setBlisterValue,
|
||||||
addBlister,
|
addBlister,
|
||||||
removeBlister,
|
removeBlister,
|
||||||
|
setIntakeValue,
|
||||||
|
addIntake,
|
||||||
|
removeIntake,
|
||||||
startEdit,
|
startEdit,
|
||||||
resetForm,
|
resetForm,
|
||||||
handleValueChange,
|
handleValueChange,
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"lowMeds": "{{count}} Medikament knapp",
|
"lowMeds": "{{count}} Medikament knapp",
|
||||||
"lowMeds_other": "{{count}} Medikamente knapp",
|
"lowMeds_other": "{{count}} Medikamente knapp",
|
||||||
"daysLeft": "{{days}} Tag übrig",
|
"daysLeft": "{{days}} Tag übrig",
|
||||||
"daysLeft_other": "{{days}} Tage übrig"
|
"daysLeft_other": "{{days}} Tage übrig",
|
||||||
|
"needsRefill": "Nachfüllen nötig"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -98,11 +99,16 @@
|
|||||||
"currentPills": "Aktuelle Tabletten",
|
"currentPills": "Aktuelle Tabletten",
|
||||||
"fullBlisters": "Volle Blister",
|
"fullBlisters": "Volle Blister",
|
||||||
"openBlister": "Offener Blister",
|
"openBlister": "Offener Blister",
|
||||||
|
"stock": "Bestand",
|
||||||
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"status": "Bestand",
|
"status": "Status",
|
||||||
"runsOut": "Aufgebraucht",
|
"runsOut": "Aufgebraucht",
|
||||||
"autoRemind": "Auto-Erinnerung",
|
"autoRemind": "Auto-Erinnerung",
|
||||||
"expiry": "Ablaufdatum"
|
"expiry": "Ablaufdatum",
|
||||||
|
"pillsCount": "{{count}} Tabletten",
|
||||||
|
"pillsCount_one": "{{count}} Tablette",
|
||||||
|
"pillsCount_other": "{{count}} Tabletten"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -116,7 +122,8 @@
|
|||||||
"blisters": "Blister pro Packung",
|
"blisters": "Blister pro Packung",
|
||||||
"pillsPerBlister": "Tabletten pro Blister",
|
"pillsPerBlister": "Tabletten pro Blister",
|
||||||
"loose": "Lose",
|
"loose": "Lose",
|
||||||
"total": "Gesamt"
|
"total": "Gesamt",
|
||||||
|
"stock": "Bestand"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -126,11 +133,16 @@
|
|||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
"takenBy": "Eingenommen von",
|
"takenBy": "Eingenommen von",
|
||||||
|
"packageType": "Verpackungsart",
|
||||||
|
"packageTypeBlister": "Blisterpackung",
|
||||||
|
"packageTypeBottle": "Pillendose / Behälter",
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
"blistersPerPack": "Blister pro Packung",
|
"blistersPerPack": "Blister pro Packung",
|
||||||
"pillsPerBlister": "Tabletten pro Blister",
|
"pillsPerBlister": "Tabletten pro Blister",
|
||||||
|
"totalCapacity": "Gesamtkapazität",
|
||||||
|
"currentPills": "Aktuelle Tabletten",
|
||||||
"loosePills": "Lose Tabletten",
|
"loosePills": "Lose Tabletten",
|
||||||
"pillWeight": "Tablettengewicht (mg)",
|
"pillWeight": "Dosis pro Tablette",
|
||||||
"total": "Gesamt (Tabletten)",
|
"total": "Gesamt (Tabletten)",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -154,7 +166,9 @@
|
|||||||
"every": "alle",
|
"every": "alle",
|
||||||
"from": "ab",
|
"from": "ab",
|
||||||
"startDate": "Datum",
|
"startDate": "Datum",
|
||||||
"startTime": "Uhrzeit"
|
"startTime": "Uhrzeit",
|
||||||
|
"takenByIntake": "Eingenommen von",
|
||||||
|
"takenByEveryone": "Alle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
@@ -260,6 +274,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"outOfStock": "Leer",
|
"outOfStock": "Leer",
|
||||||
|
"criticalStock": "Kritisch",
|
||||||
"lowStock": "Niedrig",
|
"lowStock": "Niedrig",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"highStock": "Hoch",
|
"highStock": "Hoch",
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"lowMeds": "{{count}} medication low",
|
"lowMeds": "{{count}} medication low",
|
||||||
"lowMeds_other": "{{count}} medications low",
|
"lowMeds_other": "{{count}} medications low",
|
||||||
"daysLeft": "{{days}} day left",
|
"daysLeft": "{{days}} day left",
|
||||||
"daysLeft_other": "{{days}} days left"
|
"daysLeft_other": "{{days}} days left",
|
||||||
|
"needsRefill": "Needs refill"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -98,11 +99,16 @@
|
|||||||
"currentPills": "Current pills",
|
"currentPills": "Current pills",
|
||||||
"fullBlisters": "Full blisters",
|
"fullBlisters": "Full blisters",
|
||||||
"openBlister": "Open blister",
|
"openBlister": "Open blister",
|
||||||
|
"stock": "Stock",
|
||||||
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Days left",
|
"daysLeft": "Days left",
|
||||||
"status": "Stock",
|
"status": "Status",
|
||||||
"runsOut": "Runs out",
|
"runsOut": "Runs out",
|
||||||
"autoRemind": "Auto-remind",
|
"autoRemind": "Auto-remind",
|
||||||
"expiry": "Expiry"
|
"expiry": "Expiry",
|
||||||
|
"pillsCount": "{{count}} pills",
|
||||||
|
"pillsCount_one": "{{count}} pill",
|
||||||
|
"pillsCount_other": "{{count}} pills"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -116,7 +122,8 @@
|
|||||||
"blisters": "Blisters per pack",
|
"blisters": "Blisters per pack",
|
||||||
"pillsPerBlister": "Pills per blister",
|
"pillsPerBlister": "Pills per blister",
|
||||||
"loose": "Loose",
|
"loose": "Loose",
|
||||||
"total": "Total"
|
"total": "Total",
|
||||||
|
"stock": "Stock"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -126,11 +133,16 @@
|
|||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
"takenBy": "Taken by",
|
"takenBy": "Taken by",
|
||||||
|
"packageType": "Package Type",
|
||||||
|
"packageTypeBlister": "Blister Pack",
|
||||||
|
"packageTypeBottle": "Pill Bottle / Container",
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
"blistersPerPack": "Blisters per pack",
|
"blistersPerPack": "Blisters per pack",
|
||||||
"pillsPerBlister": "Pills per blister",
|
"pillsPerBlister": "Pills per blister",
|
||||||
|
"totalCapacity": "Total Capacity",
|
||||||
|
"currentPills": "Current Pills",
|
||||||
"loosePills": "Loose pills",
|
"loosePills": "Loose pills",
|
||||||
"pillWeight": "Pill weight (mg)",
|
"pillWeight": "Dose per pill",
|
||||||
"total": "Total (pills)",
|
"total": "Total (pills)",
|
||||||
"expiryDate": "Expiry Date",
|
"expiryDate": "Expiry Date",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@@ -154,7 +166,9 @@
|
|||||||
"every": "every",
|
"every": "every",
|
||||||
"from": "from",
|
"from": "from",
|
||||||
"startDate": "Date",
|
"startDate": "Date",
|
||||||
"startTime": "Time"
|
"startTime": "Time",
|
||||||
|
"takenByIntake": "Taken by",
|
||||||
|
"takenByEveryone": "Everyone"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"planner": {
|
"planner": {
|
||||||
@@ -260,6 +274,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"outOfStock": "Empty",
|
"outOfStock": "Empty",
|
||||||
|
"criticalStock": "Critical",
|
||||||
"lowStock": "Low",
|
"lowStock": "Low",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"highStock": "High",
|
"highStock": "High",
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import type { Coverage } from "../types";
|
||||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||||
|
import { getStockStatus } from "../utils/schedule";
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
return userId ? `user_${userId}_${key}` : key;
|
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
|
// Helper function to calculate blister stock
|
||||||
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
|
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
|
||||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
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);
|
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)
|
// Notification bell SVG icon (no emoji)
|
||||||
function NotificationBellIcon() {
|
function NotificationBellIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +87,7 @@ function getReminderStatusData(
|
|||||||
const lowCount = allCoverage.filter((c) => {
|
const lowCount = allCoverage.filter((c) => {
|
||||||
if (c.medsLeft <= 0) return false;
|
if (c.medsLeft <= 0) return false;
|
||||||
if (c.daysLeft === null) return false;
|
if (c.daysLeft === null) return false;
|
||||||
return c.daysLeft < lowStockDays && c.daysLeft > 3;
|
return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
@@ -134,13 +109,16 @@ function getReminderStatusData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all low stock medications (critical + low)
|
// Collect all low stock medications (critical + low), deduplicated by name
|
||||||
const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = [];
|
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||||
|
|
||||||
// Add critical meds (from lowCoverage - these are ≤3 days)
|
// Add critical meds (from lowCoverage - these are ≤3 days)
|
||||||
for (const c of lowCoverage) {
|
for (const c of lowCoverage) {
|
||||||
if (c.daysLeft !== null) {
|
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) {
|
for (const c of allCoverage) {
|
||||||
if (c.medsLeft <= 0) continue;
|
if (c.medsLeft <= 0) continue;
|
||||||
if (c.daysLeft === null) continue;
|
if (c.daysLeft === null) continue;
|
||||||
if (c.daysLeft < lowStockDays && c.daysLeft > 3) {
|
if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) {
|
||||||
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
|
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)
|
// Convert to array and sort by days left (most urgent first)
|
||||||
lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft);
|
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||||
|
|
||||||
// Parse last sent info
|
// Parse last sent info
|
||||||
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||||
@@ -213,39 +194,9 @@ export function DashboardPage() {
|
|||||||
openUserFilter,
|
openUserFilter,
|
||||||
openShareDialog,
|
openShareDialog,
|
||||||
openScheduleLightbox,
|
openScheduleLightbox,
|
||||||
|
stockThresholds,
|
||||||
} = useAppContext();
|
} = 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
|
// Get structured reminder data
|
||||||
const reminderData = getReminderStatusData(
|
const reminderData = getReminderStatusData(
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
@@ -279,23 +230,46 @@ export function DashboardPage() {
|
|||||||
<NotificationBellIcon />
|
<NotificationBellIcon />
|
||||||
</span>
|
</span>
|
||||||
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
|
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
|
||||||
<span className={`reminder-status-badge ${reminderData.status.className}`}>
|
{reminderData.lowStockMeds.length === 0 && (
|
||||||
{reminderData.status.className === "success" && "✓ "}
|
<span className={`reminder-status-badge ${reminderData.status.className}`}>
|
||||||
{reminderData.status.text}
|
{reminderData.status.className === "success" && "✓ "}
|
||||||
</span>
|
{reminderData.status.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
|
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
|
||||||
<div className="reminder-status-details">
|
<div className="reminder-status-details">
|
||||||
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
|
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
|
||||||
<div className="reminder-low-stock-list">
|
<div className="reminder-status-row">
|
||||||
{reminderData.lowStockMeds.map((med) => (
|
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
|
||||||
<div key={med.name} className={`reminder-low-stock-item ${med.isCritical ? "critical" : ""}`}>
|
<span className="reminder-status-value">
|
||||||
<span className="reminder-med-name">{med.name}</span>
|
{reminderData.lowStockMeds.map((med, idx) => {
|
||||||
<span className="reminder-days-left">
|
const medication = meds.find((m) => m.name === med.name);
|
||||||
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
|
const cov = coverage.all.find((c) => c.name === med.name);
|
||||||
</span>
|
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
|
||||||
</div>
|
const textClass =
|
||||||
))}
|
status?.className === "danger"
|
||||||
|
? "danger-text"
|
||||||
|
: status?.className === "warning"
|
||||||
|
? "warning-text"
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<span key={med.name}>
|
||||||
|
{idx > 0 && ", "}
|
||||||
|
<span
|
||||||
|
className={`med-link clickable ${textClass}`}
|
||||||
|
onClick={() => medication && openMedDetail(medication)}
|
||||||
|
>
|
||||||
|
{med.name}
|
||||||
|
</span>
|
||||||
|
<span className={`reminder-days-left ${textClass}`}>
|
||||||
|
{" "}
|
||||||
|
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{intakeRemindersEnabled && reminderData.lastSent && (
|
{intakeRemindersEnabled && reminderData.lastSent && (
|
||||||
@@ -306,9 +280,9 @@ export function DashboardPage() {
|
|||||||
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
|
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
|
||||||
)}
|
)}
|
||||||
{reminderData.lastSent.takenBy && (
|
{reminderData.lastSent.takenBy && (
|
||||||
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
|
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span>
|
||||||
)}
|
)}
|
||||||
<span className="reminder-date">{reminderData.lastSent.date}</span>
|
<span className="reminder-date"> {reminderData.lastSent.date}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -328,149 +302,53 @@ export function DashboardPage() {
|
|||||||
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
|
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count medications with "Low" stock status (based on lowStockDays setting)
|
// Count medications with low stock (based on lowStockDays setting), deduplicated by name
|
||||||
const lowStockMeds = coverage.all.filter((c) => {
|
const lowStockMap = new Map<string, Coverage>();
|
||||||
if (c.medsLeft <= 0) return true; // out of stock
|
for (const c of coverage.all) {
|
||||||
if (c.daysLeft === null) return false; // no schedule
|
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
|
||||||
return c.daysLeft < settings.lowStockDays;
|
if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
|
||||||
});
|
const existing = lowStockMap.get(c.name);
|
||||||
const lowStockCount = lowStockMeds.length;
|
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
|
||||||
const lowStockNames = lowStockMeds.map((c) => c.name).join(", ");
|
lowStockMap.set(c.name, c);
|
||||||
|
}
|
||||||
if (coverage.low.length === 0) {
|
|
||||||
// No critical meds (≤3 days)
|
|
||||||
if (lowStockCount === 0) {
|
|
||||||
// All good - everything is Normal or High
|
|
||||||
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
|
|
||||||
} else {
|
|
||||||
// Some meds are Low but not critical - render with clickable med names
|
|
||||||
return (
|
|
||||||
<p className="warning-text">
|
|
||||||
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
|
||||||
{lowStockMeds.map((c, idx) => {
|
|
||||||
const med = meds.find((m) => m.name === c.name);
|
|
||||||
return (
|
|
||||||
<span key={c.name}>
|
|
||||||
{idx > 0 && ", "}
|
|
||||||
<span className="med-link clickable" onClick={() => med && openMedDetail(med)}>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}{" "}
|
|
||||||
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const lowStockMeds = Array.from(lowStockMap.values());
|
||||||
|
const lowStockCount = lowStockMeds.length;
|
||||||
|
|
||||||
|
if (lowStockCount === 0) {
|
||||||
|
// All good - everything is Normal or High
|
||||||
|
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some meds are low - show simple text with clickable names and days left
|
||||||
return (
|
return (
|
||||||
<>
|
<p>
|
||||||
<div className="table table-7">
|
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
||||||
<div className="table-head">
|
{lowStockMeds.map((c, idx) => {
|
||||||
<span>{t("table.name")}</span>
|
const med = meds.find((m) => m.name === c.name);
|
||||||
<span>{t("table.fullBlisters")}</span>
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
|
||||||
<span>{t("table.openBlister")}</span>
|
const textClass =
|
||||||
<span>{t("table.daysLeft")}</span>
|
status.className === "danger"
|
||||||
<span>{t("table.status")}</span>
|
? "danger-text"
|
||||||
<span>{t("table.runsOut")}</span>
|
: status.className === "warning"
|
||||||
<span>{t("table.autoRemind")}</span>
|
? "warning-text"
|
||||||
</div>
|
: "";
|
||||||
{coverage.low.map((row) => {
|
return (
|
||||||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
<span key={c.name}>
|
||||||
const med = meds.find((m) => m.name === row.name);
|
{idx > 0 && ", "}
|
||||||
const textClass =
|
<span className={`med-link clickable ${textClass}`} onClick={() => med && openMedDetail(med)}>
|
||||||
status.className === "danger"
|
{c.name}
|
||||||
? "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 (
|
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
|
||||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
|
||||||
<span className="med-name-text">{row.name}</span>
|
|
||||||
{med?.takenBy &&
|
|
||||||
med.takenBy.length > 0 &&
|
|
||||||
med.takenBy.map((person) => (
|
|
||||||
<span
|
|
||||||
key={person}
|
|
||||||
className="taken-by-badge clickable"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openUserFilter(person);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{person}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
|
||||||
<span className="med-icons">
|
|
||||||
{med?.intakeRemindersEnabled && (
|
|
||||||
<span
|
|
||||||
className="reminder-icon info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
|
||||||
>
|
|
||||||
🔔
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{med?.notes && (
|
|
||||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
|
||||||
📝
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span data-label={t("table.fullBlisters")} className={textClass}>
|
|
||||||
{formatFullBlisters(stock.fullBlisters, t)}
|
|
||||||
</span>
|
|
||||||
<span data-label={t("table.openBlister")} className={textClass}>
|
|
||||||
{formatOpenBlisterAndLoose(
|
|
||||||
stock.openBlisterPills,
|
|
||||||
stock.loosePills,
|
|
||||||
med?.pillsPerBlister ?? 1,
|
|
||||||
t
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span data-label={t("table.days")} className={textClass}>
|
|
||||||
{formatNumber(row.daysLeft)}
|
|
||||||
</span>
|
|
||||||
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
|
|
||||||
{t(status.label)}
|
|
||||||
</span>
|
|
||||||
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
|
|
||||||
<span data-label={t("table.autoRemind")} className="next-reminder-date">
|
|
||||||
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
|
||||||
<div className="email-send-action">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost"
|
|
||||||
onClick={sendReminderEmail}
|
|
||||||
disabled={sendingReminderEmail}
|
|
||||||
>
|
|
||||||
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
|
|
||||||
</button>
|
|
||||||
{reminderEmailResult && (
|
|
||||||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
|
||||||
{reminderEmailResult.message}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className={`reminder-days-left ${textClass}`}>
|
||||||
</div>
|
{" "}
|
||||||
)}
|
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
|
||||||
</>
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}{" "}
|
||||||
|
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</article>
|
</article>
|
||||||
@@ -485,15 +363,15 @@ export function DashboardPage() {
|
|||||||
<div className="table table-7">
|
<div className="table table-7">
|
||||||
<div className="table-head">
|
<div className="table-head">
|
||||||
<span>{t("table.name")}</span>
|
<span>{t("table.name")}</span>
|
||||||
<span>{t("table.fullBlisters")}</span>
|
<span>{t("table.stock")}</span>
|
||||||
<span>{t("table.openBlister")}</span>
|
<span>{t("table.stockDetails")}</span>
|
||||||
<span>{t("table.daysLeft")}</span>
|
<span>{t("table.daysLeft")}</span>
|
||||||
<span>{t("table.runsOut")}</span>
|
<span>{t("table.runsOut")}</span>
|
||||||
<span>{t("table.expiry")}</span>
|
<span>{t("table.expiry")}</span>
|
||||||
<span>{t("table.status")}</span>
|
<span>{t("table.status")}</span>
|
||||||
</div>
|
</div>
|
||||||
{coverage.all.map((row) => {
|
{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 med = meds.find((m) => m.name === row.name);
|
||||||
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
||||||
const textClass =
|
const textClass =
|
||||||
@@ -544,11 +422,20 @@ export function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.fullBlisters")} className={textClass}>
|
<span data-label={t("table.stock")} className={textClass}>
|
||||||
{formatFullBlisters(stock.fullBlisters, t)}
|
{med?.packageType === "bottle"
|
||||||
|
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
|
||||||
|
: formatFullBlisters(stock.fullBlisters, t)}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.openBlister")} className={textClass}>
|
<span data-label={t("table.stockDetails")} className={textClass}>
|
||||||
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}
|
{med?.packageType === "bottle"
|
||||||
|
? "-"
|
||||||
|
: formatOpenBlisterAndLoose(
|
||||||
|
stock.openBlisterPills,
|
||||||
|
stock.loosePills,
|
||||||
|
med?.pillsPerBlister ?? 1,
|
||||||
|
t
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.daysLeft")} className={textClass}>
|
<span data-label={t("table.daysLeft")} className={textClass}>
|
||||||
{formatNumber(row.daysLeft)}
|
{formatNumber(row.daysLeft)}
|
||||||
@@ -605,9 +492,7 @@ export function DashboardPage() {
|
|||||||
const missedCount = missedPastDoseIds.length;
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap((d) =>
|
const totalPastDoses = pastDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) =>
|
d.meds.flatMap((m) =>
|
||||||
m.doses.flatMap((dose) =>
|
m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
|
||||||
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -656,9 +541,7 @@ export function DashboardPage() {
|
|||||||
{showPastDays &&
|
{showPastDays &&
|
||||||
pastDays.map((day) => {
|
pastDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) =>
|
||||||
item.doses.flatMap((d) =>
|
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const allDayTaken =
|
const allDayTaken =
|
||||||
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
|
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 med = meds.find((m) => m.name === item.medName);
|
||||||
const medCov = coverageByMed[item.medName];
|
const medCov = coverageByMed[item.medName];
|
||||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||||
@@ -736,13 +617,14 @@ export function DashboardPage() {
|
|||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
// If no takenBy, show single checkbox; otherwise show one per person
|
// 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 (
|
return (
|
||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
@@ -795,9 +677,7 @@ export function DashboardPage() {
|
|||||||
(() => {
|
(() => {
|
||||||
const day = todayDay;
|
const day = todayDay;
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) =>
|
||||||
item.doses.flatMap((d) =>
|
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||||
@@ -808,7 +688,7 @@ export function DashboardPage() {
|
|||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
if (willBeOutOfStock) return "danger";
|
if (willBeOutOfStock) return "danger";
|
||||||
if (!medCoverage) return "success";
|
if (!medCoverage) return "success";
|
||||||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
|
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -855,11 +735,9 @@ export function DashboardPage() {
|
|||||||
const status = willBeOutOfStock
|
const status = willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
||||||
: null;
|
: null;
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||||
@@ -891,7 +769,7 @@ export function DashboardPage() {
|
|||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const isOverdue = dose.when < Date.now();
|
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)));
|
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -901,7 +779,8 @@ export function DashboardPage() {
|
|||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
@@ -954,9 +833,7 @@ export function DashboardPage() {
|
|||||||
(() => {
|
(() => {
|
||||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) =>
|
d.meds.flatMap((m) =>
|
||||||
m.doses.flatMap((dose) =>
|
m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
|
||||||
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
|
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
|
||||||
@@ -988,9 +865,7 @@ export function DashboardPage() {
|
|||||||
{showFutureDays &&
|
{showFutureDays &&
|
||||||
futureDays.map((day) => {
|
futureDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) =>
|
||||||
item.doses.flatMap((d) =>
|
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||||
@@ -1001,7 +876,7 @@ export function DashboardPage() {
|
|||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
if (willBeOutOfStock) return "danger";
|
if (willBeOutOfStock) return "danger";
|
||||||
if (!medCoverage) return "success";
|
if (!medCoverage) return "success";
|
||||||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
|
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -1047,11 +922,9 @@ export function DashboardPage() {
|
|||||||
const status = willBeOutOfStock
|
const status = willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
||||||
: null;
|
: null;
|
||||||
const itemDoseIds = item.doses.flatMap((d) =>
|
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
|
||||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
|
||||||
);
|
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||||
@@ -1082,14 +955,15 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{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)));
|
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||||
return (
|
return (
|
||||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
|
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { Medication } from "../types";
|
import type { DoseUnit, Medication } from "../types";
|
||||||
import { FIELD_LIMITS, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
|
|
||||||
export function MedicationsPage() {
|
export function MedicationsPage() {
|
||||||
@@ -25,6 +25,7 @@ export function MedicationsPage() {
|
|||||||
setRefillLoose,
|
setRefillLoose,
|
||||||
refillSaving,
|
refillSaving,
|
||||||
submitRefill,
|
submitRefill,
|
||||||
|
coverageByMed,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
// Use the medication form hook
|
// Use the medication form hook
|
||||||
@@ -47,6 +48,9 @@ export function MedicationsPage() {
|
|||||||
addBlister,
|
addBlister,
|
||||||
removeBlister,
|
removeBlister,
|
||||||
setBlisterValue,
|
setBlisterValue,
|
||||||
|
addIntake,
|
||||||
|
removeIntake,
|
||||||
|
setIntakeValue,
|
||||||
resetForm,
|
resetForm,
|
||||||
startEdit,
|
startEdit,
|
||||||
} = useMedicationForm();
|
} = useMedicationForm();
|
||||||
@@ -87,12 +91,17 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Calculate total tablets
|
// Calculate total tablets
|
||||||
const totalTablets = useMemo(() => {
|
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 packCount = Number(form.packCount) || 0;
|
||||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
const looseTablets = Number(form.looseTablets) || 0;
|
const looseTablets = Number(form.looseTablets) || 0;
|
||||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
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
|
// Open mobile edit modal
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
@@ -158,26 +167,39 @@ export function MedicationsPage() {
|
|||||||
if (saving) return;
|
if (saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
// Prepare medication data
|
// Prepare intakes data with per-intake takenBy
|
||||||
const blisters = form.blisters.map((b) => ({
|
const intakes = form.intakes.map((intake) => ({
|
||||||
usage: Number(b.usage) || 1,
|
usage: Number(intake.usage) || 1,
|
||||||
every: Number(b.every) || 1,
|
every: Number(intake.every) || 1,
|
||||||
start: combineDateAndTime(b.startDate, b.startTime),
|
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 = {
|
const body = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
genericName: form.genericName.trim() || null,
|
genericName: form.genericName.trim() || null,
|
||||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||||
|
packageType: form.packageType,
|
||||||
packCount: Number(form.packCount) || 0,
|
packCount: Number(form.packCount) || 0,
|
||||||
blistersPerPack: Number(form.blistersPerPack) || 1,
|
blistersPerPack: Number(form.blistersPerPack) || 1,
|
||||||
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
||||||
|
totalPills: Number(form.totalPills) || null,
|
||||||
looseTablets: Number(form.looseTablets) || 0,
|
looseTablets: Number(form.looseTablets) || 0,
|
||||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||||
|
doseUnit: form.doseUnit,
|
||||||
expiryDate: form.expiryDate || null,
|
expiryDate: form.expiryDate || null,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||||
blisters,
|
blisters, // Legacy format for backward compatibility
|
||||||
|
intakes, // New format with per-intake takenBy
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +353,9 @@ export function MedicationsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-total">
|
<div className="med-total">
|
||||||
{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")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
@@ -431,50 +455,100 @@ export function MedicationsPage() {
|
|||||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.packs")}
|
{t("form.packageType")}
|
||||||
<input
|
<select
|
||||||
type="number"
|
className="package-type-select"
|
||||||
min="0"
|
value={form.packageType}
|
||||||
value={form.packCount}
|
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
||||||
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
>
|
||||||
/>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{form.packageType === "blister" ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.packs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.packCount}
|
||||||
|
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.blistersPerPack")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.blistersPerPack}
|
||||||
|
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.pillsPerBlister")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.pillsPerBlister}
|
||||||
|
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.loosePills")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.totalCapacity")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.totalPills}
|
||||||
|
onChange={(e) => handleValueChange("totalPills", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.currentPills")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label>
|
<label>
|
||||||
{t("form.blistersPerPack")}
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<input
|
<div className="dose-input-group">
|
||||||
type="number"
|
<input
|
||||||
min="1"
|
type="number"
|
||||||
value={form.blistersPerPack}
|
min="0"
|
||||||
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
step="0.1"
|
||||||
/>
|
value={form.pillWeightMg}
|
||||||
</label>
|
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
||||||
<label>
|
placeholder={t("form.placeholders.weight")}
|
||||||
{t("form.pillsPerBlister")}
|
/>
|
||||||
<input
|
<select
|
||||||
type="number"
|
value={form.doseUnit}
|
||||||
min="1"
|
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||||
value={form.pillsPerBlister}
|
className="dose-unit-select"
|
||||||
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
>
|
||||||
/>
|
{DOSE_UNITS.map((unit) => (
|
||||||
</label>
|
<option key={unit.value} value={unit.value}>
|
||||||
<label>
|
{unit.label}
|
||||||
{t("form.loosePills")}
|
</option>
|
||||||
<input
|
))}
|
||||||
type="number"
|
</select>
|
||||||
min="0"
|
</div>
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("form.pillWeight")}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={form.pillWeightMg}
|
|
||||||
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
|
||||||
placeholder={t("form.placeholders.weight")}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.total")}
|
{t("form.total")}
|
||||||
@@ -558,20 +632,12 @@ export function MedicationsPage() {
|
|||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3>{t("form.blisters.title")}</h3>
|
<h3>{t("form.blisters.title")}</h3>
|
||||||
<div className="blisters-actions">
|
<div className="blisters-actions">
|
||||||
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
|
<button type="button" className="primary" onClick={() => addIntake()}>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.intakeRemindersEnabled}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<span>🔔 {t("form.blisters.remind")}</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" className="primary" onClick={addBlister}>
|
|
||||||
+ {t("form.blisters.addIntake")}
|
+ {t("form.blisters.addIntake")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.blisters.map((s, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div key={idx} className="blister-row">
|
<div key={idx} className="blister-row">
|
||||||
<div className="blister-inputs">
|
<div className="blister-inputs">
|
||||||
<label>
|
<label>
|
||||||
@@ -580,8 +646,8 @@ export function MedicationsPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={s.usage}
|
value={intake.usage}
|
||||||
onChange={(e) => setBlisterValue(idx, "usage", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -589,29 +655,48 @@ export function MedicationsPage() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={s.every}
|
value={intake.every}
|
||||||
onChange={(e) => setBlisterValue(idx, "every", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.startDate")}
|
{t("form.blisters.startDate")}
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={s.startDate}
|
value={intake.startDate}
|
||||||
onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.startTime")}
|
{t("form.blisters.startTime")}
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={s.startTime}
|
value={intake.startTime}
|
||||||
onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label title={t("form.blisters.takenByTooltip")}>
|
||||||
|
{t("form.blisters.takenByIntake")}
|
||||||
|
<select value={intake.takenBy} onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}>
|
||||||
|
<option value="">{t("form.blisters.takenByEveryone")}</option>
|
||||||
|
{existingPeople.map((person) => (
|
||||||
|
<option key={person} value={person}>
|
||||||
|
{person}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={intake.intakeRemindersEnabled}
|
||||||
|
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>🔔</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{form.blisters.length > 1 && (
|
{form.intakes.length > 1 && (
|
||||||
<button type="button" className="danger" onClick={() => removeBlister(idx)}>
|
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
|
||||||
{t("common.remove")}
|
{t("common.remove")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -716,6 +801,9 @@ export function MedicationsPage() {
|
|||||||
onSetBlisterValue={setBlisterValue}
|
onSetBlisterValue={setBlisterValue}
|
||||||
onAddBlister={addBlister}
|
onAddBlister={addBlister}
|
||||||
onRemoveBlister={removeBlister}
|
onRemoveBlister={removeBlister}
|
||||||
|
onSetIntakeValue={setIntakeValue}
|
||||||
|
onAddIntake={addIntake}
|
||||||
|
onRemoveIntake={removeIntake}
|
||||||
onHandleValueChange={handleValueChange}
|
onHandleValueChange={handleValueChange}
|
||||||
refillPacks={refillPacks}
|
refillPacks={refillPacks}
|
||||||
onRefillPacksChange={setRefillPacks}
|
onRefillPacksChange={setRefillPacks}
|
||||||
|
|||||||
@@ -9,15 +9,23 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
return userId ? `user_${userId}_${key}` : key;
|
return userId ? `user_${userId}_${key}` : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get stock status
|
// Helper function to get stock status based on thresholds
|
||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
||||||
) {
|
) {
|
||||||
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
|
// Out of stock or completely depleted = danger (red)
|
||||||
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
|
// No schedule, but has stock = normal
|
||||||
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
|
// Critical: at or below reminder threshold = danger (red)
|
||||||
|
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
// Low: below low stock threshold = warning (yellow)
|
||||||
|
if (daysLeft < settings.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
// High stock
|
||||||
|
if (daysLeft >= settings.highStockDays) return { className: "high", label: "status.highStock" };
|
||||||
|
// Normal stock
|
||||||
return { className: "success", label: "status.normal" };
|
return { className: "success", label: "status.normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +33,7 @@ function getStockStatus(
|
|||||||
function getDayStockStatus(
|
function getDayStockStatus(
|
||||||
dayMeds: Array<{ medName: string }>,
|
dayMeds: Array<{ medName: string }>,
|
||||||
coverageByMed: Record<string, Coverage>,
|
coverageByMed: Record<string, Coverage>,
|
||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
||||||
): string {
|
): string {
|
||||||
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
||||||
for (const item of dayMeds) {
|
for (const item of dayMeds) {
|
||||||
@@ -197,7 +205,7 @@ export function SchedulePage() {
|
|||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
@@ -301,7 +309,7 @@ export function SchedulePage() {
|
|||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{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"})`}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
|
|||||||
+97
-33
@@ -281,34 +281,13 @@ body.modal-open {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-low-stock-list {
|
.reminder-days-left {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding-left: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-low-stock-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-low-stock-item .reminder-med-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-low-stock-item .reminder-days-left {
|
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-low-stock-item.critical .reminder-days-left {
|
.critical .reminder-days-left {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-link {
|
.med-link {
|
||||||
@@ -994,6 +973,27 @@ textarea.auto-resize {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Package type selector - simple dropdown style */
|
||||||
|
.package-type-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-type-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Form field validation */
|
/* Form field validation */
|
||||||
.form-grid label.has-error input,
|
.form-grid label.has-error input,
|
||||||
.form-grid label.has-error textarea {
|
.form-grid label.has-error textarea {
|
||||||
@@ -1015,6 +1015,40 @@ textarea.auto-resize {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dose input with unit selector */
|
||||||
|
.dose-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-unit-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--input-radius);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-unit-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-unit-select:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag input for multi-value fields (e.g., Taken By) */
|
/* Tag input for multi-value fields (e.g., Taken By) */
|
||||||
.tag-input-container {
|
.tag-input-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3922,6 +3956,41 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Password Input with Toggle */
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-links {
|
.auth-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4336,8 +4405,7 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0;
|
||||||
border-bottom: 1px solid var(--border-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-form .form-group {
|
.profile-form .form-group {
|
||||||
@@ -4376,9 +4444,8 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0;
|
||||||
border-top: 1px solid var(--border-primary);
|
margin-top: 0.5rem;
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-actions .btn {
|
.profile-actions .btn {
|
||||||
@@ -4418,13 +4485,10 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
/* Profile danger zone */
|
/* Profile danger zone */
|
||||||
.profile-danger-zone {
|
.profile-danger-zone {
|
||||||
margin: 0 1.5rem 1.5rem;
|
margin: 0 1.5rem 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--border-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-danger-zone .profile-section-title {
|
.profile-danger-zone .profile-section-title {
|
||||||
border-bottom: none;
|
|
||||||
padding-bottom: 0;
|
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
|
|||||||
lowStockDays: 7,
|
lowStockDays: 7,
|
||||||
normalStockDays: 30,
|
normalStockDays: 30,
|
||||||
highStockDays: 90,
|
highStockDays: 90,
|
||||||
|
criticalStockDays: 3,
|
||||||
|
expiryWarningDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMedication: Medication = {
|
const mockMedication: Medication = {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ const defaultForm: FormState = {
|
|||||||
name: "",
|
name: "",
|
||||||
genericName: "",
|
genericName: "",
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
|
packageType: "blister",
|
||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "1",
|
blistersPerPack: "1",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
|
totalPills: "",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
expiryDate: "",
|
expiryDate: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -23,6 +25,16 @@ const defaultForm: FormState = {
|
|||||||
startTime: "09:00",
|
startTime: "09:00",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -44,6 +56,9 @@ const defaultProps = {
|
|||||||
onSetBlisterValue: vi.fn(),
|
onSetBlisterValue: vi.fn(),
|
||||||
onAddBlister: vi.fn(),
|
onAddBlister: vi.fn(),
|
||||||
onRemoveBlister: vi.fn(),
|
onRemoveBlister: vi.fn(),
|
||||||
|
onSetIntakeValue: vi.fn(),
|
||||||
|
onAddIntake: vi.fn(),
|
||||||
|
onRemoveIntake: vi.fn(),
|
||||||
onHandleValueChange: vi.fn(),
|
onHandleValueChange: vi.fn(),
|
||||||
refillPacks: 0,
|
refillPacks: 0,
|
||||||
onRefillPacksChange: vi.fn(),
|
onRefillPacksChange: vi.fn(),
|
||||||
@@ -185,14 +200,14 @@ describe("MobileEditModal", () => {
|
|||||||
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
|
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onAddBlister when add intake clicked", () => {
|
it("calls onAddIntake when add intake clicked", () => {
|
||||||
const onAddBlister = vi.fn();
|
const onAddIntake = vi.fn();
|
||||||
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />);
|
render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
|
||||||
|
|
||||||
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
|
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
|
||||||
fireEvent.click(addBtn);
|
fireEvent.click(addBtn);
|
||||||
|
|
||||||
expect(onAddBlister).toHaveBeenCalledTimes(1);
|
expect(onAddIntake).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders modal content", () => {
|
it("renders modal content", () => {
|
||||||
@@ -261,6 +276,24 @@ describe("MobileEditModal blister management", () => {
|
|||||||
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
||||||
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
|
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
|
||||||
],
|
],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usage: "2",
|
||||||
|
every: "7",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "10:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<MobileEditModal {...defaultProps} form={form} />);
|
render(<MobileEditModal {...defaultProps} form={form} />);
|
||||||
@@ -269,34 +302,52 @@ describe("MobileEditModal blister management", () => {
|
|||||||
expect(blisterRows.length).toBe(2);
|
expect(blisterRows.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onRemoveBlister when remove button clicked", () => {
|
it("calls onRemoveIntake when remove button clicked", () => {
|
||||||
const onRemoveBlister = vi.fn();
|
const onRemoveIntake = vi.fn();
|
||||||
const form = {
|
const form = {
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
blisters: [
|
blisters: [
|
||||||
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
||||||
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
|
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
|
||||||
],
|
],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usage: "2",
|
||||||
|
every: "7",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "10:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<MobileEditModal {...defaultProps} form={form} onRemoveBlister={onRemoveBlister} />);
|
render(<MobileEditModal {...defaultProps} form={form} onRemoveIntake={onRemoveIntake} />);
|
||||||
|
|
||||||
const removeButtons = document.querySelectorAll(".blister-row button.danger");
|
const removeButtons = document.querySelectorAll(".blister-row button.danger");
|
||||||
if (removeButtons.length > 0) {
|
if (removeButtons.length > 0) {
|
||||||
fireEvent.click(removeButtons[0]);
|
fireEvent.click(removeButtons[0]);
|
||||||
expect(onRemoveBlister).toHaveBeenCalled();
|
expect(onRemoveIntake).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onSetBlisterValue when changing blister field", () => {
|
it("calls onSetIntakeValue when changing blister field", () => {
|
||||||
const onSetBlisterValue = vi.fn();
|
const onSetIntakeValue = vi.fn();
|
||||||
|
|
||||||
render(<MobileEditModal {...defaultProps} onSetBlisterValue={onSetBlisterValue} />);
|
render(<MobileEditModal {...defaultProps} onSetIntakeValue={onSetIntakeValue} />);
|
||||||
|
|
||||||
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
|
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
|
||||||
if (usageInputs.length > 0) {
|
if (usageInputs.length > 0) {
|
||||||
fireEvent.change(usageInputs[0], { target: { value: "2" } });
|
fireEvent.change(usageInputs[0], { target: { value: "2" } });
|
||||||
expect(onSetBlisterValue).toHaveBeenCalled();
|
expect(onSetIntakeValue).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PasswordInput } from "../../components/PasswordInput";
|
||||||
|
|
||||||
|
describe("PasswordInput", () => {
|
||||||
|
it("renders password input with hidden text by default", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input.type).toBe("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles password visibility when eye button is clicked", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show password/i });
|
||||||
|
|
||||||
|
// Initially password is hidden
|
||||||
|
expect(input.type).toBe("password");
|
||||||
|
|
||||||
|
// Click to show password
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
expect(input.type).toBe("text");
|
||||||
|
|
||||||
|
// Click again to hide password
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
expect(input.type).toBe("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange when input value changes", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={handleChange} />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "newpassword" } });
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through required attribute", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} required />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
expect(input.required).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through minLength and maxLength attributes", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} minLength={8} maxLength={128} />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
expect(input.minLength).toBe(8);
|
||||||
|
expect(input.maxLength).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through placeholder attribute", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} placeholder="Enter password" />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
expect(input.placeholder).toBe("Enter password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through autoComplete attribute", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} autoComplete="new-password" />);
|
||||||
|
|
||||||
|
const input = document.getElementById("test-password") as HTMLInputElement;
|
||||||
|
expect(input.autocomplete).toBe("new-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle button has correct aria-label", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show password/i });
|
||||||
|
expect(toggleButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
const hideButton = screen.getByRole("button", { name: /hide password/i });
|
||||||
|
expect(hideButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle button has tabIndex -1 to prevent focus during form navigation", () => {
|
||||||
|
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole("button");
|
||||||
|
expect(toggleButton.tabIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
|
|||||||
lowStockDays: 7,
|
lowStockDays: 7,
|
||||||
normalStockDays: 30,
|
normalStockDays: 30,
|
||||||
highStockDays: 90,
|
highStockDays: 90,
|
||||||
|
criticalStockDays: 3,
|
||||||
|
expiryWarningDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMedication: Medication = {
|
const mockMedication: Medication = {
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ const createMockAppContext = (overrides = {}) => ({
|
|||||||
coverage: { all: [], low: [] },
|
coverage: { all: [], low: [] },
|
||||||
coverageByMed: {},
|
coverageByMed: {},
|
||||||
depletionByMed: {},
|
depletionByMed: {},
|
||||||
|
stockThresholds: {
|
||||||
|
lowStockDays: 7,
|
||||||
|
normalStockDays: 30,
|
||||||
|
highStockDays: 90,
|
||||||
|
criticalStockDays: 7,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
},
|
||||||
manuallyExpandedDays: new Set(),
|
manuallyExpandedDays: new Set(),
|
||||||
manuallyCollapsedDays: new Set(),
|
manuallyCollapsedDays: new Set(),
|
||||||
toggleDayCollapse: vi.fn(),
|
toggleDayCollapse: vi.fn(),
|
||||||
@@ -400,8 +407,8 @@ describe("DashboardPage structure", () => {
|
|||||||
|
|
||||||
// Should have all expected table columns
|
// Should have all expected table columns
|
||||||
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.stock(?!Details)/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.stockDetails/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
|
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const createMockContext = (overrides = {}) => ({
|
|||||||
setRefillLoose: vi.fn(),
|
setRefillLoose: vi.fn(),
|
||||||
refillSaving: false,
|
refillSaving: false,
|
||||||
submitRefill: vi.fn(),
|
submitRefill: vi.fn(),
|
||||||
|
coverageByMed: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,12 +66,24 @@ const createMockFormHook = (overrides = {}) => ({
|
|||||||
form: {
|
form: {
|
||||||
name: "",
|
name: "",
|
||||||
genericName: "",
|
genericName: "",
|
||||||
|
packageType: "blister" as const,
|
||||||
packCount: "0",
|
packCount: "0",
|
||||||
blistersPerPack: "0",
|
blistersPerPack: "0",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
|
totalPills: "",
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }],
|
blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
expiryDate: "",
|
expiryDate: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
@@ -93,6 +106,9 @@ const createMockFormHook = (overrides = {}) => ({
|
|||||||
addBlister: vi.fn(),
|
addBlister: vi.fn(),
|
||||||
removeBlister: vi.fn(),
|
removeBlister: vi.fn(),
|
||||||
setBlisterValue: vi.fn(),
|
setBlisterValue: vi.fn(),
|
||||||
|
addIntake: vi.fn(),
|
||||||
|
removeIntake: vi.fn(),
|
||||||
|
setIntakeValue: vi.fn(),
|
||||||
resetForm: vi.fn(),
|
resetForm: vi.fn(),
|
||||||
startEdit: vi.fn(),
|
startEdit: vi.fn(),
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
@@ -328,9 +344,9 @@ describe("MedicationsPage form interactions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls addBlister when clicking add schedule button", () => {
|
it("calls addIntake when clicking add schedule button", () => {
|
||||||
const addBlister = vi.fn();
|
const addIntake = vi.fn();
|
||||||
mockFormHookValue = createMockFormHook({ addBlister });
|
mockFormHookValue = createMockFormHook({ addIntake });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -338,11 +354,11 @@ describe("MedicationsPage form interactions", () => {
|
|||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find add blister button
|
// Find add intake button
|
||||||
const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/);
|
const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/);
|
||||||
if (addBtn) {
|
if (addBtn) {
|
||||||
fireEvent.click(addBtn);
|
fireEvent.click(addBtn);
|
||||||
expect(addBlister).toHaveBeenCalled();
|
expect(addIntake).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -393,12 +409,24 @@ describe("MedicationsPage editing", () => {
|
|||||||
form: {
|
form: {
|
||||||
name: "Aspirin",
|
name: "Aspirin",
|
||||||
genericName: "Acetylsalicylic acid",
|
genericName: "Acetylsalicylic acid",
|
||||||
|
packageType: "blister" as const,
|
||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "2",
|
blistersPerPack: "2",
|
||||||
pillsPerBlister: "10",
|
pillsPerBlister: "10",
|
||||||
looseTablets: "5",
|
looseTablets: "5",
|
||||||
|
totalPills: "",
|
||||||
takenBy: ["John"],
|
takenBy: ["John"],
|
||||||
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
|
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
expiryDate: "2025-12-31",
|
expiryDate: "2025-12-31",
|
||||||
notes: "Take with food",
|
notes: "Take with food",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
@@ -558,14 +586,24 @@ describe("MedicationsPage blister management", () => {
|
|||||||
expect(blisterSections.length).toBeGreaterThan(0);
|
expect(blisterSections.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls setBlisterValue when changing blister field", () => {
|
it("calls setIntakeValue when changing blister field", () => {
|
||||||
const setBlisterValue = vi.fn();
|
const setIntakeValue = vi.fn();
|
||||||
mockFormHookValue = createMockFormHook({
|
mockFormHookValue = createMockFormHook({
|
||||||
form: {
|
form: {
|
||||||
...createMockFormHook().form,
|
...createMockFormHook().form,
|
||||||
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
|
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
setBlisterValue,
|
setIntakeValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -578,7 +616,7 @@ describe("MedicationsPage blister management", () => {
|
|||||||
const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]');
|
const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]');
|
||||||
if (blisterInputs.length > 0) {
|
if (blisterInputs.length > 0) {
|
||||||
fireEvent.change(blisterInputs[0], { target: { value: "2" } });
|
fireEvent.change(blisterInputs[0], { target: { value: "2" } });
|
||||||
expect(setBlisterValue).toHaveBeenCalled();
|
expect(setIntakeValue).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -591,9 +629,9 @@ describe("MedicationsPage add blister", () => {
|
|||||||
mockFormHookValue = createMockFormHook();
|
mockFormHookValue = createMockFormHook();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls addBlister when clicking add intake button", () => {
|
it("calls addIntake when clicking add intake button", () => {
|
||||||
const addBlister = vi.fn();
|
const addIntake = vi.fn();
|
||||||
mockFormHookValue = createMockFormHook({ addBlister });
|
mockFormHookValue = createMockFormHook({ addIntake });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -603,7 +641,7 @@ describe("MedicationsPage add blister", () => {
|
|||||||
|
|
||||||
const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
|
const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
|
||||||
fireEvent.click(addIntakeBtn);
|
fireEvent.click(addIntakeBtn);
|
||||||
expect(addBlister).toHaveBeenCalled();
|
expect(addIntake).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -619,6 +657,24 @@ describe("MedicationsPage remove blister", () => {
|
|||||||
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
||||||
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
|
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
|
||||||
],
|
],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usage: "2",
|
||||||
|
every: "7",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "20:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -635,8 +691,8 @@ describe("MedicationsPage remove blister", () => {
|
|||||||
expect(removeButtons.length).toBeGreaterThan(0);
|
expect(removeButtons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls removeBlister when clicking remove button", () => {
|
it("calls removeIntake when clicking remove button", () => {
|
||||||
const removeBlister = vi.fn();
|
const removeIntake = vi.fn();
|
||||||
mockFormHookValue = createMockFormHook({
|
mockFormHookValue = createMockFormHook({
|
||||||
form: {
|
form: {
|
||||||
...createMockFormHook().form,
|
...createMockFormHook().form,
|
||||||
@@ -644,8 +700,26 @@ describe("MedicationsPage remove blister", () => {
|
|||||||
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
|
||||||
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
|
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
|
||||||
],
|
],
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: "1",
|
||||||
|
every: "1",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "09:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usage: "2",
|
||||||
|
every: "7",
|
||||||
|
startDate: "2024-01-01",
|
||||||
|
startTime: "20:00",
|
||||||
|
takenBy: "",
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
removeBlister,
|
removeIntake,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -657,7 +731,7 @@ describe("MedicationsPage remove blister", () => {
|
|||||||
const removeButtons = document.querySelectorAll(".blister-row .danger");
|
const removeButtons = document.querySelectorAll(".blister-row .danger");
|
||||||
if (removeButtons.length > 0) {
|
if (removeButtons.length > 0) {
|
||||||
fireEvent.click(removeButtons[0]);
|
fireEvent.click(removeButtons[0]);
|
||||||
expect(removeBlister).toHaveBeenCalled();
|
expect(removeIntake).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -670,19 +744,25 @@ describe("MedicationsPage intake reminders toggle", () => {
|
|||||||
mockFormHookValue = createMockFormHook();
|
mockFormHookValue = createMockFormHook();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders intake reminders checkbox", () => {
|
it("renders intake reminders checkbox per intake", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<MedicationsPage />
|
<MedicationsPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/form\.blisters\.remind/i)).toBeInTheDocument();
|
// Now each intake row has its own reminder checkbox with the bell icon
|
||||||
|
// Desktop form uses class "full blisters" container
|
||||||
|
const blistersContainer = document.querySelector(".blisters");
|
||||||
|
expect(blistersContainer).toBeInTheDocument();
|
||||||
|
// Check for the inline-checkbox that controls intake reminders in each blister row
|
||||||
|
const intakeCheckbox = document.querySelector(".blister-row .inline-checkbox");
|
||||||
|
expect(intakeCheckbox).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can toggle intake reminders", () => {
|
it("can toggle intake reminders per intake", () => {
|
||||||
const setForm = vi.fn();
|
const setIntakeValue = vi.fn();
|
||||||
mockFormHookValue = createMockFormHook({ setForm });
|
mockFormHookValue = createMockFormHook({ setIntakeValue });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -690,10 +770,11 @@ describe("MedicationsPage intake reminders toggle", () => {
|
|||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkbox = document.querySelector('.inline-checkbox input[type="checkbox"]');
|
// Each blister row has inline-checkbox for intake reminders
|
||||||
|
const checkbox = document.querySelector('.blister-row .inline-checkbox input[type="checkbox"]');
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
expect(setForm).toHaveBeenCalled();
|
expect(setIntakeValue).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+72
-12
@@ -2,29 +2,57 @@
|
|||||||
// Core Types for MedAssist
|
// Core Types for MedAssist
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
export type PackageType = "blister" | "bottle";
|
||||||
|
|
||||||
|
// Common medication dose units
|
||||||
|
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
|
||||||
|
|
||||||
|
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||||
|
{ value: "mg", label: "mg" },
|
||||||
|
{ value: "g", label: "g" },
|
||||||
|
{ value: "mcg", label: "mcg (µg)" },
|
||||||
|
{ value: "ml", label: "ml" },
|
||||||
|
];
|
||||||
|
|
||||||
export type Blister = {
|
export type Blister = {
|
||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intake with per-intake takenBy support.
|
||||||
|
* Extends Blister with per-intake user assignment.
|
||||||
|
*/
|
||||||
|
export type Intake = {
|
||||||
|
usage: number;
|
||||||
|
every: number;
|
||||||
|
start: string;
|
||||||
|
takenBy: string | null; // Per-intake user assignment (single person or null)
|
||||||
|
intakeRemindersEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Medication = {
|
export type Medication = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
genericName?: string | null;
|
genericName?: string | null;
|
||||||
takenBy: string[];
|
takenBy: string[]; // Medication-level takenBy (legacy, still used for filtering)
|
||||||
|
packageType: PackageType;
|
||||||
packCount: number;
|
packCount: number;
|
||||||
blistersPerPack: number;
|
blistersPerPack: number;
|
||||||
pillsPerBlister: number;
|
pillsPerBlister: number;
|
||||||
looseTablets: number;
|
totalPills?: number | null; // For bottle type: total capacity of the container
|
||||||
|
looseTablets: number; // For blister: extra loose pills; for bottle: current stock
|
||||||
stockAdjustment?: number;
|
stockAdjustment?: number;
|
||||||
lastStockCorrectionAt?: string | null;
|
lastStockCorrectionAt?: string | null;
|
||||||
pillWeightMg?: number | null;
|
pillWeightMg?: number | null;
|
||||||
blisters: Blister[];
|
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
|
blisters: Blister[]; // Legacy array format
|
||||||
|
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
expiryDate?: string | null;
|
expiryDate?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
intakeRemindersEnabled?: boolean;
|
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
|
||||||
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
||||||
updatedAt: string | number | null;
|
updatedAt: string | number | null;
|
||||||
};
|
};
|
||||||
@@ -55,19 +83,35 @@ export type FormBlister = {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form state for intake entry with per-intake takenBy support.
|
||||||
|
*/
|
||||||
|
export type FormIntake = {
|
||||||
|
usage: string;
|
||||||
|
every: string;
|
||||||
|
startDate: string;
|
||||||
|
startTime: string;
|
||||||
|
takenBy: string; // Single person or empty string (empty = null for everyone)
|
||||||
|
intakeRemindersEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type FormState = {
|
export type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
genericName: string;
|
genericName: string;
|
||||||
takenBy: string[];
|
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
|
||||||
|
packageType: PackageType;
|
||||||
packCount: string;
|
packCount: string;
|
||||||
blistersPerPack: string;
|
blistersPerPack: string;
|
||||||
pillsPerBlister: string;
|
pillsPerBlister: string;
|
||||||
looseTablets: string;
|
totalPills: string; // For bottle type: total capacity
|
||||||
|
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
||||||
pillWeightMg: string;
|
pillWeightMg: string;
|
||||||
|
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
|
||||||
blisters: FormBlister[];
|
blisters: FormBlister[]; // Legacy form format
|
||||||
|
intakes: FormIntake[]; // New form format with per-intake takenBy
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FieldErrors = {
|
export type FieldErrors = {
|
||||||
@@ -87,7 +131,7 @@ export type Coverage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StockStatus = {
|
export type StockStatus = {
|
||||||
level: "out-of-stock" | "low" | "normal" | "high";
|
level: "out-of-stock" | "critical" | "low" | "normal" | "high";
|
||||||
className: string;
|
className: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
@@ -96,6 +140,8 @@ export type StockThresholds = {
|
|||||||
lowStockDays: number;
|
lowStockDays: number;
|
||||||
normalStockDays: number;
|
normalStockDays: number;
|
||||||
highStockDays: number;
|
highStockDays: number;
|
||||||
|
criticalStockDays: number; // Threshold for critical/danger status (typically reminderDaysBefore)
|
||||||
|
expiryWarningDays: number; // Days before expiry to show warning
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScheduleEvent = {
|
export type ScheduleEvent = {
|
||||||
@@ -106,7 +152,7 @@ export type ScheduleEvent = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
when: number;
|
when: number;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string[];
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlisterStock = {
|
export type BlisterStock = {
|
||||||
@@ -121,15 +167,18 @@ export type SharedMedication = {
|
|||||||
name: string;
|
name: string;
|
||||||
genericName?: string | null;
|
genericName?: string | null;
|
||||||
pillWeightMg?: number | null;
|
pillWeightMg?: number | null;
|
||||||
|
doseUnit?: DoseUnit | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
totalPills: number;
|
totalPills: number;
|
||||||
packCount: number;
|
packCount: number;
|
||||||
blistersPerPack: number;
|
blistersPerPack: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
pillsPerBlister: number;
|
pillsPerBlister: number;
|
||||||
takenBy: string[];
|
takenBy: string[]; // Medication-level takenBy (legacy)
|
||||||
blisters: Blister[];
|
blisters: Blister[]; // Legacy array format
|
||||||
|
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||||
dismissedUntil?: string | null;
|
dismissedUntil?: string | null;
|
||||||
|
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SharedScheduleData = {
|
export type SharedScheduleData = {
|
||||||
@@ -164,14 +213,25 @@ export const FIELD_LIMITS = {
|
|||||||
|
|
||||||
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
||||||
stockAdjustment?: number;
|
stockAdjustment?: number;
|
||||||
|
packageType?: PackageType;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Calculate total pills including stockAdjustment */
|
/** Calculate total pills including stockAdjustment */
|
||||||
export function getMedTotal(med: MedLike): number {
|
export function getMedTotal(med: MedLike): number {
|
||||||
|
// For bottle type, looseTablets IS the current stock
|
||||||
|
if (med.packageType === "bottle") {
|
||||||
|
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
|
}
|
||||||
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the base package size (without stockAdjustment) */
|
/** Get the base package size (without stockAdjustment) */
|
||||||
export function getPackageSize(med: MedLike): number {
|
export function getPackageSize(med: MedLike): number {
|
||||||
|
// For bottle type, looseTablets IS the current stock
|
||||||
|
if (med.packageType === "bottle") {
|
||||||
|
return med.looseTablets;
|
||||||
|
}
|
||||||
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-16
@@ -2,9 +2,37 @@
|
|||||||
// Schedule Building and Coverage Calculations
|
// Schedule Building and Coverage Calculations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { Coverage, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
||||||
import { getMedTotal } from "../types";
|
import { getMedTotal } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
||||||
|
*/
|
||||||
|
function getIntakesForMed(med: Medication): Intake[] {
|
||||||
|
// Use new intakes array if available and non-empty
|
||||||
|
if (med.intakes && med.intakes.length > 0) {
|
||||||
|
return med.intakes;
|
||||||
|
}
|
||||||
|
// Fallback to legacy blisters (convert to Intake format)
|
||||||
|
return med.blisters.map((b) => ({
|
||||||
|
usage: b.usage,
|
||||||
|
every: b.every,
|
||||||
|
start: b.start,
|
||||||
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blisters for a medication (for backward compatibility with coverage calculations)
|
||||||
|
*/
|
||||||
|
function getBlistersForMed(med: Medication): Blister[] {
|
||||||
|
if (med.intakes && med.intakes.length > 0) {
|
||||||
|
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||||
|
}
|
||||||
|
return med.blisters;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build schedule preview events for medications
|
* Build schedule preview events for medications
|
||||||
*/
|
*/
|
||||||
@@ -22,10 +50,11 @@ export function buildSchedulePreview(
|
|||||||
end.setDate(end.getDate() + 180); // 6 months horizon
|
end.setDate(end.getDate() + 180); // 6 months horizon
|
||||||
|
|
||||||
meds.forEach((med) => {
|
meds.forEach((med) => {
|
||||||
med.blisters.forEach((blister, idx) => {
|
const intakes = getIntakesForMed(med);
|
||||||
const start = new Date(blister.start);
|
intakes.forEach((intake, idx) => {
|
||||||
|
const start = new Date(intake.start);
|
||||||
if (Number.isNaN(start.getTime())) return;
|
if (Number.isNaN(start.getTime())) return;
|
||||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
|
||||||
const isPast = d < todayStart;
|
const isPast = d < todayStart;
|
||||||
if (isPast && !includePast) continue;
|
if (isPast && !includePast) continue;
|
||||||
const whenMs = d.getTime();
|
const whenMs = d.getTime();
|
||||||
@@ -35,8 +64,8 @@ export function buildSchedulePreview(
|
|||||||
events.push({
|
events.push({
|
||||||
id: `${med.id}-${idx}-${dateOnlyMs}`,
|
id: `${med.id}-${idx}-${dateOnlyMs}`,
|
||||||
medName: med.name,
|
medName: med.name,
|
||||||
takenBy: med.takenBy || [],
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
usage: blister.usage,
|
usage: intake.usage,
|
||||||
when: whenMs,
|
when: whenMs,
|
||||||
isPast,
|
isPast,
|
||||||
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
||||||
@@ -58,7 +87,7 @@ export function buildSchedulePreview(
|
|||||||
events,
|
events,
|
||||||
today: todayCount,
|
today: todayCount,
|
||||||
nextThree: events.length,
|
nextThree: events.length,
|
||||||
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0),
|
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +96,7 @@ export function buildSchedulePreview(
|
|||||||
*/
|
*/
|
||||||
export function calculateCoverage(
|
export function calculateCoverage(
|
||||||
meds: Medication[],
|
meds: Medication[],
|
||||||
events: Array<{ medName: string; when: number }>,
|
events: Array<{ medName: string; when: number; id: string }>,
|
||||||
locale: string,
|
locale: string,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
stockCalculationMode: "automatic" | "manual",
|
stockCalculationMode: "automatic" | "manual",
|
||||||
@@ -77,32 +106,96 @@ export function calculateCoverage(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const coverage: Coverage[] = meds.map((m) => {
|
const coverage: Coverage[] = meds.map((m) => {
|
||||||
const personCount = Math.max(1, m.takenBy?.length || 1);
|
const intakes = getIntakesForMed(m);
|
||||||
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
|
const blisters = getBlistersForMed(m);
|
||||||
|
// Count unique people from all intakes (for per-intake takenBy)
|
||||||
|
const uniquePeople = new Set<string>();
|
||||||
|
intakes.forEach((intake) => {
|
||||||
|
if (intake.takenBy) uniquePeople.add(intake.takenBy);
|
||||||
|
});
|
||||||
|
// Also add medication-level takenBy for backward compatibility
|
||||||
|
m.takenBy?.forEach((person) => uniquePeople.add(person));
|
||||||
|
const personCount = Math.max(1, uniquePeople.size || m.takenBy?.length || 1);
|
||||||
|
const dailyRate = blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
|
||||||
|
|
||||||
let consumed = 0;
|
let consumed = 0;
|
||||||
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
|
||||||
if (stockCalculationMode === "automatic") {
|
if (stockCalculationMode === "automatic") {
|
||||||
m.blisters.forEach((s) => {
|
// In automatic mode, calculate expected consumption based on time
|
||||||
|
// but also account for manual corrections (doses marked as not taken)
|
||||||
|
blisters.forEach((s, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const blisterStart = new Date(s.start).getTime();
|
||||||
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
||||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
consumed += occurrences * s.usage * personCount;
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
|
||||||
|
// For per-intake takenBy, only count for that person
|
||||||
|
// For legacy (no takenBy), count for all people in medication takenBy
|
||||||
|
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
|
||||||
|
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
||||||
|
|
||||||
|
// Count how many doses were actually marked as taken for this blister
|
||||||
|
let actualConsumed = 0;
|
||||||
|
|
||||||
|
// Generate all expected dose IDs for this blister up to now
|
||||||
|
for (let i = 0; i < occurrences; i++) {
|
||||||
|
const doseDate = new Date(effectiveStart + i * period);
|
||||||
|
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
||||||
|
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
|
||||||
|
|
||||||
|
// Check if each person has taken this dose
|
||||||
|
for (const person of peopleForThisIntake) {
|
||||||
|
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||||
|
if (takenDoses.has(doseId)) {
|
||||||
|
actualConsumed += s.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have tracking data (any doses marked), use actual consumed
|
||||||
|
// Otherwise fall back to expected (for backwards compatibility)
|
||||||
|
const hasTrackingData = Array.from(takenDoses).some((id) => {
|
||||||
|
const parts = id.split("-");
|
||||||
|
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
|
||||||
|
});
|
||||||
|
|
||||||
|
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// In manual mode, only count doses that are explicitly marked as taken
|
||||||
takenDoses.forEach((doseId) => {
|
takenDoses.forEach((doseId) => {
|
||||||
const parts = doseId.split("-");
|
const parts = doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
const blisterIdx = parseInt(parts[1], 10);
|
||||||
const doseTimestamp = parseInt(parts[2], 10);
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
if (medId === m.id && m.blisters[blisterIdx]) {
|
if (medId === m.id && blisters[blisterIdx]) {
|
||||||
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
|
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
||||||
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
|
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
||||||
consumed += m.blisters[blisterIdx].usage;
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStartDate.getFullYear(),
|
||||||
|
blisterStartDate.getMonth(),
|
||||||
|
blisterStartDate.getDate()
|
||||||
|
).getTime();
|
||||||
|
// Convert stock correction cutoff to date-only as well
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
if (
|
||||||
|
!Number.isNaN(blisterStartDateOnly) &&
|
||||||
|
doseTimestamp >= blisterStartDateOnly &&
|
||||||
|
doseTimestamp >= stockCorrectionDateOnly
|
||||||
|
) {
|
||||||
|
consumed += blisters[blisterIdx].usage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,22 +239,32 @@ export function calculateCoverage(
|
|||||||
* Get stock status based on days left and thresholds
|
* Get stock status based on days left and thresholds
|
||||||
*/
|
*/
|
||||||
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
||||||
|
// Out of stock or completely depleted = danger (red)
|
||||||
if (medsLeft <= 0 || daysLeft === 0) {
|
if (medsLeft <= 0 || daysLeft === 0) {
|
||||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No schedule, but has stock = normal
|
||||||
if (daysLeft === null) {
|
if (daysLeft === null) {
|
||||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// High stock
|
||||||
if (daysLeft > thresholds.highStockDays) {
|
if (daysLeft > thresholds.highStockDays) {
|
||||||
return { level: "high", className: "high", label: "status.highStock" };
|
return { level: "high", className: "high", label: "status.highStock" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normal stock
|
||||||
if (daysLeft >= thresholds.lowStockDays) {
|
if (daysLeft >= thresholds.lowStockDays) {
|
||||||
return { level: "normal", className: "success", label: "status.normal" };
|
return { level: "normal", className: "success", label: "status.normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Critical: at or below critical threshold = danger (red)
|
||||||
|
if (daysLeft <= thresholds.criticalStockDays) {
|
||||||
|
return { level: "critical", className: "danger", label: "status.criticalStock" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low stock: below lowStockDays but above critical = warning (yellow)
|
||||||
return { level: "low", className: "warning", label: "status.lowStock" };
|
return { level: "low", className: "warning", label: "status.lowStock" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user