From 9dc0ccd03af35e9cd903e799d9217cb424132403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 19:11:53 +0200 Subject: [PATCH] fix(medications): release-QA findings on the inventory overhaul - The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall back to ledger-derived pen/dose figures when a medication has no inventory items; items always win once they exist. The delta branch on the POST is marked deprecated in favour of the inventory endpoints. - Expired containers count as available nowhere: one shared supply summary (ACTIVE/IN_USE with units) feeds the overview row, the Bestand summary, the list payload, the GLP-1 card, and the runway estimate; expired units surface as a muted suffix instead. - The inventory endpoints speak one wire dialect: request fields renamed to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated. - The reorder dialog mirrors the page's grouping: active and inactive sections, each sortable within itself, so a saved order can never interleave them. - Polish strings avoid the 2-4 plural trap, sub-dose quantities render as below one dose instead of approximately zero, and the table's name sort collates by the active locale instead of hardcoded German. --- docs/api/openapi.yaml | 16 +- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 8 +- .../[id]/glp1/__tests__/route.test.ts | 71 ++++- src/app/api/medications/[id]/glp1/route.ts | 39 ++- .../[itemId]/__tests__/route.test.ts | 16 +- .../[id]/inventory/[itemId]/route.ts | 15 +- .../[id]/inventory/__tests__/route.test.ts | 14 +- .../api/medications/[id]/inventory/route.ts | 11 +- .../__tests__/add-inventory-dialog.test.tsx | 20 +- .../inventory-section-summary.test.tsx | 113 ++++++++ .../medication-reorder-dialog.test.tsx | 172 ++++++++++++ .../__tests__/medication-table.test.tsx | 14 + .../detail/MedicationDetailTabs.tsx | 27 +- .../medications/medication-reorder-dialog.tsx | 259 +++++++++++++----- .../medications/medication-table.tsx | 14 +- .../sections/inventory-section.tsx | 48 +++- .../ai/coach/__tests__/glp1-snapshot.test.ts | 44 +++ src/lib/ai/coach/glp1-snapshot.ts | 36 ++- .../inventory/__tests__/summary.test.ts | 115 ++++++++ src/lib/medications/inventory/summary.ts | 67 +++++ src/lib/openapi/routes/medications.ts | 4 +- .../__tests__/medication-inventory.test.ts | 25 +- src/lib/validations/medication.ts | 29 +- 28 files changed, 1017 insertions(+), 180 deletions(-) create mode 100644 src/components/medications/__tests__/inventory-section-summary.test.tsx create mode 100644 src/components/medications/__tests__/medication-reorder-dialog.test.tsx create mode 100644 src/lib/medications/inventory/__tests__/summary.test.ts create mode 100644 src/lib/medications/inventory/summary.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5c80eb3a..9ff8f1ef6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2377,7 +2377,7 @@ paths: tags: - Medications summary: Register a new supply container - description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field + description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`. parameters: - in: path @@ -2413,7 +2413,7 @@ paths: - Medications summary: Mutate a supply container description: "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), - printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's + printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`." parameters: @@ -5177,12 +5177,12 @@ components: CreateMedicationInventoryItemRequest: type: object properties: - dosesTotal: + unitsTotal: type: integer minimum: 1 maximum: 1000 - description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived - readouts divide by the medication's `unitsPerDose`. + description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the + medication's `unitsPerDose`. containerType: description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER. @@ -5212,8 +5212,8 @@ components: maxLength: 200 - type: "null" required: - - dosesTotal - description: Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item + - unitsTotal + description: Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose. UpdateMedicationInventoryItemRequest: type: object @@ -5233,7 +5233,7 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - dosesRemaining: + unitsRemaining: description: Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP). type: integer diff --git a/messages/de.json b/messages/de.json index f5debfaca..9930facb5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Dosen", "quantityModeUnits": "Einheiten", "quantityInUnits": "= {units} Einheiten", - "quantityInDoses": "≈ {doses} Dosen" + "quantityInDoses": "≈ {doses} Dosen", + "quantityUnderOneDose": "< 1 Dosis", + "expiredSuffix": "+ {units} Einheiten abgelaufen" }, "erweitert": { "group": { diff --git a/messages/en.json b/messages/en.json index 5616d3971..f5927bd8b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Doses", "quantityModeUnits": "Units", "quantityInUnits": "= {units} units", - "quantityInDoses": "≈ {doses} doses" + "quantityInDoses": "≈ {doses} doses", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} units expired" }, "erweitert": { "group": { diff --git a/messages/es.json b/messages/es.json index fbb57fbb3..514b42a15 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Dosis", "quantityModeUnits": "Unidades", "quantityInUnits": "= {units} unidades", - "quantityInDoses": "≈ {doses} dosis" + "quantityInDoses": "≈ {doses} dosis", + "quantityUnderOneDose": "< 1 dosis", + "expiredSuffix": "+ {units} unidades caducadas" }, "erweitert": { "group": { diff --git a/messages/fr.json b/messages/fr.json index a4dc6c116..68b82779e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Doses", "quantityModeUnits": "Unités", "quantityInUnits": "= {units} unités", - "quantityInDoses": "≈ {doses} doses" + "quantityInDoses": "≈ {doses} doses", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} unités périmées" }, "erweitert": { "group": { diff --git a/messages/it.json b/messages/it.json index 45fadd671..7290f0b4e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Dosi", "quantityModeUnits": "Unità", "quantityInUnits": "= {units} unità", - "quantityInDoses": "≈ {doses} dosi" + "quantityInDoses": "≈ {doses} dosi", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} unità scadute" }, "erweitert": { "group": { diff --git a/messages/pl.json b/messages/pl.json index 9004b7804..21e72f180 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -773,7 +773,7 @@ "complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu", "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", - "complianceDoses": "{count} dawek", + "complianceDoses": "Dawki: {count}", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", @@ -1629,7 +1629,9 @@ "quantityModeDoses": "Dawki", "quantityModeUnits": "Jednostki", "quantityInUnits": "= {units} jednostek", - "quantityInDoses": "≈ {doses} dawek" + "quantityInDoses": "Dawki: ≈ {doses}", + "quantityUnderOneDose": "< 1 dawka", + "expiredSuffix": "+ przeterminowane jednostki: {units}" }, "erweitert": { "group": { @@ -1729,7 +1731,7 @@ "tableSortBy": "Sortuj według {column}", "tableComplianceDays": "{days} dni", "tableStockDoseOne": "1 dawka", - "tableStockDoses": "{count} dawek", + "tableStockDoses": "Dawki: {count}", "takeActionFor": "Przyjmij – {name}", "skipActionFor": "Pomiń – {name}", "reorderTitle": "Zmień kolejność", diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index c8f10a701..6f74d2026 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -405,6 +405,7 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => userId: "user-1", doseChanges: [], inventoryItems: [], + inventoryEvents: [], intakeEvents: [], schedules: [], dosesPerUnit: null, @@ -455,6 +456,9 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () // Not usable: expired stock is not supply. { state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, ], + // Items exist, so the legacy ledger below must be IGNORED — a + // stale delta history cannot override the per-item truth. + inventoryEvents: [{ delta: 9 }], } as never); const res = await GET(getReq(), { @@ -480,7 +484,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () }); }); - it("returns a null inventory block when no items exist", async () => { + it("returns a null inventory block when no items and no ledger rows exist", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue({ id: "med-1", @@ -491,6 +495,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () dosesPerUnit: null, unitsPerDose: 1, inventoryItems: [], + inventoryEvents: [], } as never); const res = await GET(getReq(), { @@ -500,4 +505,68 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () const body = (await res.json()) as { data: { inventory: unknown } }; expect(body.data.inventory).toBeNull(); }); + + // W1 — the legacy delta ledger the POST below still accepts must stay + // readable: an account that only ever posted `{inventory: {delta}}` + // sees its pen count on GET instead of a silent null. + it("falls back to the legacy ledger when the user has zero inventory items", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: 4, + unitsPerDose: 1, + inventoryItems: [], + // Running sum: +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses. + inventoryEvents: [{ delta: 2 }, { delta: -1 }], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inventory: { + pensRemaining: number; + dosesRemaining: number; + weeksOfSupply: number; + lowStock: boolean; + }; + }; + }; + expect(body.data.inventory).toEqual({ + pensRemaining: 1, + dosesRemaining: 4, + weeksOfSupply: 4, + lowStock: false, + }); + }); + + it("ignores the ledger fallback when dosesPerUnit is not configured", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: null, + unitsPerDose: 1, + inventoryItems: [], + inventoryEvents: [{ delta: 2 }], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { inventory: unknown } }; + // Pre-item contract: without `dosesPerUnit` the ledger never + // produced an inventory block either. + expect(body.data.inventory).toBeNull(); + }); }); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index ee887b531..1ede74ba7 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -56,9 +56,12 @@ export const GET = apiHandler( doseChanges: { orderBy: { effectiveFrom: "asc" } }, // v1.16.10 — the inventory block reads the per-item entities // (the same rows the Bestand tab and the intake consumption - // hook move), replacing the legacy MedicationInventoryEvent - // running-sum ledger that no write path feeds any more. + // hook move). The legacy MedicationInventoryEvent running-sum + // ledger stays as a READ fallback for accounts that only ever + // posted deltas: when zero items exist, the ledger-derived + // numbers surface instead of a silent null. inventoryItems: { orderBy: { createdAt: "asc" } }, + inventoryEvents: { orderBy: { occurredAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, orderBy: { takenAt: "desc" }, @@ -107,6 +110,30 @@ export const GET = apiHandler( weeksOfSupply, lowStock, }; + } else if ( + medication.dosesPerUnit && + medication.inventoryEvents.length > 0 + ) { + // Read-side fallback for ledger-only accounts (pre-v1.16.10 the + // POST below was the only inventory writer). Items win whenever + // any exist — the ledger never overrides them and no item rows + // are fabricated from it. Math is the pre-item contract verbatim: + // running sum of deltas counts pens, `dosesPerUnit` maps pens to + // doses, weekly cadence approximates the supply horizon. + const pens = medication.inventoryEvents.reduce( + (sum, ev) => sum + ev.delta, + 0, + ); + const pensRemaining = Math.max(0, pens); + const dosesRemaining = pensRemaining * medication.dosesPerUnit; + const weeksOfSupply = dosesRemaining; + const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; + inventory = { + pensRemaining, + dosesRemaining, + weeksOfSupply, + lowStock, + }; } return apiSuccess({ @@ -213,6 +240,14 @@ export const POST = apiHandler( // The schema's XOR refinement guarantees `inventory` is present // when `doseChange` is not, so the non-null assertion is safe. + // + // DEPRECATED branch (v1.16.10): this writes the legacy + // MedicationInventoryEvent running-sum ledger. The per-item + // endpoints (`POST /api/medications/[id]/inventory`, + // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; + // GET above and the Coach snapshot read the ledger only while the + // medication has zero inventory items. New callers must register + // containers instead of posting deltas. const { delta, reason } = parsed.data.inventory!; const created = await prisma.medicationInventoryEvent.create({ data: { medicationId: id, delta, reason }, diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 37792abf5..992409555 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -2,8 +2,8 @@ * v1.4.43 W6 — multi-issue 422 envelope on PATCH * /api/medications/[id]/inventory/[itemId]. * - * v1.16.1 — stock-correction contract: `dosesRemaining` sets the count - * absolutely, clamps to `dosesTotal`, and the canonical state machine + * v1.16.1 — stock-correction contract: `unitsRemaining` sets the count + * absolutely, clamps to `unitsTotal`, and the canonical state machine * derives the next state (0 ⇒ USED_UP). */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -83,7 +83,7 @@ beforeEach(() => { userId: "user-1", firstUseAt: null, state: "ACTIVE", - dosesRemaining: 4, + unitsRemaining: 4, printedExpiry: null, } as never); }); @@ -129,7 +129,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1 }); }); -describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => { +describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => { beforeEach(() => { vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ id: "i1", @@ -151,7 +151,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc }); it("sets the remaining count absolutely", async () => { - const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock .calls[0][0] as unknown as { data: { unitsRemaining: number } }; @@ -159,7 +159,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc }); it("derives USED_UP when corrected to zero", async () => { - const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock .calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } }; @@ -168,7 +168,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc }); it("clamps a raise above the item's capacity to unitsTotal", async () => { - const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock .calls[0][0] as unknown as { data: { unitsRemaining: number } }; @@ -176,7 +176,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc }); it("rejects a negative correction with 422", async () => { - const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX); expect(res.status).toBe(422); }); }); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index d31d2b47e..fe03720e3 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -6,9 +6,10 @@ * - mark-as-used-up (terminal — operator override when a pen is * physically discarded but the dose ledger hasn't reached zero) * - update printed expiry (e.g. carton label correction) - * - stock correction via `dosesRemaining` (v1.16.1 — the Bestand - * tab's adjust / withdraw flow; clamped to `dosesTotal`, state - * re-derived by the canonical state machine) + * - stock correction via `unitsRemaining` (v1.16.1 — the Bestand + * tab's adjust / withdraw flow; clamped to `unitsTotal`, state + * re-derived by the canonical state machine; v1.16.10 renamed + * the request field from `dosesRemaining` to match the response) * - update notes * * DELETE /api/medications/[id]/inventory/[itemId] @@ -74,7 +75,7 @@ export const PATCH = apiHandler( markAsFirstUseAt, markAsUsedUp, printedExpiry, - dosesRemaining, + unitsRemaining, notes, } = parsed.data; @@ -96,13 +97,13 @@ export const PATCH = apiHandler( nextPrintedExpiry = printedExpiry; } - if (dosesRemaining !== undefined) { + if (unitsRemaining !== undefined) { // v1.16.1 — stock correction (Bestand tab adjust / withdraw). // The wire field counts UNITS. Clamp to the item's capacity; the // state-machine re-run below owns every state consequence (0 ⇒ // USED_UP, a raise out of 0 re-evaluates against the expiry // clocks). - nextUnitsRemaining = Math.min(dosesRemaining, existing.unitsTotal); + nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal); } if (markAsUsedUp === true) { @@ -120,7 +121,7 @@ export const PATCH = apiHandler( // pure and idempotent — running it once more with the composed view // collapses every edge case onto the same decision tree the intake // hook and the daily expire cron already share. USED_UP is terminal - // (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual + // (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual // override remains sticky. nextState = computeInventoryState( { diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 14b1cd9d2..16617b3fd 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -124,19 +124,19 @@ describe("POST /api/medications/[id]/inventory", () => { vi.mocked(getSession).mockResolvedValue(null); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, }), { params: Promise.resolve({ id: "med-1" }) }, ); expect(res.status).toBe(401); }); - it("returns 422 when dosesTotal is missing or invalid", async () => { + it("returns 422 when unitsTotal is missing or invalid", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 0, + unitsTotal: 0, }), { params: Promise.resolve({ id: "med-1" }) }, ); @@ -154,7 +154,7 @@ describe("POST /api/medications/[id]/inventory", () => { const printed = "2027-06-01T00:00:00Z"; const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, printedExpiry: printed, }), { params: Promise.resolve({ id: "med-1" }) }, @@ -185,7 +185,7 @@ describe("POST /api/medications/[id]/inventory", () => { }); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, }), { params: Promise.resolve({ id: "med-1" }) }, ); @@ -357,7 +357,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: "string", + unitsTotal: "string", printedExpiry: "not-iso", }), { params: Promise.resolve({ id: "med-1" }) }, @@ -383,7 +383,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: "string", + unitsTotal: "string", printedExpiry: "not-iso", purchasedAt: "also-not-iso", notes: 123, diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 0f4bbb205..db17065b8 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -90,16 +90,17 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { dosesTotal, containerType, printedExpiry, purchasedAt, notes } = + const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } = parsed.data; const created = await prisma.medicationInventoryItem.create({ - // The wire field `dosesTotal` counts UNITS (tablets / ampoules / - // puffs) — the v1.16.10 rename to `unitsTotal` happens here. + // The wire field `unitsTotal` counts UNITS (tablets / ampoules / + // puffs) — v1.16.10 renamed the request field to match the + // response side. data: buildCreateInventoryInput({ userId: user.id, medicationId: id, - unitsTotal: dosesTotal, + unitsTotal, containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, @@ -113,7 +114,7 @@ export const POST = apiHandler( details: { medicationId: id, itemId: created.id, - unitsTotal: dosesTotal, + unitsTotal, containerType: created.containerType, }, }); diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx index 35e39d1c0..ce4c209b8 100644 --- a/src/components/medications/__tests__/add-inventory-dialog.test.tsx +++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx @@ -152,14 +152,30 @@ describe("", () => { it("always submits UNITS: dose-mode input multiplies by unitsPerDose before the POST", () => { // Interactive mode-switching is out of SSR reach; the structural // contract lives in the source: the submitted value is the converted - // unit count, and the wire field stays `dosesTotal`. + // unit count on the v1.16.10 symmetric wire field `unitsTotal`. expect(src).toMatch( /const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/, ); - expect(src).toMatch(/dosesTotal: units,/); + expect(src).toMatch(/unitsTotal: units,/); expect(src).toMatch(/containerType,/); }); + it("renders '< 1 Dosis' instead of '≈ 0 Dosen' for a sub-dose unit count", () => { + // 3 units at 4 units per dose is less than one dose — the + // conversion line must say so rather than rounding to zero. + const html = render( + {}} + />, + ); + expect(html).toContain("< 1 Dosis"); + expect(html).not.toContain("≈ 0 Dosen"); + }); + it("bounds the quantity to 1000 units (dose mode scales the max down)", () => { expect(src).toContain("units >= 1 && units <= 1000"); expect(src).toContain('Math.floor(1000 / unitsPerDose)'); diff --git a/src/components/medications/__tests__/inventory-section-summary.test.tsx b/src/components/medications/__tests__/inventory-section-summary.test.tsx new file mode 100644 index 000000000..fa6a2001d --- /dev/null +++ b/src/components/medications/__tests__/inventory-section-summary.test.tsx @@ -0,0 +1,113 @@ +/** + * v1.16.10 — Bestand summary availability semantics. + * + * Renders `` with a seeded query cache and pins the + * release-QA scenario: a single EXPIRED pen with units left shows ZERO + * available doses in the headline, with the expired units as a muted + * suffix — matching the list payload and the GLP-1 endpoint instead of + * counting expired stock as supply. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const useQueryMock = vi.fn(); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { InventorySection } = await import("../sections/inventory-section"); + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +function seedItems(items: unknown[]) { + useQueryMock.mockReturnValue({ + data: { items, meta: { total: items.length } }, + isLoading: false, + }); +} + +describe(" — summary availability", () => { + it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => { + seedItems([ + { + id: "i1", + state: "EXPIRED", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, + }, + ]); + const html = render( + , + ); + expect(html).toContain("0 von 0 Dosen übrig"); + expect(html).toContain('data-slot="inventory-expired-suffix"'); + expect(html).toContain("+ 4 Einheiten abgelaufen"); + // The item row itself stays visible with its state badge. + expect(html).toContain("Abgelaufen"); + }); + + it("usable stock counts; expired suffix absent when nothing expired", () => { + seedItems([ + { + id: "i1", + state: "ACTIVE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, + }, + { + id: "i2", + state: "USED_UP", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 0, + }, + ]); + const html = render( + , + ); + expect(html).toContain("4 von 4 Dosen übrig"); + expect(html).not.toContain('data-slot="inventory-expired-suffix"'); + }); + + it("mixed usable + expired: headline counts only the usable pool", () => { + seedItems([ + { + id: "i1", + state: "IN_USE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 2, + }, + { + id: "i2", + state: "EXPIRED", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 3, + }, + ]); + const html = render( + , + ); + expect(html).toContain("2 von 4 Dosen übrig"); + expect(html).toContain("+ 3 Einheiten abgelaufen"); + }); +}); diff --git a/src/components/medications/__tests__/medication-reorder-dialog.test.tsx b/src/components/medications/__tests__/medication-reorder-dialog.test.tsx new file mode 100644 index 000000000..29c7486a3 --- /dev/null +++ b/src/components/medications/__tests__/medication-reorder-dialog.test.tsx @@ -0,0 +1,172 @@ +/** + * v1.16.10 — reorder dialog mirrors the page's grouping. + * + * Both /medications views pin inactive medications after the active + * block, so the dialog drafts TWO sections (Aktiv / Inaktiv) with a + * muted heading each: drag and arrows work within a section, and the + * saved order is active ids first, inactive ids after — by + * construction, not by validation. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`) + * plus pure-helper tests for the interactive plumbing an SSR mount + * can't exercise, plus source-string structural assertions. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// Radix Dialog portals at runtime; collapse to plain wrappers so the +// body materialises in static markup (same trick as the sibling suites). +vi.mock("@/components/ui/dialog", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Dialog: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: Pass, + DialogDescription: Pass, + DialogFooter: Pass, + DialogHeader: Pass, + DialogTitle: Pass, + }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({}), +})); + +vi.mock("@/lib/charts/reduced-motion", () => ({ + prefersReducedMotion: () => true, +})); + +vi.mock("@/lib/queries/use-medication-list-layout", () => ({ + runSaveMedicationListOrder: vi.fn().mockResolvedValue(true), +})); + +const { MedicationReorderDialog, buildSavedOrder, moveWithinSection } = + await import("../medication-reorder-dialog"); + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const MEDS = [ + { id: "a1", name: "Ramipril", dose: "5 mg", active: true }, + { id: "a2", name: "Mounjaro", dose: "7.5 mg", active: true }, + { id: "i1", name: "Amoxicillin", dose: "500 mg", active: false }, + { id: "i2", name: "Ibuprofen", dose: "400 mg", active: false }, +]; + +describe("moveWithinSection — moves stay inside the group", () => { + it("swaps neighbours within the section (the arrow / drag contract)", () => { + expect(moveWithinSection(["a1", "a2"], "a2", -1)).toEqual(["a2", "a1"]); + expect(moveWithinSection(["i1", "i2"], "i1", 1)).toEqual(["i2", "i1"]); + }); + + it("cannot move past either section edge — the group boundary is unreachable", () => { + expect(moveWithinSection(["a1", "a2"], "a1", -1)).toEqual(["a1", "a2"]); + expect(moveWithinSection(["a1", "a2"], "a2", 1)).toEqual(["a1", "a2"]); + // The first inactive row moving "up" stays where it is — it cannot + // climb into the active block. + expect(moveWithinSection(["i1", "i2"], "i1", -1)).toEqual(["i1", "i2"]); + }); +}); + +describe("buildSavedOrder — active ids always precede inactive ids", () => { + it("composes active block first regardless of within-group churn", () => { + expect(buildSavedOrder(["a2", "a1"], ["i2", "i1"])).toEqual([ + "a2", + "a1", + "i2", + "i1", + ]); + }); + + it("an inactive med can never land above an active one in the SAVED order", () => { + // Drive the dialog's own move primitive as far up as it allows and + // compose the save payload exactly like save() does. + let inactive = ["i1", "i2"]; + inactive = moveWithinSection(inactive, "i2", -1); // i2 to the top of its group + inactive = moveWithinSection(inactive, "i2", -1); // edge — no-op + const saved = buildSavedOrder(["a1", "a2"], inactive); + expect(saved).toEqual(["a1", "a2", "i2", "i1"]); + const firstInactiveIdx = Math.min( + saved.indexOf("i1"), + saved.indexOf("i2"), + ); + const lastActiveIdx = Math.max(saved.indexOf("a1"), saved.indexOf("a2")); + expect(firstInactiveIdx).toBeGreaterThan(lastActiveIdx); + }); +}); + +describe(" — two-section rendering", () => { + it("renders an Aktiv and an Inaktiv section with the rows grouped", () => { + const html = render( + {}} + medications={MEDS} + />, + ); + expect(html).toContain('data-slot="medication-reorder-section-active"'); + expect(html).toContain('data-slot="medication-reorder-section-inactive"'); + // Muted group headings. + expect(html).toContain(">Aktiv

"); + expect(html).toContain(">Inaktiv

"); + // Every active row renders before every inactive row. + const lastActive = Math.max( + html.indexOf("Ramipril"), + html.indexOf("Mounjaro"), + ); + const firstInactive = Math.min( + html.indexOf("Amoxicillin"), + html.indexOf("Ibuprofen"), + ); + expect(lastActive).toBeLessThan(firstInactive); + }); + + it("omits the Inaktiv section when every medication is active", () => { + const html = render( + {}} + medications={MEDS.filter((m) => m.active)} + />, + ); + expect(html).toContain('data-slot="medication-reorder-section-active"'); + expect(html).not.toContain( + 'data-slot="medication-reorder-section-inactive"', + ); + }); +}); + +describe("medication-reorder-dialog — structural guards (source)", () => { + const src = readFileSync( + join( + process.cwd(), + "src/components/medications/medication-reorder-dialog.tsx", + ), + "utf8", + ); + + it("saves through buildSavedOrder (active ids, then inactive ids)", () => { + expect(src).toContain("buildSavedOrder(activeIds, inactiveIds)"); + }); + + it("each section owns its own DndContext, so a drag cannot cross groups", () => { + // One inside the per-section component, none at the + // dialog level spanning both lists. + const sectionBody = src.slice(src.indexOf("function ReorderSection")); + expect(sectionBody).toContain(" { }).map((m) => m.id), ).toEqual(["b", "c", "a"]); }); + + it("collates names by the active locale, not a hardcoded one", () => { + // Swedish sorts Ö after Z; German folds Ö next to O. The same two + // rows must order differently under the two locales. + const oe = med({ id: "oe", name: "Övriga" }); + const z = med({ id: "z", name: "Zyrtec" }); + const sort = { column: "name", direction: "asc" } as const; + expect( + sortMedicationRows([z, oe], sort, undefined, "sv").map((m) => m.id), + ).toEqual(["z", "oe"]); + expect( + sortMedicationRows([z, oe], sort, undefined, "de").map((m) => m.id), + ).toEqual(["oe", "z"]); + }); }); describe(" — reserved footprint", () => { diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index fc6738656..b90ee22b1 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -71,6 +71,7 @@ import { PhaseConfigSheet } from "@/components/medications/sections/phase-config import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; +import { summariseSupply } from "@/lib/medications/inventory/summary"; import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { useQuery } from "@tanstack/react-query"; @@ -330,19 +331,21 @@ export function MedicationDetailTabs({ }); const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : []; - const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP"); // v1.16.10 — items count UNITS; the supply readout and the runway are // dose-derived (floor over the pooled units — consumption spills // across containers, so the pool is the honest dose count). The raw // unit tally renders as secondary text when a dose spans > 1 unit. + // Availability rides the shared summary helper (ACTIVE / IN_USE with + // units only — the list / GLP-1 semantic); expired stock surfaces as + // a separate muted suffix and never feeds the runway. const perDose = Math.max(1, medication.unitsPerDose ?? 1); - const unitsRemaining = liveItems.reduce( - (sum, i) => sum + i.unitsRemaining, - 0, - ); - const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0); - const dosesRemaining = Math.floor(unitsRemaining / perDose); - const dosesTotal = Math.floor(unitsTotal / perDose); + const { + unitsRemaining, + unitsTotal, + dosesRemaining, + dosesTotal, + expiredUnits, + } = summariseSupply(inventoryItems, perDose); const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules); return ( @@ -442,6 +445,14 @@ export function MedicationDetailTabs({ {")"} )} + {expiredUnits > 0 && ( + + {" · "} + {t("medications.detail.bestand.expiredSuffix", { + units: expiredUnits, + })} + + )} {runwayDays !== null && ( {" · "} diff --git a/src/components/medications/medication-reorder-dialog.tsx b/src/components/medications/medication-reorder-dialog.tsx index 5dbb98baa..794ac85b3 100644 --- a/src/components/medications/medication-reorder-dialog.tsx +++ b/src/components/medications/medication-reorder-dialog.tsx @@ -44,10 +44,14 @@ import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-la * mode rides (the standalone extraction of the dashboard's * `reorderWidgets` contract), so the surfaces cannot drift. * - * The dialog edits a draft of the full medication list (active first, - * inactive after — matching the page's render grouping); Save persists - * the id order through `PUT /api/medications/layout` (order-only, the - * stored view is preserved server-side). + * The dialog mirrors the page's grouping: an Aktiv section and an + * Inaktiv section (muted heading), each with its own drag context and + * arrow bounds, so a row can only move WITHIN its group — both views + * pin inactive medications after the active block, and an order that + * crossed the boundary could never render. Save persists the id order + * (active ids first, then inactive ids) through + * `PUT /api/medications/layout` (order-only, the stored view is + * preserved server-side). */ export interface ReorderMedication { @@ -64,6 +68,38 @@ interface MedicationReorderDialogProps { medications: ReorderMedication[]; } +/** + * Saved-order composition: active ids always precede inactive ids, + * matching the render grouping of both list views. Exported so tests + * can pin that an inactive id cannot land above an active one. + */ +export function buildSavedOrder( + activeIds: readonly string[], + inactiveIds: readonly string[], +): string[] { + return [...activeIds, ...inactiveIds]; +} + +/** + * Arrow-button move, bounded to the section: swapping past either end + * returns the input untouched, so the boundary between the groups is + * unreachable by construction. + */ +export function moveWithinSection( + ids: readonly string[], + id: string, + delta: -1 | 1, +): string[] { + const idx = ids.indexOf(id); + const targetIdx = idx + delta; + if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return [...ids]; + return reorderById( + ids.map((rowId, order) => ({ id: rowId, order })), + id, + ids[targetIdx], + ).map((r) => r.id); +} + export function MedicationReorderDialog({ open, onOpenChange, @@ -73,67 +109,52 @@ export function MedicationReorderDialog({ const queryClient = useQueryClient(); const dragHintId = useId(); const [saving, setSaving] = useState(false); - // Draft id order — null means "the order the page passed in". Created - // on the first move so an untouched dialog can cancel without churn. - const [draft, setDraft] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); + // Per-section draft id order — null means "the order the page passed + // in". Created on the first move so an untouched dialog can cancel + // without churn. + const [draftActive, setDraftActive] = useState(null); + const [draftInactive, setDraftInactive] = useState(null); const byId = new Map(medications.map((m) => [m.id, m])); - const ids = - draft ?? medications.map((m) => m.id); - const rows = ids - .map((id) => byId.get(id)) - .filter((m): m is ReorderMedication => m !== undefined); + const activeIds = + draftActive ?? + medications.filter((m) => m.active).map((m) => m.id); + const inactiveIds = + draftInactive ?? + medications.filter((m) => !m.active).map((m) => m.id); - function applyReorder(fromId: string, toId: string) { - const reordered = reorderById( - ids.map((id, order) => ({ id, order })), - fromId, - toId, - ); - setDraft(reordered.map((r) => r.id)); - } - - function move(id: string, delta: -1 | 1) { - const idx = ids.indexOf(id); - const targetIdx = idx + delta; - if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return; - applyReorder(id, ids[targetIdx]); - } - - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id) return; - applyReorder(String(active.id), String(over.id)); - } + const labels = { + moveUp: t("dashboard.moveUp"), + moveDown: t("dashboard.moveDown"), + dragHandle: t("dashboard.dragHandle"), + inactive: t("common.inactive"), + }; async function save() { setSaving(true); const ok = await runSaveMedicationListOrder({ - order: ids, + order: buildSavedOrder(activeIds, inactiveIds), queryClient, t, }); setSaving(false); if (ok) { - setDraft(null); + setDraftActive(null); + setDraftInactive(null); onOpenChange(false); } } function close(next: boolean) { - if (!next) setDraft(null); + if (!next) { + setDraftActive(null); + setDraftInactive(null); + } onOpenChange(next); } + const dirty = draftActive !== null || draftInactive !== null; + return ( @@ -144,33 +165,32 @@ export function MedicationReorderDialog({ -
- - - {rows.map((med, index) => ( - - ))} - - - {rows.length > 0 && ( +
+ {activeIds.length > 0 && ( + + )} + {inactiveIds.length > 0 && ( + + )} + {activeIds.length + inactiveIds.length > 0 && (

{t("dashboard.dragHandleHint")}

@@ -185,7 +205,7 @@ export function MedicationReorderDialog({ > {t("common.cancel")} -