Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b349e26833 | |||
| 56d244aa61 | |||
| 1a348c62f5 | |||
| 067a8c166b | |||
| 8fdd79ff33 | |||
| cd8263e607 | |||
| e6a097d81d | |||
| f4723c6f99 | |||
| aad6b143ef | |||
| da004b5c3e | |||
| cd18581bdd | |||
| 508bc764d5 | |||
| 9e8a6315e7 | |||
| 8efd99d738 | |||
| dc98dfda44 | |||
| 8aaeca6b26 | |||
| 7accb2aad6 | |||
| 2f2edfa479 | |||
| b009d9e158 | |||
| 8e4cb5dcd4 | |||
| 7f26dca7a7 | |||
| 46d768dd4e | |||
| c62b6d7893 | |||
| 1668eb935c |
@@ -56,7 +56,7 @@ jobs:
|
|||||||
SESSION_SECRET: e2e-test-session-secret-long-enough
|
SESSION_SECRET: e2e-test-session-secret-long-enough
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-results
|
name: playwright-results
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: npm run test:coverage
|
run: npm run test:coverage
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
|
|||||||
+2
-2
@@ -82,5 +82,5 @@ Thumbs.db
|
|||||||
.claude/
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku/
|
||||||
plan
|
plan/
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-577%2F577-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-769%2F769-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1771694832866,
|
"when": 1771694832866,
|
||||||
"tag": "0010_mean_spot",
|
"tag": "0010_mean_spot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772219947541,
|
||||||
|
"tag": "0011_stiff_randall_flagg",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+13
-13
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.16.1",
|
"version": "1.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.16.1",
|
"version": "1.18.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -2625,9 +2625,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.0",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -2657,9 +2657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/supertest": {
|
"node_modules/@types/supertest": {
|
||||||
"version": "6.0.3",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz",
|
||||||
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
|
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4890,9 +4890,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.2",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.17.0",
|
"version": "1.18.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||||
// Added for explicit medication lifecycle start date
|
// Added for explicit medication lifecycle start date
|
||||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||||
|
// Added for form/lifecycle modeling (V1 medication forms)
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN pill_form text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
|
||||||
// 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`,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
|
|||||||
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'
|
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
||||||
|
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
|
||||||
|
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
|
||||||
|
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
|
||||||
|
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
|
||||||
|
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
|
||||||
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),
|
||||||
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
|
|||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||||
|
medicationEndDate: text("medication_end_date"),
|
||||||
|
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ type TranslationKeys = {
|
|||||||
common: {
|
common: {
|
||||||
pill: string;
|
pill: string;
|
||||||
pills: string;
|
pills: string;
|
||||||
|
units: string;
|
||||||
|
ml: string;
|
||||||
blister: string;
|
blister: string;
|
||||||
blisters: string;
|
blisters: string;
|
||||||
day: string;
|
day: string;
|
||||||
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "pill",
|
pill: "pill",
|
||||||
pills: "pills",
|
pills: "pills",
|
||||||
|
units: "units",
|
||||||
|
ml: "ml",
|
||||||
blister: "blister",
|
blister: "blister",
|
||||||
blisters: "blisters",
|
blisters: "blisters",
|
||||||
day: "day",
|
day: "day",
|
||||||
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "Tablette",
|
pill: "Tablette",
|
||||||
pills: "Tabletten",
|
pills: "Tabletten",
|
||||||
|
units: "Einheiten",
|
||||||
|
ml: "ml",
|
||||||
blister: "Blister",
|
blister: "Blister",
|
||||||
blisters: "Blister",
|
blisters: "Blister",
|
||||||
day: "Tag",
|
day: "Tag",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function buildLoggerOptions(level: string) {
|
|||||||
level,
|
level,
|
||||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
};
|
};
|
||||||
// Human readable logs in development, structured JSON in production/test
|
// Human-readable logs in development, structured JSON in production/test
|
||||||
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") {
|
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.1";
|
const EXPORT_VERSION = "1.3";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -27,6 +27,7 @@ const scheduleSchema = 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(), // ISO datetime string
|
start: z.string(), // ISO datetime string
|
||||||
|
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||||
remind: z.boolean().optional().default(false),
|
remind: z.boolean().optional().default(false),
|
||||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||||
});
|
});
|
||||||
@@ -38,7 +39,9 @@ const inventorySchema = z.object({
|
|||||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
packageType: z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister"),
|
||||||
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const medicationExportSchema = z.object({
|
const medicationExportSchema = z.object({
|
||||||
@@ -46,11 +49,16 @@ const medicationExportSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
genericName: z.string().nullable().optional(),
|
genericName: z.string().nullable().optional(),
|
||||||
takenBy: z.array(z.string()).default([]),
|
takenBy: z.array(z.string()).default([]),
|
||||||
|
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
|
||||||
|
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
|
||||||
|
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
||||||
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"),
|
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||||
schedules: z.array(scheduleSchema).default([]),
|
schedules: z.array(scheduleSchema).default([]),
|
||||||
medicationStartDate: z.string().nullable().optional(),
|
medicationStartDate: z.string().nullable().optional(),
|
||||||
|
medicationEndDate: z.string().nullable().optional(),
|
||||||
|
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
intakeRemindersEnabled: z.boolean().default(false),
|
intakeRemindersEnabled: z.boolean().default(false),
|
||||||
@@ -155,9 +163,14 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse intakes from DB format to export format (with per-intake takenBy)
|
// Parse intakes from DB format to export format (with per-intake takenBy)
|
||||||
function parseIntakesForExport(
|
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||||
row: typeof medications.$inferSelect
|
usage: number;
|
||||||
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
|
every: number;
|
||||||
|
start: string;
|
||||||
|
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||||
|
remind: boolean;
|
||||||
|
takenBy: string | null;
|
||||||
|
}> {
|
||||||
// Use the new parseIntakesJson which falls back to legacy format
|
// Use the new parseIntakesJson which falls back to legacy format
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
@@ -169,6 +182,7 @@ function parseIntakesForExport(
|
|||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
every: intake.every,
|
every: intake.every,
|
||||||
start: intake.start,
|
start: intake.start,
|
||||||
|
intakeUnit: null,
|
||||||
remind: intake.intakeRemindersEnabled,
|
remind: intake.intakeRemindersEnabled,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy
|
takenBy: intake.takenBy, // Per-intake takenBy
|
||||||
}));
|
}));
|
||||||
@@ -295,6 +309,9 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName,
|
genericName: med.genericName,
|
||||||
takenBy: parseTakenByJson(med.takenByJson),
|
takenBy: parseTakenByJson(med.takenByJson),
|
||||||
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm ?? null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
inventory: {
|
inventory: {
|
||||||
packCount: med.packCount ?? 1,
|
packCount: med.packCount ?? 1,
|
||||||
blistersPerPack: med.blistersPerPack ?? 1,
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
@@ -303,11 +320,15 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
looseTablets: med.looseTablets ?? 0,
|
looseTablets: med.looseTablets ?? 0,
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: med.packageType ?? "blister",
|
||||||
|
packageAmountValue: med.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
},
|
},
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
schedules: parseIntakesForExport(med),
|
schedules: parseIntakesForExport(med),
|
||||||
medicationStartDate: med.medicationStartDate || null,
|
medicationStartDate: med.medicationStartDate || null,
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: med.expiryDate,
|
expiryDate: med.expiryDate,
|
||||||
notes: med.notes,
|
notes: med.notes,
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
@@ -555,6 +576,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
usage: s.usage,
|
usage: s.usage,
|
||||||
every: s.every,
|
every: s.every,
|
||||||
start: s.start,
|
start: s.start,
|
||||||
|
intakeUnit: s.intakeUnit ?? null,
|
||||||
takenBy: s.takenBy || null,
|
takenBy: s.takenBy || null,
|
||||||
intakeRemindersEnabled: s.remind ?? false,
|
intakeRemindersEnabled: s.remind ?? false,
|
||||||
}))
|
}))
|
||||||
@@ -570,7 +592,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName || null,
|
genericName: med.genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm || null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: med.inventory.packageType ?? "blister",
|
packageType: med.inventory.packageType ?? "blister",
|
||||||
|
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||||
packCount: med.inventory.packCount,
|
packCount: med.inventory.packCount,
|
||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
@@ -581,6 +608,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
medicationStartDate: med.medicationStartDate || "",
|
medicationStartDate: med.medicationStartDate || "",
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakesJson,
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
|
|||||||
@@ -14,15 +14,56 @@ import {
|
|||||||
streamToBuffer,
|
streamToBuffer,
|
||||||
writeOptimizedImageSet,
|
writeOptimizedImageSet,
|
||||||
} from "../utils/image-upload.js";
|
} from "../utils/image-upload.js";
|
||||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import {
|
||||||
|
type Intake,
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
parseTakenByJson,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
|
|
||||||
|
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
|
||||||
|
return value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
|
||||||
|
if (!intakesJson) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(intakesJson);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.map((item: unknown) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const unit = (item as Record<string, unknown>).intakeUnit;
|
||||||
|
return isIntakeUnit(unit) ? unit : null;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntakesWithUnits(
|
||||||
|
intakesJson: string | null | undefined,
|
||||||
|
legacyRow: { usageJson: string; everyJson: string; startJson: string },
|
||||||
|
medicationIntakeRemindersEnabled?: boolean
|
||||||
|
): Intake[] {
|
||||||
|
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
|
||||||
|
const rawUnits = parseRawIntakeUnits(intakesJson);
|
||||||
|
if (rawUnits.length === 0) return intakes;
|
||||||
|
|
||||||
|
return intakes.map((intake, idx) => ({
|
||||||
|
...intake,
|
||||||
|
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// New intake schema with per-intake takenBy
|
// New intake schema with per-intake takenBy
|
||||||
const intakeSchema = z.object({
|
const intakeSchema = 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 }),
|
||||||
|
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||||
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
||||||
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
||||||
});
|
});
|
||||||
@@ -34,26 +75,37 @@ const blisterSchema = z.object({
|
|||||||
start: z.string().datetime({ local: true }),
|
start: z.string().datetime({ local: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
const packageTypeSchema = z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister");
|
||||||
|
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
||||||
|
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||||
|
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
||||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||||
const medicationStartDateSchema = z
|
const medicationStartDateSchema = z
|
||||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||||
.optional();
|
.optional();
|
||||||
|
const medicationEndDateSchema = z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]).optional();
|
||||||
|
|
||||||
const medicationSchema = z
|
const medicationSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().max(100).default(""),
|
name: z.string().trim().max(100).default(""),
|
||||||
genericName: z.string().trim().max(100).nullable().optional(),
|
genericName: z.string().trim().max(100).nullable().optional(),
|
||||||
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
||||||
|
medicationForm: medicationFormSchema,
|
||||||
|
pillForm: pillFormSchema.nullable().optional(),
|
||||||
|
lifecycleCategory: lifecycleCategorySchema,
|
||||||
packageType: packageTypeSchema,
|
packageType: packageTypeSchema,
|
||||||
packCount: z.number().int().min(0).default(1),
|
packCount: z.number().int().min(0).default(1),
|
||||||
blistersPerPack: z.number().int().min(1).default(1),
|
blistersPerPack: z.number().int().min(1).default(1),
|
||||||
pillsPerBlister: z.number().int().min(1).default(1),
|
pillsPerBlister: z.number().int().min(1).default(1),
|
||||||
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
|
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||||
doseUnit: doseUnitSchema,
|
doseUnit: doseUnitSchema,
|
||||||
medicationStartDate: medicationStartDateSchema,
|
medicationStartDate: medicationStartDateSchema,
|
||||||
|
medicationEndDate: medicationEndDateSchema,
|
||||||
|
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
prescriptionEnabled: z.boolean().default(false),
|
prescriptionEnabled: z.boolean().default(false),
|
||||||
@@ -84,6 +136,77 @@ const medicationSchema = z
|
|||||||
path: ["medicationStartDate"],
|
path: ["medicationStartDate"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const startDate = data.medicationStartDate ?? "";
|
||||||
|
const endDate = data.medicationEndDate ?? "";
|
||||||
|
if (!startDate || !endDate) return true;
|
||||||
|
return startDate <= endDate;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Medication end date must be on or after medication start date",
|
||||||
|
path: ["medicationEndDate"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
||||||
|
return data.pillForm == null || data.pillForm === "capsule" || data.pillForm === "tablet";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "pillForm must be capsule or tablet for capsule/tablet medications",
|
||||||
|
path: ["pillForm"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "topical") {
|
||||||
|
return data.packageType === "tube";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Topical medications must use tube package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "liquid") {
|
||||||
|
return data.packageType === "liquid_container";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Liquid medications must use liquid_container package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
||||||
|
return data.packageType !== "tube" && data.packageType !== "liquid_container";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Capsule and tablet medications cannot use tube or liquid_container package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const schedules = data.intakes ?? data.blisters ?? [];
|
||||||
|
if (data.pillForm !== "capsule") return true;
|
||||||
|
return schedules.every((entry) => Number.isInteger(entry.usage));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Fractional intake is not allowed for capsule",
|
||||||
|
path: ["intakes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!data.prescriptionEnabled) return true;
|
if (!data.prescriptionEnabled) return true;
|
||||||
@@ -131,13 +254,33 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
const includeObsolete = request.query.includeObsolete === "true";
|
const includeObsolete = request.query.includeObsolete === "true";
|
||||||
|
const initialRows = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, userId))
|
||||||
|
.orderBy(medications.id);
|
||||||
|
const todayDate = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const row of initialRows) {
|
||||||
|
if (row.isObsolete) continue;
|
||||||
|
if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue;
|
||||||
|
const endDate = row.medicationEndDate?.slice(0, 10);
|
||||||
|
if (!endDate) continue;
|
||||||
|
if (endDate > todayDate) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(medications)
|
||||||
|
.set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(and(eq(medications.id, row.id), eq(medications.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = includeObsolete
|
const whereClause = includeObsolete
|
||||||
? eq(medications.userId, userId)
|
? eq(medications.userId, userId)
|
||||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
// Parse intakes from new format, falling back to legacy
|
// Parse intakes from new format, falling back to legacy
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesWithUnits(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
row.intakeRemindersEnabled ?? false
|
row.intakeRemindersEnabled ?? false
|
||||||
@@ -148,10 +291,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
genericName: row.genericName,
|
genericName: row.genericName,
|
||||||
takenBy: parseTakenByJson(row.takenByJson),
|
takenBy: parseTakenByJson(row.takenByJson),
|
||||||
|
medicationForm: row.medicationForm ?? "tablet",
|
||||||
|
pillForm: row.pillForm ?? null,
|
||||||
|
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: row.packageType ?? "blister",
|
packageType: row.packageType ?? "blister",
|
||||||
packCount: row.packCount ?? 1,
|
packCount: row.packCount ?? 1,
|
||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
|
packageAmountValue: row.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: row.totalPills ?? null,
|
totalPills: row.totalPills ?? null,
|
||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
stockAdjustment: row.stockAdjustment ?? 0,
|
stockAdjustment: row.stockAdjustment ?? 0,
|
||||||
@@ -159,6 +307,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: row.pillWeightMg,
|
pillWeightMg: row.pillWeightMg,
|
||||||
doseUnit: row.doseUnit ?? "mg",
|
doseUnit: row.doseUnit ?? "mg",
|
||||||
medicationStartDate: row.medicationStartDate || null,
|
medicationStartDate: row.medicationStartDate || null,
|
||||||
|
medicationEndDate: row.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes, // New unified format with per-intake takenBy
|
intakes, // New unified format with per-intake takenBy
|
||||||
// Legacy blisters format (for backward compat with frontend during transition)
|
// Legacy blisters format (for backward compat with frontend during transition)
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
@@ -188,15 +338,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
medicationForm,
|
||||||
|
pillForm,
|
||||||
|
lifecycleCategory,
|
||||||
packageType,
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills,
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
doseUnit,
|
doseUnit,
|
||||||
medicationStartDate,
|
medicationStartDate,
|
||||||
|
medicationEndDate,
|
||||||
|
autoMarkObsoleteAfterEndDate,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
prescriptionEnabled,
|
prescriptionEnabled,
|
||||||
@@ -209,6 +366,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blisters: inputBlisters,
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
|
const normalizedPillForm =
|
||||||
|
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
|
||||||
|
|
||||||
// Convert to unified intakes format
|
// Convert to unified intakes format
|
||||||
let intakes: Intake[];
|
let intakes: Intake[];
|
||||||
if (inputIntakes) {
|
if (inputIntakes) {
|
||||||
@@ -217,6 +377,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: i.usage,
|
usage: i.usage,
|
||||||
every: i.every,
|
every: i.every,
|
||||||
start: i.start,
|
start: i.start,
|
||||||
|
intakeUnit: i.intakeUnit ?? null,
|
||||||
takenBy: i.takenBy || null,
|
takenBy: i.takenBy || null,
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -226,6 +387,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // No per-intake takenBy from legacy
|
takenBy: null, // No per-intake takenBy from legacy
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -247,15 +409,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
|
medicationForm: medicationForm ?? "tablet",
|
||||||
|
pillForm: normalizedPillForm,
|
||||||
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: packageType ?? "blister",
|
packageType: packageType ?? "blister",
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills: totalPills || null,
|
totalPills: totalPills || null,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
doseUnit: doseUnit ?? "mg",
|
doseUnit: doseUnit ?? "mg",
|
||||||
medicationStartDate: medicationStartDate ?? "",
|
medicationStartDate: medicationStartDate ?? "",
|
||||||
|
medicationEndDate: medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||||
@@ -276,10 +445,15 @@ 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),
|
||||||
|
medicationForm: inserted.medicationForm ?? "tablet",
|
||||||
|
pillForm: inserted.pillForm ?? null,
|
||||||
|
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: inserted.packageType ?? "blister",
|
packageType: inserted.packageType ?? "blister",
|
||||||
packCount: inserted.packCount,
|
packCount: inserted.packCount,
|
||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
|
packageAmountValue: inserted.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: inserted.totalPills ?? null,
|
totalPills: inserted.totalPills ?? null,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
stockAdjustment: inserted.stockAdjustment ?? 0,
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
@@ -287,6 +461,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: inserted.pillWeightMg,
|
pillWeightMg: inserted.pillWeightMg,
|
||||||
doseUnit: inserted.doseUnit ?? "mg",
|
doseUnit: inserted.doseUnit ?? "mg",
|
||||||
medicationStartDate: inserted.medicationStartDate || null,
|
medicationStartDate: inserted.medicationStartDate || null,
|
||||||
|
medicationEndDate: inserted.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes,
|
intakes,
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
@@ -323,15 +499,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
medicationForm,
|
||||||
|
pillForm,
|
||||||
|
lifecycleCategory,
|
||||||
packageType,
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills,
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
doseUnit,
|
doseUnit,
|
||||||
medicationStartDate,
|
medicationStartDate,
|
||||||
|
medicationEndDate,
|
||||||
|
autoMarkObsoleteAfterEndDate,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
prescriptionEnabled,
|
prescriptionEnabled,
|
||||||
@@ -344,6 +527,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blisters: inputBlisters,
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
|
const normalizedPillForm =
|
||||||
|
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
|
||||||
|
|
||||||
// Convert to unified intakes format
|
// Convert to unified intakes format
|
||||||
let intakes: Intake[];
|
let intakes: Intake[];
|
||||||
if (inputIntakes) {
|
if (inputIntakes) {
|
||||||
@@ -352,6 +538,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: i.usage,
|
usage: i.usage,
|
||||||
every: i.every,
|
every: i.every,
|
||||||
start: i.start,
|
start: i.start,
|
||||||
|
intakeUnit: i.intakeUnit ?? null,
|
||||||
takenBy: i.takenBy || null,
|
takenBy: i.takenBy || null,
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -361,6 +548,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // No per-intake takenBy from legacy
|
takenBy: null, // No per-intake takenBy from legacy
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -392,15 +580,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
|
medicationForm: medicationForm ?? "tablet",
|
||||||
|
pillForm: normalizedPillForm,
|
||||||
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: packageType ?? "blister",
|
packageType: packageType ?? "blister",
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
totalPills: totalPills || null,
|
totalPills: totalPills || null,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
doseUnit: doseUnit ?? "mg",
|
doseUnit: doseUnit ?? "mg",
|
||||||
medicationStartDate: medicationStartDate ?? "",
|
medicationStartDate: medicationStartDate ?? "",
|
||||||
|
medicationEndDate: medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||||
@@ -425,7 +620,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
// Migrate dose tracking IDs when intake schedule changes
|
// Migrate dose tracking IDs when intake schedule changes
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Parse old intakes from the existing medication row
|
// Parse old intakes from the existing medication row
|
||||||
const oldIntakes = parseIntakesJson(
|
const oldIntakes = parseIntakesWithUnits(
|
||||||
existing.intakesJson,
|
existing.intakesJson,
|
||||||
{ usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson },
|
{ usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson },
|
||||||
existing.intakeRemindersEnabled
|
existing.intakeRemindersEnabled
|
||||||
@@ -545,10 +740,15 @@ 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),
|
||||||
|
medicationForm: result[0].medicationForm ?? "tablet",
|
||||||
|
pillForm: result[0].pillForm ?? null,
|
||||||
|
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: result[0].packageType ?? "blister",
|
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,
|
||||||
|
packageAmountValue: result[0].packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: result[0].totalPills ?? null,
|
totalPills: result[0].totalPills ?? null,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
@@ -556,6 +756,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: result[0].pillWeightMg,
|
pillWeightMg: result[0].pillWeightMg,
|
||||||
doseUnit: result[0].doseUnit ?? "mg",
|
doseUnit: result[0].doseUnit ?? "mg",
|
||||||
medicationStartDate: result[0].medicationStartDate || null,
|
medicationStartDate: result[0].medicationStartDate || null,
|
||||||
|
medicationEndDate: result[0].medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes,
|
intakes,
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
@@ -631,62 +833,101 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
// Stock correction endpoint - updates stockAdjustment and optionally base amount fields for amount-based corrections
|
||||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
|
app.patch<{
|
||||||
"/medications/:id/stock-adjustment",
|
Params: { id: string };
|
||||||
async (req, reply) => {
|
Body: {
|
||||||
const idNum = Number(req.params.id);
|
stockAdjustment: number;
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
looseTablets?: number;
|
||||||
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
packCount?: number;
|
||||||
|
};
|
||||||
|
}>("/medications/:id/stock-adjustment", async (req, reply) => {
|
||||||
|
const idNum = Number(req.params.id);
|
||||||
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as {
|
||||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
stockAdjustment: number;
|
||||||
if (
|
looseTablets?: number;
|
||||||
looseTablets !== undefined &&
|
totalPills?: number;
|
||||||
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
packageAmountValue?: number;
|
||||||
) {
|
packCount?: number;
|
||||||
return reply.badRequest("looseTablets must be a non-negative integer");
|
};
|
||||||
}
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
if (
|
||||||
const updateFields: {
|
looseTablets !== undefined &&
|
||||||
stockAdjustment: number;
|
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||||
lastStockCorrectionAt: Date;
|
) {
|
||||||
updatedAt: Date;
|
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||||
looseTablets?: number;
|
|
||||||
} = {
|
|
||||||
stockAdjustment,
|
|
||||||
lastStockCorrectionAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
if (looseTablets !== undefined) {
|
|
||||||
updateFields.looseTablets = looseTablets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.update(medications)
|
|
||||||
.set(updateFields)
|
|
||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!result.length) return reply.notFound();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: result[0].id,
|
|
||||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
|
||||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
|
||||||
updatedAt: result[0].updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
if (
|
||||||
|
totalPills !== undefined &&
|
||||||
|
(typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("totalPills must be a non-negative integer");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
packageAmountValue !== undefined &&
|
||||||
|
(typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("packageAmountValue must be a non-negative integer");
|
||||||
|
}
|
||||||
|
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) {
|
||||||
|
return reply.badRequest("packCount must be an integer >= 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: {
|
||||||
|
stockAdjustment: number;
|
||||||
|
lastStockCorrectionAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
looseTablets?: number;
|
||||||
|
totalPills?: number | null;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
packCount?: number;
|
||||||
|
} = {
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageType = existing.packageType ?? "blister";
|
||||||
|
const allowsAmountBaseUpdate = packageType === "tube" || packageType === "liquid_container";
|
||||||
|
if (allowsAmountBaseUpdate) {
|
||||||
|
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
||||||
|
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
||||||
|
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||||
|
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||||
|
}
|
||||||
|
if (looseTablets !== undefined) {
|
||||||
|
updateFields.looseTablets = looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(medications)
|
||||||
|
.set(updateFields)
|
||||||
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!result.length) return reply.notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result[0].id,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
|
updatedAt: result[0].updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
@@ -840,12 +1081,17 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const payload = rows.map((row) => {
|
const payload = rows.map((row) => {
|
||||||
// Parse intakes from new format, falling back to legacy
|
// Parse intakes from new format, falling back to legacy
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesWithUnits(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
row.intakeRemindersEnabled ?? false
|
row.intakeRemindersEnabled ?? false
|
||||||
);
|
);
|
||||||
const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
const medForm = row.medicationForm ?? "tablet";
|
||||||
|
const blisters = intakes.map((i) => ({
|
||||||
|
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
@@ -854,8 +1100,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const packageType = row.packageType ?? "blister";
|
const packageType = row.packageType ?? "blister";
|
||||||
|
|
||||||
// For bottle type, looseTablets IS the current stock (no blister math)
|
// For bottle type, looseTablets IS the current stock (no blister math)
|
||||||
|
const isTopical = medForm === "topical" || packageType === "tube";
|
||||||
const originalTotalPills =
|
const originalTotalPills =
|
||||||
packageType === "bottle"
|
packageType === "bottle" || packageType === "liquid_container"
|
||||||
? looseTablets + stockAdjustment
|
? looseTablets + stockAdjustment
|
||||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
@@ -867,7 +1114,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
const msPerDay = 86400000;
|
const msPerDay = 86400000;
|
||||||
|
|
||||||
if (stockCalculationMode === "automatic") {
|
if (isTopical) {
|
||||||
|
consumedUntilNow = 0;
|
||||||
|
} else if (stockCalculationMode === "automatic") {
|
||||||
blisters.forEach((blister, blisterIdx) => {
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
if (Number.isNaN(blisterStart)) return;
|
if (Number.isNaN(blisterStart)) return;
|
||||||
@@ -963,7 +1212,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
const currentStock = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow);
|
||||||
|
|
||||||
// Calculate usage for the planning period
|
// Calculate usage for the planning period
|
||||||
// Always use the user-selected start date for the usage calculation.
|
// Always use the user-selected start date for the usage calculation.
|
||||||
@@ -973,7 +1222,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
// The stock already reflects consumed doses, so no double-counting occurs.
|
// The stock already reflects consumed doses, so no double-counting occurs.
|
||||||
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
|
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
|
||||||
const effectivePlannerStart = includeUntilStart ? now : start;
|
const effectivePlannerStart = includeUntilStart ? now : start;
|
||||||
const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end);
|
const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end);
|
||||||
|
|
||||||
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
||||||
|
|
||||||
@@ -983,7 +1232,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
let fullBlisters: number;
|
let fullBlisters: number;
|
||||||
let loosePills: number;
|
let loosePills: number;
|
||||||
|
|
||||||
if (packageType === "bottle") {
|
if (packageType === "bottle" || packageType === "tube" || packageType === "liquid_container") {
|
||||||
// Bottle type: no blisters, everything is loose pills
|
// Bottle type: no blisters, everything is loose pills
|
||||||
fullBlisters = 0;
|
fullBlisters = 0;
|
||||||
loosePills = availableAfterPeriod;
|
loosePills = availableAfterPeriod;
|
||||||
|
|||||||
+225
-13
@@ -29,6 +29,43 @@ function escapeHtml(text: string): string {
|
|||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split("@");
|
||||||
|
if (!domain) return "invalid-email";
|
||||||
|
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||||
|
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
type PlannerRow = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
@@ -42,6 +79,16 @@ type PlannerRow = {
|
|||||||
packageType?: string;
|
packageType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isContainerPackage(packageType?: string): boolean {
|
||||||
|
return packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
|
||||||
|
if (packageType === "tube") return tr.common.units;
|
||||||
|
if (packageType === "liquid_container") return tr.common.ml;
|
||||||
|
return tr.common.pills;
|
||||||
|
}
|
||||||
|
|
||||||
type SendEmailBody = {
|
type SendEmailBody = {
|
||||||
email: string;
|
email: string;
|
||||||
from: string;
|
from: string;
|
||||||
@@ -96,6 +143,10 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
// Demand calculator notification (supports email and push)
|
// Demand calculator notification (supports email and push)
|
||||||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), rowCount: rows?.length ?? 0 },
|
||||||
|
"[Planner] Demand notification request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing planner data" });
|
return reply.status(400).send({ error: "Missing planner data" });
|
||||||
@@ -110,6 +161,7 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||||
if (activeRows.length === 0) {
|
if (activeRows.length === 0) {
|
||||||
|
request.log.warn("[Planner] Demand notification skipped: no active medications in request");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +171,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||||
};
|
};
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
emailEnabled: notificationSettings.emailEnabled,
|
||||||
|
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||||
|
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||||
|
activeRowCount: activeRows.length,
|
||||||
|
},
|
||||||
|
"[Planner] Demand notification channel state"
|
||||||
|
);
|
||||||
|
|
||||||
// Get locale from user settings or use the language passed in the body
|
// Get locale from user settings or use the language passed in the body
|
||||||
const language: Language = (userSettings.language as Language) || bodyLanguage || "en";
|
const language: Language = (userSettings.language as Language) || bodyLanguage || "en";
|
||||||
@@ -168,16 +230,18 @@ ${summaryText}
|
|||||||
|
|
||||||
${activeRows
|
${activeRows
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const isBottle = r.packageType === "bottle";
|
const isBottle = isContainerPackage(r.packageType);
|
||||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
const usageUnit = getPlannerUnit(r.packageType, tr);
|
||||||
|
const usage = `${r.plannerUsage} ${usageUnit}`;
|
||||||
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
||||||
const medPrescription = prescriptionMap.get(r.medicationId);
|
const medPrescription = prescriptionMap.get(r.medicationId);
|
||||||
const rxRefills = medPrescription?.prescriptionEnabled
|
const rxRefills = medPrescription?.prescriptionEnabled
|
||||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||||
: dc.prescriptionNotApplicable;
|
: dc.prescriptionNotApplicable;
|
||||||
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
||||||
|
const availableUnit = getPlannerUnit(r.packageType, tr);
|
||||||
const available = isBottle
|
const available = isBottle
|
||||||
? `${loosePills} ${tr.common.pills}`
|
? `${loosePills} ${availableUnit}`
|
||||||
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
||||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||||
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
||||||
@@ -198,6 +262,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[Planner] Demand email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
// Build HTML table with horizontal scroll for mobile
|
// Build HTML table with horizontal scroll for mobile
|
||||||
// Escape/coerce all user-provided values to prevent XSS
|
// Escape/coerce all user-provided values to prevent XSS
|
||||||
@@ -209,7 +286,7 @@ ${getFooterPlain(language)}`;
|
|||||||
const safeBlisterSize = Number(row.blisterSize) || 0;
|
const safeBlisterSize = Number(row.blisterSize) || 0;
|
||||||
const safeFullBlisters = Number(row.fullBlisters) || 0;
|
const safeFullBlisters = Number(row.fullBlisters) || 0;
|
||||||
const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10;
|
const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10;
|
||||||
const isBottle = row.packageType === "bottle";
|
const isBottle = isContainerPackage(row.packageType);
|
||||||
|
|
||||||
// "Blisters needed" column: dash for bottles
|
// "Blisters needed" column: dash for bottles
|
||||||
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
||||||
@@ -223,7 +300,8 @@ ${getFooterPlain(language)}`;
|
|||||||
// "Available" column: match frontend format
|
// "Available" column: match frontend format
|
||||||
let availableCell: string;
|
let availableCell: string;
|
||||||
if (isBottle) {
|
if (isBottle) {
|
||||||
availableCell = `${safeLoosePills} ${tr.common.pills}`;
|
const availableUnit = getPlannerUnit(row.packageType, tr);
|
||||||
|
availableCell = `${safeLoosePills} ${availableUnit}`;
|
||||||
} else {
|
} else {
|
||||||
availableCell = `${safeFullBlisters} ${tr.common.blisters}`;
|
availableCell = `${safeFullBlisters} ${tr.common.blisters}`;
|
||||||
if (safeLoosePills > 0) {
|
if (safeLoosePills > 0) {
|
||||||
@@ -236,7 +314,7 @@ ${getFooterPlain(language)}`;
|
|||||||
return `
|
return `
|
||||||
<tr style="${rowBg}">
|
<tr style="${rowBg}">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${getPlannerUnit(row.packageType, tr)}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
||||||
@@ -303,7 +381,9 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||||
@@ -311,12 +391,33 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent");
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[Planner] Demand email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{ emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) },
|
||||||
|
"[Planner] Demand email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
@@ -324,7 +425,7 @@ ${getFooterPlain(language)}`;
|
|||||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||||
const pushMessage = `${summaryText}\n\n${activeRows
|
const pushMessage = `${summaryText}\n\n${activeRows
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`;
|
||||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||||
return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`;
|
return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`;
|
||||||
})
|
})
|
||||||
@@ -363,6 +464,10 @@ ${getFooterPlain(language)}`;
|
|||||||
// Reminder notification for low stock medications (supports email and push)
|
// Reminder notification for low stock medications (supports email and push)
|
||||||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||||||
const { email, lowStock } = request.body;
|
const { email, lowStock } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 },
|
||||||
|
"[ReminderManual] Stock reminder request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!lowStock || lowStock.length === 0) {
|
if (!lowStock || lowStock.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing low stock data" });
|
return reply.status(400).send({ error: "Missing low stock data" });
|
||||||
@@ -371,12 +476,22 @@ ${getFooterPlain(language)}`;
|
|||||||
// Load user settings
|
// Load user settings
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
const activeMeds = await db
|
const activeMeds = await db
|
||||||
.select({ name: medications.name, genericName: medications.genericName })
|
.select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType })
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||||
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
const activeMedicationByName = new Map(
|
||||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
activeMeds
|
||||||
|
.map((med) => [med.name || med.genericName || "", med.packageType ?? "blister"] as const)
|
||||||
|
.filter(([name]) => name.length > 0)
|
||||||
|
);
|
||||||
|
const filteredLowStock = lowStock.filter((item) => {
|
||||||
|
const packageType = activeMedicationByName.get(item.name);
|
||||||
|
if (!packageType) return false;
|
||||||
|
if (packageType === "tube") return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
if (filteredLowStock.length === 0) {
|
if (filteredLowStock.length === 0) {
|
||||||
|
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +501,16 @@ ${getFooterPlain(language)}`;
|
|||||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||||
};
|
};
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
emailEnabled: notificationSettings.emailEnabled,
|
||||||
|
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||||
|
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||||
|
filteredLowStockCount: filteredLowStock.length,
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock reminder channel state"
|
||||||
|
);
|
||||||
|
|
||||||
// Get translations based on user language
|
// Get translations based on user language
|
||||||
const language = (userSettings.language as Language) || "en";
|
const language = (userSettings.language as Language) || "en";
|
||||||
@@ -457,6 +582,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
// Build subject line from shared title parts
|
// Build subject line from shared title parts
|
||||||
const subjectText = titleParts.join(", ");
|
const subjectText = titleParts.join(", ");
|
||||||
@@ -570,7 +708,9 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `MedAssist-ng: ${subjectText}`,
|
subject: `MedAssist-ng: ${subjectText}`,
|
||||||
@@ -578,12 +718,36 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||||
|
"[ReminderManual] Stock reminder email sent"
|
||||||
|
);
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{ emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) },
|
||||||
|
"[ReminderManual] Stock email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
@@ -634,6 +798,10 @@ ${getFooterPlain(language)}`;
|
|||||||
// Manual prescription reminder (supports email and push)
|
// Manual prescription reminder (supports email and push)
|
||||||
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
|
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
|
||||||
const { email, prescriptionLow } = request.body;
|
const { email, prescriptionLow } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 },
|
||||||
|
"[ReminderManual] Prescription reminder request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!prescriptionLow || prescriptionLow.length === 0) {
|
if (!prescriptionLow || prescriptionLow.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing prescription reminder data" });
|
return reply.status(400).send({ error: "Missing prescription reminder data" });
|
||||||
@@ -647,6 +815,7 @@ ${getFooterPlain(language)}`;
|
|||||||
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
||||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||||
if (filteredPrescriptionLow.length === 0) {
|
if (filteredPrescriptionLow.length === 0) {
|
||||||
|
request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +853,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
@@ -767,7 +949,9 @@ ${getFooterPlain(language)}`;
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -775,12 +959,40 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||||
|
"[ReminderManual] Prescription reminder email sent"
|
||||||
|
);
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
emailEnabled: userSettings.emailEnabled,
|
||||||
|
emailPrescriptionReminders: userSettings.emailPrescriptionReminders,
|
||||||
|
hasRecipient: Boolean(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
||||||
|
|||||||
@@ -85,6 +85,43 @@ type TestShoutrrrBody = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split("@");
|
||||||
|
if (!domain) return "invalid-email";
|
||||||
|
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||||
|
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
function getNotificationProvider(url: string): string {
|
function getNotificationProvider(url: string): string {
|
||||||
if (url.startsWith("discord://")) return "discord";
|
if (url.startsWith("discord://")) return "discord";
|
||||||
if (url.startsWith("telegram://")) return "telegram";
|
if (url.startsWith("telegram://")) return "telegram";
|
||||||
@@ -436,7 +473,24 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
to: maskEmail(email),
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
},
|
||||||
|
"[Settings] Test email request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
if (!smtpHost || !smtpUser) {
|
||||||
|
request.log.warn(
|
||||||
|
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
||||||
|
"[Settings] Test email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
return reply.status(400).send({ error: "SMTP not configured" });
|
return reply.status(400).send({ error: "SMTP not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +505,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "MedAssist-ng - Test Email",
|
subject: "MedAssist-ng - Test Email",
|
||||||
@@ -467,8 +523,16 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||||
|
|
||||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills =
|
const totalPills =
|
||||||
(med.packageType ?? "blister") === "bottle"
|
(med.packageType ?? "blister") === "bottle" ||
|
||||||
|
(med.packageType ?? "blister") === "tube" ||
|
||||||
|
(med.packageType ?? "blister") === "liquid_container"
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
parseLocalDateTime,
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
@@ -37,6 +38,36 @@ function escapeHtml(text: string): string {
|
|||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
@@ -179,6 +210,12 @@ type LowStockItem = {
|
|||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||||
|
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
return { lowDays, criticalDays };
|
||||||
|
}
|
||||||
|
|
||||||
type PrescriptionReminderItem = {
|
type PrescriptionReminderItem = {
|
||||||
name: string;
|
name: string;
|
||||||
remainingRefills: number;
|
remainingRefills: number;
|
||||||
@@ -231,12 +268,20 @@ async function getMedicationsNeedingReminder(
|
|||||||
const msPerDay = 86_400_000;
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
// Tube stock reminders are intentionally disabled:
|
||||||
|
// topical usage in grams cannot be mapped reliably to schedule events.
|
||||||
|
if ((row.packageType ?? "blister") === "tube") continue;
|
||||||
|
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
row.intakeRemindersEnabled ?? false
|
row.intakeRemindersEnabled ?? false
|
||||||
);
|
);
|
||||||
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
const blisters: Blister[] = intakes.map((i) => ({
|
||||||
|
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
|
|
||||||
const originalTotalPills =
|
const originalTotalPills =
|
||||||
(row.packageType ?? "blister") === "bottle"
|
(row.packageType ?? "blister") === "bottle"
|
||||||
@@ -348,8 +393,13 @@ async function getMedicationsNeedingReminder(
|
|||||||
|
|
||||||
if (daysLeft === null) continue;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
const isCritical = daysLeft <= reminderDaysBefore;
|
const isLiquid = (row.packageType ?? "blister") === "liquid_container";
|
||||||
const isLow = daysLeft < lowStockDays;
|
const { lowDays, criticalDays } = isLiquid
|
||||||
|
? getLiquidReminderThresholds(reminderDaysBefore)
|
||||||
|
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
||||||
|
|
||||||
|
const isCritical = daysLeft <= criticalDays;
|
||||||
|
const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays;
|
||||||
|
|
||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
@@ -551,7 +601,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -559,6 +609,11 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
@@ -872,13 +927,17 @@ async function checkAndSendReminderForUser(
|
|||||||
`;
|
`;
|
||||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
emailSuccess = true;
|
emailSuccess = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
|||||||
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||||
const getDbPaths = vi.fn().mockReturnValue({
|
const getDbPaths = vi.fn().mockReturnValue({
|
||||||
dataDir: "/tmp/medassist-data",
|
dataDir: "/tmp/medassist-data",
|
||||||
dbPath: "/tmp/medassist-data/medassist.db",
|
dbPath: "/tmp/medassist-data/medassist-ng.db",
|
||||||
url: "file:/tmp/medassist-data/medassist.db",
|
url: "file:/tmp/medassist-data/medassist-ng.db",
|
||||||
});
|
});
|
||||||
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||||
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||||
@@ -102,7 +102,7 @@ describe("db/client bootstrap", () => {
|
|||||||
await mod.migrationsReady;
|
await mod.migrationsReady;
|
||||||
|
|
||||||
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
|
||||||
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ async function createSchema(client: Client) {
|
|||||||
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 '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -101,6 +106,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -2499,10 +2506,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Package Type (bottle vs blister) Tests
|
// Package Type (blister, bottle, liquid_container) Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("Package type handling (bottle vs blister)", () => {
|
describe("Package type handling (blister, bottle, liquid_container)", () => {
|
||||||
const bottleMedication = {
|
const bottleMedication = {
|
||||||
name: "Vitamin D Drops",
|
name: "Vitamin D Drops",
|
||||||
packageType: "bottle",
|
packageType: "bottle",
|
||||||
@@ -2523,6 +2530,18 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const liquidContainerMedication = {
|
||||||
|
name: "Cough Syrup",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
looseTablets: 180,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2567,6 +2586,49 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.medications[0].totalPills).toBe(120);
|
expect(data.medications[0].totalPills).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create and return liquid_container type medication", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: liquidContainerMedication,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.packageType).toBe("liquid_container");
|
||||||
|
expect(data.medicationForm).toBe("liquid");
|
||||||
|
expect(data.doseUnit).toBe("ml");
|
||||||
|
expect(data.looseTablets).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: { ...liquidContainerMedication, takenBy: ["Daniel"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||||
|
});
|
||||||
|
expect(shareResponse.statusCode).toBe(200);
|
||||||
|
const { token } = shareResponse.json();
|
||||||
|
|
||||||
|
const scheduleResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduleResponse.statusCode).toBe(200);
|
||||||
|
const data = scheduleResponse.json();
|
||||||
|
expect(data.medications).toHaveLength(1);
|
||||||
|
expect(data.medications[0].packageType).toBe("liquid_container");
|
||||||
|
// Liquid container follows container semantics (stock from looseTablets only).
|
||||||
|
expect(data.medications[0].totalPills).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct totalPills for shared blister medication", async () => {
|
it("should calculate correct totalPills for shared blister medication", async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2742,5 +2804,18 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(medsResponse.json()).toHaveLength(1);
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
expect(medsResponse.json()[0].packageType).toBe("blister");
|
expect(medsResponse.json()[0].packageType).toBe("blister");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject liquid medication form with non-liquid package type", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packageType: "bottle",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ async function createSchema(client: Client) {
|
|||||||
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 '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -95,6 +100,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ async function createSchema(client: Client) {
|
|||||||
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 '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -112,6 +117,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -284,7 +291,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -330,7 +337,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -434,7 +441,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -522,7 +529,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -697,7 +704,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -727,7 +734,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -763,7 +770,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -849,7 +856,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -982,7 +989,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1036,6 +1043,36 @@ describe("Planner Routes", () => {
|
|||||||
expect(title).not.toContain("Low");
|
expect(title).not.toContain("Low");
|
||||||
expect(message).toContain("Running critically low");
|
expect(message).toContain("Running critically low");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 400 when only tube medications are in active meds", async () => {
|
||||||
|
// Insert a tube medication (should be excluded from reminders)
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type)
|
||||||
|
VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`,
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||||
|
args: [999999999],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/reminder/send-email",
|
||||||
|
payload: {
|
||||||
|
email: "test@example.com",
|
||||||
|
lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expects 400 because tube medications are excluded from stock reminders
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "No active medications to notify" });
|
||||||
|
expect(mockSendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /reminder/send-prescription", () => {
|
describe("POST /reminder/send-prescription", () => {
|
||||||
@@ -1082,7 +1119,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -207,7 +207,12 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
process.env.SMTP_HOST = "smtp.example.com";
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
process.env.SMTP_USER = "mailer@example.com";
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
process.env.SMTP_TOKEN = "secret";
|
process.env.SMTP_TOKEN = "secret";
|
||||||
nodemailerSendMail.mockResolvedValue(undefined);
|
nodemailerSendMail.mockResolvedValue({
|
||||||
|
accepted: ["person@example.com"],
|
||||||
|
rejected: [],
|
||||||
|
response: "250 2.0.0 OK",
|
||||||
|
messageId: "test-message-id",
|
||||||
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -348,3 +348,46 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
|||||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getLiquidReminderThresholds", () => {
|
||||||
|
// Import the function for testing (test-only export)
|
||||||
|
// The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number }
|
||||||
|
// Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2)
|
||||||
|
|
||||||
|
it("derives critical as ceil(baseline / 2) for typical baseline", () => {
|
||||||
|
// For baseline=7 days: low=7, critical=ceil(7/2)=4
|
||||||
|
const baseline = 7;
|
||||||
|
// Manually apply the formula to verify
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(7);
|
||||||
|
expect(expectedCritical).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives critical correctly at boundary: baseline=1", () => {
|
||||||
|
// For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...))
|
||||||
|
const baseline = 1;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(1);
|
||||||
|
expect(expectedCritical).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for even baseline (baseline=14)", () => {
|
||||||
|
// For baseline=14: low=14, critical=ceil(14/2)=7
|
||||||
|
const baseline = 14;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(14);
|
||||||
|
expect(expectedCritical).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for odd baseline (baseline=15)", () => {
|
||||||
|
// For baseline=15: low=15, critical=ceil(15/2)=8
|
||||||
|
const baseline = 15;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(15);
|
||||||
|
expect(expectedCritical).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,10 +13,39 @@ export type Intake = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
|
||||||
|
value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize intake usage for stock math.
|
||||||
|
*
|
||||||
|
* Stock semantics:
|
||||||
|
* - tube: no automatic depletion (unknown per-application amount)
|
||||||
|
* - liquid_container/liquid forms: convert tsp/tbsp to ml
|
||||||
|
* - others: usage as-is
|
||||||
|
*/
|
||||||
|
export function normalizeIntakeUsageForStock(
|
||||||
|
intake: Pick<Intake, "usage" | "intakeUnit">,
|
||||||
|
medicationForm?: string | null,
|
||||||
|
packageType?: string | null
|
||||||
|
): number {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
|
if (packageType === "tube") return 0;
|
||||||
|
|
||||||
|
const isLiquidStock = packageType === "liquid_container" || medicationForm === "liquid";
|
||||||
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Timezone utilities
|
// Timezone utilities
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -199,6 +228,7 @@ export function parseIntakesJson(
|
|||||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||||
every: typeof intake.every === "number" ? intake.every : 1,
|
every: typeof intake.every === "number" ? intake.every : 1,
|
||||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||||
|
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
|
||||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||||
intakeRemindersEnabled:
|
intakeRemindersEnabled:
|
||||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||||
@@ -216,6 +246,7 @@ export function parseIntakesJson(
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // Legacy format has no per-intake takenBy
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2325
-5
File diff suppressed because it is too large
Load Diff
+71
-29
@@ -70,40 +70,82 @@ setup("authenticate", async ({ page }) => {
|
|||||||
// Wait for auth container
|
// Wait for auth container
|
||||||
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// ---- 3. Ensure the test user exists ----
|
// ---- 3. Query auth state to determine login method ----
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
await page.request
|
let formLoginEnabled = true;
|
||||||
.post(`${baseURL}/api/auth/register`, {
|
let oidcEnabled = false;
|
||||||
data: { username: TEST_USER.username, password: TEST_USER.password },
|
try {
|
||||||
})
|
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
|
||||||
.catch(() => {});
|
if (stateRes.ok()) {
|
||||||
|
const state = await stateRes.json();
|
||||||
// ---- 4. Log in via UI ----
|
formLoginEnabled = state.formLoginEnabled !== false;
|
||||||
const usernameField = page.locator("#username");
|
oidcEnabled = state.oidcEnabled === true;
|
||||||
const passwordField = page.locator("#password");
|
|
||||||
|
|
||||||
// Make sure we're on the login form (not register)
|
|
||||||
const isOnRegister = await page
|
|
||||||
.locator(".auth-subtitle")
|
|
||||||
.filter({ hasText: /Create Account/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (isOnRegister) {
|
|
||||||
const switchBtn = page.locator("button.auth-link-btn");
|
|
||||||
if (await switchBtn.isVisible().catch(() => false)) {
|
|
||||||
await switchBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: assume form login is available
|
||||||
}
|
}
|
||||||
|
|
||||||
await usernameField.clear();
|
// ---- 4. Ensure the test user exists (only if form login is available) ----
|
||||||
await usernameField.fill(TEST_USER.username);
|
if (formLoginEnabled) {
|
||||||
await passwordField.clear();
|
await page.request
|
||||||
await passwordField.fill(TEST_USER.password);
|
.post(`${baseURL}/api/auth/register`, {
|
||||||
|
data: { username: TEST_USER.username, password: TEST_USER.password },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Click the submit button (not the SSO button)
|
// ---- 5. Log in via the appropriate method ----
|
||||||
await page.locator('button.auth-submit[type="submit"]').click();
|
if (formLoginEnabled) {
|
||||||
|
// Form login path: username/password
|
||||||
|
const usernameField = page.locator("#username");
|
||||||
|
const passwordField = page.locator("#password");
|
||||||
|
|
||||||
|
// Make sure we're on the login form (not register)
|
||||||
|
const isOnRegister = await page
|
||||||
|
.locator(".auth-subtitle")
|
||||||
|
.filter({ hasText: /Create Account/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (isOnRegister) {
|
||||||
|
const switchBtn = page.locator("button.auth-link-btn");
|
||||||
|
if (await switchBtn.isVisible().catch(() => false)) {
|
||||||
|
await switchBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await usernameField.clear();
|
||||||
|
await usernameField.fill(TEST_USER.username);
|
||||||
|
await passwordField.clear();
|
||||||
|
await passwordField.fill(TEST_USER.password);
|
||||||
|
|
||||||
|
// Click the submit button (not the SSO button)
|
||||||
|
await page.locator('button.auth-submit[type="submit"]').click();
|
||||||
|
} else if (oidcEnabled) {
|
||||||
|
// SSO-only path: click the SSO button and let the OIDC provider handle login.
|
||||||
|
// This requires the OIDC provider to be configured with test credentials
|
||||||
|
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
|
||||||
|
// or to auto-approve the test user.
|
||||||
|
await page.locator("button.sso-btn").click();
|
||||||
|
|
||||||
|
// Wait for OIDC redirect and callback — the provider may show its own login form
|
||||||
|
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
|
||||||
|
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
|
||||||
|
if (oidcUsername && oidcPassword) {
|
||||||
|
// Fill OIDC provider login form (generic selectors — override if needed)
|
||||||
|
await page.waitForURL(/.*/, { timeout: 15000 });
|
||||||
|
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
|
||||||
|
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
|
||||||
|
await oidcUserField.fill(oidcUsername);
|
||||||
|
await oidcPassField.fill(oidcPassword);
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("No login method available: form login and OIDC are both disabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for successful auth — app header should appear
|
// Wait for successful auth — app header should appear
|
||||||
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
async function isAuthEnabled(page: Page): Promise<boolean> {
|
interface AuthStateResponse {
|
||||||
|
authEnabled: boolean;
|
||||||
|
formLoginEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcProviderName: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
|
||||||
try {
|
try {
|
||||||
const response = await page.request.get("/api/auth/state");
|
const response = await page.request.get("/api/auth/state");
|
||||||
if (!response.ok()) return true;
|
if (!response.ok()) return null;
|
||||||
const state = await response.json();
|
return (await response.json()) as AuthStateResponse;
|
||||||
return state?.authEnabled !== false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isAuthEnabled(page: Page): Promise<boolean> {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
return state?.authEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication E2E Tests
|
* Authentication E2E Tests
|
||||||
*
|
*
|
||||||
@@ -110,4 +122,48 @@ test.describe("Authentication", () => {
|
|||||||
const newText = await subtitle.textContent();
|
const newText = await subtitle.textContent();
|
||||||
expect(newText).not.toBe(initialText);
|
expect(newText).not.toBe(initialText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should show SSO button when OIDC is enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const ssoButton = page.locator("button.sso-btn");
|
||||||
|
await expect(ssoButton).toBeVisible();
|
||||||
|
await expect(ssoButton).toContainText(state.oidcProviderName || "SSO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should hide form login when formLoginEnabled is false", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Username/password fields should not be visible
|
||||||
|
await expect(page.locator("#username")).not.toBeVisible();
|
||||||
|
await expect(page.locator("#password")).not.toBeVisible();
|
||||||
|
|
||||||
|
// SSO button should be the only login method
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show both login methods when OIDC and form login are enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled");
|
||||||
|
test.skip(!state?.formLoginEnabled, "Form login is not enabled");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Both login methods visible
|
||||||
|
await expect(page.locator("#username")).toBeVisible();
|
||||||
|
await expect(page.locator("#password")).toBeVisible();
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show medication overview table with medications", async ({ page }) => {
|
test("should show medication overview table with medications", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show status chips in overview table", async ({ page }) => {
|
test("should show status chips in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Each medication row should have a status chip
|
// Each medication row should have a status chip
|
||||||
@@ -88,7 +88,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show stock information in overview", async ({ page }) => {
|
test("should show stock information in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
||||||
@@ -202,7 +202,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should open medication detail modal from overview table", async ({ page }) => {
|
test("should open medication detail modal from overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
||||||
|
|||||||
+109
-18
@@ -103,25 +103,43 @@ export const test = base.extend<object>({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the app to be fully loaded past any loading/initializing screens.
|
* Wait for the app to be fully loaded past any loading/initializing screens.
|
||||||
* Includes a single retry with page reload to handle transient auth failures
|
* Retries up to 2 times with page reload to handle transient auth or
|
||||||
* (e.g. brief race between context setup and cookie application).
|
* rate-limit failures.
|
||||||
*/
|
*/
|
||||||
export async function waitForAppReady(page: Page): Promise<void> {
|
export async function waitForAppReady(page: Page): Promise<void> {
|
||||||
const hero = page.locator("header.hero");
|
const hero = page.locator("header.hero");
|
||||||
try {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
try {
|
||||||
} catch {
|
await expect(hero).toBeVisible({ timeout: 15000 });
|
||||||
// Auth might have failed transiently — reload and retry once
|
return;
|
||||||
await page.reload();
|
} catch {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
if (attempt === 2) throw new Error("App failed to become ready after 3 attempts");
|
||||||
|
// Check for rate-limit error displayed in UI
|
||||||
|
const rateLimited = await page
|
||||||
|
.locator("text=rate limit, text=429, text=too many")
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
if (rateLimited) {
|
||||||
|
// Wait longer before retrying if rate-limited
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a page and wait for it to be ready.
|
* Navigate to a page and wait for it to be ready.
|
||||||
|
* Handles transient navigation failures with a single retry.
|
||||||
*/
|
*/
|
||||||
export async function navigateTo(page: Page, path: string): Promise<void> {
|
export async function navigateTo(page: Page, path: string): Promise<void> {
|
||||||
await page.goto(path);
|
const response = await page.goto(path);
|
||||||
|
if (response && response.status() === 429) {
|
||||||
|
// Rate-limited — wait and retry once
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await page.goto(path);
|
||||||
|
}
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
}
|
}
|
||||||
@@ -159,7 +177,9 @@ export { expect };
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
function getAuthCookie(): string | null {
|
let cachedAuthCookie: string | null = null;
|
||||||
|
|
||||||
|
function readAuthCookieFromFile(): string | null {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
||||||
@@ -168,6 +188,49 @@ function getAuthCookie(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeaders: string[], name: string): string | null {
|
||||||
|
for (const header of setCookieHeaders) {
|
||||||
|
const [pair] = header.split(";");
|
||||||
|
if (!pair) continue;
|
||||||
|
const [cookieName, ...valueParts] = pair.split("=");
|
||||||
|
if (cookieName?.trim() !== name) continue;
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthCookieViaLogin(): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: TEST_USER.username,
|
||||||
|
password: TEST_USER.password,
|
||||||
|
rememberMe: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const getSetCookie = (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
const setCookieHeaders = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
||||||
|
const fallback = res.headers.get("set-cookie");
|
||||||
|
if (fallback) setCookieHeaders.push(fallback);
|
||||||
|
|
||||||
|
const accessToken = extractCookieValue(setCookieHeaders, "access_token");
|
||||||
|
if (accessToken) {
|
||||||
|
cachedAuthCookie = accessToken;
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthCookie(): string | null {
|
||||||
|
if (cachedAuthCookie) return cachedAuthCookie;
|
||||||
|
cachedAuthCookie = readAuthCookieFromFile();
|
||||||
|
return cachedAuthCookie;
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed medication response (subset of fields we care about) */
|
/** Typed medication response (subset of fields we care about) */
|
||||||
export interface TestMedication {
|
export interface TestMedication {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -211,7 +274,7 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string | null;
|
takenBy?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}): Promise<TestMedication> {
|
}): Promise<TestMedication> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
const isBottle = data.packageType === "bottle";
|
const isBottle = data.packageType === "bottle";
|
||||||
const body = {
|
const body = {
|
||||||
packageType: isBottle ? "bottle" : "blister",
|
packageType: isBottle ? "bottle" : "blister",
|
||||||
@@ -243,6 +306,10 @@ export async function createMedicationViaAPI(data: {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
@@ -259,13 +326,25 @@ export async function createMedicationViaAPI(data: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a medication via the backend API.
|
* Delete a medication via the backend API.
|
||||||
|
* Includes retry for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
await fetch(`${API_BASE}/api/medications/${id}`, {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
method: "DELETE",
|
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
method: "DELETE",
|
||||||
});
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,11 +352,15 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
|||||||
* Includes retry logic for rate-limited responses.
|
* Includes retry logic for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/medications`, {
|
const res = await fetch(`${API_BASE}/api/medications`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
@@ -290,6 +373,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (delRes.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (delRes.status === 429) {
|
if (delRes.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
continue;
|
continue;
|
||||||
@@ -306,7 +393,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
* Requires a medication with takenBy to exist first.
|
* Requires a medication with takenBy to exist first.
|
||||||
*/
|
*/
|
||||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/share`, {
|
const res = await fetch(`${API_BASE}/api/share`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -316,6 +403,10 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
body: JSON.stringify({ takenBy, scheduleDays }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ async function fillAndSaveMedication(
|
|||||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = form.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
await row.getByLabel(/(Usage \((pills|tablets)\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
||||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i);
|
||||||
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
|
|
||||||
await usageField.fill("2");
|
await usageField.fill("2");
|
||||||
@@ -247,7 +247,7 @@ test.describe("Medication Editing", () => {
|
|||||||
// Verify the changes persisted
|
// Verify the changes persisted
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
const savedRow = page.locator(".blister-row").first();
|
const savedRow = page.locator(".blister-row").first();
|
||||||
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||||
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Fill the new intake row
|
// Fill the new intake row
|
||||||
const secondRow = page.locator(".blister-row").nth(1);
|
const secondRow = page.locator(".blister-row").nth(1);
|
||||||
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5");
|
||||||
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Add Intake Med");
|
await saveEditAndVerify(page, "Add Intake Med");
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medication Lifecycle Integration Tests
|
||||||
|
*
|
||||||
|
* End-to-end workflows that verify changes propagate across pages:
|
||||||
|
* create → verify on medications → check in planner → check in schedule → edit → delete
|
||||||
|
*/
|
||||||
|
test.describe("Medication lifecycle", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 90000 });
|
||||||
|
|
||||||
|
const MED_NAME = "Lifecycle TestMed";
|
||||||
|
const MED_EDITED = "Lifecycle Edited";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create medication via API and verify it appears on all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Step 1: Create medication
|
||||||
|
const created = await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Step 2: Verify on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Step 3: Verify in planner
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible();
|
||||||
|
|
||||||
|
// Step 4: Verify in schedule
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit medication name via UI and verify update propagates", async ({ page }) => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create a fresh medication for this test
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Open edit view from medication row actions
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: MED_NAME });
|
||||||
|
await expect(medRow.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await medRow.first().locator("button.info").click();
|
||||||
|
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the name
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first();
|
||||||
|
await nameInput.fill(MED_EDITED);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const submitButton = form.locator('button[type="submit"]').first();
|
||||||
|
await expect(submitButton).toBeEnabled({ timeout: 5000 });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close or save to complete
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Verify edited name appears on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
// Old name should no longer appear
|
||||||
|
await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete medication via API and verify it disappears from all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create and then delete
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 5,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify it exists first
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Delete via API
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
// Verify gone from medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify planner shows no results for this med
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
// Either no table or table without the medication name
|
||||||
|
const table = page.locator(".table");
|
||||||
|
const tableVisible = await table.isVisible().catch(() => false);
|
||||||
|
if (tableVisible) {
|
||||||
|
await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medication with multiple intakes shows all schedule entries", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const todayEvening = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(20, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: "MultiIntake Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false },
|
||||||
|
{ usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify schedule shows this medication
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The medication should appear at least twice (morning + evening)
|
||||||
|
const medEntries = page.getByText("MultiIntake Med");
|
||||||
|
expect(await medEntries.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance Tests
|
||||||
|
*
|
||||||
|
* Verify the schedule timeline and planner render within acceptable
|
||||||
|
* time limits when many medications exist.
|
||||||
|
*/
|
||||||
|
test.describe("Performance with many medications", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 120000 });
|
||||||
|
|
||||||
|
const MED_COUNT = 20;
|
||||||
|
const MED_PREFIX = "PerfTest Med";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create medications sequentially (API rate limits prevent parallel)
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: `${MED_PREFIX} ${i}`,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
|
||||||
|
// Wait for schedule entries to render
|
||||||
|
const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card");
|
||||||
|
await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify all medications appear
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goal: render under 10 seconds
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medications page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
// Wait for medication cards to render
|
||||||
|
const medEntries = page.locator(".medication-card, .card, .table-row");
|
||||||
|
await expect(medEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify count — all 20 should be visible
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planner calculates within 15 seconds with 20 medications", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const calcTime = Date.now() - start;
|
||||||
|
|
||||||
|
// All medications should appear in the results
|
||||||
|
const rows = page.locator(".table .table-row");
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT);
|
||||||
|
|
||||||
|
// Goal: calculate and render under 15 seconds
|
||||||
|
expect(calcTime).toBeLessThan(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ test.describe("Planner with medications", () => {
|
|||||||
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show usage data in results rows", async ({ page }) => {
|
test("should show correct usage values in results rows", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|
||||||
@@ -116,10 +116,15 @@ test.describe("Planner with medications", () => {
|
|||||||
const rows = resultsTable.locator(".table-row");
|
const rows = resultsTable.locator(".table-row");
|
||||||
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
const firstRowText = await rows.first().textContent();
|
// Each medication has usage=1, every=1 → plannerUsage should reflect the period
|
||||||
expect(firstRowText).toBeTruthy();
|
// Verify the usage column contains a numeric <strong> value and "pill(s)"
|
||||||
// Check for "pill" (matches both "pill" and "pills")
|
for (const row of await rows.all()) {
|
||||||
expect(firstRowText!.toLowerCase()).toContain("pill");
|
const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column
|
||||||
|
const usageStrong = usageCell.locator("strong");
|
||||||
|
await expect(usageStrong).toBeVisible();
|
||||||
|
const usageText = await usageStrong.textContent();
|
||||||
|
expect(Number(usageText)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
||||||
@@ -139,9 +144,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Low-stock med (3 pills) should have a danger chip over 90 days
|
// Low-stock med (3 pills, usage 1/day, 90 days) should have danger status
|
||||||
const dangerChips = resultsTable.locator(".status-chip.danger");
|
const dangerChips = resultsTable.locator(".status-chip.danger");
|
||||||
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the low-stock med row and verify its usage value ~90 pills
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed
|
||||||
|
expect(Number(lowUsage)).toBeLessThanOrEqual(95);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
||||||
@@ -161,9 +173,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// With 60 pills and 7-day range, high-stock should be "Enough"
|
// High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough"
|
||||||
const successChips = resultsTable.locator(".status-chip.success");
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
expect(await successChips.count()).toBeGreaterThanOrEqual(1);
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStatus = highStockRow.locator(".status-chip.success");
|
||||||
|
await expect(highStatus).toBeVisible();
|
||||||
|
|
||||||
|
// Verify usage is ~7 pills for the 7-day range
|
||||||
|
const highUsage = await highStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(highUsage)).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(Number(highUsage)).toBeLessThanOrEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show table header with correct columns", async ({ page }) => {
|
test("should show table header with correct columns", async ({ page }) => {
|
||||||
@@ -180,6 +199,28 @@ test.describe("Planner with medications", () => {
|
|||||||
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should display available stock for each medication", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await calculatePlanner(page);
|
||||||
|
|
||||||
|
const resultsTable = page.locator(".table");
|
||||||
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// High-stock med should show a blister + loose-pill stock breakdown
|
||||||
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStockText = await highStockRow.textContent();
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i);
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
|
||||||
|
// Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowStockText = await lowStockRow.textContent();
|
||||||
|
// Should show 3 loose pills
|
||||||
|
expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("should reset form and clear results", async ({ page }) => {
|
test("should reset form and clear results", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Overview table has class .table.table-7
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
const overviewTable = page.locator(".table.table-7");
|
|
||||||
await expect(overviewTable).toBeVisible();
|
await expect(overviewTable).toBeVisible();
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's medication should show "Alice" badge
|
// Alice's medication should show "Alice" badge
|
||||||
@@ -253,7 +253,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's med has notes — should show the 📝 icon
|
// Alice's med has notes — should show the 📝 icon
|
||||||
@@ -265,7 +265,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes in medication detail modal", async ({ page }) => {
|
test("should show notes in medication detail modal", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on Alice's med to open detail modal
|
// Click on Alice's med to open detail modal
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show all medications in overview table", async ({ page }) => {
|
test("should show all medications in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// All 5 medications should appear
|
// All 5 medications should appear
|
||||||
@@ -139,7 +139,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock med row should have a .status-chip.high
|
// High stock med row should have a .status-chip.high
|
||||||
@@ -151,7 +151,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
||||||
@@ -162,7 +162,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
||||||
@@ -173,7 +173,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
||||||
@@ -184,7 +184,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
||||||
@@ -195,7 +195,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock should show many days (around 299)
|
// High stock should show many days (around 299)
|
||||||
@@ -227,7 +227,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should color-code stock values depending on status", async ({ page }) => {
|
test("should color-code stock values depending on status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock row should have success-text class on stock cells
|
// High stock row should have success-text class on stock cells
|
||||||
@@ -255,7 +255,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should open medication detail modal showing stock info", async ({ page }) => {
|
test("should open medication detail modal showing stock info", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the critical stock medication row
|
// Click on the critical stock medication row
|
||||||
@@ -278,7 +278,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test.describe("MedDetail footer tooltip visibility", () => {
|
|||||||
*/
|
*/
|
||||||
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
||||||
|
|||||||
+3
-3
@@ -1,3 +1,6 @@
|
|||||||
|
# Must be defined at http-level (outside server block)
|
||||||
|
log_format timed '$time_iso8601 $status $request_method $request_uri ($request_time s)';
|
||||||
|
|
||||||
server {
|
server {
|
||||||
# Port 8080 for unprivileged nginx (non-root)
|
# Port 8080 for unprivileged nginx (non-root)
|
||||||
listen 8080;
|
listen 8080;
|
||||||
@@ -6,9 +9,6 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Custom log format with ISO timestamps
|
|
||||||
log_format timed '$time_iso8601 $status $request_method $request_uri ($request_time s)';
|
|
||||||
|
|
||||||
# Access log control (suppressed when LOG_LEVEL is warn or higher)
|
# Access log control (suppressed when LOG_LEVEL is warn or higher)
|
||||||
access_log ${NGINX_ACCESS_LOG};
|
access_log ${NGINX_ACCESS_LOG};
|
||||||
|
|
||||||
|
|||||||
Generated
+24
-28
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.16.1",
|
"version": "1.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.16.1",
|
"version": "1.18.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.13",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
@@ -1779,9 +1779,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.0",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1789,19 +1789,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.2",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.2",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2959,28 +2959,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.17.0",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
"i18next": "^25.8.13",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
: {};
|
: {};
|
||||||
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
||||||
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
|
// Default to single-worker execution to keep API-seeded E2E suites deterministic.
|
||||||
|
// Still allow explicit local overrides via PLAYWRIGHT_WORKERS.
|
||||||
|
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 1;
|
||||||
|
|
||||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
{
|
{
|
||||||
@@ -19,13 +21,13 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
retries: 1,
|
retries: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chromium-data",
|
name: "chromium-data",
|
||||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testMatch: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
},
|
},
|
||||||
@@ -42,7 +44,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Firefox"],
|
...devices["Desktop Firefox"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +52,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Safari"],
|
...devices["Desktop Safari"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+20
-3
@@ -37,13 +37,29 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialAuthTheme(): "light" | "dark" {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "light" || stored === "dark") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored === "system") {
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const { user, authState, loading, authError } = useAuth();
|
const { user, authState, loading, authError } = useAuth();
|
||||||
|
const authTheme = getInitialAuthTheme();
|
||||||
|
|
||||||
// Show loading while checking auth state
|
// Show loading while checking auth state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -55,7 +71,7 @@ function AppRouter() {
|
|||||||
// Show error if we couldn't connect to the server
|
// Show error if we couldn't connect to the server
|
||||||
if (authError) {
|
if (authError) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||||
@@ -77,7 +93,7 @@ function AppRouter() {
|
|||||||
// If auth state is null (shouldn't happen after loading, but be safe)
|
// If auth state is null (shouldn't happen after loading, but be safe)
|
||||||
if (!authState) {
|
if (!authState) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<p>Initializing...</p>
|
<p>Initializing...</p>
|
||||||
@@ -301,6 +317,7 @@ function AppContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
if (scheduleLightboxImage) {
|
if (scheduleLightboxImage) {
|
||||||
closeScheduleLightbox();
|
closeScheduleLightbox();
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} else {
|
} else {
|
||||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||||
@@ -181,7 +181,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
const res = await fetch("/api/auth/refresh", init);
|
const res = await fetch("/api/auth/refresh", init);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
if (res.status === 401) {
|
||||||
|
log.debug("[Auth] Token refresh rejected (unauthenticated)", { status: res.status, correlationId });
|
||||||
|
} else {
|
||||||
|
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.ok;
|
return res.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -433,7 +437,7 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Local login form: only show if form login is enabled */}
|
{/* Local login form - only show if form login is enabled */}
|
||||||
{authState?.formLoginEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ export function MedDetailModal({
|
|||||||
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
||||||
// Lightbox has its own useEscapeKey internally.
|
// Lightbox has its own useEscapeKey internally.
|
||||||
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
||||||
useEscapeKey(showEditStockModal, onCloseEditStockModal);
|
useEscapeKey(showEditStockModal, onCloseEditStockModal, { capture: true });
|
||||||
useEscapeKey(showRefillModal, onCloseRefillModal);
|
useEscapeKey(showRefillModal, onCloseRefillModal, { capture: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showEditStockModal) return;
|
if (showEditStockModal) return;
|
||||||
@@ -192,12 +192,20 @@ export function MedDetailModal({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!selectedMed) return null;
|
if (!selectedMed) return null;
|
||||||
|
const isAmountPackage = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
|
||||||
|
const amountUnitLabel =
|
||||||
|
selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.packageAmountUnitG");
|
||||||
|
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
|
||||||
|
|
||||||
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
||||||
const packageSize = getPackageSize(selectedMed);
|
const packageSize = getPackageSize(selectedMed);
|
||||||
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
||||||
const structuralMax =
|
const structuralMax =
|
||||||
selectedMed.packageType === "bottle"
|
selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container"
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||||
@@ -209,7 +217,23 @@ export function MedDetailModal({
|
|||||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||||
const stockDisplayTotal =
|
const stockDisplayTotal =
|
||||||
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, structuralMax);
|
selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container"
|
||||||
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
|
: Math.max(0, structuralMax);
|
||||||
|
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
||||||
|
const amountPerPackage = (() => {
|
||||||
|
const configured = Number(selectedMed.packageAmountValue ?? 0);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) return configured;
|
||||||
|
|
||||||
|
const totalAmount = Number(stockDisplayTotal ?? 0);
|
||||||
|
if (Number.isFinite(totalAmount) && totalAmount > 0) {
|
||||||
|
return Math.max(0, totalAmount / packageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
const maxPartialPills = Math.min(
|
const maxPartialPills = Math.min(
|
||||||
Math.max(0, selectedMed.pillsPerBlister),
|
Math.max(0, selectedMed.pillsPerBlister),
|
||||||
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
||||||
@@ -219,6 +243,35 @@ export function MedDetailModal({
|
|||||||
const closeLabel = t("common.close");
|
const closeLabel = t("common.close");
|
||||||
const decrementLabel = t("editStock.decreaseValue");
|
const decrementLabel = t("editStock.decreaseValue");
|
||||||
const incrementLabel = t("editStock.increaseValue");
|
const incrementLabel = t("editStock.increaseValue");
|
||||||
|
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||||
|
if (selectedMed.packageType === "liquid_container") {
|
||||||
|
if (intakeUnit === "tsp") {
|
||||||
|
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
if (intakeUnit === "tbsp") {
|
||||||
|
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (selectedMed.packageType === "tube") {
|
||||||
|
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
const scheduleIntakes =
|
||||||
|
selectedMed.intakes && selectedMed.intakes.length > 0
|
||||||
|
? selectedMed.intakes
|
||||||
|
: selectedMed.blisters.map((blister) => ({
|
||||||
|
usage: blister.usage,
|
||||||
|
every: blister.every,
|
||||||
|
start: blister.start,
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
||||||
|
intakeUnit: null,
|
||||||
|
}));
|
||||||
|
const hasAnyIntakeReminder = scheduleIntakes.some(
|
||||||
|
(intake) => (intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false) === true
|
||||||
|
);
|
||||||
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
||||||
let normalizedFull = Math.max(0, nextFull);
|
let normalizedFull = Math.max(0, nextFull);
|
||||||
let normalizedPartial = Math.max(0, nextPartial);
|
let normalizedPartial = Math.max(0, nextPartial);
|
||||||
@@ -347,6 +400,10 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
const renderEditStockModal = () => {
|
const renderEditStockModal = () => {
|
||||||
if (!showEditStockModal) return null;
|
if (!showEditStockModal) return null;
|
||||||
|
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
||||||
|
const liquidBottleCount = Math.max(1, editStockFullBlisters);
|
||||||
|
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
|
||||||
|
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
|
||||||
const fullInputMax = Math.min(
|
const fullInputMax = Math.min(
|
||||||
maxFullBlisters,
|
maxFullBlisters,
|
||||||
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
||||||
@@ -360,14 +417,14 @@ export function MedDetailModal({
|
|||||||
onCloseEditStockModal();
|
onCloseEditStockModal();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content edit-stock-modal"
|
className="modal-content edit-stock-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -395,6 +452,12 @@ export function MedDetailModal({
|
|||||||
{selectedMed.packageType === "bottle" && (
|
{selectedMed.packageType === "bottle" && (
|
||||||
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
|
{(selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container") && (
|
||||||
|
<p className="edit-stock-cap-info">
|
||||||
|
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
|
||||||
|
{amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{showStockCapNotice && (
|
{showStockCapNotice && (
|
||||||
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
@@ -402,12 +465,17 @@ export function MedDetailModal({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||||
const isBottle = selectedMed.packageType === "bottle";
|
const isBottle =
|
||||||
const enteredTotal = isBottle
|
selectedMed.packageType === "bottle" ||
|
||||||
? editStockPartialBlisterPills
|
selectedMed.packageType === "tube" ||
|
||||||
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
selectedMed.packageType === "liquid_container";
|
||||||
editStockPartialBlisterPills +
|
const enteredTotal = isLiquidPackage
|
||||||
editStockLoosePills;
|
? Math.min(liquidCapacity, editStockPartialBlisterPills)
|
||||||
|
: isBottle
|
||||||
|
? editStockPartialBlisterPills
|
||||||
|
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
||||||
|
editStockPartialBlisterPills +
|
||||||
|
editStockLoosePills;
|
||||||
const newTotal = Math.max(0, enteredTotal);
|
const newTotal = Math.max(0, enteredTotal);
|
||||||
const difference = newTotal - currentTotal;
|
const difference = newTotal - currentTotal;
|
||||||
const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : "";
|
const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : "";
|
||||||
@@ -417,36 +485,39 @@ export function MedDetailModal({
|
|||||||
<div className="edit-stock-form">
|
<div className="edit-stock-form">
|
||||||
{isBottle ? (
|
{isBottle ? (
|
||||||
<label>
|
<label>
|
||||||
{t("editStock.totalPills")}
|
{isAmountPackage ? t("form.currentAmount") : t("editStock.totalPills")}
|
||||||
{renderStepperInput({
|
{renderStepperInput({
|
||||||
value: editStockPartialInput,
|
value: editStockPartialInput,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: structuralMax,
|
max: isLiquidPackage ? liquidCapacity : structuralMax,
|
||||||
onChange: (raw) => {
|
onChange: (raw) => {
|
||||||
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||||
setEditStockPartialInput(raw);
|
setEditStockPartialInput(raw);
|
||||||
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(structuralMax, parsed));
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
setShowStockCapNotice(parsed > structuralMax);
|
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(maxTotal, parsed));
|
||||||
|
setShowStockCapNotice(parsed > maxTotal);
|
||||||
},
|
},
|
||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
const normalized = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const normalized = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput)));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput))
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(normalized);
|
onEditStockPartialBlisterPillsChange(normalized);
|
||||||
setEditStockPartialInput(String(normalized));
|
setEditStockPartialInput(String(normalized));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
onStep: (delta) => {
|
onStep: (delta) => {
|
||||||
const next = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const next = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput) + delta));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput) + delta)
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(next);
|
onEditStockPartialBlisterPillsChange(next);
|
||||||
setEditStockPartialInput(String(next));
|
setEditStockPartialInput(String(next));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
|
||||||
|
{t("form.currentAmount")}: {Math.max(0, editStockPartialBlisterPills)} {amountUnitLabel} /{" "}
|
||||||
|
{liquidCapacity} {amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -584,26 +655,72 @@ export function MedDetailModal({
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<label>
|
||||||
|
{t("form.bottles")}
|
||||||
|
{renderStepperInput({
|
||||||
|
value: editStockFullInput,
|
||||||
|
min: 1,
|
||||||
|
max: Number.MAX_SAFE_INTEGER,
|
||||||
|
onChange: (raw) => {
|
||||||
|
const nextBottleCount = raw === "" ? 1 : Math.max(1, parseStockInput(raw));
|
||||||
|
setEditStockFullInput(raw === "" ? "1" : raw);
|
||||||
|
onEditStockFullBlistersChange(nextBottleCount);
|
||||||
|
const syncedTotal = Math.round(nextBottleCount * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
const normalized = Math.max(1, parseStockInput(editStockFullInput));
|
||||||
|
onEditStockFullBlistersChange(normalized);
|
||||||
|
setEditStockFullInput(String(normalized));
|
||||||
|
const syncedTotal = Math.round(normalized * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onStep: (delta) => {
|
||||||
|
const next = Math.max(1, parseStockInput(editStockFullInput) + delta);
|
||||||
|
onEditStockFullBlistersChange(next);
|
||||||
|
setEditStockFullInput(String(next));
|
||||||
|
const syncedTotal = Math.round(next * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-stock-summary">
|
<div className="edit-stock-summary">
|
||||||
<div className="summary-row">
|
<div className="summary-row">
|
||||||
<span>{t("editStock.currentTotal")}:</span>
|
<span>{t("editStock.currentTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
|
{currentTotal}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-row">
|
<div className="summary-row">
|
||||||
<span>{t("editStock.newTotal")}:</span>
|
<span>{t("editStock.newTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
|
{newTotal}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`summary-row difference ${differenceClass}`}>
|
<div className={`summary-row difference ${differenceClass}`}>
|
||||||
<span>{t("editStock.difference")}:</span>
|
<span>{t("editStock.difference")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{difference > 0 ? "+" : ""}
|
{difference > 0 ? "+" : ""}
|
||||||
{difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}
|
{difference}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -716,9 +833,13 @@ export function MedDetailModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
<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">
|
||||||
|
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
|
||||||
|
</span>
|
||||||
<span className={`med-detail-value ${textClass}`}>
|
<span className={`med-detail-value ${textClass}`}>
|
||||||
{currentStock} / {stockDisplayTotal}
|
{isAmountPackage
|
||||||
|
? `${formatNumber(currentStock)} / ${formatNumber(stockDisplayTotal)} ${amountUnitLabel}`
|
||||||
|
: `${currentStock} / ${stockDisplayTotal}`}
|
||||||
{currentStock > stockDisplayTotal && (
|
{currentStock > stockDisplayTotal && (
|
||||||
<span
|
<span
|
||||||
className="info-tooltip tooltip-align-left warning-text"
|
className="info-tooltip tooltip-align-left warning-text"
|
||||||
@@ -737,7 +858,24 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.packageDetails")} (
|
{t("modal.packageDetails")} (
|
||||||
{selectedMed.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")})
|
{selectedMed.packageType === "bottle"
|
||||||
|
? t("form.packageTypeBottle")
|
||||||
|
: selectedMed.packageType === "tube"
|
||||||
|
? t("form.packageTypeTube")
|
||||||
|
: selectedMed.packageType === "liquid_container"
|
||||||
|
? t("form.packageTypeLiquidContainer")
|
||||||
|
: t("form.packageTypeBlister")}
|
||||||
|
)
|
||||||
|
{selectedMed.packageType === "tube" && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedMed.packageType === "liquid_container" && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{selectedMed.packageType === "blister" ? (
|
||||||
@@ -755,6 +893,44 @@ export function MedDetailModal({
|
|||||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : selectedMed.packageType === "liquid_container" ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.bottles")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerBottle")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : selectedMed.packageType === "tube" ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.tubes")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerTube")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||||
@@ -791,53 +967,33 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.intakeSchedule")}{" "}
|
{t("modal.intakeSchedule")}{" "}
|
||||||
{selectedMed.intakeRemindersEnabled && (
|
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && (
|
||||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||||
<Bell size={14} aria-hidden="true" />
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-schedules">
|
<div className="med-detail-schedules">
|
||||||
{(selectedMed.intakes && selectedMed.intakes.length > 0
|
{scheduleIntakes.map((intake, idx) => {
|
||||||
? selectedMed.intakes
|
|
||||||
: selectedMed.blisters.map((blister) => ({
|
|
||||||
usage: blister.usage,
|
|
||||||
every: blister.every,
|
|
||||||
start: blister.start,
|
|
||||||
takenBy: null,
|
|
||||||
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
|
||||||
}))
|
|
||||||
).map((intake, idx) => {
|
|
||||||
const hasPerIntakeTakenBy = !!intake.takenBy;
|
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||||
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||||
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||||
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
<div
|
||||||
|
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
|
||||||
|
className="med-schedule-row blister-row-simple"
|
||||||
|
>
|
||||||
<span className="med-schedule-usage">
|
<span className="med-schedule-usage">
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||||
{selectedMed.pillWeightMg &&
|
{selectedMed.pillWeightMg &&
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="med-schedule-freq">
|
<span className="med-schedule-freq">
|
||||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||||
</span>
|
</span>
|
||||||
{hasPerIntakeTakenBy && (
|
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
|
||||||
<span className="med-schedule-person">
|
|
||||||
{intake.takenBy}
|
|
||||||
{showIntakeBell && (
|
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={13} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!hasPerIntakeTakenBy && showIntakeBell && (
|
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={13} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="med-schedule-time">
|
<span className="med-schedule-time">
|
||||||
{t("modal.at")}{" "}
|
{t("modal.at")}{" "}
|
||||||
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
@@ -845,6 +1001,11 @@ export function MedDetailModal({
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
{showIntakeBell && (
|
||||||
|
<span className="med-schedule-bell" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<Bell size={12} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -955,11 +1116,13 @@ export function MedDetailModal({
|
|||||||
<span className="refill-amount">
|
<span className="refill-amount">
|
||||||
{(() => {
|
{(() => {
|
||||||
const total =
|
const total =
|
||||||
selectedMed.packageType === "bottle"
|
selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container"
|
||||||
? entry.loosePillsAdded
|
? entry.loosePillsAdded
|
||||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||||
entry.loosePillsAdded;
|
entry.loosePillsAdded;
|
||||||
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
|
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||||
})()}
|
})()}
|
||||||
{entry.usedPrescription && (
|
{entry.usedPrescription && (
|
||||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||||
@@ -1128,7 +1291,9 @@ export function MedDetailModal({
|
|||||||
className="success"
|
className="success"
|
||||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedMed.packageType === "bottle"
|
(selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container"
|
||||||
? refillLoose < 1
|
? refillLoose < 1
|
||||||
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
||||||
exceedsPrescriptionPackLimit ||
|
exceedsPrescriptionPackLimit ||
|
||||||
@@ -1144,7 +1309,10 @@ export function MedDetailModal({
|
|||||||
: refillLoose;
|
: refillLoose;
|
||||||
return totalRefill > 0 ? (
|
return totalRefill > 0 ? (
|
||||||
<span className="refill-preview">
|
<span className="refill-preview">
|
||||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
+{totalRefill}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import { useScrollLock } from "../hooks/useScrollLock";
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
@@ -68,7 +68,7 @@ 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") {
|
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
||||||
// For bottle type, looseTablets is the current stock
|
// For bottle type, looseTablets is the current stock
|
||||||
return Number(form.looseTablets) || 0;
|
return Number(form.looseTablets) || 0;
|
||||||
}
|
}
|
||||||
@@ -125,6 +125,33 @@ export function MobileEditModal({
|
|||||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
const activeTabIndexRef = useRef(0);
|
const activeTabIndexRef = useRef(0);
|
||||||
|
|
||||||
|
const allowFractionalIntake = useMemo(() => {
|
||||||
|
if (form.packageType === "liquid_container") return true;
|
||||||
|
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
||||||
|
return form.pillForm === "tablet";
|
||||||
|
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||||
|
|
||||||
|
const getUsageLabel = useCallback(
|
||||||
|
(intake: (typeof form.intakes)[number]) => {
|
||||||
|
if (form.packageType === "liquid_container") {
|
||||||
|
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
||||||
|
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
||||||
|
return t("form.blisters.usageMl");
|
||||||
|
}
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||||
|
}
|
||||||
|
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||||
|
return t("form.blisters.usageTablets");
|
||||||
|
},
|
||||||
|
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
||||||
|
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||||
|
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||||
|
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||||
|
|
||||||
// Reset tab when modal opens
|
// Reset tab when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show) {
|
if (show) {
|
||||||
@@ -392,6 +419,7 @@ export function MobileEditModal({
|
|||||||
<DateInput
|
<DateInput
|
||||||
value={form.medicationStartDate}
|
value={form.medicationStartDate}
|
||||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
/>
|
/>
|
||||||
{!readOnlyMode && dateConsistencyError && (
|
{!readOnlyMode && dateConsistencyError && (
|
||||||
<span className="field-error">{dateConsistencyError}</span>
|
<span className="field-error">{dateConsistencyError}</span>
|
||||||
@@ -406,8 +434,59 @@ export function MobileEditModal({
|
|||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
<option value="tube">{t("form.packageTypeTube")}</option>
|
||||||
|
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.medicationEndDate")}
|
||||||
|
<DateInput
|
||||||
|
value={form.medicationEndDate}
|
||||||
|
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.pillForm")}
|
||||||
|
<select
|
||||||
|
value={form.pillForm}
|
||||||
|
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||||
|
>
|
||||||
|
<option value="tablet">{t("form.medicationFormTablet")}</option>
|
||||||
|
<option value="capsule">{t("form.medicationFormCapsule")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.packageType === "tube" && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.medicationForm")}
|
||||||
|
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
||||||
|
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.packageType === "liquid_container" && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.medicationForm")}
|
||||||
|
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
||||||
|
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.medicationEndDate && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.autoMarkObsoleteAfterEndDate")}
|
||||||
|
<span className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoMarkObsoleteAfterEndDate}
|
||||||
|
onChange={(e) => onHandleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||||
{t("form.takenBy")}
|
{t("form.takenBy")}
|
||||||
<div className="tag-input-container">
|
<div className="tag-input-container">
|
||||||
@@ -480,101 +559,193 @@ export function MobileEditModal({
|
|||||||
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{(() => {
|
||||||
<>
|
if (form.packageType === "blister") {
|
||||||
<label>
|
return (
|
||||||
{t("form.packs")}
|
<>
|
||||||
<FormNumberStepper
|
<label>
|
||||||
value={form.packCount}
|
{t("form.packs")}
|
||||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
<FormNumberStepper
|
||||||
min={0}
|
value={form.packCount}
|
||||||
decrementLabel={decrementValueLabel}
|
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||||
incrementLabel={incrementValueLabel}
|
min={0}
|
||||||
/>
|
decrementLabel={decrementValueLabel}
|
||||||
</label>
|
incrementLabel={incrementValueLabel}
|
||||||
<label>
|
/>
|
||||||
{t("form.blistersPerPack")}
|
</label>
|
||||||
<FormNumberStepper
|
<label>
|
||||||
value={form.blistersPerPack}
|
{t("form.blistersPerPack")}
|
||||||
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
<FormNumberStepper
|
||||||
min={1}
|
value={form.blistersPerPack}
|
||||||
decrementLabel={decrementValueLabel}
|
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||||
incrementLabel={incrementValueLabel}
|
min={1}
|
||||||
/>
|
decrementLabel={decrementValueLabel}
|
||||||
</label>
|
incrementLabel={incrementValueLabel}
|
||||||
<label>
|
/>
|
||||||
{t("form.pillsPerBlister")}
|
</label>
|
||||||
<FormNumberStepper
|
<label>
|
||||||
value={form.pillsPerBlister}
|
{t("form.pillsPerBlister")}
|
||||||
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
<FormNumberStepper
|
||||||
min={1}
|
value={form.pillsPerBlister}
|
||||||
decrementLabel={decrementValueLabel}
|
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||||
incrementLabel={incrementValueLabel}
|
min={1}
|
||||||
/>
|
decrementLabel={decrementValueLabel}
|
||||||
</label>
|
incrementLabel={incrementValueLabel}
|
||||||
<label>
|
/>
|
||||||
{t("form.total")}
|
</label>
|
||||||
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
<label>
|
||||||
</label>
|
{t("form.total")}
|
||||||
</>
|
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
||||||
) : (
|
</label>
|
||||||
<>
|
</>
|
||||||
<label>
|
);
|
||||||
{t("form.totalCapacity")}
|
}
|
||||||
<FormNumberStepper
|
|
||||||
value={form.totalPills}
|
if (form.packageType === "tube") {
|
||||||
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
return (
|
||||||
min={0}
|
<>
|
||||||
decrementLabel={decrementValueLabel}
|
<label>
|
||||||
incrementLabel={incrementValueLabel}
|
{t("form.tubes")}
|
||||||
/>
|
<div className="static-value">1</div>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="full">
|
||||||
{t("form.currentPills")}
|
{t("form.packageAmountPerTube")}
|
||||||
<FormNumberStepper
|
<div className="dose-input-group">
|
||||||
value={form.looseTablets}
|
<input
|
||||||
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
type="text"
|
||||||
min={0}
|
inputMode="decimal"
|
||||||
decrementLabel={decrementValueLabel}
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
incrementLabel={incrementValueLabel}
|
value={form.packageAmountValue ?? "0"}
|
||||||
/>
|
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
||||||
</label>
|
placeholder="0"
|
||||||
</>
|
/>
|
||||||
)}
|
<select
|
||||||
|
value="g"
|
||||||
|
disabled
|
||||||
|
className="dose-unit-select"
|
||||||
|
aria-label={t("form.packageAmountUnitG")}
|
||||||
|
>
|
||||||
|
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.totalAmount")}
|
||||||
|
<div className="static-value">
|
||||||
|
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
|
||||||
|
{t("form.packageAmountUnitG")}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.packageType === "liquid_container") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.bottles")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.packCount}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageAmountPerBottle")}
|
||||||
|
<div className="dose-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={form.packageAmountValue ?? "0"}
|
||||||
|
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value="ml"
|
||||||
|
disabled
|
||||||
|
className="dose-unit-select"
|
||||||
|
aria-label={t("form.packageAmountUnitMl")}
|
||||||
|
>
|
||||||
|
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.totalAmount")}
|
||||||
|
<div className="static-value">
|
||||||
|
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
|
||||||
|
{t("form.packageAmountUnitMl")}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{totalCapacityLabel}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.totalPills}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{currentStockLabel}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{form.packageType === "bottle" && (
|
{form.packageType === "bottle" && (
|
||||||
<div className="full stock-total-row">
|
<div className="full stock-total-row">
|
||||||
<div className="stock-total-field">
|
<div className="stock-total-field">
|
||||||
<p className="sub">
|
<p className="sub">
|
||||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
|
||||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="full">
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
<label className="full">
|
||||||
<div className="dose-input-group">
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<input
|
<div className="dose-input-group">
|
||||||
type="text"
|
<input
|
||||||
inputMode="decimal"
|
type="text"
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
inputMode="decimal"
|
||||||
value={form.pillWeightMg}
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
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}
|
<select
|
||||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
value={form.doseUnit}
|
||||||
className="dose-unit-select"
|
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}>
|
{DOSE_UNITS.map((unit) => (
|
||||||
{unit.label}
|
<option key={unit.value} value={unit.value}>
|
||||||
</option>
|
{unit.label}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</select>
|
||||||
</label>
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.expiryDate")}
|
{t("form.expiryDate")}
|
||||||
<DateInput
|
<DateInput
|
||||||
@@ -626,17 +797,17 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
{form.intakes.map((intake, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
|
||||||
className="blister-row"
|
className="blister-row"
|
||||||
>
|
>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.usage")}</span>
|
<span>{getUsageLabel(intake)}</span>
|
||||||
<FormNumberStepper
|
<FormNumberStepper
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||||
min={0.5}
|
min={allowFractionalIntake ? 0.5 : 1}
|
||||||
step={0.5}
|
step={allowFractionalIntake ? 0.5 : 1}
|
||||||
allowDecimal={true}
|
allowDecimal={allowFractionalIntake}
|
||||||
decrementLabel={decrementValueLabel}
|
decrementLabel={decrementValueLabel}
|
||||||
incrementLabel={incrementValueLabel}
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
@@ -666,6 +837,21 @@ export function MobileEditModal({
|
|||||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{form.packageType === "liquid_container" && (
|
||||||
|
<label className="compact full-row">
|
||||||
|
<span>{t("form.blisters.intakeUnit")}</span>
|
||||||
|
<select
|
||||||
|
value={intake.intakeUnit}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||||
|
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||||
|
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{form.takenBy.length === 0 ? null : (
|
{form.takenBy.length === 0 ? null : (
|
||||||
<label className="compact full-row taken-by-field">
|
<label className="compact full-row taken-by-field">
|
||||||
<span>{t("form.blisters.takenByIntake")}</span>
|
<span>{t("form.blisters.takenByIntake")}</span>
|
||||||
|
|||||||
@@ -298,6 +298,39 @@ function fmtDateTime(iso: string | null | undefined): string {
|
|||||||
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
||||||
|
if (med.packageType === "liquid_container") return "form.ml";
|
||||||
|
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageText(med: Medication, usage: number, t: TFn): string {
|
||||||
|
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
|
return `${usage} ${t(getTubeUnitKey(med))}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
||||||
|
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
|
return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) });
|
||||||
|
}
|
||||||
|
return t("report.docTotalCapacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentStockText(med: Medication, t: TFn): string {
|
||||||
|
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
|
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
||||||
|
}
|
||||||
|
return `${getPackageSize(med)} ${t("common.pills")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||||
|
if (med.packageType === "bottle") return t("report.docBottle");
|
||||||
|
if (med.packageType === "tube") return t("report.docTube");
|
||||||
|
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
||||||
|
return t("report.docBlister");
|
||||||
|
}
|
||||||
|
|
||||||
function generateTextReport(
|
function generateTextReport(
|
||||||
meds: Medication[],
|
meds: Medication[],
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
@@ -340,19 +373,18 @@ function generateTextReport(
|
|||||||
|
|
||||||
// Package / Stock
|
// Package / Stock
|
||||||
lines.push(h3(t("report.docPackage")));
|
lines.push(h3(t("report.docPackage")));
|
||||||
lines.push(
|
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
|
||||||
item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))
|
|
||||||
);
|
|
||||||
if (med.packageType === "blister") {
|
if (med.packageType === "blister") {
|
||||||
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
||||||
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
||||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||||
} else {
|
} else {
|
||||||
lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets)));
|
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||||
}
|
}
|
||||||
lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`));
|
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||||
if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
|
||||||
|
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||||
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||||
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -365,7 +397,7 @@ function generateTextReport(
|
|||||||
if (intakes?.length) {
|
if (intakes?.length) {
|
||||||
lines.push(h3(t("report.docIntakeSchedule")));
|
lines.push(h3(t("report.docIntakeSchedule")));
|
||||||
for (const intake of intakes) {
|
for (const intake of intakes) {
|
||||||
let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`;
|
let entry = getUsageText(med, intake.usage, t);
|
||||||
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
||||||
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
||||||
if ("takenBy" in intake && intake.takenBy)
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
@@ -407,7 +439,7 @@ function generateTextReport(
|
|||||||
if (data.refills.length > 0) {
|
if (data.refills.length > 0) {
|
||||||
lines.push(h3(t("report.docRefillHistory")));
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
}
|
}
|
||||||
@@ -539,7 +571,7 @@ function buildPrintHtml(
|
|||||||
// Package / Stock
|
// Package / Stock
|
||||||
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||||
s += `<table><tbody>`;
|
s += `<table><tbody>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
|
||||||
if (med.packageType === "blister") {
|
if (med.packageType === "blister") {
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||||
@@ -547,10 +579,10 @@ function buildPrintHtml(
|
|||||||
if (med.looseTablets > 0)
|
if (med.looseTablets > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||||
}
|
}
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${getPackageSize(med)} ${escHtml(t("common.pills"))}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||||
if (med.pillWeightMg)
|
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||||
if (med.expiryDate)
|
if (med.expiryDate)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||||
@@ -567,7 +599,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const intake of filteredPrintIntakes) {
|
for (const intake of filteredPrintIntakes) {
|
||||||
let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`;
|
let entry = escHtml(getUsageText(med, intake.usage, t));
|
||||||
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
||||||
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
||||||
if ("takenBy" in intake && intake.takenBy)
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
@@ -614,7 +646,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
s += `<li>${entry}</li>`;
|
s += `<li>${entry}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ import { MedicationAvatar } from "./MedicationAvatar";
|
|||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number }
|
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
|
||||||
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
|
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
return { className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
||||||
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
||||||
@@ -44,6 +53,100 @@ export function SharedSchedule() {
|
|||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
|
|
||||||
|
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
||||||
|
med?.packageType === "liquid_container";
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertUsageForStock = (
|
||||||
|
usage: number,
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
||||||
|
): number => {
|
||||||
|
if (med?.packageType === "tube") return 0;
|
||||||
|
if (!isLiquidContainerMed(med)) return usage;
|
||||||
|
return convertLiquidUsageToMl(usage, unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (value: number) => {
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
return String(rounded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
total: number,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getVisibleStockStatus = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
|
||||||
|
|
||||||
// Theme preference: light, dark, or system
|
// Theme preference: light, dark, or system
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
type ThemePreference = "light" | "dark" | "system";
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
||||||
@@ -309,6 +412,7 @@ export function SharedSchedule() {
|
|||||||
when: number;
|
when: number;
|
||||||
medName: string;
|
medName: string;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
timeStr: string;
|
timeStr: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
@@ -319,7 +423,12 @@ export function SharedSchedule() {
|
|||||||
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
||||||
const intakes =
|
const intakes =
|
||||||
med.intakes ||
|
med.intakes ||
|
||||||
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
|
med.blisters.map((b) => ({
|
||||||
|
...b,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
intakes.forEach((intake, intakeIdx) => {
|
intakes.forEach((intake, intakeIdx) => {
|
||||||
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
||||||
@@ -345,6 +454,7 @@ export function SharedSchedule() {
|
|||||||
when: t,
|
when: t,
|
||||||
medName: getMedDisplayName(med),
|
medName: getMedDisplayName(med),
|
||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
|
intakeUnit: intake.intakeUnit ?? null,
|
||||||
isPast,
|
isPast,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
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" }),
|
||||||
@@ -430,8 +540,14 @@ export function SharedSchedule() {
|
|||||||
const depletion: Record<string, number | null> = {};
|
const depletion: Record<string, number | null> = {};
|
||||||
|
|
||||||
for (const med of data.medications) {
|
for (const med of data.medications) {
|
||||||
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
|
const intakes =
|
||||||
const blisters = med.blisters;
|
med.intakes ||
|
||||||
|
med.blisters.map((b) => ({
|
||||||
|
...b,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// Count unique people from all intakes (for per-intake takenBy)
|
// Count unique people from all intakes (for per-intake takenBy)
|
||||||
const uniquePeople = new Set<string>();
|
const uniquePeople = new Set<string>();
|
||||||
@@ -443,9 +559,9 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Calculate daily consumption rate accounting for per-intake takenBy
|
// Calculate daily consumption rate accounting for per-intake takenBy
|
||||||
let dailyRate = 0;
|
let dailyRate = 0;
|
||||||
blisters.forEach((s, idx) => {
|
intakes.forEach((intake) => {
|
||||||
const baseRate = s.every > 0 ? s.usage / s.every : 0;
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const intake = intakes[idx];
|
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||||
if (intake?.takenBy) {
|
if (intake?.takenBy) {
|
||||||
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
||||||
} else {
|
} else {
|
||||||
@@ -458,9 +574,10 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (calcMode === "automatic") {
|
if (calcMode === "automatic") {
|
||||||
// Time-based: every scheduled dose counts as consumed once its time has passed
|
// Time-based: every scheduled dose counts as consumed once its time has passed
|
||||||
blisters.forEach((s, blisterIdx) => {
|
intakes.forEach((intake, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const blisterStart = new Date(intake.start).getTime();
|
||||||
|
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||||
|
|
||||||
let effectiveStart: number;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
@@ -472,7 +589,6 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart)) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||||
@@ -482,7 +598,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (effectiveStart <= now) {
|
if (effectiveStart <= now) {
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
lastAutoConsumedDateMs = new Date(
|
lastAutoConsumedDateMs = new Date(
|
||||||
lastDoseTime.getFullYear(),
|
lastDoseTime.getFullYear(),
|
||||||
@@ -510,7 +626,7 @@ export function SharedSchedule() {
|
|||||||
const bIdx = parseInt(parts[1], 10);
|
const bIdx = parseInt(parts[1], 10);
|
||||||
const timestamp = parseInt(parts[2], 10);
|
const timestamp = parseInt(parts[2], 10);
|
||||||
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
earlyTakenConsumed += s.usage;
|
earlyTakenConsumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,8 +641,8 @@ export function SharedSchedule() {
|
|||||||
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 === med.id && blisters[blisterIdx]) {
|
if (medId === med.id && intakes[blisterIdx]) {
|
||||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
const blisterStartDate = new Date(intakes[blisterIdx].start);
|
||||||
const blisterStartDateOnly = new Date(
|
const blisterStartDateOnly = new Date(
|
||||||
blisterStartDate.getFullYear(),
|
blisterStartDate.getFullYear(),
|
||||||
blisterStartDate.getMonth(),
|
blisterStartDate.getMonth(),
|
||||||
@@ -534,7 +650,11 @@ export function SharedSchedule() {
|
|||||||
).getTime();
|
).getTime();
|
||||||
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
||||||
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += convertUsageForStock(
|
||||||
|
intakes[blisterIdx].usage,
|
||||||
|
med,
|
||||||
|
intakes[blisterIdx].intakeUnit ?? "ml"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,11 +689,13 @@ export function SharedSchedule() {
|
|||||||
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
||||||
const statuses = meds.map((item) => {
|
const statuses = meds.map((item) => {
|
||||||
const coverage = coverageByMed[item.medName];
|
const coverage = coverageByMed[item.medName];
|
||||||
|
const med = data?.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
||||||
if (!coverage) return "success";
|
if (!coverage) return "success";
|
||||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
const rawStatus = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds, med?.packageType);
|
||||||
return status.className;
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
|
return status?.className ?? "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
@@ -583,6 +705,11 @@ export function SharedSchedule() {
|
|||||||
const showStock = data?.shareStockStatus !== false;
|
const showStock = data?.shareStockStatus !== false;
|
||||||
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||||
|
|
||||||
|
const renderDoseUsage = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
|
||||||
|
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
|
||||||
|
|
||||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||||
function isDoseIdDone(doseId: string): boolean {
|
function isDoseIdDone(doseId: string): boolean {
|
||||||
if (takenDoses.has(doseId)) return true;
|
if (takenDoses.has(doseId)) return true;
|
||||||
@@ -809,9 +936,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -840,9 +973,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -853,9 +990,7 @@ export function SharedSchedule() {
|
|||||||
<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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -993,9 +1128,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1023,9 +1164,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1040,9 +1185,7 @@ export function SharedSchedule() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1169,9 +1312,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1199,9 +1348,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1212,9 +1365,7 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item 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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function UserFilterModal({
|
|||||||
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
|
||||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||||
const status = medCoverage
|
const status = medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||||
: getStockStatus(null, getMedTotal(med), settings);
|
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||||
const packageSize = getPackageSize(med);
|
const packageSize = getPackageSize(med);
|
||||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, use
|
|||||||
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -17,6 +17,7 @@ export type DoseInfo = {
|
|||||||
timeStr: string;
|
timeStr: string;
|
||||||
when: number;
|
when: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string[];
|
takenBy: string[];
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -384,6 +385,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
||||||
const statuses = dayMeds.map((item) => {
|
const statuses = dayMeds.map((item) => {
|
||||||
const cov = coverageByMed[item.medName];
|
const cov = coverageByMed[item.medName];
|
||||||
|
const med = activeMeds.find((m) => m.name === item.medName || m.genericName === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
|
|
||||||
// Will be out of stock by this day?
|
// Will be out of stock by this day?
|
||||||
@@ -392,21 +394,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cov) return "success";
|
if (!cov) return "success";
|
||||||
const { daysLeft, medsLeft } = cov;
|
const status = getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
if (status.className === "danger") return "danger";
|
||||||
// Currently out of stock
|
if (status.className === "warning") return "warning";
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
|
||||||
// No schedule (can't calculate)
|
|
||||||
if (daysLeft === null) return "success";
|
|
||||||
// Low stock: < lowStockDays (warning)
|
|
||||||
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
|
|
||||||
// Normal/High stock
|
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
},
|
},
|
||||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
[coverageByMed, depletionByMed, activeMeds, stockThresholds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedSchedule = useMemo(() => {
|
const groupedSchedule = useMemo(() => {
|
||||||
@@ -439,6 +435,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
timeStr: event.timeStr,
|
timeStr: event.timeStr,
|
||||||
when: event.when,
|
when: event.when,
|
||||||
usage: event.usage,
|
usage: event.usage,
|
||||||
|
intakeUnit: event.intakeUnit ?? null,
|
||||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||||
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export function useEscapeKey(active: boolean, onClose: () => void, options?: { c
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && activeRef.current) {
|
if (e.key === "Escape" && activeRef.current) {
|
||||||
|
if (capture) {
|
||||||
|
// In nested modals, consume Escape so parent/global handlers
|
||||||
|
// do not process the same key press again.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
|
|||||||
every: "1",
|
every: "1",
|
||||||
startDate: toDateValue(now),
|
startDate: toDateValue(now),
|
||||||
startTime: toTimeValue(now),
|
startTime: toTimeValue(now),
|
||||||
|
intakeUnit: "ml",
|
||||||
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
||||||
intakeRemindersEnabled: false,
|
intakeRemindersEnabled: false,
|
||||||
};
|
};
|
||||||
@@ -33,15 +34,22 @@ export const defaultForm = (): FormState => ({
|
|||||||
name: "",
|
name: "",
|
||||||
genericName: "",
|
genericName: "",
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
|
medicationForm: "tablet",
|
||||||
|
pillForm: "tablet",
|
||||||
|
lifecycleCategory: "refill_when_empty",
|
||||||
packageType: "blister",
|
packageType: "blister",
|
||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "1",
|
blistersPerPack: "1",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
|
packageAmountValue: "0",
|
||||||
|
packageAmountUnit: "ml",
|
||||||
totalPills: "",
|
totalPills: "",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
doseUnit: "mg",
|
doseUnit: "mg",
|
||||||
medicationStartDate: "",
|
medicationStartDate: "",
|
||||||
|
medicationEndDate: "",
|
||||||
|
autoMarkObsoleteAfterEndDate: true,
|
||||||
expiryDate: "",
|
expiryDate: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
prescriptionEnabled: false,
|
prescriptionEnabled: false,
|
||||||
@@ -205,6 +213,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
every: String(i.every),
|
every: String(i.every),
|
||||||
startDate: toDateValue(i.start),
|
startDate: toDateValue(i.start),
|
||||||
startTime: toTimeValue(i.start),
|
startTime: toTimeValue(i.start),
|
||||||
|
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
|
||||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||||
}))
|
}))
|
||||||
@@ -213,6 +222,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
every: String(s.every),
|
every: String(s.every),
|
||||||
startDate: toDateValue(s.start),
|
startDate: toDateValue(s.start),
|
||||||
startTime: toTimeValue(s.start),
|
startTime: toTimeValue(s.start),
|
||||||
|
intakeUnit: "ml" as const,
|
||||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -220,21 +230,80 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
||||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||||
|
const isTubeOrLiquidPackage = med.packageType === "tube" || med.packageType === "liquid_container";
|
||||||
|
let normalizedPackCount = String(med.packCount);
|
||||||
|
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
|
||||||
|
|
||||||
const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : "";
|
if (isTubeOrLiquidPackage) {
|
||||||
|
const safePackCount = med.packageType === "tube" ? 1 : Math.max(1, med.packCount || 1);
|
||||||
|
normalizedPackCount = String(safePackCount);
|
||||||
|
|
||||||
|
const rawPackageAmount = Number(med.packageAmountValue ?? 0);
|
||||||
|
const legacyKnownAmount = Math.max(0, Number(med.totalPills ?? 0), Number(med.looseTablets ?? 0));
|
||||||
|
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
normalizedPackageAmountValue = String(
|
||||||
|
legacyKnownAmount > 0 ? legacyKnownAmount : Math.max(1, rawPackageAmount)
|
||||||
|
);
|
||||||
|
} else if (rawPackageAmount > 0) {
|
||||||
|
normalizedPackageAmountValue = String(rawPackageAmount);
|
||||||
|
} else {
|
||||||
|
normalizedPackageAmountValue = String(legacyKnownAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDerivedTotal = isTubeOrLiquidPackage
|
||||||
|
? Math.max(0, (Number(normalizedPackCount) || 0) * (Number(normalizedPackageAmountValue) || 0))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const bottleTotalPills =
|
||||||
|
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
||||||
|
med.looseTablets
|
||||||
|
? String(med.looseTablets)
|
||||||
|
: "";
|
||||||
|
let resolvedForm = med.medicationForm;
|
||||||
|
if (!resolvedForm) {
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
resolvedForm = "topical";
|
||||||
|
} else if (med.packageType === "liquid_container") {
|
||||||
|
resolvedForm = "liquid";
|
||||||
|
} else {
|
||||||
|
resolvedForm = med.pillForm ?? "tablet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolvedPillForm = med.pillForm ?? (resolvedForm === "capsule" ? "capsule" : "tablet");
|
||||||
|
let normalizedPackageAmountUnit = med.packageAmountUnit ?? "ml";
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
normalizedPackageAmountUnit = "g";
|
||||||
|
} else if (med.packageType === "liquid_container") {
|
||||||
|
normalizedPackageAmountUnit = "ml";
|
||||||
|
}
|
||||||
|
let resolvedTotalPills = bottleTotalPills;
|
||||||
|
if (normalizedDerivedTotal != null) {
|
||||||
|
resolvedTotalPills = String(normalizedDerivedTotal);
|
||||||
|
} else if (med.totalPills) {
|
||||||
|
resolvedTotalPills = String(med.totalPills);
|
||||||
|
}
|
||||||
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
|
||||||
|
medicationForm: resolvedForm,
|
||||||
|
pillForm: resolvedPillForm,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: med.packageType ?? "blister",
|
||||||
packCount: String(med.packCount),
|
packCount: normalizedPackCount,
|
||||||
blistersPerPack: String(med.blistersPerPack),
|
blistersPerPack: String(med.blistersPerPack),
|
||||||
pillsPerBlister: String(med.pillsPerBlister),
|
pillsPerBlister: String(med.pillsPerBlister),
|
||||||
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
packageAmountValue: normalizedPackageAmountValue,
|
||||||
looseTablets: String(med.looseTablets),
|
packageAmountUnit: normalizedPackageAmountUnit,
|
||||||
|
totalPills: resolvedTotalPills,
|
||||||
|
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : String(med.looseTablets),
|
||||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
medicationStartDate: med.medicationStartDate ?? "",
|
medicationStartDate: med.medicationStartDate ?? "",
|
||||||
|
medicationEndDate: med.medicationEndDate ?? "",
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||||
notes: med.notes ?? "",
|
notes: med.notes ?? "",
|
||||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||||
@@ -277,6 +346,63 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const next = { ...prev, [key]: value } as FormState;
|
const next = { ...prev, [key]: value } as FormState;
|
||||||
|
|
||||||
|
if (key === "packageType") {
|
||||||
|
if (value === "tube") {
|
||||||
|
next.packCount = "1";
|
||||||
|
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||||
|
next.medicationForm = "topical";
|
||||||
|
next.lifecycleCategory = "treatment_period";
|
||||||
|
next.doseUnit = "units";
|
||||||
|
next.packageAmountUnit = "g";
|
||||||
|
} else if (value === "liquid_container") {
|
||||||
|
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
|
||||||
|
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||||
|
next.medicationForm = "liquid";
|
||||||
|
next.lifecycleCategory = "refill_when_empty";
|
||||||
|
next.doseUnit = "ml";
|
||||||
|
next.packageAmountUnit = "ml";
|
||||||
|
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
|
||||||
|
} else {
|
||||||
|
next.medicationForm = next.pillForm;
|
||||||
|
next.lifecycleCategory = "refill_when_empty";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "medicationForm") {
|
||||||
|
if (next.packageType === "tube") {
|
||||||
|
next.medicationForm = "topical";
|
||||||
|
next.lifecycleCategory = "treatment_period";
|
||||||
|
next.doseUnit = "units";
|
||||||
|
next.packageAmountUnit = "g";
|
||||||
|
} else if (next.packageType === "liquid_container") {
|
||||||
|
next.medicationForm = "liquid";
|
||||||
|
next.lifecycleCategory = "refill_when_empty";
|
||||||
|
next.doseUnit = "ml";
|
||||||
|
next.packageAmountUnit = "ml";
|
||||||
|
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.packageType === "tube") {
|
||||||
|
next.packCount = "1";
|
||||||
|
next.packageAmountUnit = "g";
|
||||||
|
} else if (next.packageType === "liquid_container") {
|
||||||
|
next.packageAmountUnit = "ml";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "pillForm" && value === "capsule") {
|
||||||
|
next.medicationForm = "capsule";
|
||||||
|
next.intakes = next.intakes.map((intake) => {
|
||||||
|
const parsedUsage = Number.parseFloat(intake.usage);
|
||||||
|
const rounded = Number.isFinite(parsedUsage) ? Math.max(0, Math.round(parsedUsage)) : 1;
|
||||||
|
return { ...intake, usage: String(rounded || 1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "pillForm" && value === "tablet") {
|
||||||
|
next.medicationForm = "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "prescriptionAuthorizedRefills") {
|
if (key === "prescriptionAuthorizedRefills") {
|
||||||
const raw = String(value);
|
const raw = String(value);
|
||||||
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
||||||
|
|||||||
@@ -137,51 +137,97 @@ export function useRefill(): UseRefillReturn {
|
|||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockSaving(true);
|
setEditStockSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const isTubePackage = selectedMed.packageType === "tube";
|
||||||
|
const isBottlePackage = selectedMed.packageType === "bottle";
|
||||||
|
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
||||||
|
const isAmountPackage = isBottlePackage || isTubePackage || isLiquidPackage;
|
||||||
|
const liquidAmountPerBottle = Math.max(
|
||||||
|
1,
|
||||||
|
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
|
||||||
|
? Number(selectedMed.packageAmountValue)
|
||||||
|
: Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(Number(getPackageSize(selectedMed) || 0) / Math.max(1, Number(selectedMed.packCount || 1)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Clamp all fields to non-negative values.
|
// Clamp all fields to non-negative values.
|
||||||
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
||||||
let finalPartialPills =
|
let finalPartialPills = isAmountPackage
|
||||||
selectedMed.packageType === "bottle"
|
? Math.max(0, editStockPartialBlisterPills)
|
||||||
? Math.max(0, editStockPartialBlisterPills)
|
: Math.max(0, editStockPartialBlisterPills);
|
||||||
: Math.max(0, editStockPartialBlisterPills);
|
|
||||||
const finalLoosePills = Math.max(0, editStockLoosePills);
|
const finalLoosePills = Math.max(0, editStockLoosePills);
|
||||||
|
|
||||||
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
||||||
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
|
if (!isAmountPackage && selectedMed.pillsPerBlister > 0) {
|
||||||
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
||||||
finalPartialPills %= selectedMed.pillsPerBlister;
|
finalPartialPills %= selectedMed.pillsPerBlister;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structural max = sealed package capacity only (no looseTablets offset).
|
// Structural max = sealed package capacity only (no looseTablets offset).
|
||||||
const structuralMax =
|
const structuralMax = isAmountPackage
|
||||||
selectedMed.packageType === "bottle"
|
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
const correctedLiquidBottleCount = isLiquidPackage
|
||||||
|
? Math.max(1, finalFullBlisters)
|
||||||
|
: Math.max(1, selectedMed.packCount);
|
||||||
|
const liquidStructuralMax = isLiquidPackage
|
||||||
|
? correctedLiquidBottleCount * liquidAmountPerBottle
|
||||||
|
: structuralMax;
|
||||||
|
|
||||||
// For blister meds, only sealed pills are capped to package size.
|
// For blister meds, only sealed pills are capped to package size.
|
||||||
// Loose pills are extra and can be above package size.
|
// Loose pills are extra and can be above package size.
|
||||||
const desiredTotal =
|
let desiredTotal: number;
|
||||||
selectedMed.packageType === "bottle"
|
if (isTubePackage) {
|
||||||
? Math.min(structuralMax, Math.max(0, finalPartialPills))
|
desiredTotal = Math.max(0, finalPartialPills);
|
||||||
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
} else if (isAmountPackage) {
|
||||||
finalLoosePills;
|
desiredTotal = Math.min(liquidStructuralMax, Math.max(0, finalPartialPills));
|
||||||
|
} else {
|
||||||
|
desiredTotal =
|
||||||
|
Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||||
|
finalLoosePills;
|
||||||
|
}
|
||||||
|
|
||||||
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
||||||
// - Bottle: looseTablets is the base (not changed during correction)
|
// - Bottle: looseTablets is the base (not changed during correction)
|
||||||
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
||||||
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
||||||
const baseTotal =
|
let baseTotal: number;
|
||||||
selectedMed.packageType === "bottle"
|
if (isLiquidPackage) {
|
||||||
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
|
baseTotal = liquidStructuralMax;
|
||||||
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
} else if (isAmountPackage) {
|
||||||
|
baseTotal = getPackageSize(selectedMed); // bottle: stockAdjustment relative to fixed looseTablets base
|
||||||
|
} else {
|
||||||
|
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||||
|
}
|
||||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||||
const newStockAdjustment = desiredTotal - baseTotal;
|
const newStockAdjustment = desiredTotal - baseTotal;
|
||||||
|
|
||||||
// For blister corrections also send the new looseTablets value so the DB
|
// For blister corrections also send the new looseTablets value so the DB
|
||||||
// reflects the actual loose count (avoids stale-split display on reload).
|
// reflects the actual loose count (avoids stale-split display on reload).
|
||||||
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
|
const patchBody: {
|
||||||
|
stockAdjustment: number;
|
||||||
|
looseTablets?: number;
|
||||||
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
packCount?: number;
|
||||||
|
} = {
|
||||||
stockAdjustment: newStockAdjustment,
|
stockAdjustment: newStockAdjustment,
|
||||||
};
|
};
|
||||||
if (selectedMed.packageType !== "bottle") {
|
if (isTubePackage) {
|
||||||
|
// Tube has fixed count=1 and no automatic depletion.
|
||||||
|
// Correction must update the base amount fields directly.
|
||||||
|
patchBody.stockAdjustment = 0;
|
||||||
|
patchBody.packCount = 1;
|
||||||
|
patchBody.totalPills = desiredTotal;
|
||||||
|
patchBody.looseTablets = desiredTotal;
|
||||||
|
patchBody.packageAmountValue = desiredTotal;
|
||||||
|
} else if (isLiquidPackage) {
|
||||||
|
// Liquid correction supports bottle-count updates.
|
||||||
|
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
|
||||||
|
patchBody.packCount = correctedLiquidBottleCount;
|
||||||
|
patchBody.totalPills = liquidStructuralMax;
|
||||||
|
} else if (!isAmountPackage) {
|
||||||
patchBody.looseTablets = finalLoosePills;
|
patchBody.looseTablets = finalLoosePills;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +268,10 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockMedication(selectedMed);
|
setEditStockMedication(selectedMed);
|
||||||
|
const isAmountPackage =
|
||||||
|
selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container";
|
||||||
// Get current stock from coverage (after consumption)
|
// Get current stock from coverage (after consumption)
|
||||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
@@ -231,15 +281,20 @@ export function useRefill(): UseRefillReturn {
|
|||||||
// For blister, keep loose pills separated from sealed blister/partial counts.
|
// For blister, keep loose pills separated from sealed blister/partial counts.
|
||||||
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
||||||
const sealedPills = Math.max(0, currentStock - knownLoose);
|
const sealedPills = Math.max(0, currentStock - knownLoose);
|
||||||
const fullBlisters =
|
let fullBlisters: number;
|
||||||
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
if (selectedMed.packageType === "liquid_container") {
|
||||||
const partialPills =
|
fullBlisters = Math.max(1, selectedMed.packCount);
|
||||||
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
} else if (isAmountPackage) {
|
||||||
|
fullBlisters = 0;
|
||||||
|
} else {
|
||||||
|
fullBlisters = Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
||||||
|
}
|
||||||
|
const partialPills = isAmountPackage ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
||||||
|
|
||||||
// Pre-fill with current values
|
// Pre-fill with current values
|
||||||
setEditStockFullBlisters(fullBlisters);
|
setEditStockFullBlisters(fullBlisters);
|
||||||
setEditStockPartialBlisterPills(partialPills);
|
setEditStockPartialBlisterPills(partialPills);
|
||||||
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
|
setEditStockLoosePills(isAmountPackage ? 0 : knownLoose);
|
||||||
setShowEditStockModal(true);
|
setShowEditStockModal(true);
|
||||||
window.history.pushState({ modal: "editStock" }, "");
|
window.history.pushState({ modal: "editStock" }, "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"fullBlisters": "Volle Blister",
|
"fullBlisters": "Volle Blister",
|
||||||
"openBlister": "Offener Blister",
|
"openBlister": "Offener Blister",
|
||||||
"stock": "Bestand",
|
"stock": "Bestand",
|
||||||
|
"dailyConsumption": "Taeglicher Verbrauch",
|
||||||
"stockDetails": "Details",
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -118,7 +119,8 @@
|
|||||||
"expiry": "Ablaufdatum",
|
"expiry": "Ablaufdatum",
|
||||||
"pillsCount": "{{count}} Tabletten",
|
"pillsCount": "{{count}} Tabletten",
|
||||||
"pillsCount_one": "{{count}} Tablette",
|
"pillsCount_one": "{{count}} Tablette",
|
||||||
"pillsCount_other": "{{count}} Tabletten"
|
"pillsCount_other": "{{count}} Tabletten",
|
||||||
|
"perDayWithUnit": "{{value}} {{unit}}"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -130,7 +132,8 @@
|
|||||||
"reactivate": "Reaktivieren",
|
"reactivate": "Reaktivieren",
|
||||||
"obsoleteTitle": "Obsolet ({{count}})",
|
"obsoleteTitle": "Obsolet ({{count}})",
|
||||||
"obsoleteSince": "Beendet",
|
"obsoleteSince": "Beendet",
|
||||||
"started": "Gestartet"
|
"started": "Gestartet",
|
||||||
|
"emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
@@ -139,6 +142,7 @@
|
|||||||
"loose": "Lose",
|
"loose": "Lose",
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
"stock": "Bestand",
|
"stock": "Bestand",
|
||||||
|
"capacityPerPackage": "Kapazitaet pro Packung",
|
||||||
"totalCapacity": "Kapazität",
|
"totalCapacity": "Kapazität",
|
||||||
"type": "Typ"
|
"type": "Typ"
|
||||||
},
|
},
|
||||||
@@ -167,18 +171,41 @@
|
|||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
"takenBy": "Eingenommen von",
|
"takenBy": "Eingenommen von",
|
||||||
|
"medicationForm": "Medikationsform",
|
||||||
|
"medicationFormCapsule": "Kapsel",
|
||||||
|
"medicationFormTablet": "Tablette",
|
||||||
|
"medicationFormLiquid": "Fluessigkeit",
|
||||||
|
"medicationFormTopical": "Topisch",
|
||||||
|
"pillForm": "Pillenform",
|
||||||
|
"lifecycleCategory": "Lebenszyklus",
|
||||||
|
"lifecycleRefillWhenEmpty": "Nachfuellen wenn leer",
|
||||||
|
"lifecycleTreatmentPeriod": "Behandlungszeitraum",
|
||||||
"packageType": "Verpackungsart",
|
"packageType": "Verpackungsart",
|
||||||
"packageTypeBlister": "Blisterpackung",
|
"packageTypeBlister": "Blisterpackung",
|
||||||
"packageTypeBottle": "Pillendose",
|
"packageTypeBottle": "Pillendose",
|
||||||
|
"packageTypeTube": "Tube",
|
||||||
|
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
|
"bottles": "Flaschen",
|
||||||
|
"tubes": "Tuben",
|
||||||
"blistersPerPack": "Blister pro Packung",
|
"blistersPerPack": "Blister pro Packung",
|
||||||
"pillsPerBlister": "Tabletten pro Blister",
|
"pillsPerBlister": "Tabletten pro Blister",
|
||||||
"totalCapacity": "Gesamtkapazität",
|
"totalCapacity": "Gesamtkapazität",
|
||||||
"currentPills": "Aktuelle Tabletten",
|
"currentPills": "Aktuelle Tabletten",
|
||||||
|
"totalAmount": "Gesamtmenge",
|
||||||
|
"currentAmount": "Aktuelle Menge",
|
||||||
|
"totalAmountLabel": "Gesamt (Menge)",
|
||||||
|
"packageAmount": "Packungsinhalt",
|
||||||
|
"packageAmountPerBottle": "Inhalt pro Flasche",
|
||||||
|
"packageAmountPerTube": "Inhalt pro Tube",
|
||||||
|
"packageAmountUnitMl": "ml",
|
||||||
|
"packageAmountUnitG": "g",
|
||||||
"loosePills": "Lose Tabletten",
|
"loosePills": "Lose Tabletten",
|
||||||
"pillWeight": "Dosis pro Tablette",
|
"pillWeight": "Dosis pro Tablette",
|
||||||
"total": "Gesamt (Tabletten)",
|
"total": "Gesamt (Tabletten)",
|
||||||
"medicationStartDate": "Startdatum der Medikation",
|
"medicationStartDate": "Startdatum der Medikation",
|
||||||
|
"medicationEndDate": "Enddatum der Medikation",
|
||||||
|
"autoMarkObsoleteAfterEndDate": "Nach Enddatum automatisch als obsolet markieren",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"medicationImage": "Medikamentenbild",
|
"medicationImage": "Medikamentenbild",
|
||||||
@@ -198,15 +225,38 @@
|
|||||||
"weight": "z.B. 240",
|
"weight": "z.B. 240",
|
||||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
||||||
},
|
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
||||||
|
},
|
||||||
"blisters": {
|
"blisters": {
|
||||||
"title": "Einnahmeplan",
|
"title": "Einnahmeplan",
|
||||||
"remind": "Erinnern",
|
"remind": "Erinnern",
|
||||||
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
|
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
|
||||||
"addIntake": "Einnahme",
|
"addIntake": "Einnahme",
|
||||||
"usage": "Dosis (Tabletten)",
|
"usage": "Dosis",
|
||||||
|
"usageTablets": "Dosis (Tabletten)",
|
||||||
|
"usageCapsules": "Dosis (Kapseln)",
|
||||||
|
"usageMl": "Dosis (ml)",
|
||||||
|
"usageTsp": "Dosis (tsp)",
|
||||||
|
"usageTbsp": "Dosis (tbsp)",
|
||||||
|
"usageApplication": "Dosis (Anwendungen)",
|
||||||
|
"intakeUnit": "Einnahmeeinheit",
|
||||||
|
"intakeUnitMl": "Milliliter (ml)",
|
||||||
|
"intakeUnitTsp": "Teeloeffel (5 ml)",
|
||||||
|
"intakeUnitTbsp": "Essloeffel (15 ml)",
|
||||||
|
"intakes": "Einnahmen",
|
||||||
|
"intakes_one": "Einnahme",
|
||||||
|
"intakes_other": "Einnahmen",
|
||||||
|
"teaspoons": "Teeloeffel",
|
||||||
|
"teaspoons_one": "Teeloeffel",
|
||||||
|
"teaspoons_other": "Teeloeffel",
|
||||||
|
"tablespoons": "Essloeffel",
|
||||||
|
"tablespoons_one": "Essloeffel",
|
||||||
|
"tablespoons_other": "Essloeffel",
|
||||||
|
"applications": "Anwendungen",
|
||||||
|
"applications_one": "Anwendung",
|
||||||
|
"applications_other": "Anwendungen",
|
||||||
"everyDays": "Alle (Tage)",
|
"everyDays": "Alle (Tage)",
|
||||||
"every": "alle",
|
"every": "alle",
|
||||||
"from": "ab",
|
"from": "ab",
|
||||||
@@ -299,7 +349,8 @@
|
|||||||
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
||||||
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
||||||
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
||||||
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
|
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen",
|
||||||
|
"packageTypesNote": "Hinweis: Tubenmedikamente sind von Bestands-Erinnerungen ausgeschlossen. Flüssigbehälter-Medikamente verwenden einen einzelnen Reminder-Basiswert (Niedrig und Kritisch werden automatisch von diesem Wert abgeleitet)."
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "Allgemeine UI",
|
"title": "Allgemeine UI",
|
||||||
@@ -316,7 +367,7 @@
|
|||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Bestands-Erinnerung",
|
"title": "Bestands-Erinnerung",
|
||||||
"description": "Bestands-Erinnerungen aktivieren",
|
"description": "Bestands-Erinnerungen aktivieren",
|
||||||
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Niedrig: Bestand wird knapp. Kritisch: Bestand ist kritisch niedrig — bald nachbestellen.",
|
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Hinweis: Tubenmedikamente sind ausgeschlossen; Flüssigbehälter verwenden einen einzelnen Basiswert (Niedrig und Kritisch werden abgeleitet).",
|
||||||
"repeatDaily": "Täglich wiederholen",
|
"repeatDaily": "Täglich wiederholen",
|
||||||
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
|
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
|
||||||
},
|
},
|
||||||
@@ -327,6 +378,8 @@
|
|||||||
"at": "um",
|
"at": "um",
|
||||||
"stockInfo": "Aktueller Bestand",
|
"stockInfo": "Aktueller Bestand",
|
||||||
"packageDetails": "Packungsdetails",
|
"packageDetails": "Packungsdetails",
|
||||||
|
"packageTypeTubeHint": "Tubenmedikamente enthalten feste Mengen (z. B. Cremes, Gele). Der Bestand wird nicht verfolgt und Erinnerungen werden nicht gesendet.",
|
||||||
|
"packageTypeLiquidHint": "Flüssigbehälter verwenden ein vereinfachtes Erinnerungsmodell. Niedrig- und Kritisch-Stufen werden automatisch von einem einzelnen Basiswert abgeleitet.",
|
||||||
"currentStock": "Tabletten",
|
"currentStock": "Tabletten",
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
"blistersPerPack": "Blister/Packung",
|
"blistersPerPack": "Blister/Packung",
|
||||||
@@ -577,9 +630,11 @@
|
|||||||
"loosePills": "Lose Tabletten",
|
"loosePills": "Lose Tabletten",
|
||||||
"pillsPerBlister": "(je {{count}} Tabletten)",
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
||||||
|
"packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}",
|
||||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
||||||
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
||||||
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
||||||
|
"maxExceededAmount": "Die maximale Packungsgroesse betraegt {{count}} {{unit}}. Werte wurden begrenzt.",
|
||||||
"decreaseValue": "Wert verringern",
|
"decreaseValue": "Wert verringern",
|
||||||
"increaseValue": "Wert erhöhen",
|
"increaseValue": "Wert erhöhen",
|
||||||
"currentTotal": "Aktueller Bestand",
|
"currentTotal": "Aktueller Bestand",
|
||||||
@@ -639,6 +694,7 @@
|
|||||||
"docPackageType": "Verpackungsart",
|
"docPackageType": "Verpackungsart",
|
||||||
"docBlister": "Blisterpackung",
|
"docBlister": "Blisterpackung",
|
||||||
"docBottle": "Pillendose",
|
"docBottle": "Pillendose",
|
||||||
|
"docTube": "Tube",
|
||||||
"docPacks": "Packungen",
|
"docPacks": "Packungen",
|
||||||
"docBlistersPerPack": "Blister pro Packung",
|
"docBlistersPerPack": "Blister pro Packung",
|
||||||
"docPillsPerBlister": "Tabletten pro Blister",
|
"docPillsPerBlister": "Tabletten pro Blister",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"fullBlisters": "Full blisters",
|
"fullBlisters": "Full blisters",
|
||||||
"openBlister": "Open blister",
|
"openBlister": "Open blister",
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
|
"dailyConsumption": "Daily consumption",
|
||||||
"stockDetails": "Details",
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Days left",
|
"daysLeft": "Days left",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -118,7 +119,8 @@
|
|||||||
"expiry": "Expiry",
|
"expiry": "Expiry",
|
||||||
"pillsCount": "{{count}} pills",
|
"pillsCount": "{{count}} pills",
|
||||||
"pillsCount_one": "{{count}} pill",
|
"pillsCount_one": "{{count}} pill",
|
||||||
"pillsCount_other": "{{count}} pills"
|
"pillsCount_other": "{{count}} pills",
|
||||||
|
"perDayWithUnit": "{{value}} {{unit}}"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -130,7 +132,8 @@
|
|||||||
"reactivate": "Reactivate",
|
"reactivate": "Reactivate",
|
||||||
"obsoleteTitle": "Obsolete ({{count}})",
|
"obsoleteTitle": "Obsolete ({{count}})",
|
||||||
"obsoleteSince": "Stopped",
|
"obsoleteSince": "Stopped",
|
||||||
"started": "Started"
|
"started": "Started",
|
||||||
|
"emptyState": "No medications yet. Add your first medication to get started."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
@@ -139,6 +142,7 @@
|
|||||||
"loose": "Loose",
|
"loose": "Loose",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
|
"capacityPerPackage": "Capacity per package",
|
||||||
"totalCapacity": "Capacity",
|
"totalCapacity": "Capacity",
|
||||||
"type": "Type"
|
"type": "Type"
|
||||||
},
|
},
|
||||||
@@ -167,18 +171,41 @@
|
|||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
"takenBy": "Taken by",
|
"takenBy": "Taken by",
|
||||||
|
"medicationForm": "Medication Form",
|
||||||
|
"medicationFormCapsule": "Capsule",
|
||||||
|
"medicationFormTablet": "Tablet",
|
||||||
|
"medicationFormLiquid": "Liquid",
|
||||||
|
"medicationFormTopical": "Topical",
|
||||||
|
"pillForm": "Pill Form",
|
||||||
|
"lifecycleCategory": "Lifecycle",
|
||||||
|
"lifecycleRefillWhenEmpty": "Refill when empty",
|
||||||
|
"lifecycleTreatmentPeriod": "Treatment period",
|
||||||
"packageType": "Package Type",
|
"packageType": "Package Type",
|
||||||
"packageTypeBlister": "Blister Pack",
|
"packageTypeBlister": "Blister Pack",
|
||||||
"packageTypeBottle": "Pill Bottle",
|
"packageTypeBottle": "Pill Bottle",
|
||||||
|
"packageTypeTube": "Tube",
|
||||||
|
"packageTypeLiquidContainer": "Liquid Container",
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
|
"bottles": "Bottles",
|
||||||
|
"tubes": "Tubes",
|
||||||
"blistersPerPack": "Blisters per pack",
|
"blistersPerPack": "Blisters per pack",
|
||||||
"pillsPerBlister": "Pills per blister",
|
"pillsPerBlister": "Pills per blister",
|
||||||
"totalCapacity": "Total Capacity",
|
"totalCapacity": "Total Capacity",
|
||||||
"currentPills": "Current Pills",
|
"currentPills": "Current Pills",
|
||||||
|
"totalAmount": "Total Amount",
|
||||||
|
"currentAmount": "Current Amount",
|
||||||
|
"totalAmountLabel": "Total (amount)",
|
||||||
|
"packageAmount": "Package amount",
|
||||||
|
"packageAmountPerBottle": "Amount per bottle",
|
||||||
|
"packageAmountPerTube": "Amount per tube",
|
||||||
|
"packageAmountUnitMl": "ml",
|
||||||
|
"packageAmountUnitG": "g",
|
||||||
"loosePills": "Loose pills",
|
"loosePills": "Loose pills",
|
||||||
"pillWeight": "Dose per pill",
|
"pillWeight": "Dose per pill",
|
||||||
"total": "Total (pills)",
|
"total": "Total (pills)",
|
||||||
"medicationStartDate": "Medication Start Date",
|
"medicationStartDate": "Medication Start Date",
|
||||||
|
"medicationEndDate": "Medication End Date",
|
||||||
|
"autoMarkObsoleteAfterEndDate": "Automatically mark obsolete after end date",
|
||||||
"expiryDate": "Expiry Date",
|
"expiryDate": "Expiry Date",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"medicationImage": "Medication Image",
|
"medicationImage": "Medication Image",
|
||||||
@@ -199,14 +226,37 @@
|
|||||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
||||||
|
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
||||||
},
|
},
|
||||||
"blisters": {
|
"blisters": {
|
||||||
"title": "Intake schedule",
|
"title": "Intake schedule",
|
||||||
"remind": "Remind",
|
"remind": "Remind",
|
||||||
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
|
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
|
||||||
"addIntake": "Intake",
|
"addIntake": "Intake",
|
||||||
"usage": "Usage (pills)",
|
"usage": "Usage",
|
||||||
|
"usageTablets": "Usage (tablets)",
|
||||||
|
"usageCapsules": "Usage (capsules)",
|
||||||
|
"usageMl": "Usage (ml)",
|
||||||
|
"usageTsp": "Usage (tsp)",
|
||||||
|
"usageTbsp": "Usage (tbsp)",
|
||||||
|
"usageApplication": "Usage (applications)",
|
||||||
|
"intakeUnit": "Intake unit",
|
||||||
|
"intakeUnitMl": "Milliliters (ml)",
|
||||||
|
"intakeUnitTsp": "Teaspoon (5 ml)",
|
||||||
|
"intakeUnitTbsp": "Tablespoon (15 ml)",
|
||||||
|
"intakes": "intakes",
|
||||||
|
"intakes_one": "intake",
|
||||||
|
"intakes_other": "intakes",
|
||||||
|
"teaspoons": "teaspoons",
|
||||||
|
"teaspoons_one": "teaspoon",
|
||||||
|
"teaspoons_other": "teaspoons",
|
||||||
|
"tablespoons": "tablespoons",
|
||||||
|
"tablespoons_one": "tablespoon",
|
||||||
|
"tablespoons_other": "tablespoons",
|
||||||
|
"applications": "applications",
|
||||||
|
"applications_one": "application",
|
||||||
|
"applications_other": "applications",
|
||||||
"everyDays": "Every (days)",
|
"everyDays": "Every (days)",
|
||||||
"every": "every",
|
"every": "every",
|
||||||
"from": "from",
|
"from": "from",
|
||||||
@@ -299,7 +349,8 @@
|
|||||||
"highStockTooltip": "Stock above this value means you are well supplied",
|
"highStockTooltip": "Stock above this value means you are well supplied",
|
||||||
"thresholdValidation": "Values must be: Critical < Low < High",
|
"thresholdValidation": "Values must be: Critical < Low < High",
|
||||||
"shareStockStatus": "Show Stock on Shared Links",
|
"shareStockStatus": "Show Stock on Shared Links",
|
||||||
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
|
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users",
|
||||||
|
"packageTypesNote": "Note: Tube medications are excluded from stock reminders. Liquid container medications use a single reminder baseline (Low and Critical are automatically derived from this value)."
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "General UI",
|
"title": "General UI",
|
||||||
@@ -316,7 +367,7 @@
|
|||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Stock Reminder",
|
"title": "Stock Reminder",
|
||||||
"description": "Enable stock reminders",
|
"description": "Enable stock reminders",
|
||||||
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Low: stock is running low. Critical: stock is critically low — reorder soon.",
|
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Note: Tube medications are excluded; Liquid containers use a single baseline threshold (Low and Critical are derived).",
|
||||||
"repeatDaily": "Repeat daily",
|
"repeatDaily": "Repeat daily",
|
||||||
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
|
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
|
||||||
},
|
},
|
||||||
@@ -327,6 +378,8 @@
|
|||||||
"at": "at",
|
"at": "at",
|
||||||
"stockInfo": "Current Stock",
|
"stockInfo": "Current Stock",
|
||||||
"packageDetails": "Package Details",
|
"packageDetails": "Package Details",
|
||||||
|
"packageTypeTubeHint": "Tube medications contain fixed amounts (e.g., creams, gels). Stock is not tracked and reminders are not sent.",
|
||||||
|
"packageTypeLiquidHint": "Liquid containers use a simplified reminder model. Low and Critical levels are automatically derived from a single baseline threshold for simplicity.",
|
||||||
"currentStock": "Pills",
|
"currentStock": "Pills",
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
"blistersPerPack": "Blisters/Pack",
|
"blistersPerPack": "Blisters/Pack",
|
||||||
@@ -577,9 +630,11 @@
|
|||||||
"loosePills": "Loose pills",
|
"loosePills": "Loose pills",
|
||||||
"pillsPerBlister": "({{count}} pills each)",
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
"packageSize": "Package size: {{count}} pills",
|
"packageSize": "Package size: {{count}} pills",
|
||||||
|
"packageSizeAmount": "Package size: {{count}} {{unit}}",
|
||||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
||||||
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
||||||
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
||||||
|
"maxExceededAmount": "Maximum package size is {{count}} {{unit}}. Values were capped.",
|
||||||
"decreaseValue": "Decrease value",
|
"decreaseValue": "Decrease value",
|
||||||
"increaseValue": "Increase value",
|
"increaseValue": "Increase value",
|
||||||
"currentTotal": "Current total",
|
"currentTotal": "Current total",
|
||||||
@@ -639,6 +694,7 @@
|
|||||||
"docPackageType": "Package Type",
|
"docPackageType": "Package Type",
|
||||||
"docBlister": "Blister Pack",
|
"docBlister": "Blister Pack",
|
||||||
"docBottle": "Pill Bottle",
|
"docBottle": "Pill Bottle",
|
||||||
|
"docTube": "Tube",
|
||||||
"docPacks": "Packs",
|
"docPacks": "Packs",
|
||||||
"docBlistersPerPack": "Blisters per pack",
|
"docBlistersPerPack": "Blisters per pack",
|
||||||
"docPillsPerBlister": "Pills per blister",
|
"docPillsPerBlister": "Pills per blister",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function DashboardPage() {
|
|||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
coverage.low,
|
coverage.low,
|
||||||
coverage.all,
|
coverage.all,
|
||||||
|
meds,
|
||||||
settings.lastAutoEmailSent,
|
settings.lastAutoEmailSent,
|
||||||
settings.lastNotificationType,
|
settings.lastNotificationType,
|
||||||
settings.lastNotificationChannel,
|
settings.lastNotificationChannel,
|
||||||
@@ -129,6 +130,158 @@ export function DashboardPage() {
|
|||||||
const showOnlyToday = settings.upcomingTodayOnly;
|
const showOnlyToday = settings.upcomingTodayOnly;
|
||||||
|
|
||||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||||
|
|
||||||
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
|
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||||
|
|
||||||
|
const formatStockLabel = (med: (typeof meds)[number] | undefined, medsLeft: number) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return `${formatNumber(medsLeft)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med, medsLeft)}`;
|
||||||
|
}
|
||||||
|
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
total: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLiquidUsageLabel(total, intakeUnit);
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
|
}
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
|
||||||
|
if (!med) return "-";
|
||||||
|
|
||||||
|
const intakes =
|
||||||
|
med.intakes && med.intakes.length > 0
|
||||||
|
? med.intakes
|
||||||
|
: med.blisters.map((blister) => ({
|
||||||
|
usage: blister.usage,
|
||||||
|
every: blister.every,
|
||||||
|
intakeUnit: null as "ml" | "tsp" | "tbsp" | null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (intakes.length === 0) return "-";
|
||||||
|
|
||||||
|
let dailyTotal = 0;
|
||||||
|
for (const intake of intakes) {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
const every = Math.max(1, Number(intake.every) || 1);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) continue;
|
||||||
|
|
||||||
|
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0;
|
||||||
|
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
||||||
|
const normalizedUsage = (usage * personMultiplier) / every;
|
||||||
|
|
||||||
|
if (med.packageType === "liquid_container") {
|
||||||
|
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
||||||
|
} else {
|
||||||
|
dailyTotal += normalizedUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dailyTotal <= 0) return "-";
|
||||||
|
|
||||||
|
if (med.packageType === "liquid_container") {
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: t("form.packageAmountUnitMl") });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
const tubeUnit =
|
||||||
|
med.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.blisters.applications", { count: Math.abs(dailyTotal) });
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: tubeUnit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillUnit = dailyTotal === 1 ? t("common.pill") : t("common.pills");
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: pillUnit });
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getVisibleStockStatus = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
|
||||||
|
|
||||||
|
const getMedByName = (name: string) => meds.find((m) => getMedDisplayName(m) === name);
|
||||||
|
|
||||||
const prescriptionStatus =
|
const prescriptionStatus =
|
||||||
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
|
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
|
||||||
? {
|
? {
|
||||||
@@ -253,7 +406,9 @@ export function DashboardPage() {
|
|||||||
{reminderData.lowStockMeds.map((med, idx) => {
|
{reminderData.lowStockMeds.map((med, idx) => {
|
||||||
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
|
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
|
||||||
const cov = coverage.all.find((c) => c.name === med.name);
|
const cov = coverage.all.find((c) => c.name === med.name);
|
||||||
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
|
const status = cov
|
||||||
|
? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType)
|
||||||
|
: null;
|
||||||
const textClass =
|
const textClass =
|
||||||
status?.className === "danger"
|
status?.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
@@ -411,7 +566,9 @@ export function DashboardPage() {
|
|||||||
const lowStockMap = new Map<string, Coverage>();
|
const lowStockMap = new Map<string, Coverage>();
|
||||||
for (const c of coverage.all) {
|
for (const c of coverage.all) {
|
||||||
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
|
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
|
||||||
if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
|
const med = getMedByName(c.name);
|
||||||
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
if (status.className === "danger" || status.className === "warning") {
|
||||||
const existing = lowStockMap.get(c.name);
|
const existing = lowStockMap.get(c.name);
|
||||||
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
|
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
|
||||||
lowStockMap.set(c.name, c);
|
lowStockMap.set(c.name, c);
|
||||||
@@ -432,7 +589,7 @@ export function DashboardPage() {
|
|||||||
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
||||||
{lowStockMeds.map((c, idx) => {
|
{lowStockMeds.map((c, idx) => {
|
||||||
const med = meds.find((m) => getMedDisplayName(m) === c.name);
|
const med = meds.find((m) => getMedDisplayName(m) === c.name);
|
||||||
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
status.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
@@ -476,10 +633,11 @@ export function DashboardPage() {
|
|||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("dashboard.overview.title")}</h2>
|
<h2>{t("dashboard.overview.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="table table-7">
|
<div className="table table-8">
|
||||||
<div className="table-head">
|
<div className="table-head">
|
||||||
<span>{t("table.name")}</span>
|
<span>{t("table.name")}</span>
|
||||||
<span>{t("table.stock")}</span>
|
<span>{t("table.stock")}</span>
|
||||||
|
<span>{t("table.dailyConsumption")}</span>
|
||||||
<span>{t("table.stockDetails")}</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>
|
||||||
@@ -487,13 +645,14 @@ export function DashboardPage() {
|
|||||||
<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, stockThresholds);
|
|
||||||
const med = meds.find((m) => getMedDisplayName(m) === row.name);
|
const med = meds.find((m) => getMedDisplayName(m) === row.name);
|
||||||
|
const rawStatus = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
rawStatus.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
: status.className === "warning"
|
: rawStatus.className === "warning"
|
||||||
? "warning-text"
|
? "warning-text"
|
||||||
: "success-text";
|
: "success-text";
|
||||||
const stock = getBlisterStock(
|
const stock = getBlisterStock(
|
||||||
@@ -587,15 +746,22 @@ export function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.stock")} className={textClass}>
|
<span data-label={t("table.stock")} className={textClass}>
|
||||||
{med?.packageType === "bottle"
|
{med?.packageType === "bottle" ||
|
||||||
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
|
med?.packageType === "tube" ||
|
||||||
|
med?.packageType === "liquid_container"
|
||||||
|
? formatStockLabel(med, row.medsLeft)
|
||||||
: formatFullBlisters(stock.fullBlisters, t)}
|
: formatFullBlisters(stock.fullBlisters, t)}
|
||||||
</span>
|
</span>
|
||||||
|
<span data-label={t("table.dailyConsumption")} className={textClass}>
|
||||||
|
{formatDailyConsumption(med)}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
data-label={t("table.stockDetails")}
|
data-label={t("table.stockDetails")}
|
||||||
className={`${textClass}${med?.packageType === "bottle" ? " hide-on-card" : ""}`}
|
className={`${textClass}${med?.packageType === "bottle" || med?.packageType === "tube" || med?.packageType === "liquid_container" ? " hide-on-card" : ""}`}
|
||||||
>
|
>
|
||||||
{med?.packageType === "bottle"
|
{med?.packageType === "bottle" ||
|
||||||
|
med?.packageType === "tube" ||
|
||||||
|
med?.packageType === "liquid_container"
|
||||||
? "—"
|
? "—"
|
||||||
: formatOpenBlisterAndLoose(
|
: formatOpenBlisterAndLoose(
|
||||||
stock.openBlisterPills,
|
stock.openBlisterPills,
|
||||||
@@ -617,8 +783,8 @@ export function DashboardPage() {
|
|||||||
})
|
})
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
|
<span data-label={t("table.status")} className={status ? `status-chip ${status.className}` : ""}>
|
||||||
{t(status.label)}
|
{status ? t(status.label) : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -735,9 +901,10 @@ export function DashboardPage() {
|
|||||||
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === 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 status = medCov
|
const rawStatus = medCov
|
||||||
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds)
|
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
|
||||||
: null;
|
: null;
|
||||||
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -772,7 +939,9 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
|
</span>
|
||||||
{status && (
|
{status && (
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -787,11 +956,13 @@ 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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.packageType !== "tube" &&
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
med?.packageType !== "liquid_container" &&
|
||||||
)}
|
med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -834,7 +1005,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -946,7 +1118,13 @@ 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, stockThresholds);
|
const med = getMedByName(item.medName);
|
||||||
|
const status = getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -996,8 +1174,14 @@ 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, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -1032,9 +1216,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1051,11 +1239,13 @@ 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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.packageType !== "tube" &&
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
med?.packageType !== "liquid_container" &&
|
||||||
)}
|
med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -1098,7 +1288,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -1178,7 +1369,13 @@ 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, stockThresholds);
|
const med = getMedByName(item.medName);
|
||||||
|
const status = getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -1227,8 +1424,14 @@ 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, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -1263,9 +1466,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1278,11 +1485,13 @@ 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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.packageType !== "tube" &&
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
med?.packageType !== "liquid_container" &&
|
||||||
)}
|
med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -1325,7 +1534,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, Medication } from "../types";
|
import type { DoseUnit, FormState, Medication } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||||
@@ -239,7 +239,7 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Calculate total tablets
|
// Calculate total tablets
|
||||||
const totalTablets = useMemo(() => {
|
const totalTablets = useMemo(() => {
|
||||||
if (form.packageType === "bottle") {
|
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
||||||
// For bottle type, looseTablets is the current stock
|
// For bottle type, looseTablets is the current stock
|
||||||
return Number(form.looseTablets) || 0;
|
return Number(form.looseTablets) || 0;
|
||||||
}
|
}
|
||||||
@@ -254,6 +254,14 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const dateConsistencyError = useMemo(() => {
|
const dateConsistencyError = useMemo(() => {
|
||||||
const medicationStartDate = form.medicationStartDate;
|
const medicationStartDate = form.medicationStartDate;
|
||||||
|
const medicationEndDate = form.medicationEndDate;
|
||||||
|
if (medicationStartDate && medicationEndDate && medicationEndDate < medicationStartDate) {
|
||||||
|
return t("form.validation.endDateBeforeStart", {
|
||||||
|
medicationStartDate,
|
||||||
|
medicationEndDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!medicationStartDate) return null;
|
if (!medicationStartDate) return null;
|
||||||
|
|
||||||
const conflictingIntake = form.intakes.find((intake) => intake.startDate && intake.startDate < medicationStartDate);
|
const conflictingIntake = form.intakes.find((intake) => intake.startDate && intake.startDate < medicationStartDate);
|
||||||
@@ -263,7 +271,65 @@ export function MedicationsPage() {
|
|||||||
medicationStartDate,
|
medicationStartDate,
|
||||||
intakeDate: conflictingIntake.startDate,
|
intakeDate: conflictingIntake.startDate,
|
||||||
});
|
});
|
||||||
}, [form.medicationStartDate, form.intakes, t]);
|
}, [form.medicationStartDate, form.medicationEndDate, form.intakes, t]);
|
||||||
|
|
||||||
|
const allowFractionalIntake = useMemo(() => {
|
||||||
|
if (form.packageType === "liquid_container") return true;
|
||||||
|
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
||||||
|
return form.pillForm === "tablet";
|
||||||
|
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||||
|
|
||||||
|
const getUsageLabel = useCallback(
|
||||||
|
(intakeUnit: "ml" | "tsp" | "tbsp") => {
|
||||||
|
if (form.packageType === "liquid_container") {
|
||||||
|
if (intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
||||||
|
if (intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
||||||
|
return t("form.blisters.usageMl");
|
||||||
|
}
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||||
|
}
|
||||||
|
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||||
|
return t("form.blisters.usageTablets");
|
||||||
|
},
|
||||||
|
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
||||||
|
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||||
|
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||||
|
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||||
|
|
||||||
|
const getMedicationPackageTypeLabel = useCallback(
|
||||||
|
(med: Medication) => {
|
||||||
|
if (med.packageType === "bottle") return t("form.packageTypeBottle");
|
||||||
|
if (med.packageType === "tube") return t("form.packageTypeTube");
|
||||||
|
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
||||||
|
return t("form.packageTypeBlister");
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMedicationStockSuffix = useCallback(
|
||||||
|
(med: Medication) => {
|
||||||
|
if (med.packageType === "tube") return "";
|
||||||
|
if (med.packageType === "liquid_container") return " ml";
|
||||||
|
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMedicationUsageUnitLabel = useCallback(
|
||||||
|
(med: Medication, usage: number) => {
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
|
||||||
|
}
|
||||||
|
if (med.packageType === "liquid_container") return "ml";
|
||||||
|
if (usage === 1) return t("common.pill");
|
||||||
|
return t("common.pills");
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const clearEditMedIdParam = useCallback(() => {
|
const clearEditMedIdParam = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
@@ -450,6 +516,10 @@ export function MedicationsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (saving) return;
|
if (saving) return;
|
||||||
|
if (form.pillForm === "capsule" && form.intakes.some((i) => !Number.isInteger(Number(i.usage)))) {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
// Prepare intakes data with per-intake takenBy
|
// Prepare intakes data with per-intake takenBy
|
||||||
@@ -457,6 +527,7 @@ export function MedicationsPage() {
|
|||||||
usage: Number(intake.usage) || 1,
|
usage: Number(intake.usage) || 1,
|
||||||
every: Number(intake.every) || 1,
|
every: Number(intake.every) || 1,
|
||||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||||
|
intakeUnit: form.packageType === "liquid_container" ? intake.intakeUnit : null,
|
||||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||||
}));
|
}));
|
||||||
@@ -472,19 +543,46 @@ export function MedicationsPage() {
|
|||||||
const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills);
|
const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills);
|
||||||
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
||||||
|
|
||||||
|
let derivedMedicationForm: string;
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
derivedMedicationForm =
|
||||||
|
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
|
||||||
|
} else if (form.packageType === "liquid_container") {
|
||||||
|
derivedMedicationForm = "liquid";
|
||||||
|
} else {
|
||||||
|
derivedMedicationForm = form.pillForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tubeTotalAmount =
|
||||||
|
form.packageType === "tube" ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null;
|
||||||
|
|
||||||
|
let packageAmountUnit = form.packageAmountUnit ?? "ml";
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
packageAmountUnit = "g";
|
||||||
|
} else if (form.packageType === "liquid_container") {
|
||||||
|
packageAmountUnit = "ml";
|
||||||
|
}
|
||||||
|
|
||||||
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 : [],
|
||||||
|
medicationForm: derivedMedicationForm,
|
||||||
|
pillForm: form.packageType === "tube" || form.packageType === "liquid_container" ? null : form.pillForm,
|
||||||
|
lifecycleCategory: form.lifecycleCategory,
|
||||||
packageType: form.packageType,
|
packageType: form.packageType,
|
||||||
packCount: Number(form.packCount) || 0,
|
packCount: form.packageType === "tube" ? Math.max(1, Number(form.packCount) || 1) : Number(form.packCount) || 0,
|
||||||
blistersPerPack: Number(form.blistersPerPack) || 1,
|
blistersPerPack: form.packageType === "tube" ? 1 : Number(form.blistersPerPack) || 1,
|
||||||
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
pillsPerBlister: form.packageType === "tube" ? 1 : Number(form.pillsPerBlister) || 1,
|
||||||
totalPills: Number(form.totalPills) || null,
|
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
||||||
looseTablets: Number(form.looseTablets) || 0,
|
packageAmountUnit,
|
||||||
|
totalPills: form.packageType === "tube" ? tubeTotalAmount : Number(form.totalPills) || null,
|
||||||
|
looseTablets: form.packageType === "tube" ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
||||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||||
doseUnit: form.doseUnit,
|
doseUnit: form.doseUnit,
|
||||||
medicationStartDate: form.medicationStartDate || null,
|
medicationStartDate: form.medicationStartDate || null,
|
||||||
|
medicationEndDate: form.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: form.autoMarkObsoleteAfterEndDate,
|
||||||
expiryDate: form.expiryDate || null,
|
expiryDate: form.expiryDate || null,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||||
@@ -881,10 +979,7 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="med-details">
|
<div className="med-details">
|
||||||
<span>
|
<span>
|
||||||
{t("medications.details.type")}:{" "}
|
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
|
||||||
<strong>
|
|
||||||
{med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
{med.packageType === "blister" ? (
|
{med.packageType === "blister" ? (
|
||||||
<>
|
<>
|
||||||
@@ -918,7 +1013,8 @@ export function MedicationsPage() {
|
|||||||
{coverageByMed[getMedDisplayName(med)]
|
{coverageByMed[getMedDisplayName(med)]
|
||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
: getPackageSize(med)}{" "}
|
: getPackageSize(med)}{" "}
|
||||||
/ {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
/ {getPackageSize(med)}
|
||||||
|
{med.packageType === "tube" ? "" : getMedicationStockSuffix(med)}
|
||||||
{(coverageByMed[getMedDisplayName(med)]
|
{(coverageByMed[getMedDisplayName(med)]
|
||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
: getPackageSize(med)) > getPackageSize(med) && (
|
: getPackageSize(med)) > getPackageSize(med) && (
|
||||||
@@ -936,7 +1032,7 @@ export function MedicationsPage() {
|
|||||||
<div className="blister-list">
|
<div className="blister-list">
|
||||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "}
|
||||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||||
@@ -1143,6 +1239,7 @@ export function MedicationsPage() {
|
|||||||
<DateInput
|
<DateInput
|
||||||
value={form.medicationStartDate}
|
value={form.medicationStartDate}
|
||||||
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
/>
|
/>
|
||||||
{!readOnlyView && dateConsistencyError && (
|
{!readOnlyView && dateConsistencyError && (
|
||||||
<span className="field-error">{dateConsistencyError}</span>
|
<span className="field-error">{dateConsistencyError}</span>
|
||||||
@@ -1159,8 +1256,59 @@ export function MedicationsPage() {
|
|||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
<option value="tube">{t("form.packageTypeTube")}</option>
|
||||||
|
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.medicationEndDate")}
|
||||||
|
<DateInput
|
||||||
|
value={form.medicationEndDate}
|
||||||
|
onChange={(e) => handleValueChange("medicationEndDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
|
<label>
|
||||||
|
{t("form.pillForm")}
|
||||||
|
<select
|
||||||
|
value={form.pillForm}
|
||||||
|
onChange={(e) => handleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||||
|
>
|
||||||
|
<option value="tablet">{t("form.medicationFormTablet")}</option>
|
||||||
|
<option value="capsule">{t("form.medicationFormCapsule")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.packageType === "tube" && (
|
||||||
|
<label>
|
||||||
|
{t("form.medicationForm")}
|
||||||
|
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
||||||
|
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.packageType === "liquid_container" && (
|
||||||
|
<label>
|
||||||
|
{t("form.medicationForm")}
|
||||||
|
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
||||||
|
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.medicationEndDate && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.autoMarkObsoleteAfterEndDate")}
|
||||||
|
<label className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoMarkObsoleteAfterEndDate}
|
||||||
|
onChange={(e) => handleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||||
{t("form.takenBy")}
|
{t("form.takenBy")}
|
||||||
<div className="tag-input-container">
|
<div className="tag-input-container">
|
||||||
@@ -1274,99 +1422,177 @@ export function MedicationsPage() {
|
|||||||
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{(() => {
|
||||||
<>
|
if (form.packageType === "blister") {
|
||||||
<label>
|
return (
|
||||||
{t("form.packs")}
|
<>
|
||||||
<FormNumberStepper
|
<label>
|
||||||
value={form.packCount}
|
{t("form.packs")}
|
||||||
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
<FormNumberStepper
|
||||||
min={0}
|
value={form.packCount}
|
||||||
decrementLabel={decrementValueLabel}
|
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||||
incrementLabel={incrementValueLabel}
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.blistersPerPack")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.blistersPerPack}
|
||||||
|
onChange={(nextValue) => handleValueChange("blistersPerPack", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.pillsPerBlister")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.pillsPerBlister}
|
||||||
|
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.total")}
|
||||||
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.tubes")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.packCount}
|
||||||
|
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageAmountPerTube")}
|
||||||
|
<div className="dose-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={form.packageAmountValue ?? "0"}
|
||||||
|
onChange={(e) => handleValueChange("packageAmountValue", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value="g"
|
||||||
|
disabled
|
||||||
|
className="dose-unit-select"
|
||||||
|
aria-label={t("form.packageAmountUnitG")}
|
||||||
|
>
|
||||||
|
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.totalAmount")}
|
||||||
|
<div className="static-value">
|
||||||
|
{formatNumber(
|
||||||
|
(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)
|
||||||
|
)}
|
||||||
|
{t("form.packageAmountUnitG")}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{totalCapacityLabel}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.totalPills}
|
||||||
|
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{currentStockLabel}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.looseTablets}
|
||||||
|
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
|
<div className="dose-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={form.pillWeightMg}
|
||||||
|
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
||||||
|
placeholder={t("form.placeholders.weight")}
|
||||||
/>
|
/>
|
||||||
</label>
|
<select
|
||||||
<label>
|
value={form.doseUnit}
|
||||||
{t("form.blistersPerPack")}
|
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||||
<FormNumberStepper
|
className="dose-unit-select"
|
||||||
value={form.blistersPerPack}
|
>
|
||||||
onChange={(nextValue) => handleValueChange("blistersPerPack", nextValue)}
|
{DOSE_UNITS.map((unit) => (
|
||||||
min={1}
|
<option key={unit.value} value={unit.value}>
|
||||||
decrementLabel={decrementValueLabel}
|
{unit.label}
|
||||||
incrementLabel={incrementValueLabel}
|
</option>
|
||||||
/>
|
))}
|
||||||
</label>
|
</select>
|
||||||
<label>
|
</div>
|
||||||
{t("form.pillsPerBlister")}
|
</label>
|
||||||
<FormNumberStepper
|
|
||||||
value={form.pillsPerBlister}
|
|
||||||
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
|
|
||||||
min={1}
|
|
||||||
decrementLabel={decrementValueLabel}
|
|
||||||
incrementLabel={incrementValueLabel}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("form.total")}
|
|
||||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
{t("form.totalCapacity")}
|
|
||||||
<FormNumberStepper
|
|
||||||
value={form.totalPills}
|
|
||||||
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
|
||||||
min={0}
|
|
||||||
decrementLabel={decrementValueLabel}
|
|
||||||
incrementLabel={incrementValueLabel}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("form.currentPills")}
|
|
||||||
<FormNumberStepper
|
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
|
||||||
min={0}
|
|
||||||
decrementLabel={decrementValueLabel}
|
|
||||||
incrementLabel={incrementValueLabel}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<label className="full">
|
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
|
||||||
<div className="dose-input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={form.pillWeightMg}
|
|
||||||
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
|
||||||
placeholder={t("form.placeholders.weight")}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={form.doseUnit}
|
|
||||||
onChange={(e) => handleValueChange("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>
|
|
||||||
{form.packageType === "bottle" && (
|
|
||||||
<div className="full stock-total-row">
|
<div className="full stock-total-row">
|
||||||
<label className="stock-total-field">
|
<label className="stock-total-field">
|
||||||
{t("form.total")}
|
{totalLabel}
|
||||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{form.packageType === "liquid_container" && (
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageAmount")}
|
||||||
|
<div className="dose-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={form.packageAmountValue ?? "0"}
|
||||||
|
onChange={(e) => handleValueChange("packageAmountValue", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value="ml"
|
||||||
|
disabled
|
||||||
|
className="dose-unit-select"
|
||||||
|
aria-label={t("form.packageAmountUnitMl")}
|
||||||
|
>
|
||||||
|
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label>
|
<label>
|
||||||
{t("form.expiryDate")}
|
{t("form.expiryDate")}
|
||||||
<DateInput
|
<DateInput
|
||||||
@@ -1482,13 +1708,13 @@ export function MedicationsPage() {
|
|||||||
<div key={idx} className="blister-row">
|
<div key={idx} className="blister-row">
|
||||||
<div className="blister-inputs">
|
<div className="blister-inputs">
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.usage")}
|
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||||
<FormNumberStepper
|
<FormNumberStepper
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||||
min={0.5}
|
min={allowFractionalIntake ? 0.5 : 1}
|
||||||
step={0.5}
|
step={allowFractionalIntake ? 0.5 : 1}
|
||||||
allowDecimal={true}
|
allowDecimal={allowFractionalIntake}
|
||||||
decrementLabel={decrementValueLabel}
|
decrementLabel={decrementValueLabel}
|
||||||
incrementLabel={incrementValueLabel}
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
@@ -1518,6 +1744,21 @@ export function MedicationsPage() {
|
|||||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{form.packageType === "liquid_container" && (
|
||||||
|
<label>
|
||||||
|
{t("form.blisters.intakeUnit")}
|
||||||
|
<select
|
||||||
|
value={intake.intakeUnit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||||
|
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||||
|
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{form.takenBy.length === 0 ? null : (
|
{form.takenBy.length === 0 ? null : (
|
||||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||||
{t("form.blisters.takenByIntake")}
|
{t("form.blisters.takenByIntake")}
|
||||||
|
|||||||
@@ -122,6 +122,30 @@ export function PlannerPage() {
|
|||||||
const canSendNotification =
|
const canSendNotification =
|
||||||
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
|
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
|
||||||
|
|
||||||
|
const getUsageUnitLabel = (medicationId: number, count: number): string => {
|
||||||
|
const med = meds.find((m) => m.id === medicationId);
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return t("form.ml");
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||||
|
}
|
||||||
|
return count === 1 ? t("common.pill") : t("common.pills");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
|
||||||
|
const med = meds.find((m) => m.id === medicationId);
|
||||||
|
const roundedLoose = Math.round(loosePills * 10) / 10;
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return `${roundedLoose} ${t("form.ml")}`;
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||||
|
return `${roundedLoose} ${unit}`;
|
||||||
|
}
|
||||||
|
return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
};
|
||||||
|
|
||||||
async function sendPlannerNotification() {
|
async function sendPlannerNotification() {
|
||||||
if (!canSendNotification || plannerRows.length === 0) return;
|
if (!canSendNotification || plannerRows.length === 0) return;
|
||||||
setSendingPlannerEmail(true);
|
setSendingPlannerEmail(true);
|
||||||
@@ -226,16 +250,22 @@ export function PlannerPage() {
|
|||||||
<span data-label={t("planner.table.usage")}>
|
<span data-label={t("planner.table.usage")}>
|
||||||
<span>
|
<span>
|
||||||
<strong>{row.plannerUsage}</strong>
|
<strong>{row.plannerUsage}</strong>
|
||||||
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
{getUsageUnitLabel(row.medicationId, row.plannerUsage)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("planner.table.blisters")}>
|
<span data-label={t("planner.table.blisters")}>
|
||||||
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
{row.packageType === "bottle" ||
|
||||||
|
row.packageType === "tube" ||
|
||||||
|
row.packageType === "liquid_container"
|
||||||
|
? "–"
|
||||||
|
: `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
||||||
<span data-label={t("planner.table.available")}>
|
<span data-label={t("planner.table.available")}>
|
||||||
{row.packageType === "bottle" ? (
|
{row.packageType === "bottle" ||
|
||||||
`${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`
|
row.packageType === "tube" ||
|
||||||
|
row.packageType === "liquid_container" ? (
|
||||||
|
getAvailableLabel(row.medicationId, row.loosePills)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{row.fullBlisters} {t("common.blisters")}
|
{row.fullBlisters} {t("common.blisters")}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "../components/Auth";
|
|||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import type { Coverage } from "../types";
|
||||||
import { getMedDisplayName } from "../types";
|
import { getMedDisplayName } from "../types";
|
||||||
|
import { formatNumber } from "../utils/formatters";
|
||||||
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
@@ -17,12 +18,21 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
||||||
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
|
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
||||||
// Out of stock or completely depleted = danger (red)
|
// Out of stock or completely depleted = danger (red)
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
// No schedule, but has stock = normal
|
// No schedule, but has stock = normal
|
||||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const lowDays = Math.max(1, Math.floor(settings.reminderDaysBefore));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
return { className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
// Critical: at or below reminder threshold = danger (red)
|
// Critical: at or below reminder threshold = danger (red)
|
||||||
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
||||||
// Low: below low stock threshold = warning (yellow)
|
// Low: below low stock threshold = warning (yellow)
|
||||||
@@ -37,13 +47,15 @@ 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; reminderDaysBefore: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
||||||
|
meds: Array<{ name: string; genericName?: string | null; packageType?: string }>
|
||||||
): 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) {
|
||||||
const cov = coverageByMed[item.medName];
|
const cov = coverageByMed[item.medName];
|
||||||
if (!cov) continue;
|
if (!cov) continue;
|
||||||
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
|
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings, med?.packageType);
|
||||||
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
|
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
|
||||||
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
|
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
|
||||||
}
|
}
|
||||||
@@ -80,6 +92,87 @@ export function SchedulePage() {
|
|||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
|
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
total: number,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatNumber(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
|
}
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
<article className="card schedule-full">
|
<article className="card schedule-full">
|
||||||
@@ -133,7 +226,7 @@ export function SchedulePage() {
|
|||||||
|
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings, meds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -185,7 +278,7 @@ export function SchedulePage() {
|
|||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
@@ -197,7 +290,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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
@@ -341,8 +434,9 @@ export function SchedulePage() {
|
|||||||
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, settings, med?.packageType)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -353,8 +447,10 @@ export function SchedulePage() {
|
|||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
|
||||||
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
|
{visibleStatus && (
|
||||||
|
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
@@ -368,7 +464,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">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
|||||||
@@ -663,6 +663,9 @@ export function SettingsPage() {
|
|||||||
settings.lowStockDays >= settings.highStockDays) && (
|
settings.lowStockDays >= settings.highStockDays) && (
|
||||||
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="hint-text" style={{ marginTop: "12px" }}>
|
||||||
|
ℹ️ {t("settings.stock.packageTypesNote")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Coverage, PackageType } from "../types";
|
import type { Coverage, Medication, PackageType } from "../types";
|
||||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ export function getReminderStatusData(
|
|||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
_allLowCoverage: Coverage[],
|
_allLowCoverage: Coverage[],
|
||||||
allCoverage: Coverage[],
|
allCoverage: Coverage[],
|
||||||
|
meds: Medication[],
|
||||||
lastAutoEmailSent: string | null,
|
lastAutoEmailSent: string | null,
|
||||||
_lastNotificationType: string | null,
|
_lastNotificationType: string | null,
|
||||||
_lastNotificationChannel: string | null,
|
_lastNotificationChannel: string | null,
|
||||||
@@ -73,8 +74,12 @@ export function getReminderStatusData(
|
|||||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||||
} {
|
} {
|
||||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||||
|
const medByName = new Map(meds.map((med) => [med.name || med.genericName || "", med] as const));
|
||||||
|
|
||||||
for (const c of allCoverage) {
|
for (const c of allCoverage) {
|
||||||
|
const med = medByName.get(c.name);
|
||||||
|
if (med?.packageType === "tube") continue;
|
||||||
|
|
||||||
if (c.medsLeft <= 0) {
|
if (c.medsLeft <= 0) {
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||||
continue;
|
continue;
|
||||||
@@ -83,8 +88,11 @@ export function getReminderStatusData(
|
|||||||
if (c.daysLeft === null) continue;
|
if (c.daysLeft === null) continue;
|
||||||
|
|
||||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
const isLiquid = med?.packageType === "liquid_container";
|
||||||
const isLow = c.daysLeft < lowStockDays;
|
const liquidLowDays = Math.max(1, Math.floor(reminderDaysBefore));
|
||||||
|
const liquidCriticalDays = Math.max(1, Math.ceil(liquidLowDays / 2));
|
||||||
|
const isCritical = isLiquid ? c.daysLeft <= liquidCriticalDays : c.daysLeft <= reminderDaysBefore;
|
||||||
|
const isLow = isLiquid ? c.daysLeft <= liquidLowDays : c.daysLeft < lowStockDays;
|
||||||
if (!isCritical && !isLow) continue;
|
if (!isCritical && !isLow) continue;
|
||||||
|
|
||||||
const existing = lowStockMap.get(c.name);
|
const existing = lowStockMap.get(c.name);
|
||||||
|
|||||||
+60
-22
@@ -104,7 +104,7 @@ body.modal-open {
|
|||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.5rem 3rem;
|
padding: 2.5rem 1.5rem 1.5rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ body.modal-open {
|
|||||||
.route-transition-mask.active {
|
.route-transition-mask.active {
|
||||||
transition: none;
|
transition: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@@ -669,6 +669,16 @@ body.modal-open {
|
|||||||
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.med-grid-wrapper.is-empty .med-group-active {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-empty-state {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 0.35rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.med-group-head {
|
.med-group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2660,6 +2670,11 @@ button.has-validation-error {
|
|||||||
grid-template-columns: minmax(140px, 1.5fr) 90px 70px 100px 100px 90px 90px;
|
grid-template-columns: minmax(140px, 1.5fr) 90px 70px 100px 100px 90px 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-8 .table-head,
|
||||||
|
.table-8 .table-row {
|
||||||
|
grid-template-columns: minmax(130px, 1.4fr) 90px 130px 70px 95px 95px 90px 95px;
|
||||||
|
}
|
||||||
|
|
||||||
.email-sent-status {
|
.email-sent-status {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
@@ -2842,7 +2857,7 @@ button.has-validation-error {
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.page {
|
.page {
|
||||||
padding: 0.75rem 0.4rem 2rem;
|
padding: 0.75rem 0.4rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -4674,55 +4689,78 @@ button.has-validation-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-schedules {
|
.med-detail-schedules {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: auto auto 1fr auto auto auto;
|
||||||
gap: 0.5rem;
|
gap: 0.45rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-item {
|
.med-schedule-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
column-gap: 0.75rem;
|
||||||
gap: 0.35rem 0.75rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-usage {
|
.med-schedule-usage {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-freq {
|
.med-schedule-freq {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
grid-column: 2;
|
||||||
|
|
||||||
.med-schedule-time {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-person {
|
.med-schedule-person {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
grid-column: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-time {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
grid-column: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-bell {
|
.med-schedule-bell {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 0.35rem;
|
grid-column: 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .med-schedule-bell {
|
[data-theme="light"] .med-schedule-bell {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.med-detail-schedules {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-usage,
|
||||||
|
.med-schedule-freq,
|
||||||
|
.med-schedule-person,
|
||||||
|
.med-schedule-time,
|
||||||
|
.med-schedule-bell {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
padding: 1rem 2rem 1.5rem;
|
padding: 1rem 2rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -500,6 +500,8 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -644,9 +644,9 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
// Each intake should show "1 pill" (not "2 pills")
|
// Each intake should show "1" in usage (not "2")
|
||||||
usageElements.forEach((el) => {
|
rows.forEach((el) => {
|
||||||
expect(el.textContent).toContain("1");
|
expect(el.textContent).toContain("1");
|
||||||
expect(el.textContent).not.toMatch(/^2\b/);
|
expect(el.textContent).not.toMatch(/^2\b/);
|
||||||
});
|
});
|
||||||
@@ -662,10 +662,10 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
// Legacy: 1 pill * 2 people = "2 pills"
|
// Legacy: 1 pill * 2 people = "2 pills"
|
||||||
expect(usageElements.length).toBe(1);
|
expect(rows.length).toBe(1);
|
||||||
expect(usageElements[0].textContent).toContain("2");
|
expect(rows[0].textContent).toContain("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows correct usage for single person with per-intake takenBy", () => {
|
it("shows correct usage for single person with per-intake takenBy", () => {
|
||||||
@@ -678,11 +678,11 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
expect(usageElements.length).toBe(1);
|
expect(rows.length).toBe(1);
|
||||||
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
|
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
|
||||||
expect(usageElements[0].textContent).toContain("2");
|
expect(rows[0].textContent).toContain("2");
|
||||||
expect(usageElements[0].textContent).toContain("1000");
|
expect(rows[0].textContent).toContain("1000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ const defaultForm: FormState = {
|
|||||||
name: "",
|
name: "",
|
||||||
genericName: "",
|
genericName: "",
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
|
medicationForm: "tablet",
|
||||||
|
pillForm: "tablet",
|
||||||
|
lifecycleCategory: "refill_when_empty",
|
||||||
packageType: "blister",
|
packageType: "blister",
|
||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "1",
|
blistersPerPack: "1",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
|
packageAmountValue: "0",
|
||||||
|
packageAmountUnit: "ml",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
totalPills: "",
|
totalPills: "",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
doseUnit: "mg",
|
doseUnit: "mg",
|
||||||
medicationStartDate: "",
|
medicationStartDate: "",
|
||||||
|
medicationEndDate: "",
|
||||||
|
autoMarkObsoleteAfterEndDate: true,
|
||||||
expiryDate: "",
|
expiryDate: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
intakeRemindersEnabled: false,
|
intakeRemindersEnabled: false,
|
||||||
@@ -235,6 +242,54 @@ describe("MobileEditModal", () => {
|
|||||||
const header = document.querySelector(".edit-modal-header");
|
const header = document.querySelector(".edit-modal-header");
|
||||||
expect(header).toBeInTheDocument();
|
expect(header).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses plain numeric input for tube amount without stepper controls", () => {
|
||||||
|
render(
|
||||||
|
<MobileEditModal
|
||||||
|
{...defaultProps}
|
||||||
|
form={{
|
||||||
|
...defaultForm,
|
||||||
|
packageType: "tube",
|
||||||
|
medicationForm: "topical",
|
||||||
|
packageAmountValue: "150",
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountInput = screen.getByLabelText("form.packageAmountPerTube") as HTMLInputElement;
|
||||||
|
expect(amountInput).toBeInTheDocument();
|
||||||
|
expect(amountInput.tagName).toBe("INPUT");
|
||||||
|
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
||||||
|
|
||||||
|
const unitSelect = screen.getByLabelText("form.packageAmountUnitG") as HTMLSelectElement;
|
||||||
|
expect(unitSelect).toBeDisabled();
|
||||||
|
expect(unitSelect.value).toBe("g");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses plain numeric input for liquid container package amount", () => {
|
||||||
|
render(
|
||||||
|
<MobileEditModal
|
||||||
|
{...defaultProps}
|
||||||
|
form={{
|
||||||
|
...defaultForm,
|
||||||
|
packageType: "liquid_container",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageAmountValue: "250",
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountInput = screen.getByLabelText("form.packageAmountPerBottle") as HTMLInputElement;
|
||||||
|
expect(amountInput).toBeInTheDocument();
|
||||||
|
expect(amountInput.tagName).toBe("INPUT");
|
||||||
|
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
||||||
|
|
||||||
|
const unitSelect = screen.getByLabelText("form.packageAmountUnitMl") as HTMLSelectElement;
|
||||||
|
expect(unitSelect).toBeDisabled();
|
||||||
|
expect(unitSelect.value).toBe("ml");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("MobileEditModal with existing people", () => {
|
describe("MobileEditModal with existing people", () => {
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ vi.mock("../../utils/formatters", () => ({
|
|||||||
getSystemLocale: () => "en-US",
|
getSystemLocale: () => "en-US",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../utils/schedule", () => ({
|
vi.mock("../../utils/schedule", async () => {
|
||||||
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
|
const actual = await vi.importActual<typeof import("../../utils/schedule")>("../../utils/schedule");
|
||||||
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
|
return {
|
||||||
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
|
...actual,
|
||||||
isDoseDismissed: vi.fn(() => false),
|
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
|
||||||
}));
|
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
|
||||||
|
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
|
||||||
|
isDoseDismissed: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const meds: Medication[] = [
|
const meds: Medication[] = [
|
||||||
{
|
{
|
||||||
@@ -464,7 +468,7 @@ describe("useAppContext", () => {
|
|||||||
all: [
|
all: [
|
||||||
{
|
{
|
||||||
name: "Aspirin",
|
name: "Aspirin",
|
||||||
daysLeft: 2,
|
daysLeft: 8,
|
||||||
medsLeft: 5,
|
medsLeft: 5,
|
||||||
depletionTime: Date.now() + 100000,
|
depletionTime: Date.now() + 100000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -155,6 +155,78 @@ describe("useMedicationForm", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces liquid defaults when packageType is liquid_container", () => {
|
||||||
|
const { result } = renderHook(() => useMedicationForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleValueChange("packageType", "liquid_container");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.packageType).toBe("liquid_container");
|
||||||
|
expect(result.current.form.medicationForm).toBe("liquid");
|
||||||
|
expect(result.current.form.lifecycleCategory).toBe("refill_when_empty");
|
||||||
|
expect(result.current.form.doseUnit).toBe("ml");
|
||||||
|
expect(result.current.form.packageAmountUnit).toBe("ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps liquid settings locked when editing medicationForm under liquid_container", () => {
|
||||||
|
const { result } = renderHook(() => useMedicationForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleValueChange("packageType", "liquid_container");
|
||||||
|
result.current.handleValueChange("medicationForm", "tablet");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.packageType).toBe("liquid_container");
|
||||||
|
expect(result.current.form.medicationForm).toBe("liquid");
|
||||||
|
expect(result.current.form.doseUnit).toBe("ml");
|
||||||
|
expect(result.current.form.packageAmountUnit).toBe("ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces tube defaults and locks amount unit to grams", () => {
|
||||||
|
const { result } = renderHook(() => useMedicationForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleValueChange("packageType", "tube");
|
||||||
|
result.current.handleValueChange("medicationForm", "liquid");
|
||||||
|
result.current.handleValueChange("packageAmountUnit", "ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.packageType).toBe("tube");
|
||||||
|
expect(result.current.form.medicationForm).toBe("topical");
|
||||||
|
expect(result.current.form.lifecycleCategory).toBe("treatment_period");
|
||||||
|
expect(result.current.form.doseUnit).toBe("units");
|
||||||
|
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy tube records to grams in startEdit", () => {
|
||||||
|
const { result } = renderHook(() => useMedicationForm());
|
||||||
|
const openEditModal = vi.fn();
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
|
||||||
|
|
||||||
|
const med: Medication = {
|
||||||
|
id: 12,
|
||||||
|
name: "Topical Gel",
|
||||||
|
takenBy: [],
|
||||||
|
packageType: "tube",
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
looseTablets: 0,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startEdit(med, openEditModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.packageType).toBe("tube");
|
||||||
|
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||||
|
});
|
||||||
|
|
||||||
it("adds, edits and removes blister rows", () => {
|
it("adds, edits and removes blister rows", () => {
|
||||||
const { result } = renderHook(() => useMedicationForm());
|
const { result } = renderHook(() => useMedicationForm());
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null },
|
{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null },
|
||||||
{ name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null },
|
{ name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null },
|
||||||
],
|
],
|
||||||
|
[],
|
||||||
"2026-01-01T10:00:00.000Z",
|
"2026-01-01T10:00:00.000Z",
|
||||||
"intake",
|
"intake",
|
||||||
"email",
|
"email",
|
||||||
@@ -270,6 +271,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
30,
|
30,
|
||||||
[],
|
[],
|
||||||
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
||||||
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -288,6 +290,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
30,
|
30,
|
||||||
[],
|
[],
|
||||||
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
||||||
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ describe("getMedTotal", () => {
|
|||||||
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
|
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
|
||||||
expect(getMedTotal(med)).toBe(80);
|
expect(getMedTotal(med)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates tube/liquid totals from amount fields, not blister math", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 600,
|
||||||
|
looseTablets: 600,
|
||||||
|
stockAdjustment: 4,
|
||||||
|
};
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 3,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getMedTotal(tube)).toBe(604);
|
||||||
|
expect(getMedTotal(liquid)).toBe(450);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPackageSize", () => {
|
describe("getPackageSize", () => {
|
||||||
@@ -148,6 +171,28 @@ describe("getPackageSize", () => {
|
|||||||
// Should use looseTablets only, ignore stockAdjustment and blister math
|
// Should use looseTablets only, ignore stockAdjustment and blister math
|
||||||
expect(getPackageSize(med)).toBe(80);
|
expect(getPackageSize(med)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns totalPills for tube/liquid container package size", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 600,
|
||||||
|
looseTablets: 600,
|
||||||
|
};
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 3,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPackageSize(tube)).toBe(600);
|
||||||
|
expect(getPackageSize(liquid)).toBe(450);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FIELD_LIMITS", () => {
|
describe("FIELD_LIMITS", () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
calculateCoverage,
|
calculateCoverage,
|
||||||
computeMissedPastDoseIds,
|
computeMissedPastDoseIds,
|
||||||
expandDoseIds,
|
expandDoseIds,
|
||||||
getNextReminderForMed,
|
|
||||||
getReminderStatusText,
|
getReminderStatusText,
|
||||||
getStockStatus,
|
getStockStatus,
|
||||||
isDoseDismissed,
|
isDoseDismissed,
|
||||||
@@ -1202,6 +1201,80 @@ describe("getStockStatus", () => {
|
|||||||
expect(result.level).toBe("critical");
|
expect(result.level).toBe("critical");
|
||||||
expect(result.className).toBe("danger");
|
expect(result.className).toBe("danger");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns normal (no stock reminder semantics) for tube packageType regardless of stock thresholds", () => {
|
||||||
|
// Tubes have no stock reminder semantics: thresholds (low, critical, high) do not apply.
|
||||||
|
// However, if truly empty or exhausted, out-of-stock is still returned.
|
||||||
|
const resultWithMeds = getStockStatus(100, 50, thresholds, "tube");
|
||||||
|
expect(resultWithMeds.level).toBe("normal");
|
||||||
|
expect(resultWithMeds.className).toBe("success");
|
||||||
|
expect(resultWithMeds.label).toBe("status.noSchedule");
|
||||||
|
|
||||||
|
// Even with low days remaining (would be critical for non-tube)
|
||||||
|
const resultLow = getStockStatus(2, 50, thresholds, "tube");
|
||||||
|
expect(resultLow.level).toBe("normal");
|
||||||
|
expect(resultLow.className).toBe("success");
|
||||||
|
|
||||||
|
// Exhausted/empty tubes still show as out-of-stock
|
||||||
|
const resultEmpty = getStockStatus(0, 0, thresholds, "tube");
|
||||||
|
expect(resultEmpty.level).toBe("out-of-stock");
|
||||||
|
expect(resultEmpty.className).toBe("danger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies liquid_container thresholds: low=critical(threshold), critical=ceil(critical/2)", () => {
|
||||||
|
// For liquid_container, baseline is criticalStockDays (7)
|
||||||
|
// low = 7, critical = ceil(7/2) = 4
|
||||||
|
const thresholdsLiquid: StockThresholds = {
|
||||||
|
lowStockDays: 30,
|
||||||
|
criticalStockDays: 7,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// daysLeft = 8 (above low threshold of 7)
|
||||||
|
const resultNormal = getStockStatus(8, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultNormal.level).toBe("normal");
|
||||||
|
expect(resultNormal.className).toBe("success");
|
||||||
|
|
||||||
|
// daysLeft = 7 (at low threshold, below normal)
|
||||||
|
const resultLow = getStockStatus(7, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultLow.level).toBe("low");
|
||||||
|
expect(resultLow.className).toBe("warning");
|
||||||
|
|
||||||
|
// daysLeft = 4 (at critical threshold)
|
||||||
|
const resultCritical = getStockStatus(4, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultCritical.level).toBe("critical");
|
||||||
|
expect(resultCritical.className).toBe("danger");
|
||||||
|
|
||||||
|
// daysLeft = 2 (below critical threshold)
|
||||||
|
const resultVeryCritical = getStockStatus(2, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultVeryCritical.level).toBe("critical");
|
||||||
|
expect(resultVeryCritical.className).toBe("danger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles liquid_container with boundary baseline (criticalStockDays=1)", () => {
|
||||||
|
// Boundary case: criticalStockDays=1, so low=1, critical=ceil(1/2)=1
|
||||||
|
const boundaryThresholds: StockThresholds = {
|
||||||
|
lowStockDays: 30,
|
||||||
|
criticalStockDays: 1,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// daysLeft = 2 (above low threshold)
|
||||||
|
const resultNormal = getStockStatus(2, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultNormal.level).toBe("normal");
|
||||||
|
|
||||||
|
// daysLeft = 1 (at low and critical thresholds)
|
||||||
|
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultCritical.level).toBe("critical");
|
||||||
|
|
||||||
|
// daysLeft = 0 (out of stock)
|
||||||
|
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultEmpty.level).toBe("out-of-stock");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getNextReminderForMed", () => {
|
describe("getNextReminderForMed", () => {
|
||||||
@@ -1213,55 +1286,9 @@ describe("getNextReminderForMed", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns "—" when no depletion time', () => {
|
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 100,
|
|
||||||
daysLeft: null,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: null,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(getNextReminderForMed(med, 7, "en")).toBe("—");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "Due now" when reminder time is past', () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 5,
|
|
||||||
daysLeft: 3,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: now + 3 * 86400000,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reminder 7 days before = already past
|
|
||||||
expect(getNextReminderForMed(med, 7, "en")).toBe("Due now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns formatted date for future reminder", () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 100,
|
|
||||||
daysLeft: 30,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: now + 30 * 86400000,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = getNextReminderForMed(med, 7, "en-US");
|
|
||||||
expect(result).not.toBe("—");
|
|
||||||
expect(result).not.toBe("Due now");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getReminderStatusText", () => {
|
|
||||||
const mockT = (key: string, options?: Record<string, unknown>) => {
|
const mockT = (key: string, options?: Record<string, unknown>) => {
|
||||||
if (options?.count) return `${key} (${options.count})`;
|
if (typeof options?.count === "number") return `${key} (${options.count})`;
|
||||||
if (options?.days) return `${key} (${options.days})`;
|
if (typeof options?.days === "number") return `${key} (${options.days})`;
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
// Core Types for MedAssist
|
// Core Types for MedAssist
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export type PackageType = "blister" | "bottle";
|
export type PackageType = "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
|
|
||||||
// Common medication dose units
|
// Common medication dose units
|
||||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
|
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||||
|
|
||||||
|
export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
|
||||||
|
export type PillForm = "tablet" | "capsule";
|
||||||
|
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
||||||
|
export type PackageAmountUnit = "ml" | "g";
|
||||||
|
|
||||||
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||||
{ value: "mg", label: "mg" },
|
{ value: "mg", label: "mg" },
|
||||||
{ value: "g", label: "g" },
|
{ value: "g", label: "g" },
|
||||||
{ value: "mcg", label: "mcg (µg)" },
|
{ value: "mcg", label: "mcg (µg)" },
|
||||||
{ value: "ml", label: "ml" },
|
{ value: "ml", label: "ml" },
|
||||||
|
{ value: "units", label: "units" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Blister = {
|
export type Blister = {
|
||||||
@@ -20,6 +26,8 @@ export type Blister = {
|
|||||||
start: string;
|
start: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IntakeUnit = "ml" | "tsp" | "tbsp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intake with per-intake takenBy support.
|
* Intake with per-intake takenBy support.
|
||||||
* Extends Blister with per-intake user assignment.
|
* Extends Blister with per-intake user assignment.
|
||||||
@@ -28,6 +36,7 @@ export type Intake = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
|
intakeUnit?: IntakeUnit | null;
|
||||||
takenBy: string | null; // Per-intake user assignment (single person or null)
|
takenBy: string | null; // Per-intake user assignment (single person or null)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -47,7 +56,14 @@ export type Medication = {
|
|||||||
lastStockCorrectionAt?: string | null;
|
lastStockCorrectionAt?: string | null;
|
||||||
pillWeightMg?: number | null;
|
pillWeightMg?: number | null;
|
||||||
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
|
medicationForm?: MedicationForm | null;
|
||||||
|
pillForm?: PillForm | null;
|
||||||
|
lifecycleCategory?: LifecycleCategory | null;
|
||||||
|
packageAmountValue?: number | null;
|
||||||
|
packageAmountUnit?: PackageAmountUnit | null;
|
||||||
medicationStartDate?: string | null;
|
medicationStartDate?: string | null;
|
||||||
|
medicationEndDate?: string | null;
|
||||||
|
autoMarkObsoleteAfterEndDate?: boolean;
|
||||||
blisters: Blister[]; // Legacy array format
|
blisters: Blister[]; // Legacy array format
|
||||||
intakes?: Intake[]; // New intake format with per-intake takenBy
|
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -102,6 +118,7 @@ export type FormIntake = {
|
|||||||
every: string;
|
every: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
intakeUnit?: IntakeUnit;
|
||||||
takenBy: string; // Single person or empty string (empty = null for everyone)
|
takenBy: string; // Single person or empty string (empty = null for everyone)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -110,15 +127,22 @@ export type FormState = {
|
|||||||
name: string;
|
name: string;
|
||||||
genericName: string;
|
genericName: string;
|
||||||
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
|
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
|
||||||
|
medicationForm: MedicationForm;
|
||||||
|
pillForm: PillForm;
|
||||||
|
lifecycleCategory: LifecycleCategory;
|
||||||
packageType: PackageType;
|
packageType: PackageType;
|
||||||
packCount: string;
|
packCount: string;
|
||||||
blistersPerPack: string;
|
blistersPerPack: string;
|
||||||
pillsPerBlister: string;
|
pillsPerBlister: string;
|
||||||
|
packageAmountValue: string;
|
||||||
|
packageAmountUnit: PackageAmountUnit;
|
||||||
totalPills: string; // For bottle type: total capacity
|
totalPills: string; // For bottle type: total capacity
|
||||||
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
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.)
|
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
medicationStartDate: string;
|
medicationStartDate: string;
|
||||||
|
medicationEndDate: string;
|
||||||
|
autoMarkObsoleteAfterEndDate: boolean;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
prescriptionEnabled: boolean;
|
prescriptionEnabled: boolean;
|
||||||
@@ -167,6 +191,7 @@ export type ScheduleEvent = {
|
|||||||
timeStr: string;
|
timeStr: string;
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: IntakeUnit | null;
|
||||||
when: number;
|
when: number;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
@@ -248,13 +273,16 @@ export function getMedDisplayName(med: { name: string; genericName?: string | nu
|
|||||||
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
||||||
stockAdjustment?: number;
|
stockAdjustment?: number;
|
||||||
packageType?: PackageType;
|
packageType?: PackageType;
|
||||||
|
totalPills?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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
|
// Amount-based package types store their current base stock directly
|
||||||
if (med.packageType === "bottle") {
|
// in totalPills (fallback looseTablets for legacy rows).
|
||||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
|
const baseStock = med.totalPills ?? med.looseTablets;
|
||||||
|
return baseStock + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// 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);
|
||||||
@@ -262,9 +290,9 @@ export function getMedTotal(med: MedLike): number {
|
|||||||
|
|
||||||
/** 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
|
// Amount-based package types use totalPills as base capacity
|
||||||
if (med.packageType === "bottle") {
|
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
return med.looseTablets;
|
return med.totalPills ?? med.looseTablets;
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// 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;
|
||||||
|
|||||||
@@ -2,9 +2,31 @@
|
|||||||
// Schedule Building and Coverage Calculations
|
// Schedule Building and Coverage Calculations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
import type {
|
||||||
|
Blister,
|
||||||
|
Coverage,
|
||||||
|
Intake,
|
||||||
|
Medication,
|
||||||
|
PackageType,
|
||||||
|
ScheduleEvent,
|
||||||
|
StockStatus,
|
||||||
|
StockThresholds,
|
||||||
|
} from "../types";
|
||||||
import { getMedDisplayName, getMedTotal } from "../types";
|
import { getMedDisplayName, getMedTotal } from "../types";
|
||||||
|
|
||||||
|
function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
|
if (med.packageType === "tube") return 0;
|
||||||
|
|
||||||
|
const isLiquidStock = med.packageType === "liquid_container" || med.medicationForm === "liquid";
|
||||||
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +40,7 @@ function getIntakesForMed(med: Medication): Intake[] {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // Legacy format has no per-intake takenBy
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -66,6 +89,7 @@ export function buildSchedulePreview(
|
|||||||
medName: getMedDisplayName(med),
|
medName: getMedDisplayName(med),
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
|
intakeUnit: intake.intakeUnit ?? null,
|
||||||
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" }),
|
||||||
@@ -124,9 +148,11 @@ export function calculateCoverage(
|
|||||||
// one person's dose — do NOT multiply by personCount again.
|
// one person's dose — do NOT multiply by personCount again.
|
||||||
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
||||||
let dailyRate = 0;
|
let dailyRate = 0;
|
||||||
blisters.forEach((s, idx) => {
|
blisters.forEach((_s, idx) => {
|
||||||
const baseRate = s.every > 0 ? s.usage / s.every : 0;
|
|
||||||
const intake = intakes[idx];
|
const intake = intakes[idx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
|
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||||
if (intake?.takenBy) {
|
if (intake?.takenBy) {
|
||||||
// Per-intake takenBy: this intake is for exactly 1 person
|
// Per-intake takenBy: this intake is for exactly 1 person
|
||||||
dailyRate += baseRate;
|
dailyRate += baseRate;
|
||||||
@@ -149,6 +175,9 @@ export function calculateCoverage(
|
|||||||
blisters.forEach((s, blisterIdx) => {
|
blisters.forEach((s, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const blisterStart = new Date(s.start).getTime();
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
|
|
||||||
// After a stock correction, start counting consumption from the NEXT
|
// After a stock correction, start counting consumption from the NEXT
|
||||||
// scheduled dose on this blister's grid, because the user's pill count
|
// scheduled dose on this blister's grid, because the user's pill count
|
||||||
@@ -166,7 +195,6 @@ export function calculateCoverage(
|
|||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart)) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
|
|
||||||
// For per-intake takenBy, only count for that person
|
// For per-intake takenBy, only count for that person
|
||||||
@@ -180,7 +208,7 @@ export function calculateCoverage(
|
|||||||
|
|
||||||
if (effectiveStart <= now) {
|
if (effectiveStart <= now) {
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||||
|
|
||||||
// Date-only timestamp of the last auto-consumed dose
|
// Date-only timestamp of the last auto-consumed dose
|
||||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
@@ -212,7 +240,7 @@ export function calculateCoverage(
|
|||||||
const bIdx = parseInt(parts[1], 10);
|
const bIdx = parseInt(parts[1], 10);
|
||||||
const timestamp = parseInt(parts[2], 10);
|
const timestamp = parseInt(parts[2], 10);
|
||||||
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
earlyTakenConsumed += s.usage;
|
earlyTakenConsumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +260,9 @@ export function calculateCoverage(
|
|||||||
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 && blisters[blisterIdx]) {
|
if (medId === m.id && blisters[blisterIdx]) {
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
||||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
||||||
const blisterStartDateOnly = new Date(
|
const blisterStartDateOnly = new Date(
|
||||||
@@ -251,7 +282,7 @@ export function calculateCoverage(
|
|||||||
doseTimestamp >= blisterStartDateOnly &&
|
doseTimestamp >= blisterStartDateOnly &&
|
||||||
afterCorrectionOrNoCorrectionMs
|
afterCorrectionOrNoCorrectionMs
|
||||||
) {
|
) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,20 +323,47 @@ export function calculateCoverage(
|
|||||||
return { low, all: coverage };
|
return { low, all: coverage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLiquidDerivedThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||||
|
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
return { lowDays, criticalDays };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
|
packageType?: PackageType
|
||||||
|
): StockStatus {
|
||||||
// Out of stock or completely depleted = danger (red)
|
// 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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tube has no stock reminder semantics.
|
||||||
|
if (packageType === "tube") {
|
||||||
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
|
}
|
||||||
|
|
||||||
// No schedule, but has stock = normal
|
// 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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const liquidThresholds = getLiquidDerivedThresholds(thresholds.criticalStockDays);
|
||||||
|
if (daysLeft <= liquidThresholds.criticalDays) {
|
||||||
|
return { level: "critical", className: "danger", label: "status.criticalStock" };
|
||||||
|
}
|
||||||
|
if (daysLeft <= liquidThresholds.lowDays) {
|
||||||
|
return { level: "low", className: "warning", label: "status.lowStock" };
|
||||||
|
}
|
||||||
|
return { level: "normal", className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
|
|
||||||
// High stock
|
// 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" };
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function splitCurrentBlisterStock(
|
|||||||
*/
|
*/
|
||||||
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
||||||
const total =
|
const total =
|
||||||
med.packageType === "bottle"
|
med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container"
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
|
|
||||||
|
|||||||
Generated
+17
-34
@@ -8,7 +8,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
@@ -439,19 +439,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lint-staged": {
|
"node_modules/lint-staged": {
|
||||||
"version": "16.2.7",
|
"version": "16.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.1.tgz",
|
||||||
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
|
"integrity": "sha512-bqvvquXzFBAlSbluugR4KXAe4XnO/QZcKVszpkBtqLWa2KEiVy8n6Xp38OeUbv/gOJOX4Vo9u5pFt/ADvbm42Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.3",
|
||||||
"listr2": "^9.0.5",
|
"listr2": "^9.0.5",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"nano-spawn": "^2.0.0",
|
|
||||||
"pidtree": "^0.6.0",
|
|
||||||
"string-argv": "^0.3.2",
|
"string-argv": "^0.3.2",
|
||||||
"yaml": "^2.8.1"
|
"tinyexec": "^1.0.2",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"lint-staged": "bin/lint-staged.js"
|
"lint-staged": "bin/lint-staged.js"
|
||||||
@@ -541,19 +540,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nano-spawn": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.17"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||||
@@ -570,19 +556,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pidtree": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"pidtree": "bin/pidtree.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -680,6 +653,16 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.3.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"backend/src/**/*.ts": [
|
"backend/src/**/*.ts": [
|
||||||
|
|||||||
Reference in New Issue
Block a user