From 7d3557a3fd4385ce91cdd168e8a3852f963d0a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 17:42:31 +0200 Subject: [PATCH 01/13] fix(medications): every taken dose moves the stock, in units that match the product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API. --- docs/api/openapi.yaml | 443 +++++++++++- messages/de.json | 39 +- messages/en.json | 39 +- messages/es.json | 39 +- messages/fr.json | 39 +- messages/it.json | 39 +- messages/pl.json | 39 +- .../0149_v11610_inventory_units/migration.sql | 36 + prisma/schema.prisma | 47 +- .../inventory-write-isolation.test.ts | 79 +++ .../ingest/medication/__tests__/route.test.ts | 49 ++ src/app/api/ingest/medication/route.ts | 12 + src/app/api/insights/glp1-timeline/route.ts | 9 + .../[id]/glp1/__tests__/route.test.ts | 75 +- src/app/api/medications/[id]/glp1/route.ts | 35 +- .../intake/[eventId]/__tests__/route.test.ts | 174 +++++ .../[id]/intake/[eventId]/route.ts | 50 ++ .../[id]/intake/__tests__/route.test.ts | 24 +- .../bulk-delete/__tests__/route.test.ts | 41 ++ .../[id]/intake/bulk-delete/route.ts | 16 +- src/app/api/medications/[id]/intake/route.ts | 65 +- .../[itemId]/__tests__/route.test.ts | 18 +- .../[id]/inventory/[itemId]/route.ts | 21 +- .../[id]/inventory/__tests__/route.test.ts | 17 +- .../api/medications/[id]/inventory/route.ts | 11 +- src/app/api/medications/[id]/route.ts | 4 + .../intake/__tests__/route.test.ts | 154 ++++ .../intake/bulk/__tests__/route.test.ts | 185 +++++ src/app/api/medications/intake/bulk/route.ts | 54 +- src/app/api/medications/intake/route.ts | 41 ++ src/app/api/medications/route.ts | 3 + .../telegram/webhook/__tests__/route.test.ts | 21 + src/app/api/telegram/webhook/route.ts | 39 +- .../__tests__/add-inventory-dialog.test.tsx | 173 +++++ .../__tests__/supply-runway.test.ts | 24 + .../detail/MedicationDetailTabs.tsx | 32 +- .../sections/inventory-section.tsx | 229 +++++- .../wizard/__tests__/wizard-payload.test.ts | 44 ++ .../medications/wizard/steps/Step3Dose.tsx | 32 +- .../medications/wizard/wizard-payload.ts | 21 + .../ai/coach/__tests__/glp1-snapshot.test.ts | 24 +- src/lib/ai/coach/glp1-snapshot.ts | 31 +- .../intake-slot-dedup-inventory.test.ts | 171 +++++ src/lib/medications/intake-slot-dedup.ts | 23 + .../inventory/__tests__/consumption.test.ts | 655 ++++++++++++++++++ .../inventory/__tests__/service.test.ts | 112 +-- .../inventory/__tests__/state-machine.test.ts | 84 +-- src/lib/medications/inventory/consumption.ts | 377 ++++++++++ src/lib/medications/inventory/service.ts | 114 +-- .../medications/inventory/state-machine.ts | 131 +--- src/lib/openapi/routes/medications.ts | 193 ++++++ .../__tests__/medication-inventory.test.ts | 166 +++++ src/lib/validations/medication.ts | 166 +++-- 53 files changed, 4118 insertions(+), 641 deletions(-) create mode 100644 prisma/migrations/0149_v11610_inventory_units/migration.sql create mode 100644 src/__tests__/inventory-write-isolation.test.ts create mode 100644 src/components/medications/__tests__/add-inventory-dialog.test.tsx create mode 100644 src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts create mode 100644 src/lib/medications/inventory/__tests__/consumption.test.ts create mode 100644 src/lib/medications/inventory/consumption.ts create mode 100644 src/lib/validations/__tests__/medication-inventory.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 541c54915..c01249a6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2289,6 +2289,145 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/inventory: + get: + tags: + - Medications + summary: List a medication's supply containers + description: Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then + `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Inventory item list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationInventoryResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Medications + summary: Register a new supply container + description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field + carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationInventoryItemRequest" + responses: + "201": + description: Created inventory item. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/medications/{id}/inventory/{itemId}: + patch: + tags: + - 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 + capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as + `medication.inventory.update`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: itemId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationInventoryItemRequest" + responses: + "200": + description: Updated inventory item. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Inventory item not found (or owned by another user / medication). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Medications + summary: Delete a supply container + description: Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so + a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; + a later restore skips the missing container. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: itemId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Inventory item not found (or owned by another user / medication). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/medications/extract: post: tags: @@ -4532,6 +4671,12 @@ components: dosesPerUnit: type: integer minimum: 1 + maximum: 1000 + unitsPerDose: + description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake + consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. + type: integer + minimum: 1 maximum: 100 deliveryForm: type: string @@ -4779,8 +4924,14 @@ components: anyOf: - type: integer minimum: 1 - maximum: 100 + maximum: 1000 - type: "null" + unitsPerDose: + description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook + decrements this many units per taken dose; already-stamped intake events keep their recorded consumption. + type: integer + minimum: 1 + maximum: 100 deliveryForm: type: string enum: @@ -4940,6 +5091,78 @@ components: the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value). + CreateMedicationInventoryItemRequest: + type: object + properties: + dosesTotal: + 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`. + containerType: + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; + defaults to OTHER. + type: string + enum: + - PEN + - AMPOULE + - BLISTER + - INHALER + - BOTTLE + - OTHER + printedExpiry: + anyOf: + - type: string + 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" + purchasedAt: + anyOf: + - type: string + 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" + notes: + anyOf: + - type: string + maxLength: 200 + - type: "null" + required: + - dosesTotal + description: Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item + starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose. + UpdateMedicationInventoryItemRequest: + type: object + properties: + markAsFirstUseAt: + description: Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE + flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED. + type: string + 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)))$ + markAsUsedUp: + description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)." + type: boolean + printedExpiry: + anyOf: + - type: string + 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: + 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 + minimum: 0 + maximum: 1000 + notes: + anyOf: + - type: string + maxLength: 200 + - type: "null" + description: "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute + remaining-unit correction, notes. Every field is optional and commutative." MedicationExtractRequest: type: object properties: @@ -8264,6 +8487,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -8372,6 +8602,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8449,6 +8680,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -8542,6 +8780,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8702,6 +8941,200 @@ components: - data - error additionalProperties: false + ListMedicationInventoryResponse: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/MedicationInventoryItem" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + additionalProperties: false + required: + - items + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationInventoryItem: + type: object + properties: + id: + type: string + userId: + type: string + medicationId: + type: string + state: + type: string + enum: + - ACTIVE + - IN_USE + - EXPIRED + - USED_UP + description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = + printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal). + containerType: + type: string + enum: + - PEN + - AMPOULE + - BLISTER + - INHALER + - BOTTLE + - OTHER + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level + classification; defaults to OTHER. + unitsTotal: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the + medication's `unitsPerDose`. + unitsRemaining: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across + containers); refunded when a taken dose is skipped, edited away, or deleted. + firstUseAt: + anyOf: + - type: string + 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" + description: Instant the container was first used. NULL until opened; starts the 30-day in-use clock. + expiresAt: + anyOf: + - type: string + 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" + description: Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started. + printedExpiry: + anyOf: + - type: string + 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" + purchasedAt: + anyOf: + - type: string + 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" + notes: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + 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)))$ + updatedAt: + type: string + 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)))$ + required: + - id + - userId + - medicationId + - state + - containerType + - unitsTotal + - unitsRemaining + - firstUseAt + - expiresAt + - printedExpiry + - purchasedAt + - notes + - createdAt + - updatedAt + additionalProperties: false + description: One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's + `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then + first-expiry-first-out over unopened stock. + CreateMedicationInventoryItemResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationInventoryItem" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationInventoryItemResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationInventoryItem" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationInventoryItemResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + deleted: + type: boolean + required: + - id + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationExtractResponse: type: object properties: @@ -12887,6 +13320,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -12978,6 +13418,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled diff --git a/messages/de.json b/messages/de.json index 008dd5f5f..d49bcd2ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", + "scoreComputing": "Score wird berechnet …", "doses": { "summary": "{taken} von {scheduled} Dosen genommen", "nextAt": "Nächste um {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.", "injectionSiteToggle": "Injektionsstellen-Rotation", "injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.", - "short": "Dosis" + "short": "Dosis", + "unitsPerDoseLabel": "Einheiten pro Dosis", + "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Bestand", "summary": "{remaining} von {total} Dosen übrig", - "itemsTitle": "Pens / Ampullen", + "itemsTitle": "Packungen & Behälter", "doses": "{remaining} / {total} Dosen", "state": { "ACTIVE": "Ungeöffnet", @@ -1594,25 +1597,39 @@ "USED_UP": "Aufgebraucht" }, "empty": "Kein Bestand erfasst.", - "emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen.", + "emptyHelper": "Erfasse eine Packung oder einen Behälter, um den verbleibenden Vorrat und die Reichweite zu verfolgen.", "emptyCta": "Packung erfassen", "addButton": "Erfassen", - "addTitle": "Pen oder Packung erfassen", - "addDescription": "Lege einen neuen Vorrat mit seiner Dosenanzahl an; das Ablaufdatum ist optional.", - "addQuantityLabel": "Menge (Dosen / Einheiten)", - "addQuantityHelper": "1–100 Dosen pro Pen oder Packung.", + "addTitle": "Packung oder Behälter erfassen", + "addDescription": "Lege einen neuen Vorrat mit seiner Einheitenanzahl an; das Ablaufdatum ist optional.", + "addQuantityLabel": "Menge (Einheiten)", + "addQuantityHelper": "1–1000 Einheiten pro Packung oder Behälter.", "addExpiryLabel": "Ablaufdatum (optional)", "addSubmit": "Erfassen", "addSuccess": "Bestand erfasst.", "addFailed": "Bestand konnte nicht erfasst werden.", "adjustButton": "Anpassen", "adjustTitle": "Bestand korrigieren", - "adjustDescription": "Setze die verbleibenden Dosen neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.", - "adjustQuantityLabel": "Verbleibende Dosen", - "adjustHelper": "Kapazität: {total} Dosen.", + "adjustDescription": "Setze die verbleibenden Einheiten neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.", + "adjustQuantityLabel": "Verbleibende Einheiten", + "adjustHelper": "Kapazität: {total} Einheiten.", "adjustSubmit": "Speichern", "adjustSuccess": "Bestand aktualisiert.", - "adjustFailed": "Bestand konnte nicht aktualisiert werden." + "adjustFailed": "Bestand konnte nicht aktualisiert werden.", + "containerTypeLabel": "Behälterart", + "containerType": { + "PEN": "Pen", + "AMPOULE": "Ampulle", + "BLISTER": "Tablettenpackung", + "INHALER": "Inhalator", + "BOTTLE": "Flasche", + "OTHER": "Sonstiges" + }, + "unitsDetail": "{remaining} / {total} Einheiten", + "quantityModeDoses": "Dosen", + "quantityModeUnits": "Einheiten", + "quantityInUnits": "= {units} Einheiten", + "quantityInDoses": "≈ {doses} Dosen" }, "erweitert": { "group": { diff --git a/messages/en.json b/messages/en.json index 891a06406..e0c9d9a60 100644 --- a/messages/en.json +++ b/messages/en.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", + "scoreComputing": "Calculating score …", "doses": { "summary": "{taken} of {scheduled} doses taken", "nextAt": "Next at {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.", "injectionSiteToggle": "Injection-site rotation", "injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Units per dose", + "unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose" }, "step4": { "title": "Over what period are you taking it?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Supply", "summary": "{remaining} of {total} doses remaining", - "itemsTitle": "Pens / vials", + "itemsTitle": "Packs & containers", "doses": "{remaining} / {total} doses", "state": { "ACTIVE": "Unopened", @@ -1594,25 +1597,39 @@ "USED_UP": "Used up" }, "empty": "No supply recorded.", - "emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply.", + "emptyHelper": "Register a pack or container to track remaining supply and runway.", "emptyCta": "Register a pack", "addButton": "Register", - "addTitle": "Register a pen or pack", - "addDescription": "Record a new supply with its dose count; the expiry date is optional.", - "addQuantityLabel": "Quantity (doses / units)", - "addQuantityHelper": "1–100 doses per pen or pack.", + "addTitle": "Register a pack or container", + "addDescription": "Record a new supply with its unit count; the expiry date is optional.", + "addQuantityLabel": "Quantity (units)", + "addQuantityHelper": "1–1000 units per pack or container.", "addExpiryLabel": "Expiry date (optional)", "addSubmit": "Register", "addSuccess": "Supply registered.", "addFailed": "The supply could not be registered.", "adjustButton": "Adjust", "adjustTitle": "Correct stock", - "adjustDescription": "Set the remaining doses — for corrections or a withdrawal. 0 marks the item as used up.", - "adjustQuantityLabel": "Remaining doses", - "adjustHelper": "Capacity: {total} doses.", + "adjustDescription": "Set the remaining units — for corrections or a withdrawal. 0 marks the item as used up.", + "adjustQuantityLabel": "Remaining units", + "adjustHelper": "Capacity: {total} units.", "adjustSubmit": "Save", "adjustSuccess": "Stock updated.", - "adjustFailed": "The stock could not be updated." + "adjustFailed": "The stock could not be updated.", + "containerTypeLabel": "Container type", + "containerType": { + "PEN": "Pen", + "AMPOULE": "Ampoule", + "BLISTER": "Tablet pack", + "INHALER": "Inhaler", + "BOTTLE": "Bottle", + "OTHER": "Other" + }, + "unitsDetail": "{remaining} / {total} units", + "quantityModeDoses": "Doses", + "quantityModeUnits": "Units", + "quantityInUnits": "= {units} units", + "quantityInDoses": "≈ {doses} doses" }, "erweitert": { "group": { diff --git a/messages/es.json b/messages/es.json index 06537b0b8..6413beec0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Aún no hay datos suficientes", + "scoreComputing": "Calculando la puntuación …", "doses": { "summary": "{taken} de {scheduled} dosis tomadas", "nextAt": "Próxima a las {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.", "injectionSiteToggle": "Rotación del sitio de inyección", "injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.", - "short": "Dosis" + "short": "Dosis", + "unitsPerDoseLabel": "Unidades por dosis", + "unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg" }, "step4": { "title": "¿Durante qué periodo lo tomas?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Existencias", "summary": "{remaining} de {total} dosis restantes", - "itemsTitle": "Plumas / viales", + "itemsTitle": "Envases y recipientes", "doses": "{remaining} / {total} dosis", "state": { "ACTIVE": "Sin abrir", @@ -1594,25 +1597,39 @@ "USED_UP": "Agotado" }, "empty": "No hay existencias registradas.", - "emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro.", + "emptyHelper": "Registra un envase o recipiente para hacer seguimiento de las existencias restantes y la autonomía.", "emptyCta": "Registrar un envase", "addButton": "Registrar", - "addTitle": "Registrar una pluma o un envase", - "addDescription": "Registra una nueva existencia con su número de dosis; la fecha de caducidad es opcional.", - "addQuantityLabel": "Cantidad (dosis / unidades)", - "addQuantityHelper": "1–100 dosis por pluma o envase.", + "addTitle": "Registrar un envase o recipiente", + "addDescription": "Registra una nueva existencia con su número de unidades; la fecha de caducidad es opcional.", + "addQuantityLabel": "Cantidad (unidades)", + "addQuantityHelper": "1–1000 unidades por envase o recipiente.", "addExpiryLabel": "Fecha de caducidad (opcional)", "addSubmit": "Registrar", "addSuccess": "Existencias registradas.", "addFailed": "No se pudieron registrar las existencias.", "adjustButton": "Ajustar", "adjustTitle": "Corregir existencias", - "adjustDescription": "Establece las dosis restantes — para correcciones o una retirada. 0 marca el envase como agotado.", - "adjustQuantityLabel": "Dosis restantes", - "adjustHelper": "Capacidad: {total} dosis.", + "adjustDescription": "Establece las unidades restantes — para correcciones o una retirada. 0 marca el envase como agotado.", + "adjustQuantityLabel": "Unidades restantes", + "adjustHelper": "Capacidad: {total} unidades.", "adjustSubmit": "Guardar", "adjustSuccess": "Existencias actualizadas.", - "adjustFailed": "No se pudieron actualizar las existencias." + "adjustFailed": "No se pudieron actualizar las existencias.", + "containerTypeLabel": "Tipo de envase", + "containerType": { + "PEN": "Pluma", + "AMPOULE": "Ampolla", + "BLISTER": "Envase de comprimidos", + "INHALER": "Inhalador", + "BOTTLE": "Frasco", + "OTHER": "Otro" + }, + "unitsDetail": "{remaining} / {total} unidades", + "quantityModeDoses": "Dosis", + "quantityModeUnits": "Unidades", + "quantityInUnits": "= {units} unidades", + "quantityInDoses": "≈ {doses} dosis" }, "erweitert": { "group": { diff --git a/messages/fr.json b/messages/fr.json index 445d9e1f3..19487aaa5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Pas encore assez de données", + "scoreComputing": "Calcul du score en cours …", "doses": { "summary": "{taken} dose(s) prise(s) sur {scheduled}", "nextAt": "Prochaine à {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.", "injectionSiteToggle": "Rotation des sites d'injection", "injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Unités par dose", + "unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg" }, "step4": { "title": "Sur quelle période le prenez-vous ?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Stock", "summary": "{remaining} doses restantes sur {total}", - "itemsTitle": "Stylos / flacons", + "itemsTitle": "Boîtes et contenants", "doses": "{remaining} / {total} doses", "state": { "ACTIVE": "Non ouvert", @@ -1594,25 +1597,39 @@ "USED_UP": "Épuisé" }, "empty": "Aucun stock enregistré.", - "emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie.", + "emptyHelper": "Enregistrez une boîte ou un contenant pour suivre le stock restant et l'autonomie.", "emptyCta": "Enregistrer une boîte", "addButton": "Enregistrer", - "addTitle": "Enregistrer un stylo ou une boîte", - "addDescription": "Enregistrez un nouveau stock avec son nombre de doses ; la date de péremption est facultative.", - "addQuantityLabel": "Quantité (doses / unités)", - "addQuantityHelper": "1–100 doses par stylo ou boîte.", + "addTitle": "Enregistrer une boîte ou un contenant", + "addDescription": "Enregistrez un nouveau stock avec son nombre d'unités ; la date de péremption est facultative.", + "addQuantityLabel": "Quantité (unités)", + "addQuantityHelper": "1–1000 unités par boîte ou contenant.", "addExpiryLabel": "Date de péremption (facultatif)", "addSubmit": "Enregistrer", "addSuccess": "Stock enregistré.", "addFailed": "Le stock n'a pas pu être enregistré.", "adjustButton": "Ajuster", "adjustTitle": "Corriger le stock", - "adjustDescription": "Définissez les doses restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.", - "adjustQuantityLabel": "Doses restantes", - "adjustHelper": "Capacité : {total} doses.", + "adjustDescription": "Définissez les unités restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.", + "adjustQuantityLabel": "Unités restantes", + "adjustHelper": "Capacité : {total} unités.", "adjustSubmit": "Enregistrer", "adjustSuccess": "Stock mis à jour.", - "adjustFailed": "Le stock n'a pas pu être mis à jour." + "adjustFailed": "Le stock n'a pas pu être mis à jour.", + "containerTypeLabel": "Type de contenant", + "containerType": { + "PEN": "Stylo", + "AMPOULE": "Ampoule", + "BLISTER": "Plaquette de comprimés", + "INHALER": "Inhalateur", + "BOTTLE": "Flacon", + "OTHER": "Autre" + }, + "unitsDetail": "{remaining} / {total} unités", + "quantityModeDoses": "Doses", + "quantityModeUnits": "Unités", + "quantityInUnits": "= {units} unités", + "quantityInDoses": "≈ {doses} doses" }, "erweitert": { "group": { diff --git a/messages/it.json b/messages/it.json index a71112349..097725139 100644 --- a/messages/it.json +++ b/messages/it.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Dati ancora insufficienti", + "scoreComputing": "Calcolo del punteggio in corso …", "doses": { "summary": "{taken} dosi assunte su {scheduled}", "nextAt": "Prossima alle {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.", "injectionSiteToggle": "Rotazione del sito di iniezione", "injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Unità per dose", + "unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg" }, "step4": { "title": "Per quanto tempo lo assumi?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Scorta", "summary": "{remaining} dosi rimanenti su {total}", - "itemsTitle": "Penne / flaconi", + "itemsTitle": "Confezioni e contenitori", "doses": "{remaining} / {total} dosi", "state": { "ACTIVE": "Non aperto", @@ -1594,25 +1597,39 @@ "USED_UP": "Esaurito" }, "empty": "Nessuna scorta registrata.", - "emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia.", + "emptyHelper": "Registra una confezione o un contenitore per monitorare la scorta rimanente e l'autonomia.", "emptyCta": "Registra una confezione", "addButton": "Registra", - "addTitle": "Registra una penna o una confezione", - "addDescription": "Registra una nuova scorta con il numero di dosi; la data di scadenza è facoltativa.", - "addQuantityLabel": "Quantità (dosi / unità)", - "addQuantityHelper": "1–100 dosi per penna o confezione.", + "addTitle": "Registra una confezione o un contenitore", + "addDescription": "Registra una nuova scorta con il numero di unità; la data di scadenza è facoltativa.", + "addQuantityLabel": "Quantità (unità)", + "addQuantityHelper": "1–1000 unità per confezione o contenitore.", "addExpiryLabel": "Data di scadenza (facoltativa)", "addSubmit": "Registra", "addSuccess": "Scorta registrata.", "addFailed": "Impossibile registrare la scorta.", "adjustButton": "Correggi", "adjustTitle": "Correggi la scorta", - "adjustDescription": "Imposta le dosi rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.", - "adjustQuantityLabel": "Dosi rimanenti", - "adjustHelper": "Capacità: {total} dosi.", + "adjustDescription": "Imposta le unità rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.", + "adjustQuantityLabel": "Unità rimanenti", + "adjustHelper": "Capacità: {total} unità.", "adjustSubmit": "Salva", "adjustSuccess": "Scorta aggiornata.", - "adjustFailed": "Impossibile aggiornare la scorta." + "adjustFailed": "Impossibile aggiornare la scorta.", + "containerTypeLabel": "Tipo di contenitore", + "containerType": { + "PEN": "Penna", + "AMPOULE": "Fiala", + "BLISTER": "Confezione di compresse", + "INHALER": "Inalatore", + "BOTTLE": "Flacone", + "OTHER": "Altro" + }, + "unitsDetail": "{remaining} / {total} unità", + "quantityModeDoses": "Dosi", + "quantityModeUnits": "Unità", + "quantityInUnits": "= {units} unità", + "quantityInDoses": "≈ {doses} dosi" }, "erweitert": { "group": { diff --git a/messages/pl.json b/messages/pl.json index 18ac910b5..61fb2f695 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Za mało danych", + "scoreComputing": "Trwa obliczanie wyniku …", "doses": { "summary": "Przyjęto {taken} z {scheduled} dawek", "nextAt": "Następna o {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.", "injectionSiteToggle": "Rotacja miejsc wstrzyknięcia", "injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.", - "short": "Dawka" + "short": "Dawka", + "unitsPerDoseLabel": "Jednostki na dawkę", + "unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg" }, "step4": { "title": "Przez jaki okres go przyjmujesz?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Zapas", "summary": "Pozostało {remaining} z {total} dawek", - "itemsTitle": "Wstrzykiwacze / fiolki", + "itemsTitle": "Opakowania i pojemniki", "doses": "{remaining} / {total} dawek", "state": { "ACTIVE": "Nieotwarte", @@ -1594,25 +1597,39 @@ "USED_UP": "Zużyte" }, "empty": "Nie zarejestrowano zapasu.", - "emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie.", + "emptyHelper": "Zarejestruj opakowanie lub pojemnik, aby śledzić pozostały zapas i jego wystarczalność.", "emptyCta": "Zarejestruj opakowanie", "addButton": "Zarejestruj", - "addTitle": "Zarejestruj wstrzykiwacz lub opakowanie", - "addDescription": "Zapisz nowy zapas z liczbą dawek; data ważności jest opcjonalna.", - "addQuantityLabel": "Ilość (dawki / jednostki)", - "addQuantityHelper": "1–100 dawek na wstrzykiwacz lub opakowanie.", + "addTitle": "Zarejestruj opakowanie lub pojemnik", + "addDescription": "Zapisz nowy zapas z liczbą jednostek; data ważności jest opcjonalna.", + "addQuantityLabel": "Ilość (jednostki)", + "addQuantityHelper": "1–1000 jednostek na opakowanie lub pojemnik.", "addExpiryLabel": "Data ważności (opcjonalnie)", "addSubmit": "Zarejestruj", "addSuccess": "Zapas zarejestrowany.", "addFailed": "Nie udało się zarejestrować zapasu.", "adjustButton": "Skoryguj", "adjustTitle": "Skoryguj zapas", - "adjustDescription": "Ustaw pozostałe dawki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.", - "adjustQuantityLabel": "Pozostałe dawki", - "adjustHelper": "Pojemność: {total} dawek.", + "adjustDescription": "Ustaw pozostałe jednostki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.", + "adjustQuantityLabel": "Pozostałe jednostki", + "adjustHelper": "Pojemność: {total} jednostek.", "adjustSubmit": "Zapisz", "adjustSuccess": "Zapas zaktualizowany.", - "adjustFailed": "Nie udało się zaktualizować zapasu." + "adjustFailed": "Nie udało się zaktualizować zapasu.", + "containerTypeLabel": "Rodzaj pojemnika", + "containerType": { + "PEN": "Wstrzykiwacz", + "AMPOULE": "Ampułka", + "BLISTER": "Opakowanie tabletek", + "INHALER": "Inhalator", + "BOTTLE": "Butelka", + "OTHER": "Inne" + }, + "unitsDetail": "{remaining} / {total} jednostek", + "quantityModeDoses": "Dawki", + "quantityModeUnits": "Jednostki", + "quantityInUnits": "= {units} jednostek", + "quantityInDoses": "≈ {doses} dawek" }, "erweitert": { "group": { diff --git a/prisma/migrations/0149_v11610_inventory_units/migration.sql b/prisma/migrations/0149_v11610_inventory_units/migration.sql new file mode 100644 index 000000000..2c56f2dad --- /dev/null +++ b/prisma/migrations/0149_v11610_inventory_units/migration.sql @@ -0,0 +1,36 @@ +-- v1.16.10 — multi-unit inventory consumption. +-- +-- `units_per_dose` (medications): how many inventory UNITS one dose +-- consumes (2 × 2 mg tablets for a 4 mg dose). Default 1 keeps every +-- existing medication on the one-unit-per-dose behaviour. +-- +-- `container_type` (medication_inventory_items): what kind of physical +-- container the item is (pen / ampoule / blister pack / inhaler / +-- bottle). Display-level only. Backfilled PEN for items whose +-- medication is an INJECTION (the pre-v1.16.10 surface was pen-only), +-- OTHER for everything else. +-- +-- `inventory_consumption` (medication_intake_events): the per-event +-- consumption stamp `[{itemId, units}]` — the exactly-once gate for +-- the consume hook and the refund ledger for the restore hook. NULL = +-- never consumed (pending / skipped / pre-v1.16.10 rows). +-- +-- The Prisma fields `dosesTotal` / `dosesRemaining` are renamed to +-- `unitsTotal` / `unitsRemaining` at the client level only; the DB +-- columns keep their names via @map — zero data migration. + +CREATE TYPE "medication_container_type" AS ENUM ('PEN', 'AMPOULE', 'BLISTER', 'INHALER', 'BOTTLE', 'OTHER'); + +ALTER TABLE "medications" ADD COLUMN "units_per_dose" INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE "medication_inventory_items" ADD COLUMN "container_type" "medication_container_type" NOT NULL DEFAULT 'OTHER'; + +ALTER TABLE "medication_intake_events" ADD COLUMN "inventory_consumption" JSONB; + +-- Backfill: every existing item was registered through the pen-framed +-- surface; items on injection medications are pens. +UPDATE "medication_inventory_items" i +SET "container_type" = 'PEN' +FROM "medications" m +WHERE i."medication_id" = m."id" + AND m."delivery_form" = 'INJECTION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 439ea78ad..099674630 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1261,6 +1261,12 @@ model Medication { /// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose /// pen = 1). NULL = inventory tracking off. dosesPerUnit Int? @map("doses_per_unit") + /// v1.16.10 — units consumed per dose. The inventory items count UNITS + /// (tablets, ampoules, puffs); a single dose may consume several of + /// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption + /// hook multiplies by this factor; dose-level readouts divide by it. + /// Default 1 = one unit per dose (the overwhelmingly common case). + unitsPerDose Int @default(1) @map("units_per_dose") active Boolean @default(true) notificationsEnabled Boolean @default(true) @map("notifications_enabled") pausedAt DateTime? @map("paused_at") @@ -1599,6 +1605,15 @@ model MedicationIntakeEvent { /// log time (half tablet, titration day). NULL = the configured /// medication / schedule dose applies; read paths fall back to it. doseTaken String? @map("dose_taken") + /// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the + /// containers this taken event decremented. NULL = never consumed + /// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly- + /// once gate for the consume hook (a stamped row never decrements + /// again, however often a sync replays it) and the refund ledger for + /// the restore hook (taken → skipped / deleted refunds per item). The + /// stamp freezes history: editing `Medication.unitsPerDose` later + /// never changes what an already-taken dose consumed. + inventoryConsumption Json? @map("inventory_consumption") // v1.7.0 sync — server-set mutation timestamp. The model had no // `updatedAt`; the offline-sync keyset feed orders on it, so it is // added here. `@updatedAt` makes Prisma maintain it on every write. @@ -1706,16 +1721,38 @@ enum MedicationInventoryState { @@map("medication_inventory_state") } +/// v1.16.10 — what kind of physical container an inventory item is. +/// Display-level only (no state-machine behaviour hangs off it); the +/// register dialog defaults it from the medication's delivery form +/// (INJECTION → PEN, ORAL → BLISTER, else OTHER). +enum MedicationContainerType { + PEN + AMPOULE + BLISTER + INHALER + BOTTLE + OTHER + + @@map("medication_container_type") +} + model MedicationInventoryItem { id String @id @default(cuid()) userId String @map("user_id") medicationId String @map("medication_id") state MedicationInventoryState @default(ACTIVE) - /// Doses the pen / vial shipped with. Mounjaro KwikPen = 4, - /// Trulicity single-dose = 1, Byetta = 60. - dosesTotal Int @map("doses_total") - /// Remaining doses; decremented by the intake hook. - dosesRemaining Int @map("doses_remaining") + /// v1.16.10 — container kind (pen / ampoule / blister pack / …). + /// Backfilled PEN for items on INJECTION medications, OTHER otherwise. + containerType MedicationContainerType @default(OTHER) @map("container_type") + /// Units the container shipped with (v1.16.10 — renamed from + /// `dosesTotal`; the DB column stays `doses_total`). A unit is one + /// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to + /// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1. + unitsTotal Int @map("doses_total") + /// Remaining units; decremented by the intake consumption hook + /// (v1.16.10 — renamed from `dosesRemaining`; column stays + /// `doses_remaining`). + unitsRemaining Int @map("doses_remaining") /// NULL until the pen is first opened. Sets the 30-day in-use /// clock per EMA EPAR §6.3. firstUseAt DateTime? @map("first_use_at") diff --git a/src/__tests__/inventory-write-isolation.test.ts b/src/__tests__/inventory-write-isolation.test.ts new file mode 100644 index 000000000..4c02bc53a --- /dev/null +++ b/src/__tests__/inventory-write-isolation.test.ts @@ -0,0 +1,79 @@ +/** + * v1.16.10 — inventory write-path isolation guard. + * + * The consumption stamp on `MedicationIntakeEvent.inventoryConsumption` + * is only an exactly-once ledger as long as EVERY stock movement flows + * through `src/lib/medications/inventory/consumption.ts` (intake-driven + * consume / restore) or the dedicated inventory surfaces (the CRUD + * routes, the create-input builder, and the expire cron in + * `service.ts`). A `medicationInventoryItem` write sprinkled into some + * future intake path would bypass the stamp and re-open the + * double-decrement / stranded-stock class of bugs this release closed. + * + * This test walks every TypeScript / TSX file under `src/` (excluding + * tests + generated code) and asserts that no file outside the + * allowlist invokes a Prisma write method on `medicationInventoryItem`. + * Reads (`findFirst`, `findMany`, …) stay legal everywhere. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = join(__dirname, "../.."); +const SRC = join(ROOT, "src"); + +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +/** + * Files allowed to write `medicationInventoryItem` rows: + * - the consumption module (intake-driven consume / restore); + * - the inventory persistence helpers (expire cron `updateMany`); + * - the two inventory CRUD routes (register / mutate / delete). + */ +const ALLOWED_WRITERS = new Set( + [ + "src/lib/medications/inventory/consumption.ts", + "src/lib/medications/inventory/service.ts", + "src/app/api/medications/[id]/inventory/route.ts", + "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + ].map((p) => p.split("/").join(sep)), +); + +const WRITE_PATTERN = + /medicationInventoryItem\s*\.\s*(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + const stat = statSync(p); + if (stat.isDirectory()) { + walk(p, out); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + out.push(p); + } + } + return out; +} + +describe("medicationInventoryItem write isolation", () => { + it("no file outside the consumption module + inventory surfaces writes inventory items", () => { + const offenders: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED_WRITERS.has(rel)) continue; + const content = readFileSync(file, "utf8"); + if (WRITE_PATTERN.test(content)) { + offenders.push(rel); + WRITE_PATTERN.lastIndex = 0; + } + } + expect(offenders).toEqual([]); + }); + + it("the allowlisted writers actually exist (allowlist stays honest)", () => { + for (const rel of ALLOWED_WRITERS) { + expect(() => statSync(join(ROOT, rel))).not.toThrow(); + } + }); +}); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 629b8c247..d33c3457a 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -40,6 +40,10 @@ vi.mock("@/lib/app-settings", () => ({ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -303,4 +307,49 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime()); expect(create.data.takenAt.getTime()).toBe(offWindow.getTime()); }); + + // ── v1.16.10 — inventory consumption seam ─────────────────────────── + + it("consumes inventory exactly once on the landed row", async () => { + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + wireHappyPath(); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-inv-1", + }), + ); + expect(res.status).toBe(201); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "evt-pending-0900", + }), + ); + }); + + it("an idempotency replay returns the original row without consuming", async () => { + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + wireHappyPath(); + // The replay probe finds the original event. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "evt-original", + } as never); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-inv-1", + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index dfeafa5f4..4b01a61c1 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -19,6 +19,7 @@ import { applyCanonicalSlotWrite, resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { NextRequest, NextResponse } from "next/server"; @@ -224,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // v1.16.10 — the external ingest records a take; consume inventory + // units. The stamp on the row keeps a retried POST exactly-once (the + // idempotency probe above already short-circuits the common replay). + await consumeForIntake({ + client: prisma, + userId: apiToken.userId, + medicationId: medication.id, + eventId: event.id, + intakeAt: effectiveTakenAt, + }); + await auditLog("medication.ingest.external", { userId: apiToken.userId, ipAddress: ip, diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 45d18cb43..306322109 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -82,6 +82,15 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: { takenAt: "desc" }, take: 50, }, + // v1.16.10 divergence note: the stock READOUTS (glp1 details + // endpoint, Coach snapshot) moved to the per-item + // MedicationInventoryItem entities, but this timeline renders the + // append-only MedicationInventoryEvent LEDGER as point-in-time + // entries ("+2 pens purchased") — a per-item swap is not + // mechanical (items carry no per-mutation history). The ledger + // only grows through the legacy `POST /api/medications/[id]/glp1` + // inventory branch, so timelines on item-tracked medications show + // no new inventory entries. inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 }, }, }); 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 b9dfb690e..c8f10a701 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -404,10 +404,11 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => id: "med-1", userId: "user-1", doseChanges: [], - inventoryEvents: [], + inventoryItems: [], intakeEvents: [], schedules: [], dosesPerUnit: null, + unitsPerDose: 1, } as never); const res = await GET(getReq(), { params: Promise.resolve({ id: "med-1" }), @@ -428,3 +429,75 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => expect(prisma.medication.findUnique).not.toHaveBeenCalled(); }); }); + +describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () => { + function getReq(): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/glp1"); + } + + it("sums usable inventory items and derives doses via unitsPerDose", 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: 2, + inventoryItems: [ + // Usable: open container with 3 units. + { state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + // Usable: unopened container with 4 units. + { state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + // Not usable: drained. + { state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + // Not usable: expired stock is not supply. + { state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + ], + } 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; + }; + }; + }; + // 2 usable containers; 7 pooled units / 2 units per dose = 3 doses. + expect(body.data.inventory).toEqual({ + pensRemaining: 2, + dosesRemaining: 3, + weeksOfSupply: 3, + lowStock: true, + }); + }); + + it("returns a null inventory block when no items exist", 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: [], + } 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 } }; + 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 a951fc643..ee887b531 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -54,7 +54,11 @@ export const GET = apiHandler( where: { id }, include: { doseChanges: { orderBy: { effectiveFrom: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "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. + inventoryItems: { orderBy: { createdAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, orderBy: { takenAt: "desc" }, @@ -69,25 +73,32 @@ export const GET = apiHandler( return apiError("Medication not found", 404); } - // Inventory math: running sum of every inventory event. Negative - // when more doses were consumed than purchased — surfaces as a - // low-stock warning rather than a hard error so the UI keeps - // working when the user backdates an inventory event. + // Inventory math over the per-item entities. The response shape is + // locked for the iOS client: `pensRemaining` = count of usable + // containers (ACTIVE / IN_USE with units left), `dosesRemaining` = + // pooled units divided by `unitsPerDose` (floored — consumption + // spills across containers), `weeksOfSupply` keeps the weekly- + // cadence approximation (the canonical GLP-1 case). let inventory: { pensRemaining: number | null; dosesRemaining: number | null; weeksOfSupply: number | null; lowStock: boolean; } | null = null; - if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) { - const pens = medication.inventoryEvents.reduce( - (sum, ev) => sum + ev.delta, + if (medication.inventoryItems.length > 0) { + const usable = medication.inventoryItems.filter( + (item) => + (item.state === "ACTIVE" || item.state === "IN_USE") && + item.unitsRemaining > 0, + ); + const unitsRemaining = usable.reduce( + (sum, item) => sum + item.unitsRemaining, 0, ); - const pensRemaining = Math.max(0, pens); - const dosesRemaining = pensRemaining * medication.dosesPerUnit; - // Approximate weeks-of-supply assuming weekly cadence (the - // canonical GLP-1 case). The Coach snapshot does the same. + const pensRemaining = usable.length; + const dosesRemaining = Math.floor( + unitsRemaining / Math.max(1, medication.unitsPerDose), + ); const weeksOfSupply = dosesRemaining; const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; inventory = { diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index cc1223c6c..b832029b5 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -33,6 +33,10 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); @@ -567,3 +571,173 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect(res.status).toBe(422); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.16.10 — inventory consumption seams on PUT / DELETE +// ──────────────────────────────────────────────────────────────────── + +describe("PUT / DELETE — v1.16.10 inventory consumption", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + stubTwiceDailyMed(); + }); + + it("an edit into taken consumes on the row (no slot move)", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + // PUT lookup — pending row anchored on the 07:00 slot. + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: null, + skipped: false, + } as never) + // lifecycle liveIntake probe + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + + const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "m1", + eventId: "e1", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("a taken → skipped edit restores the row's stamp and never consumes", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + scheduledFor: at(7, 0), + takenAt: null, + skipped: true, + } as never); + + const res = await PUT(putReq({ skipped: true, takenAt: null }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(19, 0), + takenAt: at(19, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: at(19, 5).toISOString() }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e1" }), + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e2" }), + ); + // The refund runs BEFORE the tombstone write. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); + }); + + it("DELETE refunds the row's stamp before the tombstone", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + + const res = await DELETE( + new NextRequest("http://localhost/api/medications/m1/intake/e1", { + method: "DELETE", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 997db221a..c6440447f 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -11,6 +11,10 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; @@ -241,6 +245,26 @@ export const PUT = apiHandler( syncVersion: { increment: 1 }, }, }); + // v1.16.10 — inventory follows the post-edit state: a row that is + // (now) taken consumes (the stamp keeps an already-consumed row + // untouched — the stamp freezes what the original take pulled, + // even when `unitsPerDose` changed since); a row edited out of + // taken (skip flip, takenAt cleared) refunds its stamp. + if (nextTakenAt !== null && !nextSkipped) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId, + intakeAt: nextTakenAt, + }); + } else { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + } } else { // The edit moved the dose to a different slot. Tombstone the original // row (the iOS "a correction is a tombstone + re-insert" model the @@ -248,6 +272,15 @@ export const PUT = apiHandler( // shared canonical-slot upsert, which converges onto any row already at // the target slot (a pending REMINDER row) rather than bare-updating // into an occupied slot and risking a P2002 (audit HIGH-4). + // + // v1.16.10 — refund the source row's consumption stamp BEFORE the + // tombstone; the consume on the converged row below nets the move + // to exactly one consumption. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); await prisma.medicationIntakeEvent.update({ where: { id: eventId }, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, @@ -271,6 +304,15 @@ export const PUT = apiHandler( doseTaken: event.doseTaken, }); updated = applied.row; + if (nextTakenAt !== null && !nextSkipped) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: applied.row.id, + intakeAt: nextTakenAt, + }); + } } await auditLog("medication.intake.update", { @@ -335,6 +377,14 @@ export const DELETE = apiHandler( return apiError("Intake not found", 404); } + // v1.16.10 — a deleted dose record refunds its consumption stamp + // (no-op for a never-consumed row; a re-delete finds it cleared). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + // v1.7.0 sync — soft-delete instead of a hard `delete`. An intake is // an immutable fact, so a "correction" is a tombstone + re-insert // (never an in-place edit). Setting `deletedAt` (+ bumping diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index a1ddc4662..4f7f52e5a 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -53,8 +53,9 @@ vi.mock("@/lib/idempotency", () => ({ fn(...args), })); -vi.mock("@/lib/medications/inventory/service", () => ({ - consumeOneDose: vi.fn().mockResolvedValue(null), +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/route-guards", () => ({ @@ -412,8 +413,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" }); it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => { - const { consumeOneDose } = await import( - "@/lib/medications/inventory/service" + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" ); // Resolver load → scheduled med. vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce( @@ -455,15 +456,15 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" expect(res.status).toBe(201); // Re-post of an already-taken slot → no pending→taken transition → // inventory must NOT be consumed. - expect(consumeOneDose).not.toHaveBeenCalled(); + expect(consumeForIntake).not.toHaveBeenCalled(); // The row was updated, not duplicated. expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); }); it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => { - const { consumeOneDose } = await import( - "@/lib/medications/inventory/service" + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" ); vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce( SCHEDULED_MED as never, @@ -501,7 +502,14 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" ROUTE_PARAMS, ); expect(res.status).toBe(201); - expect(consumeOneDose).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "row-pending", + }), + ); }); it("re-finds and updates the slot when the create races a P2002 (C1)", async () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index f733a8821..2a93e23ee 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -53,6 +53,10 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -241,3 +245,40 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", ); }); }); + +describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => { + it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => { + const { restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0)); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "evt-stamped", + scheduledFor: day1, + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + { id: "evt-unstamped", scheduledFor: day1, inventoryConsumption: null }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({ + count: 2, + } as never); + + const res = await POST( + postWithBody({ eventIds: ["evt-stamped", "evt-unstamped"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }), + ); + // The refund runs BEFORE the soft-delete sweep. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(sweepOrder); + }); +}); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 29eb45e76..d6daf54ad 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dayKeyForScheduledFor, @@ -106,7 +107,7 @@ export const POST = apiHandler( userId: user.id, medicationId: id, }, - select: { id: true, scheduledFor: true }, + select: { id: true, scheduledFor: true, inventoryConsumption: true }, }); if (targetRows.length === 0) { @@ -118,6 +119,19 @@ export const POST = apiHandler( dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz)); } + // v1.16.10 — refund each stamped row's inventory consumption before + // the tombstone sweep. Only stamped rows pay the round trip; the + // hook is best-effort and never blocks the delete. + for (const row of targetRows) { + if (row.inventoryConsumption !== null) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: row.id, + }); + } + } + // v1.15.18 LOW-6 — soft-delete to match the single-event DELETE handler // and the iOS tombstone contract: an intake is an immutable fact, so a // bulk "delete" sets `deletedAt` (+ bumps `syncVersion`) so the diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0fe4beafb..31bad637b 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -17,7 +17,10 @@ import { import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { withIdempotency } from "@/lib/idempotency"; -import { consumeOneDose } from "@/lib/medications/inventory/service"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -415,35 +418,27 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { } } - // v1.4.25 W19b — pen-inventory dose decrement. Only fires for - // non-skipped intakes; a skipped event is not a consumption event. - // No-op when the medication has no tracked pens (most non-GLP-1 - // meds). Failures here must never block the intake write, so - // errors are swallowed and logged — the intake is the source of - // truth, the inventory is an opt-in companion. - // - // v1.8.2 M2 — gate on an ACTUAL pending→taken transition. An idempotent - // re-post of an already-taken slot updates the row in place but must NOT - // decrement again, else a repeated iOS sync drifts the GLP-1 pen count - // down on every replay. `consumedTransition` is false when the slot was - // already taken (or on a no-downgrade no-op). - let inventoryOutcome: Awaited> = null; + // v1.16.10 — inventory consumption / restore. A taken write consumes + // `unitsPerDose` units (the stamp on the event row makes replays and + // re-posts exactly-once; `consumedTransition` skips the read for the + // no-op echo cases the slot upsert already classified). An explicit + // skip can downgrade a previously-taken row (last-write-wins), so it + // refunds whatever that row's stamp recorded. Both hooks are + // best-effort and never block the intake write. if (!skipped && consumedTransition) { - try { - inventoryOutcome = await consumeOneDose({ - userId: user.id, - medicationId: id, - intakeAt: event.takenAt ?? event.scheduledFor, - }); - } catch (err) { - annotate({ - action: { name: "medication.inventory.consume_error" }, - meta: { - medication_id: id, - message: err instanceof Error ? err.message : String(err), - }, - }); - } + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: event.id, + intakeAt: event.takenAt ?? event.scheduledFor, + }); + } else if (skipped) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: event.id, + }); } await auditLog("medication.intake", { @@ -453,12 +448,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { medicationId: id, eventId: event.id, skipped, - ...(inventoryOutcome - ? { - inventoryItemId: inventoryOutcome.itemId, - inventoryChange: inventoryOutcome.change, - } - : {}), }, }); @@ -471,12 +460,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { meta: { medication_id: id, skipped, - ...(inventoryOutcome - ? { - inventory_item_id: inventoryOutcome.itemId, - inventory_change: inventoryOutcome.change, - } - : {}), }, }); 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 799378209..37792abf5 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 @@ -137,8 +137,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc userId: "user-1", firstUseAt: null, state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 3, + unitsTotal: 4, + unitsRemaining: 3, printedExpiry: null, notes: null, } as never); @@ -154,25 +154,25 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number } }; - expect(update.data.dosesRemaining).toBe(1); + .calls[0][0] as unknown as { data: { unitsRemaining: number } }; + expect(update.data.unitsRemaining).toBe(1); }); it("derives USED_UP when corrected to zero", async () => { const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number; state: string } }; - expect(update.data.dosesRemaining).toBe(0); + .calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } }; + expect(update.data.unitsRemaining).toBe(0); expect(update.data.state).toBe("USED_UP"); }); - it("clamps a raise above the item's capacity to dosesTotal", async () => { + it("clamps a raise above the item's capacity to unitsTotal", async () => { const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number } }; - expect(update.data.dosesRemaining).toBe(4); + .calls[0][0] as unknown as { data: { unitsRemaining: number } }; + expect(update.data.unitsRemaining).toBe(4); }); it("rejects a negative correction with 422", async () => { diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 684026b20..d31d2b47e 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -82,7 +82,7 @@ export const PATCH = apiHandler( // commutative — applying them in any order produces the same row. let nextFirstUseAt = existing.firstUseAt; let nextState = existing.state; - let nextDosesRemaining = existing.dosesRemaining; + let nextUnitsRemaining = existing.unitsRemaining; let nextPrintedExpiry = existing.printedExpiry; if (markAsFirstUseAt) { @@ -98,14 +98,15 @@ export const PATCH = apiHandler( if (dosesRemaining !== undefined) { // v1.16.1 — stock correction (Bestand tab adjust / withdraw). - // 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). - nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal); + // 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); } if (markAsUsedUp === true) { - nextDosesRemaining = 0; + nextUnitsRemaining = 0; nextState = "USED_UP"; } @@ -124,8 +125,8 @@ export const PATCH = apiHandler( nextState = computeInventoryState( { state: nextState, - dosesTotal: existing.dosesTotal, - dosesRemaining: nextDosesRemaining, + unitsTotal: existing.unitsTotal, + unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, printedExpiry: nextPrintedExpiry, }, @@ -137,7 +138,7 @@ export const PATCH = apiHandler( data: { state: nextState, firstUseAt: nextFirstUseAt, - dosesRemaining: nextDosesRemaining, + unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, notes: notes === undefined ? existing.notes : notes, @@ -185,7 +186,7 @@ export const DELETE = apiHandler( medicationId: id, itemId, finalState: existing.state, - dosesRemaining: existing.dosesRemaining, + unitsRemaining: existing.unitsRemaining, }, }); 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 4d23b8c1f..14b1cd9d2 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -165,8 +165,9 @@ describe("POST /api/medications/[id]/inventory", () => { userId: "user-1", medicationId: "med-1", state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + containerType: "OTHER", + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: new Date(printed), expiresAt: new Date(printed), @@ -198,8 +199,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { userId: "user-1", medicationId: "med-1", state: "ACTIVE" as const, - dosesTotal: 4, - dosesRemaining: 4, + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: null, purchasedAt: null, @@ -243,12 +244,12 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { ...existingActive, state: "IN_USE", firstUseAt: new Date("2026-05-01"), - dosesRemaining: 2, + unitsRemaining: 2, } as never); vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ ...existingActive, state: "USED_UP", - dosesRemaining: 0, + unitsRemaining: 0, } as never); const res = await PATCH( @@ -264,7 +265,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { .calls[0][0]; expect(updateCall.data).toMatchObject({ state: "USED_UP", - dosesRemaining: 0, + unitsRemaining: 0, }); }); @@ -331,7 +332,7 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => { userId: "user-1", medicationId: "med-1", state: "EXPIRED", - dosesRemaining: 2, + unitsRemaining: 2, } as never); vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({ id: "inv-1", diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 41b3a5ebf..0f4bbb205 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -90,13 +90,17 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data; + const { dosesTotal, 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. data: buildCreateInventoryInput({ userId: user.id, medicationId: id, - dosesTotal, + unitsTotal: dosesTotal, + containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, notes: notes ?? null, @@ -109,7 +113,8 @@ export const POST = apiHandler( details: { medicationId: id, itemId: created.id, - dosesTotal, + unitsTotal: dosesTotal, + containerType: created.containerType, }, }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index b7c729399..d8b7b4263 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -222,6 +222,7 @@ export const PUT = apiHandler( category, treatmentClass, dosesPerUnit, + unitsPerDose, deliveryForm, trackInjectionSites, allowedInjectionSites, @@ -515,6 +516,9 @@ export const PUT = apiHandler( ...(dose !== undefined && { dose }), ...(treatmentClass !== undefined && { treatmentClass }), ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), ...(deliveryForm !== undefined && { deliveryForm }), // v1.8.5 — injection-site tracking opt-in + per-medication allowed // sites. Field-by-field; `false` / `[]` are valid explicit values diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index d5f824d08..a09280791 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), + // v1.16.10 — the slot-move path re-inserts via the shared upsert. + create: vi.fn(), // v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing // rows for schedules whose window opens today (covers daily // meds with `daysOfWeek: null` whose reminder hasn't fired yet). @@ -18,6 +20,8 @@ vi.mock("@/lib/db", () => ({ // v1.4.39 W-SERVER-FIX — today's projection reads active meds + // their schedules to know what to backfill. findMany: vi.fn(), + // v1.15.18 — the band resolver loads the medication via findFirst. + findFirst: vi.fn(), }, medicationComplianceRollup: { findFirst: vi.fn(), @@ -44,6 +48,11 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -62,6 +71,10 @@ vi.mock("next/headers", () => ({ import { GET, POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -366,6 +379,147 @@ describe("POST /api/medications/intake", () => { where: { id: "e1" }, data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, }); + // v1.16.10 — a row toggled out of taken refunds its consumption + // stamp (no-op for a never-consumed row) and never consumes. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + // ── v1.16.10 — inventory consumption seams ────────────────────────── + + it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const slot = new Date("2026-05-18T10:00:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: slot, + takenAt: null, + skipped: false, + injectionSite: null, + doseTaken: null, + medication: { + deliveryForm: "ORAL", + trackInjectionSites: false, + allowedInjectionSites: [], + }, + } as never); + // Band resolver: schedule-less medication → ad-hoc attribution on the + // takenAt, which equals the existing anchor → no slot move. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [], + scheduleRevisions: [], + } as never); + vi.mocked(prisma.$transaction).mockImplementation(async (arg: unknown) => + Array.isArray(arg) ? Promise.all(arg) : (arg as (c: unknown) => unknown)(prisma), + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + takenAt: slot, + skipped: false, + scheduledFor: slot, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + + const res = await POST( + req({ + intakeId: "e1", + status: "taken", + takenAt: slot.toISOString(), + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "m1", + eventId: "e1", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("taken with a slot move restores the old row's stamp before tombstoning and consumes the converged row — net one", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const oldSlot = new Date("2026-05-18T10:00:00.000Z"); + const takenAt = new Date("2026-05-18T15:00:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: oldSlot, + takenAt: oldSlot, + skipped: false, + injectionSite: null, + doseTaken: null, + medication: { + deliveryForm: "ORAL", + trackInjectionSites: false, + allowedInjectionSites: [], + }, + } as never); + // Schedule-less → ad-hoc attribution on the new takenAt → slot moves. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [], + scheduleRevisions: [], + } as never); + // Tombstone write on e1, then the shared upsert: no row at the target + // slot → create the converged row e2. + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + takenAt, + skipped: false, + idempotencyKey: null, + scheduledFor: takenAt, + source: "WEB", + createdAt: takenAt, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + + const res = await POST( + req({ + intakeId: "e1", + status: "taken", + takenAt: takenAt.toISOString(), + }), + ); + expect(res.status).toBe(200); + // Net one: refund the source row, consume the converged row. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e1" }), + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e2" }), + ); + // The refund runs BEFORE the tombstone write on the source row. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); }); }); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 92d669497..61c827638 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -42,6 +42,10 @@ vi.mock("@/lib/idempotency", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), @@ -64,6 +68,10 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -805,3 +813,180 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () = ); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.16.10 — inventory consumption seams +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption", () => { + const SCHEDULED_MED = { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + }; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z")); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1" }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue( + SCHEDULED_MED as never, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("consumes exactly once per taken entry, on the landed row id", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "row-pending", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("C2 — a pending echo onto an already-taken slot never reaches consume", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-taken", + takenAt: new Date("2026-06-15T05:01:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T05:01:00Z"), + }, + ] as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + // no takenAt, skipped defaults false → pending echo + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("an explicit skip entry restores the slot row's stamp instead of consuming", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-taken", + takenAt: new Date("2026-06-15T05:01:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T05:01:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-taken", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "row-taken" }), + ); + }); + + it("an idempotency-key replay (duplicate) never consumes again", async () => { + // An off-window take (00:20 local night, no scheduledFor) resolves + // ad-hoc → the resolver-null branch runs its replay pre-check and + // classifies the re-submission as a duplicate. + vi.setSystemTime(new Date("2026-06-15T00:30:00.000Z")); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValueOnce({ + id: "row-existing", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + takenAt: "2026-06-15T00:20:00.000Z", + idempotencyKey: "ios-replay-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { duplicates: number; entries: Array<{ status: string }> }; + }; + expect(body.data.duplicates).toBe(1); + expect(body.data.entries[0].status).toBe("duplicate"); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 5a1440f62..aa231b9ea 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -58,6 +58,10 @@ import { resolveSlotInstantForWrite, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { boundedTakenAtSchema, injectionSiteEnum, @@ -407,7 +411,8 @@ async function postBulk(request: NextRequest): Promise { if (applied.noDowngradeNoOp) { // C2 — pending echo onto an already-actioned slot. Report it as a // duplicate so the iOS cursor advances WITHOUT downgrading the - // recorded dose. + // recorded dose. A pending echo is never a taken write, so it + // must never reach the inventory consume below. duplicates += 1; results.push({ index: i, status: "duplicate", id: applied.row.id }); } else if (applied.outcome === "updated") { @@ -417,6 +422,25 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } + // v1.16.10 — inventory: a taken write consumes (the stamp on the + // row makes a replayed sync batch exactly-once), an explicit skip + // refunds a previously-taken row's stamp. Pending echoes carry + // neither flag and never touch stock. + if (!applied.noDowngradeNoOp && isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: applied.row.id, + intakeAt: entry.takenAt as Date, + }); + } else if (!applied.noDowngradeNoOp && isExplicitSkip) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: applied.row.id, + }); + } } else { // Resolver-null: PRN / off-slot / future-slot-guarded. Before the // standalone insert, two guards keep the slot from forking into a @@ -485,6 +509,22 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } + // v1.16.10 — same inventory rule as the canonical-slot branch. + if (!applied.noDowngradeNoOp && isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: applied.row.id, + intakeAt: entry.takenAt as Date, + }); + } else if (!applied.noDowngradeNoOp && isExplicitSkip) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: applied.row.id, + }); + } } else { // Genuinely standalone. Anchor the row on the intake instant — // the documented ad-hoc contract (`scheduledFor = takenAt`) — so @@ -515,6 +555,18 @@ async function postBulk(request: NextRequest): Promise { }); inserted += 1; results.push({ index: i, status: "inserted", id: row.id }); + // v1.16.10 — a fresh standalone taken row consumes inventory + // units (a fresh skip / pending row has nothing to consume or + // refund). + if (isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: row.id, + intakeAt: entry.takenAt as Date, + }); + } } } const dayKey = dayKeyForScheduledFor( diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index cbc3461a5..2ffc077f3 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -42,6 +42,10 @@ import { resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { injectionSiteEnum, type InjectionSiteValue, @@ -540,11 +544,32 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { snoozedUntil: null }, }), ]); + // v1.16.10 — the toggle recorded a take; consume inventory units. + // The stamp on the row makes a re-toggle of an already-taken slot + // a no-op, so the count only moves on the genuine pending→taken + // (or skipped→taken) transition. + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: intakeId, + intakeAt: resolvedTakenAt, + }); } else { // The take re-attributed to a different slot. Tombstone the source row // and route the dose through the shared canonical-slot upsert, which // converges onto any row already at the target slot rather than // bare-updating into an occupied slot (P2002-safe). + // + // v1.16.10 — refund the source row's consumption stamp BEFORE the + // tombstone, then consume on the converged row below: a slot move + // is a re-binding of one real dose, so the stock nets exactly one + // consumption. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: intakeId, + }); await prisma.medicationIntakeEvent.update({ where: { id: intakeId }, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, @@ -574,6 +599,15 @@ export const POST = apiHandler(async (request: NextRequest) => { where: { id: existing.medicationId }, data: { snoozedUntil: null }, }); + // v1.16.10 — consume on the converged row (the old row's stamp + // was refunded above, so the move nets one consumption). + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: applied.row.id, + intakeAt: resolvedTakenAt, + }); } } else if (status === "skipped") { updated = await prisma.medicationIntakeEvent.update({ @@ -581,6 +615,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.7.0 sync — bump the reconciliation counter on the skip toggle. data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, }); + // v1.16.10 — the row stopped being taken; refund whatever its + // consumption stamp recorded (no-op for a never-consumed row). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: intakeId, + }); } else { // snoozed: snoozedUntil lives on the Medication row. const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 2da559eaa..f8da7dde9 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -245,6 +245,7 @@ export const POST = apiHandler(async (request: NextRequest) => { category, treatmentClass, dosesPerUnit, + unitsPerDose, deliveryForm, trackInjectionSites, allowedInjectionSites, @@ -312,6 +313,8 @@ export const POST = apiHandler(async (request: NextRequest) => { // wire schema; Prisma fills the default GENERIC when omitted. ...(treatmentClass !== undefined && { treatmentClass }), ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted. + ...(unitsPerDose !== undefined && { unitsPerDose }), // v1.6.0 — route of administration; Prisma defaults to ORAL when omitted. ...(deliveryForm !== undefined && { deliveryForm }), // v1.8.5 — injection-site tracking opt-in + per-medication allowed diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index ce8c8f72d..69b863a78 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -55,6 +55,11 @@ vi.mock("@/lib/logging/context", () => ({ })), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -257,6 +262,15 @@ describe("Telegram webhook — callback dispatch", () => { }); // Intake write + snooze reset commit atomically. expect(prisma.$transaction).toHaveBeenCalledTimes(1); + // v1.16.10 — the confirmed take consumes inventory units (after the + // intake transaction committed, on the created row). + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", medicationId: "med-1" }), + ); expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); expect(deleteMessage).toHaveBeenCalledWith( @@ -329,6 +343,13 @@ describe("Telegram webhook — callback dispatch", () => { expect(snoozedUntil.getMinutes()).toBe(59); // Skip row + rest-of-day snooze commit atomically. expect(prisma.$transaction).toHaveBeenCalledTimes(1); + // v1.16.10 — an explicit skip refunds a previously-consumed stamp + // (no-op for a never-consumed row) and never consumes. + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).toHaveBeenCalledTimes(1); }); it("'ack:' answers the callback without creating an intake event", async () => { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 6a95195a6..e5e5620ef 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -20,6 +20,10 @@ import { resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { localHmAsUtc } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -268,8 +272,9 @@ async function markMedicationTaken( // an aborted Postgres transaction, so the rare create race rolls the // whole write back instead of converging in place — Telegram // redelivers the non-200 update and the retry finds the raced row. - scheduledFor = await prisma.$transaction(async (tx) => { + const written = await prisma.$transaction(async (tx) => { let slotScheduledFor: Date; + let eventId: string; if (canonicalSlot) { const applied = await applyCanonicalSlotWrite({ client: tx, @@ -285,6 +290,7 @@ async function markMedicationTaken( attributionSource: "AUTO", }); slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; } else { // Ad-hoc / PRN — standalone row under the documented contract // (`scheduledFor = takenAt`). @@ -300,12 +306,25 @@ async function markMedicationTaken( }, }); slotScheduledFor = created.scheduledFor; + eventId = created.id; } await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: null }, }); - return slotScheduledFor; + return { slotScheduledFor, eventId }; + }); + scheduledFor = written.slotScheduledFor; + // v1.16.10 — the button confirmed a take; consume inventory units. + // Runs after the intake transaction committed (the dose record must + // never hinge on the stock write); the stamp keeps a Telegram + // redelivery exactly-once. + await consumeForIntake({ + client: prisma, + userId, + medicationId: medication.id, + eventId: written.eventId, + intakeAt: takenAt, }); // The confirmation must reflect on the next read (today feed, card // pill, compliance) rather than wait out the cache TTL. @@ -494,8 +513,9 @@ async function handleCallback(update: TelegramUpdate) { // re-reminded for a recorded skip or silenced without one. Same // P2002 trade-off as the take path — the rare create race rolls // back and Telegram's redelivery converges on the raced row. - skippedScheduledFor = await prisma.$transaction(async (tx) => { + const writtenSkip = await prisma.$transaction(async (tx) => { let slotScheduledFor: Date; + let eventId: string; if (canonicalSlot) { const applied = await applyCanonicalSlotWrite({ client: tx, @@ -510,6 +530,7 @@ async function handleCallback(update: TelegramUpdate) { createSource: "REMINDER", }); slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; } else { const created = await tx.medicationIntakeEvent.create({ data: { @@ -523,12 +544,22 @@ async function handleCallback(update: TelegramUpdate) { }, }); slotScheduledFor = created.scheduledFor; + eventId = created.id; } await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: endOfDay }, }); - return slotScheduledFor; + return { slotScheduledFor, eventId }; + }); + skippedScheduledFor = writtenSkip.slotScheduledFor; + // v1.16.10 — an explicit skip can downgrade a previously-taken + // slot row (last-write-wins); refund whatever its consumption + // stamp recorded. No-op for a never-consumed row. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: writtenSkip.eventId, }); invalidateUserMedications(user.id, { evict: true }); } diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx new file mode 100644 index 000000000..35e39d1c0 --- /dev/null +++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx @@ -0,0 +1,173 @@ +/** + * v1.16.10 — `` register-flow coverage. + * + * Pins the multi-unit quantity input: the segmented Dosen | Einheiten + * control only surfaces when a dose spans more than one unit, the live + * conversion line renders next to the quantity, the POST always carries + * UNITS, and the container-type select defaults from the delivery form. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`, + * no `@testing-library/react`) plus source-string structural assertions + * for the interactive plumbing an SSR mount can't exercise. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// The Radix Dialog / Select portal at runtime, so their bodies never +// materialise in static markup. Collapse the primitives to plain +// wrappers (same trick as the LogInjectionSiteDialog suite). +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("@/components/ui/select", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Select: ({ + value, + children, + }: { + value: string; + children?: React.ReactNode; + }) =>
{children}
, + SelectContent: Pass, + SelectItem: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + SelectTrigger: Pass, + SelectValue: () => null, + }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ data: undefined, isLoading: false })), + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { AddInventoryDialog } = await import( + "../sections/inventory-section" +); + +function render(node: React.ReactNode, locale: "en" | "de" = "de"): string { + return renderToStaticMarkup( + {node}, + ); +} + +const src = readFileSync( + resolve(__dirname, "../sections/inventory-section.tsx"), + "utf8", +); + +describe("", () => { + it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => { + const multi = render( + {}} + />, + ); + expect(multi).toContain('data-slot="inventory-quantity-mode"'); + expect(multi).toContain(">Dosen<"); + expect(multi).toContain(">Einheiten<"); + + const single = render( + {}} + />, + ); + expect(single).not.toContain('data-slot="inventory-quantity-mode"'); + }); + + it("renders the live conversion next to the prefilled quantity (units mode → dose equivalent)", () => { + // Prefill 28 units at 4 units per dose → "≈ 7 Dosen". + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="inventory-quantity-conversion"'); + expect(html).toContain("≈ 7 Dosen"); + }); + + it("defaults the container type from the caller (delivery-form mapping upstream)", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-mock-select-value="PEN"'); + // All six container kinds are offered. + for (const label of [ + "Pen", + "Ampulle", + "Tablettenpackung", + "Inhalator", + "Flasche", + "Sonstiges", + ]) { + expect(html).toContain(label); + } + }); + + 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`. + expect(src).toMatch( + /const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/, + ); + expect(src).toMatch(/dosesTotal: units,/); + expect(src).toMatch(/containerType,/); + }); + + 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)'); + }); + + it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => { + expect(src).toMatch( + /if \(deliveryForm === "INJECTION"\) return "PEN";\s*\n\s*if \(deliveryForm === "ORAL"\) return "BLISTER";\s*\n\s*return "OTHER";/, + ); + }); +}); diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index bb08eb09b..727cb9f6e 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -106,3 +106,27 @@ describe("estimateRunwayDays", () => { ).toBe(28); }); }); + +describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => { + it("runs off the dose-derived count: floor(units / unitsPerDose) before dividing by daily consumption", () => { + // 9 units at 2 units per dose → floor(9 / 2) = 4 doses; a daily + // single-dose schedule then reads 4 days of runway. The call sites + // (Übersicht supply row, Bestand summary) derive the dose count this + // way before handing it to the estimator. + const unitsRemaining = 9; + const unitsPerDose = 2; + const dosesRemaining = Math.floor(unitsRemaining / unitsPerDose); + expect( + estimateRunwayDays(dosesRemaining, [schedule({ timesOfDay: ["08:00"] })]), + ).toBe(4); + }); + + it("a partial dose's worth of units never counts as a day", () => { + // 1 unit at 2 units per dose is not a dose → no runway. + expect( + estimateRunwayDays(Math.floor(1 / 2), [ + schedule({ timesOfDay: ["08:00"] }), + ]), + ).toBeNull(); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index 25f90fb8b..fc6738656 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -125,6 +125,8 @@ export interface MedicationDetailSnapshot { treatmentClass?: string; deliveryForm?: string; dosesPerUnit?: number | null; + /** v1.16.10 — inventory units one dose consumes (default 1). */ + unitsPerDose?: number | null; trackInjectionSites?: boolean; allowedInjectionSites?: string[]; active: boolean; @@ -179,6 +181,7 @@ function snapshotToWizardPayload( treatmentClass: med.treatmentClass, deliveryForm: med.deliveryForm, dosesPerUnit: med.dosesPerUnit ?? null, + unitsPerDose: med.unitsPerDose ?? 1, trackInjectionSites: med.trackInjectionSites ?? false, allowedInjectionSites: med.allowedInjectionSites ?? [], notificationsEnabled: med.notificationsEnabled, @@ -314,8 +317,8 @@ export function MedicationDetailTabs({ return await apiGet<{ items: Array<{ state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - dosesTotal: number; - dosesRemaining: number; + unitsTotal: number; + unitsRemaining: number; }>; } | null>(`/api/medications/${id}/inventory`); } catch { @@ -328,11 +331,18 @@ export function MedicationDetailTabs({ const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : []; const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP"); - const dosesRemaining = liveItems.reduce( - (sum, i) => sum + i.dosesRemaining, + // 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. + const perDose = Math.max(1, medication.unitsPerDose ?? 1); + const unitsRemaining = liveItems.reduce( + (sum, i) => sum + i.unitsRemaining, 0, ); - const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0); + const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0); + const dosesRemaining = Math.floor(unitsRemaining / perDose); + const dosesTotal = Math.floor(unitsTotal / perDose); const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules); return ( @@ -422,6 +432,16 @@ export function MedicationDetailTabs({ remaining: dosesRemaining, total: dosesTotal, })} + {perDose > 1 && ( + + {" ("} + {t("medications.detail.bestand.unitsDetail", { + remaining: unitsRemaining, + total: unitsTotal, + })} + {")"} + + )} {runwayDays !== null && ( {" · "} @@ -562,6 +582,8 @@ export function MedicationDetailTabs({ diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 195763ce1..93e062d5d 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -6,20 +6,29 @@ * v1.16.1 — the tab grows its write affordances: * * - REGISTER: a "+" flow (header button + the empty-state CTA) that - * records a new pen / pack via `POST /api/medications/[id]/inventory` - * — quantity in doses/units, optional printed expiry. The quantity - * prefills from the medication's `dosesPerUnit` when configured. + * records a new pack / container via + * `POST /api/medications/[id]/inventory` — quantity in units, + * optional printed expiry. The quantity prefills from the + * medication's `dosesPerUnit` when configured. * - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with * `dosesRemaining`) for stock corrections and manual withdrawals — * the count is set absolutely, clamped server-side to the item's * capacity, and the canonical state machine derives the next state * (0 ⇒ used up). * + * v1.16.10 — items count UNITS (tablets / ampoules / puffs); + * `Medication.unitsPerDose` maps units to doses. Every dose-facing + * readout divides by it (floor), with the raw unit count as secondary + * text when the factor is > 1. The register flow gains a container-type + * select (pen / ampoule / tablet pack / …, defaulted from the delivery + * form) and, for multi-unit doses, a segmented Dosen | Einheiten + * quantity input with live conversion — the wire always carries units. + * * Reads stay on `GET /api/medications/[id]/inventory` (the same - * per-item list the pen/vial CRUD route serves): a calm summary — the + * per-item list the inventory CRUD route serves): a calm summary — the * doses remaining across every non-terminal item — plus a per-item list - * with its state badge. Shown for ALL medications (pill packs count - * too); the richer pen math is implicit in the per-item doses figures. + * with its container type and state badge. Shown for ALL medications + * (pill packs count too). */ import { useState, type FormEvent } from "react"; @@ -39,6 +48,13 @@ import { } from "@/components/ui/dialog"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { SettingsGroup } from "@/components/medications/settings-group"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -46,11 +62,22 @@ import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; +const CONTAINER_TYPES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +type ContainerType = (typeof CONTAINER_TYPES)[number]; + interface InventoryItem { id: string; state: InventoryState; - dosesTotal: number; - dosesRemaining: number; + containerType: ContainerType; + unitsTotal: number; + unitsRemaining: number; } interface InventoryResponse { @@ -65,18 +92,33 @@ const STATE_BADGE: Record(null); + const perDose = Math.max(1, unitsPerDose ?? 1); + const { data, isLoading } = useQuery({ queryKey: queryKeys.medicationInventory(medicationId), queryFn: async () => { @@ -102,17 +144,22 @@ export function InventorySection({ } const items = Array.isArray(data?.items) ? data.items : []; - // Active supply: doses left across every non-terminal (not used-up) item. + // Active supply: units left across every non-terminal (not used-up) item. const live = items.filter((i) => i.state !== "USED_UP"); - const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0); - const total = live.reduce((sum, i) => sum + i.dosesTotal, 0); + const remainingUnits = live.reduce((sum, i) => sum + i.unitsRemaining, 0); + const totalUnits = live.reduce((sum, i) => sum + i.unitsTotal, 0); + // Dose-derived headline (floor — a partial dose is not a dose). + const remaining = Math.floor(remainingUnits / perDose); + const total = Math.floor(totalUnits / perDose); const dialogs = ( <> {addOpen && ( setAddOpen(false)} /> )} @@ -155,6 +202,14 @@ export function InventorySection({

{t("medications.detail.bestand.summary", { remaining, total })} + {perDose > 1 && ( + + {t("medications.detail.bestand.unitsDetail", { + remaining: remainingUnits, + total: totalUnits, + })} + + )}

+ +
+ )} setDosesTotal(e.target.value)} + value={quantity} + onChange={(e) => setQuantity(e.target.value)} aria-describedby="inventory-add-quantity-helper" /> + {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( +

+ {effectiveMode === "doses" + ? t("medications.detail.bestand.quantityInUnits", { units }) + : t("medications.detail.bestand.quantityInDoses", { + doses: Math.floor(parsed / unitsPerDose), + })} +

+ )}

= 0 && parsed <= item.dosesTotal; + Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal; async function submit(e: FormEvent) { e.preventDefault(); @@ -365,6 +521,7 @@ function AdjustInventoryDialog({ setBusy(true); try { await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, { + // Wire field name is `dosesRemaining`; it carries UNITS. dosesRemaining: parsed, }); await queryClient.invalidateQueries({ @@ -403,7 +560,7 @@ function AdjustInventoryDialog({ type="number" inputMode="numeric" min={0} - max={item.dosesTotal} + max={item.unitsTotal} step={1} required autoComplete="off" @@ -416,7 +573,7 @@ function AdjustInventoryDialog({ className="text-muted-foreground text-xs" > {t("medications.detail.bestand.adjustHelper", { - total: item.dosesTotal, + total: item.unitsTotal, })}

diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 5d41d10cc..481649cff 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -383,6 +383,50 @@ describe("buildCreateBody", () => { expect(body.schedules[0].windowEnd).toBe("09:00"); }); + it("carries unitsPerDose onto the body and round-trips it on edit-hydrate (v1.16.10)", () => { + const p: WizardPayload = { + ...withCadence("daily"), + treatmentRow: "bloodPressure", + timesOfDay: ["08:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + endsOn: null, + unitsPerDose: "2", + }; + expect(buildCreateBody(p).unitsPerDose).toBe(2); + + // The default "1" still ships (an edit back to 1 must reach the server). + expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1); + // An empty field omits it (server default applies). + expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined(); + + const hydrated = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "5 mg", + category: "BLOOD_PRESSURE", + unitsPerDose: 2, + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }); + expect(hydrated.unitsPerDose).toBe("2"); + // Absent on the snapshot → the default 1. + const hydratedDefault = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "5 mg", + category: "BLOOD_PRESSURE", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }); + expect(hydratedDefault.unitsPerDose).toBe("1"); + }); + it("emits DIABETES category for the diabetes row", () => { const p: WizardPayload = { ...withCadence("daily"), diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx index 8a1593abd..c58cb8ff0 100644 --- a/src/components/medications/wizard/steps/Step3Dose.tsx +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -147,13 +147,14 @@ export function Step3Dose({ payload, applyPartial }: StepProps) { value={payload.dosesPerUnit} onChange={(e) => applyPartial({ - dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3), + // v1.16.10 — the cap rose to 1000, so four digits. + dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 4), }) } placeholder={t( "medications.wizard.steps.step3.dosesPerUnitPlaceholder", )} - maxLength={3} + maxLength={4} autoComplete="off" />

@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) { + {/* v1.16.10 — units consumed per dose. The inventory counts units + (tablets / ampoules); a multi-unit dose (2 × 2 mg for 4 mg) + decrements several per take and every dose-level readout + divides by this factor. */} +

+ + + applyPartial({ + unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3), + }) + } + placeholder="1" + maxLength={3} + autoComplete="off" + /> +

+ {t("medications.wizard.steps.step3.unitsPerDoseHint")} +

+
+ {/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor. Shown only for an INJECTION delivery form. Default off; enabling it surfaces the post-dose site prompt and the rotation diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index c9f3bf8a5..fff9cd3d2 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -219,6 +219,11 @@ export interface WizardPayload { * inventory tracking off (maps to `undefined` / NULL on the wire). */ dosesPerUnit: string; + /** + * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets for + * a 4 mg dose). Empty string / "1" both map to the server default 1. + */ + unitsPerDose: string; /** * v1.8.5 — per-medication injection-site tracking opt-in. Only * surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default @@ -283,6 +288,7 @@ export function emptyWizardPayload(): WizardPayload { treatmentRow: null, deliveryForm: "ORAL", dosesPerUnit: "", + unitsPerDose: "1", trackInjectionSites: false, allowedInjectionSites: [], mode: draft.mode, @@ -528,6 +534,8 @@ export interface CreateMedicationBody { deliveryForm: MedicationDeliveryForm; /** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */ dosesPerUnit?: number; + /** v1.16.10 — inventory units per dose. Omitted at the default 1. */ + unitsPerDose?: number; /** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */ trackInjectionSites?: boolean; /** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */ @@ -673,6 +681,7 @@ export function buildCreateBody( : committed.schedules; const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10); + const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10); const body: CreateMedicationBody = { name: committed.name.trim(), dose, @@ -683,6 +692,12 @@ export function buildCreateBody( parsedDosesPerUnit >= 1 && { dosesPerUnit: parsedDosesPerUnit, }), + // v1.16.10 — always sent when valid (an edit back to 1 must reach + // the server; the create default matches the schema default). + ...(Number.isFinite(parsedUnitsPerDose) && + parsedUnitsPerDose >= 1 && { + unitsPerDose: parsedUnitsPerDose, + }), // v1.8.5 — injection-site tracking is only meaningful for an // INJECTION delivery form. Always send the boolean for an injection // (so edits can deactivate it); send the allowed list only when @@ -903,6 +918,8 @@ export interface MedicationPayload { deliveryForm?: string; /** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */ dosesPerUnit?: number | null; + /** v1.16.10 — inventory units per dose (default 1). */ + unitsPerDose?: number | null; /** v1.8.5 — injection-site tracking opt-in. */ trackInjectionSites?: boolean; /** v1.8.5 — per-medication allowed / preferred injection sites. */ @@ -1014,6 +1031,10 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1 ? String(initial.dosesPerUnit) : "", + unitsPerDose: + typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1 + ? String(initial.unitsPerDose) + : "1", trackInjectionSites: initial.trackInjectionSites ?? false, allowedInjectionSites: (initial.allowedInjectionSites ?? []) as InjectionSiteKey[], diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index a15dfd158..62542b13b 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -30,7 +30,8 @@ function fakeMedication(overrides: Partial> = {}) { note: null, }, ], - inventoryEvents: [], + unitsPerDose: 1, + inventoryItems: [], intakeEvents: [], ...overrides, }; @@ -134,4 +135,25 @@ describe("buildGlp1SnapshotBlock", () => { expect(out?.medications[0].genericName).toBe("Semaglutide"); expect(out?.medications[0].currentDose?.unit).toBe("mg"); }); + + it("derives pen inventory from the per-item entities (v1.16.10)", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + unitsPerDose: 2, + inventoryItems: [ + { state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + { state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + { state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + { state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + ], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1"); + // 2 usable containers; 7 pooled units / 2 units per dose = 3 doses. + expect(out?.medications[0].penInventory).toEqual({ + pensRemaining: 2, + dosesRemaining: 3, + weeksOfSupplyApprox: 3, + }); + }); }); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 917995eae..c48221731 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -266,7 +266,9 @@ export async function buildGlp1SnapshotBlock( include: { schedules: true, doseChanges: { orderBy: { effectiveFrom: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "asc" } }, + // v1.16.10 — pen inventory reads the per-item entities (the rows + // the intake consumption hook moves), not the legacy event ledger. + inventoryItems: { orderBy: { createdAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, orderBy: { takenAt: "desc" }, @@ -368,16 +370,29 @@ export async function buildGlp1SnapshotBlock( const nextInjection = predictNextInjection(schedule, lastInjection, now); - // Inventory math: running sum of all inventory events for this med. - // Convert to "weeks of supply" via dosesPerUnit + the cadence so the - // Coach can answer "how long until my next refill?". + // Inventory math over the per-item entities (v1.16.10 — the same + // rows the Bestand tab and the consumption hook move; the legacy + // event ledger no longer receives writes). `pensRemaining` counts + // usable containers; `dosesRemaining` pools the units and divides + // by `unitsPerDose` so the Coach can answer "how long until my + // next refill?". let dosesRemaining: number | null = null; let pensRemaining: number | null = null; let weeksOfSupplyApprox: number | null = null; - if (med.inventoryEvents.length > 0 && med.dosesPerUnit) { - const units = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0); - pensRemaining = Math.max(0, units); - dosesRemaining = pensRemaining * med.dosesPerUnit; + if (med.inventoryItems.length > 0) { + const usable = med.inventoryItems.filter( + (item) => + (item.state === "ACTIVE" || item.state === "IN_USE") && + item.unitsRemaining > 0, + ); + const unitsRemaining = usable.reduce( + (sum, item) => sum + item.unitsRemaining, + 0, + ); + pensRemaining = usable.length; + dosesRemaining = Math.floor( + unitsRemaining / Math.max(1, med.unitsPerDose), + ); const dosesPerWeek = schedule?.cadence === "weekly" ? 1 diff --git a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts new file mode 100644 index 000000000..704bee41c --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts @@ -0,0 +1,171 @@ +/** + * v1.16.10 — inventory refunds in the intake-slot dedup pass. + * + * Collapsing a duplicate slot soft-deletes the loser rows. A loser that + * carries a consumption stamp is a REAL taken row whose units left the + * stock — tombstoning it without a refund would strand those units + * forever (the row is no longer reachable through any user-facing + * restore path). The dedup therefore refunds every stamped loser before + * the tombstone; unstamped losers (pending / skipped / pre-stamp rows) + * never consumed and are swept without a refund round-trip. The winner + * keeps its own stamp untouched. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +const SCHEDULE = { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); +}); + +describe("dedupeUserIntakeSlots — inventory refunds (v1.16.10)", () => { + it("refunds a stamped loser before tombstoning; the winner keeps its stamp", async () => { + // 07:00 CEST = 05:00Z. Two TAKEN rows for the same dose — the iOS + // drift duplicate (+30 s) beside the server row. The fresher row + // (higher syncVersion) wins; the stamped loser must be refunded. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-loser", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:01:00.000Z"), + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", + source: "WEB", + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + { + id: "r-winner", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:30.000Z"), + takenAt: new Date("2026-06-15T05:01:30.000Z"), + skipped: false, + syncVersion: 3, + createdAt: new Date("2026-06-15T05:01:30Z"), + attributionSource: "AUTO", + source: "API", + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.rowsSoftDeleted).toBe(1); + + // Only the LOSER is refunded — the winner keeps its stamp. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "u1", eventId: "r-loser" }), + ); + + // The refund runs before the loser tombstone sweep. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(sweepOrder); + }); + + it("ignores unstamped losers (a pending sibling never consumed)", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-taken", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:01:00.000Z"), + skipped: false, + syncVersion: 1, + createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", + source: "WEB", + inventoryConsumption: [{ itemId: "item-1", units: 1 }], + }, + { + id: "r-pending", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:30.000Z"), + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T04:00:00Z"), + attributionSource: "AUTO", + source: "REMINDER", + inventoryConsumption: null, + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.rowsSoftDeleted).toBe(1); + // The taken row wins; the pending loser carries no stamp → no refund. + expect(restoreForIntake).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 1e475ad9f..073c47db4 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -44,6 +44,7 @@ */ import { prisma } from "@/lib/db"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { isP2002 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -119,6 +120,12 @@ interface DedupIntakeRow { * takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape. */ source: string; + /** + * v1.16.10 — inventory consumption stamp. A stamped LOSER row refunds + * its consumed units before the tombstone (the winner keeps its own + * stamp untouched), so collapsing a duplicate never strands stock. + */ + inventoryConsumption: unknown; } /** @@ -266,6 +273,8 @@ export async function dedupeUserIntakeSlots( attributionSource: true, // v1.16.9 — write origin for the ad-hoc→pending convergence gate. source: true, + // v1.16.10 — stamped losers refund their consumption on collapse. + inventoryConsumption: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; @@ -347,6 +356,20 @@ export async function dedupeUserIntakeSlots( const winner = pickWinner(slotRows); const losers = slotRows.filter((r) => r.id !== winner.id); + // v1.16.10 — a stamped loser (a real taken row losing to a pin) + // refunds its consumed units before the tombstone; unstamped + // losers (pending / skipped / pre-stamp rows) skip the round + // trip. The winner keeps its own stamp untouched. + for (const loser of losers) { + if (loser.inventoryConsumption !== null) { + await restoreForIntake({ + client: prisma, + userId, + eventId: loser.id, + }); + } + } + // Soft-delete every loser in one shot. Bump syncVersion so the // sync feed echoes a monotonic value and iOS drops the tombstone. const loserIds = losers.map((r) => r.id); diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts new file mode 100644 index 000000000..8929d7bf6 --- /dev/null +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -0,0 +1,655 @@ +/** + * v1.16.10 — consumption hook contract tests. + * + * Pins the exactly-once stamp semantics, the multi-unit FEFO selection + * with spillover + auto-open, the floor-at-zero partial stamp, and the + * symmetric restore (clamped refund, state re-derivation, stamp clear). + * The fake client mirrors the Prisma surface the hook touches and + * honours the orderBy specs the hook passes, so the selection order the + * tests assert is the order production gets from Postgres. + * + * Timezone-stable: every instant is an explicit UTC ISO string and the + * wall clock is pinned with fake timers. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { Prisma } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { consumeForIntake, restoreForIntake } from "../consumption"; + +const NOW = new Date("2026-06-10T12:00:00.000Z"); +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +interface FakeEvent { + id: string; + userId: string; + medicationId: string; + takenAt: Date | null; + skipped: boolean; + deletedAt: Date | null; + inventoryConsumption: unknown; +} + +interface FakeItem { + id: string; + userId: string; + medicationId: string; + state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + unitsTotal: number; + unitsRemaining: number; + firstUseAt: Date | null; + expiresAt: Date | null; + printedExpiry: Date | null; + purchasedAt: Date | null; + createdAt: Date; +} + +interface FakeState { + events: FakeEvent[]; + items: FakeItem[]; + unitsPerDose: number; +} + +type OrderSpec = Record< + string, + "asc" | "desc" | { sort: "asc" | "desc"; nulls?: "first" | "last" } +>; + +/** Honour the hook's orderBy the way Postgres would. */ +function sortRows(rows: FakeItem[], orderBy: OrderSpec[]): FakeItem[] { + return [...rows].sort((a, b) => { + for (const spec of orderBy) { + const [field, raw] = Object.entries(spec)[0]; + const dir = typeof raw === "string" ? raw : raw.sort; + const nulls = typeof raw === "object" ? raw.nulls : undefined; + const av = a[field as keyof FakeItem] as Date | null; + const bv = b[field as keyof FakeItem] as Date | null; + if (av === null && bv === null) continue; + if (av === null) return nulls === "last" ? 1 : -1; + if (bv === null) return nulls === "last" ? -1 : 1; + const diff = av.getTime() - bv.getTime(); + if (diff !== 0) return dir === "asc" ? diff : -diff; + } + return 0; + }); +} + +function makeClient(state: FakeState) { + return { + medication: { + findFirst: vi.fn(async () => ({ unitsPerDose: state.unitsPerDose })), + }, + medicationIntakeEvent: { + findFirst: vi.fn( + async (args: { where: { id: string; userId: string } }) => + state.events.find( + (e) => e.id === args.where.id && e.userId === args.where.userId, + ) ?? null, + ), + update: vi.fn( + async (args: { + where: { id: string }; + data: Record; + }) => { + const event = state.events.find((e) => e.id === args.where.id)!; + if ("inventoryConsumption" in args.data) { + event.inventoryConsumption = + args.data.inventoryConsumption === Prisma.DbNull + ? null + : args.data.inventoryConsumption; + } + return event; + }, + ), + }, + medicationInventoryItem: { + findMany: vi.fn( + async (args: { + where: { + userId: string; + medicationId: string; + state: FakeItem["state"]; + unitsRemaining: { gt: number }; + }; + orderBy: OrderSpec[]; + }) => + sortRows( + state.items.filter( + (i) => + i.userId === args.where.userId && + i.medicationId === args.where.medicationId && + i.state === args.where.state && + i.unitsRemaining > args.where.unitsRemaining.gt, + ), + args.orderBy, + ), + ), + findFirst: vi.fn( + async (args: { where: { id: string; userId: string } }) => + state.items.find( + (i) => i.id === args.where.id && i.userId === args.where.userId, + ) ?? null, + ), + update: vi.fn( + async (args: { + where: { id: string }; + data: Partial; + }) => { + const item = state.items.find((i) => i.id === args.where.id)!; + Object.assign(item, args.data); + return item; + }, + ), + }, + }; +} + +function takenEvent(overrides: Partial = {}): FakeEvent { + return { + id: "evt-1", + userId: "user-1", + medicationId: "med-1", + takenAt: NOW, + skipped: false, + deletedAt: null, + inventoryConsumption: null, + ...overrides, + }; +} + +function item(overrides: Partial = {}): FakeItem { + return { + id: "item-1", + userId: "user-1", + medicationId: "med-1", + state: "ACTIVE", + unitsTotal: 10, + unitsRemaining: 10, + firstUseAt: null, + expiresAt: null, + printedExpiry: null, + purchasedAt: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }; +} + +function consumeArgs(client: ReturnType) { + return { + client: client as never, + userId: "user-1", + medicationId: "med-1", + eventId: "evt-1", + intakeAt: NOW, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("consumeForIntake", () => { + it("consumes unitsPerDose units from the open container and stamps the event", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 5, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(state.items[0].state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 2 }, + ]); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.consumed" }, + meta: expect.objectContaining({ + medication_id: "med-1", + units: 2, + item_ids: ["open"], + auto_opened: 0, + }), + }), + ); + }); + + it("prefers the IN_USE container over fresher ACTIVE stock", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ id: "sealed", state: "ACTIVE", unitsRemaining: 10 }), + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 2, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items.find((i) => i.id === "open")!.unitsRemaining).toBe(1); + expect(state.items.find((i) => i.id === "sealed")!.unitsRemaining).toBe(10); + }); + + it("opens unopened stock first-expiry-first-out, null printed expiry last", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ + id: "no-expiry", + printedExpiry: null, + createdAt: new Date("2026-05-01T00:00:00.000Z"), + }), + item({ + id: "later-expiry", + printedExpiry: new Date("2027-06-01T00:00:00.000Z"), + }), + item({ + id: "sooner-expiry", + printedExpiry: new Date("2026-09-01T00:00:00.000Z"), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect( + state.items.find((i) => i.id === "sooner-expiry")!.unitsRemaining, + ).toBe(9); + expect(state.items.find((i) => i.id === "no-expiry")!.unitsRemaining).toBe( + 10, + ); + expect( + state.items.find((i) => i.id === "later-expiry")!.unitsRemaining, + ).toBe(10); + }); + + it("auto-opens an ACTIVE container: firstUseAt = intakeAt, state IN_USE, expiresAt derived", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + const opened = state.items[0]; + expect(opened.firstUseAt).toEqual(NOW); + expect(opened.state).toBe("IN_USE"); + expect(opened.expiresAt?.getTime()).toBe( + NOW.getTime() + 30 * MS_PER_DAY, + ); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ auto_opened: 1 }), + }), + ); + }); + + it("lands a backdated auto-open straight in EXPIRED when its window has already lapsed", async () => { + const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY); + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent({ takenAt: backdated })], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake({ + ...consumeArgs(client), + intakeAt: backdated, + }); + + const opened = state.items[0]; + expect(opened.firstUseAt).toEqual(backdated); + // firstUseAt + 30 days lies in the past — the state machine says + // EXPIRED at the wall clock, and that is the honest answer. + expect(opened.state).toBe("EXPIRED"); + expect(opened.unitsRemaining).toBe(9); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "sealed", units: 1 }, + ]); + }); + + it("spills across containers when the open one runs dry", async () => { + const state: FakeState = { + unitsPerDose: 3, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + item({ id: "sealed", unitsRemaining: 4, unitsTotal: 4 }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + const open = state.items.find((i) => i.id === "open")!; + const sealed = state.items.find((i) => i.id === "sealed")!; + expect(open.unitsRemaining).toBe(0); + expect(open.state).toBe("USED_UP"); + expect(sealed.unitsRemaining).toBe(2); + expect(sealed.state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + { itemId: "sealed", units: 2 }, + ]); + }); + + it("floors at zero with a partial stamp when stock runs out — never negative, never an error", async () => { + const state: FakeState = { + unitsPerDose: 4, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(0); + expect(state.items[0].state).toBe("USED_UP"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + ]); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ units: 1 }), + }), + ); + }); + + it("stamps an empty consumption when no tracked stock exists (exactly-once gate)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.events[0].inventoryConsumption).toEqual([]); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + }); + + it("is a no-op on an already-stamped row (replay never double-decrements)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ inventoryConsumption: [{ itemId: "open", units: 1 }] }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 3, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it.each([ + ["pending", { takenAt: null }], + ["skipped", { skipped: true }], + ["tombstoned", { deletedAt: NOW }], + ])("never consumes for a %s row", async (_label, overrides) => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent(overrides as Partial)], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(10); + expect(state.events[0].inventoryConsumption).toBeNull(); + }); + + it("swallows unexpected failures and annotates consume_error", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + client.medicationInventoryItem.findMany.mockRejectedValue( + new Error("connection lost"), + ); + + await expect(consumeForIntake(consumeArgs(client))).resolves.toBeUndefined(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.consume_error" }, + }), + ); + }); + + it("uses the caller's interactive transaction when the client carries one", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const inner = makeClient(state); + const $transaction = vi.fn(async (fn: (tx: unknown) => Promise) => + fn(inner), + ); + await consumeForIntake({ + client: { ...inner, $transaction } as never, + userId: "user-1", + medicationId: "med-1", + eventId: "evt-1", + intakeAt: NOW, + }); + expect($transaction).toHaveBeenCalledTimes(1); + expect(state.items[0].unitsRemaining).toBe(9); + }); +}); + +describe("restoreForIntake", () => { + function restoreArgs(client: ReturnType) { + return { client: client as never, userId: "user-1", eventId: "evt-1" }; + } + + it("refunds per stamp entry, re-derives the state and clears the stamp", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 2 }], + }), + ], + items: [ + item({ + id: "open", + state: "USED_UP", + unitsTotal: 4, + unitsRemaining: 0, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(2); + // USED_UP flips back to IN_USE — the window is still open. + expect(state.items[0].state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toBeNull(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.restored" }, + meta: expect.objectContaining({ + medication_id: "med-1", + units: 2, + item_ids: ["open"], + }), + }), + ); + }); + + it("clamps the refund to the item's capacity", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 2 }], + }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsTotal: 4, + // A stock correction raised the count since the take. + unitsRemaining: 3, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(4); + }); + + it("re-derives EXPIRED when the refunded container's window has lapsed meanwhile", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 1 }], + }), + ], + items: [ + item({ + id: "open", + state: "USED_UP", + unitsTotal: 4, + unitsRemaining: 0, + firstUseAt: new Date(NOW.getTime() - 35 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(1); + expect(state.items[0].state).toBe("EXPIRED"); + }); + + it("is a no-op when the row carries no stamp", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "open", unitsRemaining: 3 })], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("skips a stamped container that was deleted since, still clearing the stamp", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ + inventoryConsumption: [ + { itemId: "gone", units: 1 }, + { itemId: "open", units: 1 }, + ], + }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(2); + expect(state.events[0].inventoryConsumption).toBeNull(); + }); + + it("allows a re-take to consume afresh after a restore (skip → take cycle)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 4, + unitsTotal: 4, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + + await consumeForIntake(consumeArgs(client)); + expect(state.items[0].unitsRemaining).toBe(3); + + await restoreForIntake(restoreArgs(client)); + expect(state.items[0].unitsRemaining).toBe(4); + expect(state.events[0].inventoryConsumption).toBeNull(); + + await consumeForIntake(consumeArgs(client)); + expect(state.items[0].unitsRemaining).toBe(3); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + ]); + }); +}); diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 1864a30c3..9ac65a5dd 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -14,118 +14,19 @@ vi.mock("@/lib/db", () => ({ import { prisma } from "@/lib/db"; import { buildCreateInventoryInput, - consumeOneDose, expireStaleInUseItems, } from "../service"; const NOW = new Date("2026-05-14T12:00:00Z"); -const MS_PER_DAY = 24 * 60 * 60 * 1000; beforeEach(() => { vi.resetAllMocks(); }); -describe("consumeOneDose", () => { - it("returns null when no eligible pen exists for the medication", async () => { - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toBeNull(); - expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled(); - }); - - it("flips an ACTIVE pen to IN_USE on first use", async () => { - // No IN_USE pen, fall back to ACTIVE. - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-1", - state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, - firstUseAt: null, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-1", change: "first_use" }); - - const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0]; - expect(updateCall.data).toMatchObject({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: NOW, - }); - }); - - it("prefers the IN_USE pen with the earliest expiry", async () => { - const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-active", - state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 3, - firstUseAt: firstUse, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-active", change: "consumed" }); - expect(prisma.medicationInventoryItem.findFirst).toHaveBeenCalledTimes(1); - }); - - it("returns 'depleted' when the final dose is consumed", async () => { - const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-last", - state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 1, - firstUseAt: firstUse, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-last", change: "depleted" }); - const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0]; - expect(updateCall.data).toMatchObject({ - state: "USED_UP", - dosesRemaining: 0, - }); - }); -}); - describe("expireStaleInUseItems", () => { it("transitions IN_USE rows whose deadline has lapsed via a single updateMany", async () => { // The selector `state = IN_USE AND expiresAt < now` already proves - // the row must transition to EXPIRED (IN_USE implies dosesRemaining + // the row must transition to EXPIRED (IN_USE implies unitsRemaining // > 0, and the expiresAt filter proves the deadline lapsed). Using // `updateMany` collapses N row round-trips into one. vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({ @@ -174,15 +75,17 @@ describe("buildCreateInventoryInput", () => { const input = buildCreateInventoryInput({ userId: "u", medicationId: "m", - dosesTotal: 4, + unitsTotal: 4, + containerType: "PEN", printedExpiry: printed, purchasedAt: null, notes: null, }); expect(input).toMatchObject({ state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, expiresAt: printed, }); @@ -192,7 +95,8 @@ describe("buildCreateInventoryInput", () => { const input = buildCreateInventoryInput({ userId: "u", medicationId: "m", - dosesTotal: 4, + unitsTotal: 4, + containerType: "OTHER", printedExpiry: null, purchasedAt: null, notes: null, diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts index 120347070..2126d5b1b 100644 --- a/src/lib/medications/inventory/__tests__/state-machine.test.ts +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -4,8 +4,6 @@ import { computeExpiresAt, computeInventoryState, daysRemainingInUse, - decrementDose, - DEFAULT_IN_USE_WINDOW_DAYS, type InventoryItemView, } from "../state-machine"; @@ -16,8 +14,8 @@ const NOW_MS = NOW.getTime(); function makeItem(overrides: Partial = {}): InventoryItemView { return { state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: null, ...overrides, @@ -58,17 +56,17 @@ describe("computeInventoryState", () => { expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); }); - it("returns USED_UP when dosesRemaining is zero (terminal — outranks EXPIRED)", () => { + it("returns USED_UP when unitsRemaining is zero (terminal — outranks EXPIRED)", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), }); expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); }); - it("returns USED_UP even when printed expiry has lapsed (dosesRemaining wins)", () => { + it("returns USED_UP even when printed expiry has lapsed (unitsRemaining wins)", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, printedExpiry: new Date(NOW_MS - 5 * MS_PER_DAY), }); expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); @@ -122,74 +120,6 @@ describe("computeExpiresAt", () => { }); }); -describe("decrementDose", () => { - it("first_use — flips ACTIVE → IN_USE and sets firstUseAt", () => { - const item = makeItem(); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("first_use"); - expect(result.item.state).toBe("IN_USE"); - expect(result.item.firstUseAt).toEqual(NOW); - expect(result.item.dosesRemaining).toBe(3); - expect(result.item.expiresAt?.getTime()).toBe( - NOW_MS + DEFAULT_IN_USE_WINDOW_DAYS * MS_PER_DAY, - ); - }); - - it("consumed — IN_USE pen with doses left stays IN_USE", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: firstUse, - }); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("consumed"); - expect(result.item.state).toBe("IN_USE"); - expect(result.item.firstUseAt).toEqual(firstUse); - expect(result.item.dosesRemaining).toBe(2); - }); - - it("depleted — final dose flips state to USED_UP", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 1, - firstUseAt: firstUse, - }); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("depleted"); - expect(result.item.state).toBe("USED_UP"); - expect(result.item.dosesRemaining).toBe(0); - }); - - it("does not mutate its input", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: firstUse, - }); - const snapshot = JSON.stringify(item); - decrementDose(item, NOW); - expect(JSON.stringify(item)).toBe(snapshot); - }); - - it("clamps dosesRemaining at zero even if called on an already-empty pen", () => { - const item = makeItem({ - state: "USED_UP", - dosesRemaining: 0, - firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), - }); - const result = decrementDose(item, NOW); - - expect(result.item.dosesRemaining).toBe(0); - expect(result.item.state).toBe("USED_UP"); - }); -}); - describe("daysRemainingInUse", () => { it("returns null for an ACTIVE (unopened) pen", () => { expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull(); @@ -204,7 +134,7 @@ describe("daysRemainingInUse", () => { it("returns null for a USED_UP pen", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), }); expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts new file mode 100644 index 000000000..c80a38d2b --- /dev/null +++ b/src/lib/medications/inventory/consumption.ts @@ -0,0 +1,377 @@ +/** + * v1.16.10 — per-intake inventory consumption + restore. + * + * The single write path between intake events and inventory items. + * Every seam that flips an intake event to TAKEN calls + * `consumeForIntake`; every seam that takes a row OUT of taken + * (skip toggle, edit, delete, bulk delete, slot-dedup tombstone) + * calls `restoreForIntake`. No other module may write + * `medicationInventoryItem` rows outside the inventory CRUD routes — + * a convention test pins that. + * + * Exactly-once semantics hang off the consumption STAMP — the + * `MedicationIntakeEvent.inventoryConsumption` Json column holding + * `[{itemId, units}]`: + * + * - `consumeForIntake` is a no-op on a stamped row, so idempotent + * replays (iOS sync, Telegram redelivery, double taps) can call it + * freely and the stock only ever moves once per taken event. + * - `restoreForIntake` refunds exactly what the stamp recorded and + * clears it, so a later re-take consumes afresh. The stamp freezes + * history: editing `Medication.unitsPerDose` after the fact never + * changes what an already-taken dose consumed or refunds. + * + * Consumption is in UNITS (`Medication.unitsPerDose` per dose), pulled + * from the open container first, then FEFO over unopened stock with + * auto-open, spilling across containers, flooring at zero when the + * tracked stock runs out (partial stamp — never negative, never an + * error). + * + * Both functions are best-effort and NEVER throw: a failure annotates + * (`medication.inventory.consume_error` / `.restore_error`) and + * returns — the intake write is the source of truth, the inventory is + * an opt-in companion that must never block a dose record. + */ + +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { + computeExpiresAt, + computeInventoryState, +} from "./state-machine"; + +/** The Prisma surface the consumption hook needs. A bare transaction + * client satisfies it; the base client additionally carries + * `$transaction`, which the hook uses to keep the item updates and + * the stamp write atomic. */ +type ConsumptionClient = Pick< + PrismaClient, + "medication" | "medicationIntakeEvent" | "medicationInventoryItem" +> & + Partial>; + +type TxClient = Pick< + PrismaClient, + "medication" | "medicationIntakeEvent" | "medicationInventoryItem" +>; + +/** One consumed slice of the stamp: `units` taken from `itemId`. */ +export interface InventoryConsumptionEntry { + itemId: string; + units: number; +} + +/** + * Run `fn` atomically. When the caller hands us the base client we + * open an interactive transaction; when it hands us a transaction + * client (no `$transaction` on it) the caller's transaction already + * provides the atomicity and we run inline. + */ +async function runAtomically( + client: ConsumptionClient, + fn: (tx: TxClient) => Promise, +): Promise { + if (typeof client.$transaction === "function") { + return client.$transaction((tx) => fn(tx as TxClient)); + } + return fn(client); +} + +/** Parse a stamp column value defensively — a malformed stamp refunds + * nothing rather than throwing. */ +function parseStamp(value: Prisma.JsonValue | null): InventoryConsumptionEntry[] | null { + if (value === null || !Array.isArray(value)) return null; + const entries: InventoryConsumptionEntry[] = []; + for (const raw of value) { + if ( + typeof raw === "object" && + raw !== null && + !Array.isArray(raw) && + typeof (raw as { itemId?: unknown }).itemId === "string" && + typeof (raw as { units?: unknown }).units === "number" && + Number.isFinite((raw as { units: number }).units) && + (raw as { units: number }).units > 0 + ) { + entries.push({ + itemId: (raw as { itemId: string }).itemId, + units: Math.floor((raw as { units: number }).units), + }); + } + } + return entries; +} + +/** + * Consume the medication's `unitsPerDose` units for one taken intake + * event and stamp the event with what was consumed. + * + * No-op unless the event row is live (`deletedAt: null`), taken + * (`takenAt` set), non-skipped, and UNSTAMPED — the stamp is the + * exactly-once gate, so replays and re-posts never double-decrement. + * + * Container selection: + * 1. the open container — IN_USE with `unitsRemaining > 0`, earliest + * `expiresAt` first (createdAt tiebreak); + * 2. then FEFO over unopened ACTIVE stock — `printedExpiry` asc with + * nulls last, then `purchasedAt` asc (nulls last), then + * `createdAt` asc. Pulling from an ACTIVE container auto-opens it + * (`firstUseAt = intakeAt`, state via the state machine — a + * backdated intake can land it straight in EXPIRED when the + * 30-day window has already lapsed; that is correct). + * + * Consumption spills: when the current container holds fewer units + * than still owed, it drains to zero and the next container covers the + * rest. When the tracked stock runs out the consumption floors at + * zero overall — the stamp records the partial amount, no error, no + * negative counts. + */ +export async function consumeForIntake(input: { + client: ConsumptionClient; + userId: string; + medicationId: string; + eventId: string; + intakeAt: Date; +}): Promise { + const { client, userId, medicationId, eventId, intakeAt } = input; + try { + await runAtomically(client, async (tx) => { + const event = await tx.medicationIntakeEvent.findFirst({ + where: { id: eventId, userId }, + select: { + id: true, + medicationId: true, + takenAt: true, + skipped: true, + deletedAt: true, + inventoryConsumption: true, + }, + }); + if ( + !event || + event.deletedAt !== null || + event.takenAt === null || + event.skipped || + event.inventoryConsumption !== null + ) { + return; + } + + const medication = await tx.medication.findFirst({ + where: { id: medicationId, userId }, + select: { unitsPerDose: true }, + }); + if (!medication) return; + const unitsPerDose = Math.max(1, medication.unitsPerDose); + + // Candidate containers, in consumption order: the open container + // first (earliest in-use deadline), then FEFO over unopened + // stock. Both lists are loaded inside the transaction so the + // updates apply to the rows just read. + const inUse = await tx.medicationInventoryItem.findMany({ + where: { + userId, + medicationId, + state: "IN_USE", + unitsRemaining: { gt: 0 }, + }, + orderBy: [ + { expiresAt: { sort: "asc", nulls: "last" } }, + { createdAt: "asc" }, + ], + }); + const active = await tx.medicationInventoryItem.findMany({ + where: { + userId, + medicationId, + state: "ACTIVE", + unitsRemaining: { gt: 0 }, + }, + orderBy: [ + { printedExpiry: { sort: "asc", nulls: "last" } }, + { purchasedAt: { sort: "asc", nulls: "last" } }, + { createdAt: "asc" }, + ], + }); + + const stamp: InventoryConsumptionEntry[] = []; + const itemIds: string[] = []; + let autoOpened = 0; + let owed = unitsPerDose; + // State derives at the WALL clock, not the intake instant: a + // backdated take that auto-opens a container starts its in-use + // window at the (past) intakeAt, so the container can land + // straight in EXPIRED when that window has already lapsed by now + // — correct, the daily cron would flip it on the next sweep + // anyway. + const nowMs = Date.now(); + + for (const item of [...inUse, ...active]) { + if (owed <= 0) break; + const take = Math.min(owed, item.unitsRemaining); + if (take <= 0) continue; + + const wasUnopened = item.firstUseAt === null; + const nextFirstUseAt = item.firstUseAt ?? intakeAt; + const nextUnitsRemaining = item.unitsRemaining - take; + const nextExpiresAt = computeExpiresAt( + nextFirstUseAt, + item.printedExpiry, + ); + const nextState = computeInventoryState( + { + state: item.state, + unitsTotal: item.unitsTotal, + unitsRemaining: nextUnitsRemaining, + firstUseAt: nextFirstUseAt, + printedExpiry: item.printedExpiry, + }, + nowMs, + ); + + await tx.medicationInventoryItem.update({ + where: { id: item.id }, + data: { + state: nextState, + unitsRemaining: nextUnitsRemaining, + firstUseAt: nextFirstUseAt, + expiresAt: nextExpiresAt, + }, + }); + + stamp.push({ itemId: item.id, units: take }); + itemIds.push(item.id); + if (wasUnopened) autoOpened += 1; + owed -= take; + } + + // Stamp the event even when nothing (or only part) could be + // consumed: the stamp is the exactly-once gate, and a dose taken + // with no tracked stock must not retro-consume containers + // registered later when a sync replay revisits the row. + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue }, + }); + + annotate({ + action: { name: "medication.inventory.consumed" }, + meta: { + medication_id: medicationId, + units: unitsPerDose - owed, + item_ids: itemIds, + auto_opened: autoOpened, + }, + }); + }); + } catch (err) { + annotate({ + action: { name: "medication.inventory.consume_error" }, + meta: { + medication_id: medicationId, + event_id: eventId, + message: err instanceof Error ? err.message : String(err), + }, + }); + } +} + +/** + * Refund the consumption a stamped intake event recorded and clear + * the stamp. + * + * No stamp = no-op (the row never consumed, or was already restored). + * Each refund is clamped to the item's `unitsTotal` so a stock + * correction in between can never overfill a container; the state + * re-derives through the state machine, so a USED_UP container whose + * units come back flips to IN_USE (or EXPIRED when its clock has + * lapsed meanwhile). A stamped item that was deleted since is skipped. + */ +export async function restoreForIntake(input: { + client: ConsumptionClient; + userId: string; + eventId: string; +}): Promise { + const { client, userId, eventId } = input; + try { + await runAtomically(client, async (tx) => { + const event = await tx.medicationIntakeEvent.findFirst({ + where: { id: eventId, userId }, + select: { + id: true, + medicationId: true, + inventoryConsumption: true, + }, + }); + if (!event) return; + const stamp = parseStamp(event.inventoryConsumption); + if (stamp === null || stamp.length === 0) { + // Clear an empty/malformed stamp so a later re-take can + // consume afresh; a NULL stamp is already clear. + if (event.inventoryConsumption !== null) { + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: Prisma.DbNull }, + }); + } + return; + } + + const nowMs = Date.now(); + let refunded = 0; + const itemIds: string[] = []; + for (const entry of stamp) { + const item = await tx.medicationInventoryItem.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) continue; // container deleted since — nothing to refund. + + const nextUnitsRemaining = Math.min( + item.unitsTotal, + item.unitsRemaining + entry.units, + ); + const refundedHere = nextUnitsRemaining - item.unitsRemaining; + const nextState = computeInventoryState( + { + state: item.state, + unitsTotal: item.unitsTotal, + unitsRemaining: nextUnitsRemaining, + firstUseAt: item.firstUseAt, + printedExpiry: item.printedExpiry, + }, + nowMs, + ); + await tx.medicationInventoryItem.update({ + where: { id: item.id }, + data: { + state: nextState, + unitsRemaining: nextUnitsRemaining, + }, + }); + refunded += refundedHere; + itemIds.push(item.id); + } + + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: Prisma.DbNull }, + }); + + annotate({ + action: { name: "medication.inventory.restored" }, + meta: { + medication_id: event.medicationId, + units: refunded, + item_ids: itemIds, + }, + }); + }); + } catch (err) { + annotate({ + action: { name: "medication.inventory.restore_error" }, + meta: { + event_id: eventId, + message: err instanceof Error ? err.message : String(err), + }, + }); + } +} diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index 58e9f8bc7..83df1c318 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -1,97 +1,19 @@ /** * v1.4.25 W19b — inventory persistence helpers. * - * Thin layer on top of Prisma that the route handlers and the - * intake hook compose. Keeps the state-machine math (pure) separate - * from the I/O (here), so unit tests on the math don't need a DB. + * Thin layer on top of Prisma that the route handlers compose. Keeps + * the state-machine math (pure) separate from the I/O (here), so unit + * tests on the math don't need a DB. + * + * v1.16.10 — the per-intake consumption hook moved to + * `./consumption.ts` (multi-unit, FEFO, spillover, stamped on the + * intake event); this module keeps the expire cron and the + * create-input builder. */ import { prisma } from "@/lib/db"; -import { - computeExpiresAt, - decrementDose, - type DecrementOutcome, -} from "./state-machine"; - -/** - * Consume one dose from the user's active in-use pen for the given - * medication. Called from the intake POST handler after the intake - * event has been created. - * - * Selection policy: - * 1. Prefer the IN_USE pen with the earliest `expiresAt` — that's - * the pen the user is currently working through. - * 2. Fall back to the oldest ACTIVE pen (FIFO consumption — the - * pen that was purchased first gets opened first). The intake - * flips it ACTIVE → IN_USE and sets firstUseAt. - * 3. If no eligible pen exists, this is a no-op. The user just - * logged an intake without a tracked pen — perfectly valid - * since inventory is opt-in per the W19a doses-per-unit - * design. - * - * The function intentionally only updates one pen per intake — if a - * user double-doses (rare), they need to record two intake events - * and the second one will pick up the same or a new pen. - */ -export async function consumeOneDose(input: { - userId: string; - medicationId: string; - intakeAt: Date; -}): Promise<{ - itemId: string; - change: DecrementOutcome; -} | null> { - const { userId, medicationId, intakeAt } = input; - - // Prefer the IN_USE pen with the earliest expiry; tie-break by - // createdAt ascending. - const inUsePen = await prisma.medicationInventoryItem.findFirst({ - where: { - userId, - medicationId, - state: "IN_USE", - dosesRemaining: { gt: 0 }, - }, - orderBy: [{ expiresAt: "asc" }, { createdAt: "asc" }], - }); - - const target = - inUsePen ?? - (await prisma.medicationInventoryItem.findFirst({ - where: { - userId, - medicationId, - state: "ACTIVE", - dosesRemaining: { gt: 0 }, - }, - orderBy: [{ purchasedAt: "asc" }, { createdAt: "asc" }], - })); - - if (!target) return null; - - const { item: nextValues, change } = decrementDose( - { - state: target.state, - dosesTotal: target.dosesTotal, - dosesRemaining: target.dosesRemaining, - firstUseAt: target.firstUseAt, - printedExpiry: target.printedExpiry, - }, - intakeAt, - ); - - await prisma.medicationInventoryItem.update({ - where: { id: target.id }, - data: { - state: nextValues.state, - dosesRemaining: nextValues.dosesRemaining, - firstUseAt: nextValues.firstUseAt, - expiresAt: nextValues.expiresAt, - }, - }); - - return { itemId: target.id, change }; -} +import type { MedicationContainerType } from "@/generated/prisma/client"; +import { computeExpiresAt } from "./state-machine"; /** * Daily cron pass — flip any IN_USE rows whose 30-day window has @@ -111,10 +33,10 @@ export async function expireStaleInUseItems(input: { // selector + state-machine semantics line up here — every row that // matches `state = IN_USE AND expiresAt < now` is also a row that // `computeInventoryState` would classify EXPIRED (clause 2 or 3), - // because IN_USE implies `dosesRemaining > 0` (decrementDose flips - // an empty pen to USED_UP) and the `expiresAt` filter already - // proves the printed-expiry-or-window deadline has lapsed. EXPIRED - // rows are already terminal; USED_UP rows are off-window by + // because IN_USE implies `unitsRemaining > 0` (the consumption hook + // flips an empty container to USED_UP) and the `expiresAt` filter + // already proves the printed-expiry-or-window deadline has lapsed. + // EXPIRED rows are already terminal; USED_UP rows are off-window by // definition. Use a single `updateMany` instead of one update per // row — the daily cron sweep ran N round-trips for nothing. const result = await prisma.medicationInventoryItem.updateMany({ @@ -136,7 +58,8 @@ export async function expireStaleInUseItems(input: { export function buildCreateInventoryInput(input: { userId: string; medicationId: string; - dosesTotal: number; + unitsTotal: number; + containerType: MedicationContainerType; printedExpiry: Date | null; purchasedAt: Date | null; notes: string | null; @@ -145,8 +68,9 @@ export function buildCreateInventoryInput(input: { userId: input.userId, medicationId: input.medicationId, state: "ACTIVE" as const, - dosesTotal: input.dosesTotal, - dosesRemaining: input.dosesTotal, + containerType: input.containerType, + unitsTotal: input.unitsTotal, + unitsRemaining: input.unitsTotal, firstUseAt: null, printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index a095f05a5..83d50980d 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -2,16 +2,22 @@ * v1.4.25 W19b — pen / vial inventory state machine. * * Pure functions over a minimal `InventoryItemView` shape — no DB - * access, no Prisma imports. The route handlers and the daily expire - * cron both compose this module with their own persistence layer. + * access, no Prisma imports. The route handlers, the consumption hook + * (`./consumption.ts`), and the daily expire cron all compose this + * module with their own persistence layer. * * Decision tree (in evaluation order): - * 1. dosesRemaining === 0 → USED_UP (terminal) + * 1. unitsRemaining === 0 → USED_UP (terminal) * 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock) * 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED * 4. firstUseAt is set → IN_USE * 5. otherwise → ACTIVE * + * v1.16.10 — the item counts UNITS (tablets / ampoules / puffs), not + * doses; `Medication.unitsPerDose` maps between the two. The state + * machine is unit-agnostic — it only cares whether the count reached + * zero. + * * The window length is fixed at 30 days per EMA EPAR §6.3 for * Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day * window per its own EPAR — that drug-specific override is *not* @@ -38,8 +44,8 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; */ export interface InventoryItemView { state: MedicationInventoryState; - dosesTotal: number; - dosesRemaining: number; + unitsTotal: number; + unitsRemaining: number; firstUseAt: Date | null; printedExpiry: Date | null; } @@ -49,26 +55,26 @@ export interface InventoryItemView { * * The function is pure — given the same input it always returns the * same state — so the caller can re-evaluate as often as it wants - * without side-effects. The expire-cron and the intake-hook both use - * this same evaluator; the only difference is what they do with the - * result. + * without side-effects. The expire-cron and the consumption hook both + * use this same evaluator; the only difference is what they do with + * the result. */ export function computeInventoryState( item: InventoryItemView, nowMs: number, inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, ): MedicationInventoryState { - // (1) Terminal — once a pen is used up, it stays used up. The - // printed expiry on an empty pen is irrelevant; the EXPIRED state - // exists to warn the user not to inject from a pen that's still - // got doses but has gone stale, and an empty pen has neither + // (1) Terminal — once a container is used up, it stays used up. The + // printed expiry on an empty container is irrelevant; the EXPIRED + // state exists to warn the user not to dose from a container that's + // still got units but has gone stale, and an empty one has neither // problem. - if (item.dosesRemaining <= 0) { + if (item.unitsRemaining <= 0) { return "USED_UP"; } - // (2) Printed expiry trumps the in-use clock. A pen whose carton - // expiry has lapsed is EXPIRED even if it was never opened. + // (2) Printed expiry trumps the in-use clock. A container whose + // carton expiry has lapsed is EXPIRED even if it was never opened. if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) { return "EXPIRED"; } @@ -91,9 +97,9 @@ export function computeInventoryState( /** * Compute the `expiresAt` column. It's the min of the in-use * deadline (firstUseAt + window) and the printed expiry — whichever - * lands first. If the pen has never been opened we fall back to the - * printed expiry alone (in-use clock hasn't started). If neither is - * set we return null so the daily expire-cron has nothing to scan. + * lands first. If the container has never been opened we fall back to + * the printed expiry alone (in-use clock hasn't started). If neither + * is set we return null so the daily expire-cron has nothing to scan. */ export function computeExpiresAt( firstUseAt: Date | null, @@ -112,93 +118,10 @@ export function computeExpiresAt( return inUseDeadline ?? printedExpiry ?? null; } -/** - * Outcome of decrementing a dose. `first_use` fires when a pen - * transitions from ACTIVE → IN_USE (this intake event was its first - * use). `consumed` is the routine case. `depleted` fires on the - * intake that brings dosesRemaining to zero. - */ -export type DecrementOutcome = "first_use" | "consumed" | "depleted"; - -/** - * Result of `decrementDose`. The returned `item` is a fresh object — - * the function does not mutate its input. The caller persists the - * returned shape (typically via a `prisma.medicationInventoryItem.update`). - */ -export interface DecrementResult { - item: { - state: MedicationInventoryState; - dosesRemaining: number; - firstUseAt: Date | null; - expiresAt: Date | null; - }; - change: DecrementOutcome; -} - -/** - * Consume one dose from a pen. - * - * If the pen is ACTIVE (never opened), this intake event sets - * `firstUseAt = intakeAt` and starts the 30-day clock. If the pen - * was already IN_USE, only `dosesRemaining` decrements. When the - * last dose is taken, the state moves to USED_UP and `expiresAt` - * is left untouched (the in-use window is no longer meaningful for - * an empty pen, but persisting the deadline preserves the audit - * trail). - * - * The function is defensive — if called on an EXPIRED or USED_UP - * pen it still decrements (caller's job to gate). The state - * recomputation respects whatever the final dose count + clock say. - */ -export function decrementDose( - item: InventoryItemView, - intakeAt: Date, - inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, -): DecrementResult { - const wasFirstUse = item.firstUseAt === null; - const nextFirstUseAt = item.firstUseAt ?? intakeAt; - const nextDosesRemaining = Math.max(0, item.dosesRemaining - 1); - const nextExpiresAt = computeExpiresAt( - nextFirstUseAt, - item.printedExpiry, - inUseWindowDays, - ); - - const projected: InventoryItemView = { - state: item.state, - dosesTotal: item.dosesTotal, - dosesRemaining: nextDosesRemaining, - firstUseAt: nextFirstUseAt, - printedExpiry: item.printedExpiry, - }; - const nextState = computeInventoryState( - projected, - intakeAt.getTime(), - inUseWindowDays, - ); - - const change: DecrementOutcome = - nextDosesRemaining === 0 - ? "depleted" - : wasFirstUse - ? "first_use" - : "consumed"; - - return { - item: { - state: nextState, - dosesRemaining: nextDosesRemaining, - firstUseAt: nextFirstUseAt, - expiresAt: nextExpiresAt, - }, - change, - }; -} - /** * Whole-days remaining in the 30-day in-use window, rounded toward - * zero. Returns null when the pen is not currently IN_USE (the UI - * surface for ACTIVE / EXPIRED / USED_UP shows a different label). + * zero. Returns null when the container is not currently IN_USE (the + * UI surface for ACTIVE / EXPIRED / USED_UP shows a different label). * * The day count is computed against the in-use deadline only — * the printed expiry is its own separate countdown surfaced as the @@ -229,7 +152,7 @@ export function daysRemainingInUse( // (the inventory disclosure UI on the medication card) supply just // `firstUseAt` and have already filtered on state === "IN_USE" before // reaching here. The gate is silently skipped for the thin form. - if ("state" in item && "dosesRemaining" in item) { + if ("state" in item && "unitsRemaining" in item) { const state = computeInventoryState( { ...item, firstUseAt }, nowMs, diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 2bec317a1..52791dff5 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -11,7 +11,10 @@ import { createMedicationSchema, updateMedicationSchema, intakeSchema, + createInventoryItemSchema, + updateInventoryItemSchema, MEDICATION_CATEGORY_VALUES, + MEDICATION_CONTAINER_TYPE_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; @@ -139,6 +142,12 @@ export const medicationResource = z .describe( "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", ), + unitsPerDose: z + .number() + .int() + .describe( + "v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), active: z.boolean(), notificationsEnabled: z.boolean(), liveActivityEnabled: z @@ -236,6 +245,61 @@ const medicationDetailEntry = medicationResource "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", }); +// v1.16.10 — per-container inventory entity (pen / blister pack / +// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to +// doses. The intake consumption hook decrements `unitsRemaining` per +// taken dose and stamps the intake event with what it consumed. +const medicationInventoryItemResource = z + .object({ + id: z.string(), + userId: z.string(), + medicationId: z.string(), + state: z + .enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"]) + .describe( + "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).", + ), + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.", + ), + unitsTotal: z + .number() + .int() + .describe( + "Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.", + ), + unitsRemaining: z + .number() + .int() + .describe( + "Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.", + ), + firstUseAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.", + ), + expiresAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.", + ), + printedExpiry: z.iso.datetime({ offset: true }).nullable(), + purchasedAt: z.iso.datetime({ offset: true }).nullable(), + notes: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationInventoryItem", + description: + "One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.", + }); + const medicationIntakeEventResource = z .object({ id: z.string(), @@ -792,6 +856,135 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/inventory": { + get: { + tags: ["Medications"], + summary: "List a medication's supply containers", + description: + "Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Inventory item list.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + items: z.array(medicationInventoryItemResource), + meta: z.object({ total: z.number().int().nonnegative() }), + }), + "ListMedicationInventoryResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Register a new supply container", + description: + "Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: createInventoryItemSchema }, + }, + }, + responses: { + "201": { + description: "Created inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "CreateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/inventory/{itemId}": { + patch: { + tags: ["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 capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateInventoryItemSchema }, + }, + }, + responses: { + "200": { + description: "Updated inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "UpdateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a supply container", + description: + "Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.boolean() }), + "DeleteMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/extract": { post: { tags: ["Medications"], diff --git a/src/lib/validations/__tests__/medication-inventory.test.ts b/src/lib/validations/__tests__/medication-inventory.test.ts new file mode 100644 index 000000000..e29fc0399 --- /dev/null +++ b/src/lib/validations/__tests__/medication-inventory.test.ts @@ -0,0 +1,166 @@ +/** + * v1.16.10 — inventory + units-per-dose validation bounds. + * + * Pins the raised unit caps (100 → 1000 on container capacity and the + * stock correction; `dosesPerUnit` rides along) and the new + * `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy + * ledger delta deliberately KEEPS its ±100 bound — it counts pens, not + * units. + */ +import { describe, expect, it } from "vitest"; + +import { + createInventoryItemSchema, + updateInventoryItemSchema, + createMedicationSchema, + updateMedicationSchema, + glp1InventoryPostSchema, +} from "../medication"; + +const MINIMAL_MEDICATION = { + name: "Med", + dose: "5 mg", + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + }, + ], +}; + +describe("createInventoryItemSchema — unit cap 1000", () => { + it("accepts 1000 units", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001 units", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 }); + expect(r.success).toBe(false); + }); + + it("rejects zero", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 }); + expect(r.success).toBe(false); + }); + + it("accepts every container type and defaults to absent", () => { + for (const ct of [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", + ]) { + const r = createInventoryItemSchema.safeParse({ + dosesTotal: 4, + containerType: ct, + }); + expect(r.success).toBe(true); + } + const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 }); + expect(r.success).toBe(true); + expect(r.success && r.data.containerType).toBeUndefined(); + }); + + it("rejects an unknown container type", () => { + const r = createInventoryItemSchema.safeParse({ + dosesTotal: 4, + containerType: "BAG", + }); + expect(r.success).toBe(false); + }); +}); + +describe("updateInventoryItemSchema — stock correction cap 1000", () => { + it("accepts 1000", () => { + const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001", () => { + const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 }); + expect(r.success).toBe(false); + }); +}); + +describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100", () => { + it("create accepts dosesPerUnit 1000 and rejects 1001", () => { + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + dosesPerUnit: 1000, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + dosesPerUnit: 1001, + }).success, + ).toBe(false); + }); + + it("update accepts dosesPerUnit 1000 and rejects 1001", () => { + expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1000 }).success).toBe( + true, + ); + expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1001 }).success).toBe( + false, + ); + }); + + it("create bounds unitsPerDose to 1–100", () => { + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 1, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 100, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 0, + }).success, + ).toBe(false); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 101, + }).success, + ).toBe(false); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 1.5, + }).success, + ).toBe(false); + }); + + it("update bounds unitsPerDose to 1–100", () => { + expect(updateMedicationSchema.safeParse({ unitsPerDose: 2 }).success).toBe( + true, + ); + expect(updateMedicationSchema.safeParse({ unitsPerDose: 0 }).success).toBe( + false, + ); + expect( + updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success, + ).toBe(false); + }); +}); + +describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => { + it("accepts ±100 and rejects ±101", () => { + expect(glp1InventoryPostSchema.safeParse({ delta: 100, reason: "purchased" }).success).toBe(true); + expect(glp1InventoryPostSchema.safeParse({ delta: -100, reason: "damaged" }).success).toBe(true); + expect(glp1InventoryPostSchema.safeParse({ delta: 101, reason: "purchased" }).success).toBe(false); + expect(glp1InventoryPostSchema.safeParse({ delta: -101, reason: "damaged" }).success).toBe(false); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 1c9213675..d8ab0b5cb 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -456,8 +456,23 @@ export const createMedicationSchema = z category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - /** v1.4.25 W4d — doses per pen/vial for inventory tracking. */ - dosesPerUnit: z.number().int().min(1).max(100).optional(), + /** v1.4.25 W4d — doses per pen/vial for inventory tracking. + * v1.16.10 raises the cap to 1000 (large tablet packs). */ + dosesPerUnit: z.number().int().min(1).max(1000).optional(), + /** + * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets + * for a 4 mg dose). Default 1. Dose-level readouts divide the + * unit counts by this factor. + */ + unitsPerDose: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + "Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), /** @@ -528,7 +543,17 @@ export const updateMedicationSchema = z dose: z.string().min(1).max(50).optional(), category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(), + dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), + /** v1.16.10 — inventory units one dose consumes. Default 1. */ + unitsPerDose: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + "Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.", + ), /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), /** v1.8.5 — per-medication injection-site tracking opt-in. */ @@ -799,41 +824,103 @@ export type BulkDeleteIntakeEventsInput = z.infer< * USED_UP (manual override). EXPIRED is owned by the daily cron and * the intake hook, so the PATCH schema deliberately omits it. */ -export const createInventoryItemSchema = z.object({ - dosesTotal: z.number().int().min(1).max(100), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - purchasedAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - notes: z.string().max(200).nullable().optional(), -}); +/** + * v1.16.10 — container kinds, mirrored from the Prisma + * `MedicationContainerType` enum. Display-level classification only. + */ +export const MEDICATION_CONTAINER_TYPE_VALUES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +export type MedicationContainerTypeValue = + (typeof MEDICATION_CONTAINER_TYPE_VALUES)[number]; -export const updateInventoryItemSchema = z.object({ - markAsFirstUseAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - markAsUsedUp: z.boolean().optional(), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - /** - * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw - * flow). Sets the remaining-dose count directly; the route clamps to - * `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, - * a raise out of 0 re-evaluates against the expiry clocks). - */ - dosesRemaining: z.number().int().min(0).max(100).optional(), - notes: z.string().max(200).nullable().optional(), -}); +export const createInventoryItemSchema = z + .object({ + /** Units the container ships with. v1.16.10 raises the cap from + * 100 to 1000 (large tablet packs); the wire field name stays + * `dosesTotal` — it counts units, mapped to doses via + * `Medication.unitsPerDose`. */ + dosesTotal: z + .number() + .int() + .min(1) + .max(1000) + .describe( + "Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.", + ), + /** v1.16.10 — container kind. Defaults to OTHER when absent. */ + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .optional() + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + purchasedAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "CreateMedicationInventoryItemRequest", + description: + "Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.", + }); + +export const updateInventoryItemSchema = z + .object({ + markAsFirstUseAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.", + ), + markAsUsedUp: z + .boolean() + .optional() + .describe( + "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + /** + * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw + * flow). Sets the remaining-unit count directly; the route clamps to + * `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, + * a raise out of 0 re-evaluates against the expiry clocks). + * v1.16.10 raises the cap to 1000 alongside the capacity cap. + */ + dosesRemaining: z + .number() + .int() + .min(0) + .max(1000) + .optional() + .describe( + "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).", + ), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "UpdateMedicationInventoryItemRequest", + description: + "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.", + }); export type CreateInventoryItemInput = z.infer< typeof createInventoryItemSchema @@ -859,8 +946,9 @@ export type UpdateInventoryItemInput = z.infer< * - `effectiveFrom` is constrained to a ±5-year window around now — * a paper-record back-fill or a planned future step both fit, but * "1970" / "9999" do not. - * - `delta` is a non-zero finite integer in [−100, 100] (the existing - * inventory route caps `dosesTotal` at 100 doses per pen). + * - `delta` is a non-zero finite integer in [−100, 100] — the legacy + * ledger counts pens, and ±100 pens per correction stays plenty + * (deliberately NOT raised with the v1.16.10 per-item unit cap). * - `reason` is a bounded string (the route logs it; raw blob bad). */ const MAX_DOSE_MG = 100; From d142a6ed31e6b133a255251e5cdf0a848148ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 17:42:44 +0200 Subject: [PATCH 02/13] fix(dashboard,rollups): the score survives new data types and folds stop eating buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot's thick phase gated on every type the account had ever logged having rollup coverage — one fresh reading from a new integration nulled the health score and the blood-pressure extras while the insights page showed a score for the same account. The gate now checks only the three types the phase reads, per the established per-type pattern. Underneath sat the destabiliser: the large rollup fold deleted the window's buckets and re-inserted without a transaction or duplicate tolerance, so a racing sync write aborted the insert and left the delete committed — whole types lost their buckets until the next clean fold, and accounts with data older than the five-year fold window re-ran the full backfill on every boot because discovery scanned unbounded history. Delete and insert now commit atomically with duplicate tolerance, discovery shares the fold's bound, the provisional ring says the score is being computed when the inputs exist and reserves the no-data wording for accounts that have none, and the greeting leads the band typographically. --- .../hero/__tests__/dashboard-hero.test.tsx | 71 +++++++++ .../dashboard/hero/dashboard-hero.tsx | 43 +++++- src/lib/dashboard/__tests__/snapshot.test.ts | 104 +++++++++++++ src/lib/dashboard/snapshot.ts | 55 +++++-- .../__tests__/measurement-rollups.test.ts | 139 +++++++++++++++++- src/lib/rollups/measurement-rollups.ts | 73 +++++++-- 6 files changed, 448 insertions(+), 37 deletions(-) diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 0c386bbf8..30ee06eb4 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -494,6 +494,77 @@ describe(" — score column", () => { // collapses (the 120 px sm geometry rides an inline style). expect(html).toContain("width:120px"); }); + + // A null score on an account that already carries score inputs is a + // warm-up state (the rollup tier is mid-fold), not a data gap — the + // copy must say "computing", never "not enough data". + it("null score WITH existing score inputs renders the computing label, not the data-gap one", () => { + const html = render( + baseSnapshot({ + healthScore: null, + tiles: { + summaries: { WEIGHT: summary({ count: 12, latest: 80 }) }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-2), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain("Score wird berechnet"); + expect(html).not.toContain("Noch nicht genug Daten"); + }); + + it("active medications alone count as a score input (compliance pillar)", () => { + const html = render( + baseSnapshot({ + healthScore: null, + medsToday: medsToday({ activeCount: 1 }), + }), + ); + expect(html).toContain("Score wird berechnet"); + expect(html).not.toContain("Noch nicht genug Daten"); + }); + + it("keeps the genuine data-gap label when no score input exists at all", () => { + // Empty summaries, no mood entries, no active medications — the + // baseSnapshot. A non-score-pillar summary (steps) must not flip + // the copy either. + const html = render( + baseSnapshot({ + healthScore: null, + tiles: { + summaries: { ACTIVITY_STEPS: summary({ count: 30, latest: 9000 }) }, + lastSeenByType: {}, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain("Noch nicht genug Daten"); + expect(html).not.toContain("Score wird berechnet"); + }); +}); + +describe(" — typographic hierarchy", () => { + it("the greeting leads: larger + heavier than the verdict line", () => { + const html = render(baseSnapshot()); + const greeting = html.match( + /]*data-slot="dashboard-hero-greeting"[^>]*>/, + ); + expect(greeting).not.toBeNull(); + expect(greeting![0]).toContain("text-lg"); + expect(greeting![0]).toContain("font-semibold"); + expect(greeting![0]).not.toContain("text-muted-foreground"); + const verdict = html.match( + /]*data-slot="dashboard-hero-verdict"[^>]*>/, + ); + expect(verdict).not.toBeNull(); + // The verdict stays one step down — base size, medium weight. + expect(verdict![0]).toContain("text-base"); + expect(verdict![0]).toContain("font-medium"); + expect(verdict![0]).not.toContain("font-semibold"); + }); }); describe(" — source pins", () => { diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 3d917740c..6daeb2566 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -11,11 +11,18 @@ * * - left column: greeting line (absorbed from the old header * paragraph — the snapshot's server-computed `greetingHour` keeps - * the daypart free of any client `Intl` work), the verdict sentence - * with its single CTA button, and the dose row; + * the daypart free of any client `Intl` work; typographically the + * band's anchor, larger and heavier than the verdict so the + * personalised line is not drowned by the content below it), the + * verdict sentence with its single CTA button, and the dose row; * - right column: the shared `` at `sm` geometry. A null * score renders the ring's provisional state at the identical - * 120 px footprint, so the column never collapses. + * 120 px footprint, so the column never collapses. The provisional + * label is honest about WHY the score is null: when the snapshot + * already carries score inputs (weight / BP summaries, mood + * entries, active medications) the rollup tier is merely mid-fold + * and the label reads "computing"; only an account with no score + * inputs at all gets "not enough data". * * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the * past with `nextDueOverdue: false` means the slot's anchor passed @@ -167,6 +174,22 @@ export function DashboardHero({ const cta = verdict.cta; const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + // ── Provisional score copy ────────────────────────────────────────── + // The score's four pillars are weight, BP, mood, and medication + // compliance. When ANY of them already carries data, a null score is + // a warm-up state (the rollup tier hasn't folded the buckets the + // warm-phase score rides on yet) — telling an account with weeks of + // readings "not enough data" would be a lie. Only an account with no + // score inputs at all keeps the genuine empty-state copy. Cheap + // heuristic over fields the snapshot already carries; no extra reads. + const summaries = snapshot.tiles.summaries; + const hasScoreInputs = + (summaries.WEIGHT?.count ?? 0) > 0 || + (summaries.BLOOD_PRESSURE_SYS?.count ?? 0) > 0 || + (summaries.BLOOD_PRESSURE_DIA?.count ?? 0) > 0 || + snapshot.tiles.mood.entries.length > 0 || + snapshot.medsToday.activeCount > 0; + // ── Dose row ──────────────────────────────────────────────────────── const meds = snapshot.medsToday; const now = new Date(); @@ -206,9 +229,15 @@ export function DashboardHero({ >
+ {/* Greeting leads the typographic hierarchy: larger + heavier + than the verdict so the personalised line reads first. + The verdict keeps its medium weight one step down — still + the content lead, clearly subordinate to the greeting. + Both stay on the existing foreground token; the band adds + no new colour. */}

{welcomeText}

@@ -216,7 +245,7 @@ export function DashboardHero({

{sentence}

@@ -276,7 +305,9 @@ export function DashboardHero({ label={ snapshot.healthScore ? t("dashboard.hero.scoreLabel") - : t("dashboard.hero.scoreProvisional") + : hasScoreInputs + ? t("dashboard.hero.scoreComputing") + : t("dashboard.hero.scoreProvisional") } />
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index ef9da94e1..74149aba8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -216,6 +216,110 @@ describe("buildDashboardSnapshot — two-phase null extras", () => { }); }); +describe("buildDashboardSnapshot — per-type thick-phase gate", () => { + // The thick phase gates ONLY on the types it reads through the + // rollup tier (WEIGHT + BLOOD_PRESSURE_SYS/_DIA). An uncovered + // UNRELATED type (a fresh wearable reading whose async fold hasn't + // landed) must not null extras/healthScore — the all-types + // `isFullyCovered` AND was the bug, not the per-type misses. + it("an uncovered UNRELATED type no longer nulls extras + healthScore", async () => { + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + // Fresh WHOOP recovery reading, no DAY bucket yet. + ["RECOVERY_SCORE", false], + ]), + ); + // Pin that the builder no longer consults the all-types helper: + // even an explicit `false` from it must not cold the phase. + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + gradedScore: 84, + }); + computeUserHealthScoreFastPath.mockResolvedValue({ + score: 71, + band: "yellow", + delta: 3, + components: {}, + }); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).not.toBeNull(); + expect(snap.extras!.bpInTargetPct).toBe(80); + expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 }); + expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(1); + expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); + }); + + it("a fresh account with zero coverage entries stays null", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).toBeNull(); + expect(snap.healthScore).toBeNull(); + expect(computeBpInTargetFastPath).not.toHaveBeenCalled(); + expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled(); + }); + + it("a gated type ABSENT from the map (never logged) keeps the phase warm", async () => { + // BP never logged at all — the BP helper's live fallback is an + // empty read, so absence must not cold the phase. + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: null, + last30Days: null, + allTime: null, + priorMonth: null, + priorYear: null, + gradedScore: null, + }); + computeUserHealthScoreFastPath.mockResolvedValue(null); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).not.toBeNull(); + }); + + it.each([ + ["WEIGHT"], + ["BLOOD_PRESSURE_SYS"], + ["BLOOD_PRESSURE_DIA"], + ])( + "an uncovered %s (rows without buckets) keeps the phase cold", + async (uncoveredType) => { + const coverage = new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + coverage.set(uncoveredType, false); + probeRollupCoverage.mockResolvedValue(coverage); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + // Rows without buckets on a gated type would route the helper + // onto its multi-second live fallback — the snapshot must not + // wait on it. Null is acceptable here; the all-types poison was + // the bug. + expect(snap.extras).toBeNull(); + expect(snap.healthScore).toBeNull(); + expect(computeBpInTargetFastPath).not.toHaveBeenCalled(); + }, + ); +}); + describe("buildDashboardSnapshot — medsToday (fast phase)", () => { it("rides the fast phase and is present even on a rollup-coverage miss", async () => { probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index bb633d37d..376dcb7e5 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,7 +49,6 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; import { probeRollupCoverage, - isFullyCovered, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { @@ -414,11 +413,12 @@ async function buildMoodBlock( /** * Thick slice — BD-Zielbereich + per-context glucose + health score. - * Only called by the builder when the rollup tier is warm - * (`isFullyCovered`); the BP fast-path then stays on the sub-second - * rollup branch and never drops into the multi-second live fallback - * that would make the whole strip wait. On a coverage miss the builder - * skips this entirely and emits `extras: null` + `healthScore: null` + * Only called by the builder when the rollup tier is warm for the + * types this slice actually reads (`isThickPhaseWarm`); the BP + * fast-path then stays on the sub-second rollup branch and never drops + * into the multi-second live fallback that would make the whole strip + * wait. On a coverage miss for one of those types the builder skips + * this entirely and emits `extras: null` + `healthScore: null` * (per-tile shimmer until the boot backfill converges). * * The health score rides this warm phase because it reuses the BP @@ -685,6 +685,37 @@ function buildLayoutCatalogue( return out.sort((a, b) => a.order - b.order); } +/** + * Per-type warm gate for the thick phase (extras + healthScore), + * replacing the all-types `isFullyCovered` AND. The all-types gate + * nulled the whole thick phase whenever ANY unrelated type lacked a + * DAY bucket — one fresh wearable reading whose async fold hadn't + * landed yet zeroed the hero score, while the insights analytics + * route (which calls the self-gating helpers unconditionally) kept + * showing a score for the same account. Mirrors the v1.4.38.8 + * per-type pattern already inside `computeBpInTargetFastPath` and + * `computeUserHealthScoreFastPath`: gate only on the types the thick + * phase actually reads through the rollup tier — WEIGHT (score weight + * pillar) + BLOOD_PRESSURE_SYS / _DIA (BP windows + BP pillar). + * + * `!== false` rather than `=== true`: a type ABSENT from the map has + * zero measurements, so the helpers' own live fallbacks are trivially + * cheap (empty reads) and absence must not cold the phase. A type + * present-but-`false` has live rows without buckets — for the three + * gated types that is exactly the multi-second live fallback the + * snapshot must never wait on, so the phase stays `null` until the + * backfill converges. An empty map is a fresh account with no + * measurements at all — nothing to compute, stay `null`. + */ +function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { + if (coverage.size === 0) return false; + return ( + coverage.get("WEIGHT") !== false && + coverage.get("BLOOD_PRESSURE_SYS") !== false && + coverage.get("BLOOD_PRESSURE_DIA") !== false + ); +} + /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable @@ -711,12 +742,14 @@ export async function buildDashboardSnapshot( options.time ?? ((_label: string, fn: () => Promise) => fn()); // Probe coverage once up front so the thick `extras` phase only runs - // when the rollup tier is warm. A coverage miss returns `extras: null` - // immediately rather than dropping into the live-SQL fallback that - // would make the whole strip wait on the slowest read (R-firstpaint - // §6 — paint-together vs slowest-wins). + // when the rollup tier is warm for the types it reads. A coverage + // miss on one of those types returns `extras: null` immediately + // rather than dropping into the live-SQL fallback that would make + // the whole strip wait on the slowest read (R-firstpaint §6 — + // paint-together vs slowest-wins). Unrelated uncovered types do NOT + // cold the phase (see `isThickPhaseWarm`). const coverage = await time("coverage", () => probeRollupCoverage(user.id)); - const warm = isFullyCovered(coverage); + const warm = isThickPhaseWarm(coverage); const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index 23e86443d..45237f0a3 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -92,6 +92,7 @@ import { enqueueRollupRecompute, ensureUserRollupsFresh, recomputeBucketsForMeasurement, + recomputeUserRollups, } from "../measurement-rollups"; beforeEach(() => { @@ -110,13 +111,23 @@ beforeEach(() => { // resolved promise does not short-circuit the next test's recompute // assertion. _resetEnsureUserRollupsFreshInFlightForTests(); - // Default: $transaction takes an array of pre-built promises (the - // populator passes `[deleteMany(...), createMany(...)]`) and resolves - // them like the real client so the destructured create result is the - // resolved value, not a pending promise. - transaction.mockImplementation(async (operations: unknown[]) => - Promise.all(operations), - ); + // Default: $transaction supports both client shapes the populator + // uses — the batched array form (`[deleteMany(...), createMany(...)]` + // on the ≤CHUNK path) resolves the pre-built promises like the real + // client, and the interactive callback form (large path) invokes the + // callback with a tx proxy wired to the same delegate mocks so the + // assertions can observe the in-transaction calls. + transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === "function") { + return (arg as (tx: unknown) => Promise)({ + measurementRollup: { + deleteMany: mocks.deleteMany, + createMany: mocks.createMany, + }, + }); + } + return Promise.all(arg as unknown[]); + }); // v1.11.1 — persistRollupRows writes via createMany; default the count. createMany.mockResolvedValue({ count: 1 }); }); @@ -366,6 +377,93 @@ describe("recomputeBucketsForMeasurement", () => { }); }); +describe("persistRollupRows — large path (via recomputeUserRollups)", () => { + /** One synthetic aggregate row per call — distinct bucket per index. */ + function syntheticRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + type: "WEIGHT", + source: "MANUAL", + bucket_start: new Date(Date.UTC(2024, 0, 1 + (i % 1800))), + count: BigInt(1), + mean: 80, + min_value: 80, + max_value: 80, + sum_value: 80, + sd: 0, + slope: 0, + r2: 0, + })); + } + + it("wraps the delete + every insert chunk in ONE interactive transaction with skipDuplicates", async () => { + // 1200 rows → large path (> 500), 3 insert chunks (500/500/200). + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1200)); + createMany.mockResolvedValue({ count: 400 }); + + const result = await recomputeUserRollups("user-1", { + granularities: ["DAY"], + }); + + // ONE interactive transaction — the callback form, not the batched + // array. The committed-delete-without-inserts window of the + // pre-v1.16.10 shape (delete commits, a concurrent write-hook + // P2002 aborts the chunk loop, the types lose ALL buckets) cannot + // exist when both legs share the transaction. + expect(transaction).toHaveBeenCalledTimes(1); + expect(typeof transaction.mock.calls[0][0]).toBe("function"); + // A generous interactive timeout — the chunk loop on a multi-year + // fold outlives Prisma's 5 s default. + expect(transaction.mock.calls[0][1]).toMatchObject({ + timeout: 120_000, + }); + + // Delete fired once over the full (types × bucket-range) partition. + expect(deleteMany).toHaveBeenCalledTimes(1); + expect(deleteMany.mock.calls[0][0].where.userId).toBe("user-1"); + expect(deleteMany.mock.calls[0][0].where.granularity).toBe("DAY"); + + // 3 chunks, every one tolerant of a mid-transaction hook insert. + expect(createMany).toHaveBeenCalledTimes(3); + expect(createMany.mock.calls[0][0].data).toHaveLength(500); + expect(createMany.mock.calls[1][0].data).toHaveLength(500); + expect(createMany.mock.calls[2][0].data).toHaveLength(200); + for (const call of createMany.mock.calls) { + expect(call[0].skipDuplicates).toBe(true); + } + + expect(result.rowsUpserted).toBe(1200); + }); + + it("a failing chunk rejects through the transaction so the delete rolls back with it", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(700)); + createMany + .mockResolvedValueOnce({ count: 500 }) + .mockRejectedValueOnce(new Error("connection reset")); + + // The error must bubble (pg-boss retries the fold); the structural + // guarantee is that the delete ran INSIDE the same transaction, so + // the real client rolls it back — no standalone committed delete. + await expect( + recomputeUserRollups("user-1", { granularities: ["DAY"] }), + ).rejects.toThrow("connection reset"); + expect(transaction).toHaveBeenCalledTimes(1); + expect(typeof transaction.mock.calls[0][0]).toBe("function"); + // The delete was only ever issued through the transaction client — + // it cannot have committed ahead of the failed chunk. + expect(deleteMany).toHaveBeenCalledTimes(1); + }); + + it("the ≤500-row path keeps the batched two-statement transaction", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(2)); + createMany.mockResolvedValue({ count: 2 }); + + await recomputeUserRollups("user-1", { granularities: ["DAY"] }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(Array.isArray(transaction.mock.calls[0][0])).toBe(true); + }); +}); + describe("enqueueRollupRecompute", () => { it("is a silent no-op when no boss is attached", async () => { getGlobalBossMock.mockReturnValue(null); @@ -641,4 +739,31 @@ describe("enqueueBootTimeRollupBackfill", () => { // LEFT JOIN now compares bucket_start on both sides. expect(sqlText).toContain('r."bucket_start" = mt."bucket_start"'); }); + + // v1.16.10 — discovery and fold share one trailing window. Unbounded + // discovery re-flagged accounts whose oldest measurement pre-dated + // the 5-year fold bound on EVERY worker boot: the fold can never + // write buckets for those days, so the per-day gap never closed and + // the full backfill re-ran forever. + it("bounds discovery to the fold window so pre-window history cannot re-flag accounts", async () => { + getGlobalBossMock.mockReturnValue({ send: bossSend }); + queryRaw.mockResolvedValueOnce([]); + const before = Date.now(); + + await enqueueBootTimeRollupBackfill(); + + const after = Date.now(); + const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray; + const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : ""; + // The inner scan filters on measured_at >= . + expect(sqlText).toContain('m."measured_at" >='); + // The bound parameter is now − ROLLUP_FOLD_WINDOW_MS (5 years), + // matching the `recomputeUserRollups` default the backfill worker + // runs with. + const boundArg = queryRaw.mock.calls[0][1] as Date; + expect(boundArg).toBeInstanceOf(Date); + const fiveYearsMs = 5 * 365 * 24 * 60 * 60 * 1000; + expect(boundArg.getTime()).toBeGreaterThanOrEqual(before - fiveYearsMs); + expect(boundArg.getTime()).toBeLessThanOrEqual(after - fiveYearsMs); + }); }); diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index b420262f4..b13695620 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -97,6 +97,17 @@ export interface RollupFullBackfillPayload { enqueuedAt: string; } +/** + * Trailing fold window for `recomputeUserRollups` defaults AND the + * boot-time discovery scan. The two MUST share this bound: the fold + * only writes buckets inside it, so a discovery scan over unbounded + * history would re-flag any account whose oldest measurement pre-dates + * the window and re-run the full backfill on every worker boot without + * ever converging. Measurements older than the window deliberately + * never get buckets — the read tier falls back to live SQL for them. + */ +export const ROLLUP_FOLD_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; + /** Postgres `date_trunc` unit per granularity. */ const DATE_TRUNC_UNIT: Record = { DAY: "day", @@ -141,7 +152,7 @@ export async function recomputeUserRollups( ): Promise<{ rowsUpserted: number; durationMs: number }> { const startedAt = Date.now(); const granularities = opts.granularities ?? ALL_GRANULARITIES; - const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); + const fiveYearsAgo = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const from = opts.from ?? fiveYearsAgo; const to = opts.to ?? new Date(); @@ -526,19 +537,45 @@ async function persistRollupRows( } } - // Large backfill — delete the partition range once, then insert in chunks. - // The brief gap between delete and the first insert can only make a - // concurrent reader fall back to live SQL for those buckets (correct - // value), never serve a wrong one. - await prisma.measurementRollup.deleteMany({ where: deleteWhere }); + // Large backfill — delete the partition range and insert the chunks + // inside ONE interactive transaction. Pre-v1.16.10 the deleteMany + // committed standalone before the chunk loop, and the chunks ran + // without the P2002 tolerance the ≤CHUNK path has: a concurrent + // write-hook insert landing mid-loop made a createMany throw, the + // loop aborted, the delete stayed committed — and every type in the + // window lost ALL its buckets until the next race-free fold (the + // coverage probe then cycled covered/uncovered as the boot backfill + // re-raced the hooks). The transaction keeps the delete invisible + // until every chunk landed; `skipDuplicates` (ON CONFLICT DO + // NOTHING) additionally absorbs a hook row that commits between two + // chunks — READ COMMITTED lets a later statement see it — without + // aborting the batch. The hook's row is the per-(type, day) loser of + // the same aggregate, so skipping the fold's duplicate is benign. + // + // Transaction sizing: one call covers ONE granularity, so the worst + // case is the full 5-year DAY fold of a multi-source power user — + // tens of thousands of rows in 500-row chunks, well inside Postgres + // limits but potentially slow on self-host disks. A per-type + // transaction was considered and rejected: it would re-open the + // committed-delete gap between types sharing the bucket range. The + // 120 s timeout (Prisma default: 5 s) covers the chunk round-trips + // with headroom; the fold runs on the serial backfill worker, so a + // long-held transaction cannot pile up behind itself. let touched = 0; - for (let i = 0; i < rows.length; i += CHUNK) { - const slice = rows.slice(i, i + CHUNK); - const res = await prisma.measurementRollup.createMany({ - data: slice.map(toData), - }); - touched += res.count; - } + await prisma.$transaction( + async (tx) => { + await tx.measurementRollup.deleteMany({ where: deleteWhere }); + for (let i = 0; i < rows.length; i += CHUNK) { + const slice = rows.slice(i, i + CHUNK); + const res = await tx.measurementRollup.createMany({ + data: slice.map(toData), + skipDuplicates: true, + }); + touched += res.count; + } + }, + { maxWait: 10_000, timeout: 120_000 }, + ); return touched; } @@ -779,6 +816,15 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ // has been retired. The maintainer's tenant converged at v1.4.40, so the // arm was a permanent no-op seq scan over `measurement_rollups`. // Per-day missing coverage remains the sole discovery anchor. + // + // v1.16.10 — discovery is bounded to the same trailing window the + // fold writes (`ROLLUP_FOLD_WINDOW_MS`). Unbounded, any account + // with measurements older than the window was re-flagged on every + // boot: the fold can never produce buckets for those days, so the + // per-day gap re-surfaced forever and the full backfill re-ran on + // every restart. Days outside the window are not foldable by + // design and must not anchor discovery. + const foldWindowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const users = await prisma.$queryRaw>` SELECT DISTINCT mt."user_id" AS id FROM ( @@ -788,6 +834,7 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ date_trunc('day', m."measured_at") AS bucket_start FROM measurements m WHERE m."deleted_at" IS NULL + AND m."measured_at" >= ${foldWindowStart} ) mt LEFT JOIN measurement_rollups r ON r."user_id" = mt."user_id" From e21dfd040e2ed4c610b744551916bfdd6f67d209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 18:10:21 +0200 Subject: [PATCH 03/13] feat(medications): a sortable table view with an order you choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medications page gains a compact table beside the card view — one row per medication with the same served-due status pill, next-intake line, cadence-scaled adherence bars, dose-derived stock and the take and skip actions the cards use, all from the payloads the cards already read; the list response aggregates stock in the same round trip. Columns sort tri-state with nulls last, the default order is yours to arrange — the same drag dialog the dashboard and insights use, applied to both views — and the chosen view and order persist per user on their own settings column with field-scoped writes, so toggling the view never touches the order. Inactive medications pin to the end, the first column stays put under horizontal scroll, and the loading skeleton follows the view you left. --- docs/api/openapi.yaml | 192 ++++- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/pl.json | 23 +- .../migration.sql | 11 + prisma/schema.prisma | 8 + .../api/medications/__tests__/route.test.ts | 6 + .../layout/__tests__/route.test.ts | 277 +++++++ src/app/api/medications/layout/route.ts | 177 +++++ src/app/api/medications/route.ts | 55 +- .../__tests__/list-view-toggle.test.tsx | 165 +++++ src/app/medications/page.tsx | 166 +++-- .../__tests__/medication-table.test.tsx | 439 ++++++++++++ .../card-parts/medication-status-pill.tsx | 28 +- .../medications/medication-reorder-dialog.tsx | 294 ++++++++ .../medications/medication-table.tsx | 678 ++++++++++++++++++ .../medications/medication-view-toggle.tsx | 73 ++ .../__tests__/medication-list-layout.test.ts | 92 +++ src/lib/cache/invalidate.ts | 11 + src/lib/cache/server-cache.ts | 12 + src/lib/medication-list-layout.ts | 113 +++ .../__tests__/medication-order.test.ts | 52 ++ src/lib/medications/medication-order.ts | 37 + src/lib/openapi/routes/medications.ts | 116 ++- .../use-medication-list-layout.test.ts | 150 ++++ .../use-medication-compliance-summary.ts | 33 +- src/lib/queries/use-medication-list-layout.ts | 116 +++ src/lib/query-keys/medications.ts | 8 + 31 files changed, 3382 insertions(+), 65 deletions(-) create mode 100644 prisma/migrations/0150_v11610_medication_list_layout/migration.sql create mode 100644 src/app/api/medications/layout/__tests__/route.test.ts create mode 100644 src/app/api/medications/layout/route.ts create mode 100644 src/app/medications/__tests__/list-view-toggle.test.tsx create mode 100644 src/components/medications/__tests__/medication-table.test.tsx create mode 100644 src/components/medications/medication-reorder-dialog.tsx create mode 100644 src/components/medications/medication-table.tsx create mode 100644 src/components/medications/medication-view-toggle.tsx create mode 100644 src/lib/__tests__/medication-list-layout.test.ts create mode 100644 src/lib/medication-list-layout.ts create mode 100644 src/lib/medications/__tests__/medication-order.test.ts create mode 100644 src/lib/medications/medication-order.ts create mode 100644 src/lib/queries/__tests__/use-medication-list-layout.test.ts create mode 100644 src/lib/queries/use-medication-list-layout.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c01249a6a..d5c80eb3a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2153,6 +2153,61 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/medications/layout: + get: + tags: + - Medications + summary: Read the calling user's medications list presentation + description: Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults + (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract. + responses: + "200": + description: The resolved presentation (custom or default). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutResponse" + "401": *a1 + "422": *a3 + "429": *a4 + put: + tags: + - Medications + summary: Update the calling user's medications list presentation + description: "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from + the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is + returned. Invalid bodies return the multi-issue 422 envelope." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayout" + responses: + "200": + description: Presentation saved; the normalised blob is echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutSaved" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Medications + summary: Reset the calling user's medications list presentation + description: Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent. + responses: + "200": + description: Presentation reset; the defaults are returned. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutReset" + "401": *a1 + "422": *a3 + "429": *a4 /api/medications/{id}: get: tags: @@ -4888,6 +4943,34 @@ components: description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. + MedicationListLayout: + type: object + properties: + version: + type: number + const: 1 + view: + description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored + value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on + responses. + type: string + enum: + - cards + - table + order: + description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — + unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always + present on responses. + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - version + description: "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by + both views. Mirrors the dashboard-widgets / insights-layout contract." UpdateMedicationRequest: type: object properties: @@ -8596,6 +8679,23 @@ components: description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered." + stockUnitsRemaining: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over + ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = + tracking on, supply ran out. Read-only — aggregated, not stored. + stockDosesRemaining: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory + tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored." required: - id - name @@ -8622,12 +8722,14 @@ components: - category - lastTakenAt - todayEventCount + - stockUnitsRemaining + - stockDosesRemaining additionalProperties: false - description: List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and - `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, - `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, - `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for - their semantics. + description: List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, + `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the + dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, + `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, + `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics. MedicationCategory: type: string enum: @@ -8801,6 +8903,57 @@ components: additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. + MedicationListLayoutResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationListLayoutSaved: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationListLayoutReset: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetMedicationResponse: type: object properties: @@ -13162,6 +13315,35 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + MedicationListLayoutOutput: + type: object + properties: + version: + type: number + const: 1 + view: + description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored + value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on + responses. + type: string + enum: + - cards + - table + order: + description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — + unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always + present on responses. + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - version + additionalProperties: false + description: "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by + both views. Mirrors the dashboard-widgets / insights-layout contract." DisableCoachFlagOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index d49bcd2ae..f5debfaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Injektionsstellen erfassen", "trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.", "allowedSitesLabel": "Erlaubte Stellen", - "allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten." + "allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.", + "viewToggleLabel": "Ansicht", + "viewCards": "Kartenansicht", + "viewTable": "Tabellenansicht", + "viewSaveFailed": "Ansicht konnte nicht gespeichert werden", + "tableCaption": "Medikamente in Tabellenform mit Status, nächster Einnahme, Therapietreue und Bestand", + "tableColName": "Name", + "tableColStatus": "Status", + "tableColNextDose": "Nächste Einnahme", + "tableColCompliance": "Therapietreue", + "tableColStock": "Bestand", + "tableColActions": "Aktionen", + "tableSortBy": "Sortieren nach {column}", + "tableComplianceDays": "{days} T", + "tableStockDoseOne": "1 Dosis", + "tableStockDoses": "{count} Dosen", + "takeActionFor": "Einnehmen – {name}", + "skipActionFor": "Überspringen – {name}", + "reorderTitle": "Reihenfolge anpassen", + "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.", + "reorderSaved": "Reihenfolge gespeichert", + "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden" }, "charts": { "loadingLabel": "Diagramm wird geladen…", diff --git a/messages/en.json b/messages/en.json index e0c9d9a60..5616d3971 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Track injection sites", "trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.", "allowedSitesLabel": "Allowed sites", - "allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered." + "allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.", + "viewToggleLabel": "View", + "viewCards": "Card view", + "viewTable": "Table view", + "viewSaveFailed": "View could not be saved", + "tableCaption": "Medications as a table with status, next dose, adherence and stock", + "tableColName": "Name", + "tableColStatus": "Status", + "tableColNextDose": "Next dose", + "tableColCompliance": "Adherence", + "tableColStock": "Stock", + "tableColActions": "Actions", + "tableSortBy": "Sort by {column}", + "tableComplianceDays": "{days} d", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} doses", + "takeActionFor": "Take – {name}", + "skipActionFor": "Skip – {name}", + "reorderTitle": "Adjust order", + "reorderDescription": "Set the order of your medications – it applies to both the card and the table view.", + "reorderSaved": "Order saved", + "reorderSaveFailed": "Order could not be saved" }, "charts": { "loadingLabel": "Loading chart…", diff --git a/messages/es.json b/messages/es.json index 6413beec0..fbb57fbb3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Registrar sitios de inyección", "trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.", "allowedSitesLabel": "Sitios permitidos", - "allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen." + "allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen.", + "viewToggleLabel": "Vista", + "viewCards": "Vista de tarjetas", + "viewTable": "Vista de tabla", + "viewSaveFailed": "No se pudo guardar la vista", + "tableCaption": "Medicamentos en tabla con estado, próxima toma, adherencia y existencias", + "tableColName": "Nombre", + "tableColStatus": "Estado", + "tableColNextDose": "Próxima toma", + "tableColCompliance": "Adherencia", + "tableColStock": "Existencias", + "tableColActions": "Acciones", + "tableSortBy": "Ordenar por {column}", + "tableComplianceDays": "{days} d", + "tableStockDoseOne": "1 dosis", + "tableStockDoses": "{count} dosis", + "takeActionFor": "Tomar – {name}", + "skipActionFor": "Omitir – {name}", + "reorderTitle": "Ajustar orden", + "reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.", + "reorderSaved": "Orden guardado", + "reorderSaveFailed": "No se pudo guardar el orden" }, "charts": { "loadingLabel": "Cargando la gráfica…", diff --git a/messages/fr.json b/messages/fr.json index 19487aaa5..a4dc6c116 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Suivre les sites d'injection", "trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.", "allowedSitesLabel": "Sites autorisés", - "allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés." + "allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés.", + "viewToggleLabel": "Affichage", + "viewCards": "Vue cartes", + "viewTable": "Vue tableau", + "viewSaveFailed": "L'affichage n'a pas pu être enregistré", + "tableCaption": "Médicaments sous forme de tableau avec statut, prochaine prise, observance et stock", + "tableColName": "Nom", + "tableColStatus": "Statut", + "tableColNextDose": "Prochaine prise", + "tableColCompliance": "Observance", + "tableColStock": "Stock", + "tableColActions": "Actions", + "tableSortBy": "Trier par {column}", + "tableComplianceDays": "{days} j", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} doses", + "takeActionFor": "Prendre – {name}", + "skipActionFor": "Passer – {name}", + "reorderTitle": "Modifier l'ordre", + "reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.", + "reorderSaved": "Ordre enregistré", + "reorderSaveFailed": "L'ordre n'a pas pu être enregistré" }, "charts": { "loadingLabel": "Chargement du graphique…", diff --git a/messages/it.json b/messages/it.json index 097725139..45fadd671 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Registra i siti di iniezione", "trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.", "allowedSitesLabel": "Siti consentiti", - "allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti." + "allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti.", + "viewToggleLabel": "Vista", + "viewCards": "Vista a schede", + "viewTable": "Vista tabella", + "viewSaveFailed": "Impossibile salvare la vista", + "tableCaption": "Farmaci in tabella con stato, prossima assunzione, aderenza e scorte", + "tableColName": "Nome", + "tableColStatus": "Stato", + "tableColNextDose": "Prossima assunzione", + "tableColCompliance": "Aderenza", + "tableColStock": "Scorte", + "tableColActions": "Azioni", + "tableSortBy": "Ordina per {column}", + "tableComplianceDays": "{days} g", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} dosi", + "takeActionFor": "Assumi – {name}", + "skipActionFor": "Salta – {name}", + "reorderTitle": "Modifica ordine", + "reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.", + "reorderSaved": "Ordine salvato", + "reorderSaveFailed": "Impossibile salvare l’ordine" }, "charts": { "loadingLabel": "Caricamento del grafico…", diff --git a/messages/pl.json b/messages/pl.json index 61fb2f695..9004b7804 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Rejestruj miejsca iniekcji", "trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.", "allowedSitesLabel": "Dozwolone miejsca", - "allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane." + "allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane.", + "viewToggleLabel": "Widok", + "viewCards": "Widok kart", + "viewTable": "Widok tabeli", + "viewSaveFailed": "Nie udało się zapisać widoku", + "tableCaption": "Leki w formie tabeli ze statusem, następną dawką, przestrzeganiem zaleceń i zapasem", + "tableColName": "Nazwa", + "tableColStatus": "Status", + "tableColNextDose": "Następna dawka", + "tableColCompliance": "Przestrzeganie", + "tableColStock": "Zapas", + "tableColActions": "Akcje", + "tableSortBy": "Sortuj według {column}", + "tableComplianceDays": "{days} dni", + "tableStockDoseOne": "1 dawka", + "tableStockDoses": "{count} dawek", + "takeActionFor": "Przyjmij – {name}", + "skipActionFor": "Pomiń – {name}", + "reorderTitle": "Zmień kolejność", + "reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.", + "reorderSaved": "Kolejność zapisana", + "reorderSaveFailed": "Nie udało się zapisać kolejności" }, "charts": { "loadingLabel": "Ładowanie wykresu…", diff --git a/prisma/migrations/0150_v11610_medication_list_layout/migration.sql b/prisma/migrations/0150_v11610_medication_list_layout/migration.sql new file mode 100644 index 000000000..13785ab4e --- /dev/null +++ b/prisma/migrations/0150_v11610_medication_list_layout/migration.sql @@ -0,0 +1,11 @@ +-- v1.16.10 — medications list presentation preference. +-- +-- `medication_list_layout_json` (users): the per-user card/table view +-- choice for /medications plus the user-defined manual medication order +-- shared by both views. Follows the per-surface-column convention of +-- `dashboard_widgets_json` / `insights_layout_json`. NULL = defaults +-- (cards, alphabetical order); the GET endpoint never lazy-writes. +-- +-- Shape: { "version": 1, "view": "table", "order": ["", ...] } + +ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 099674630..79c5acbfd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,6 +137,14 @@ model User { // the user has explicitly saved a layout. // Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 } insightsLayoutJson Json? @map("insights_layout_json") + // Per-user medications list presentation — the card/table view choice + // plus the user-defined manual medication order shared by BOTH views. + // Follows the per-surface-column convention of `dashboardWidgetsJson` / + // `insightsLayoutJson`. Null = defaults (cards, alphabetical order). + // The GET endpoint never lazy-writes — the column only carries data + // once the user has explicitly toggled the view or saved an order. + // Shape: { "version": 1, "view": "table", "order": ["", ...] } + medicationListLayoutJson Json? @map("medication_list_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.7.0 unit pref — global metric/imperial display preference. diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index c00b3f3b0..5f54b423e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -17,6 +17,9 @@ vi.mock("@/lib/db", () => ({ medicationScheduleRevision: { groupBy: vi.fn().mockResolvedValue([]), }, + medicationInventoryItem: { + groupBy: vi.fn().mockResolvedValue([]), + }, medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]), }, @@ -390,6 +393,9 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1 vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( [] as never, ); + vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue( + [] as never, + ); // The handler body takes no parameters; `apiHandler` still receives // the request through the wrapper's rest args. const res = await ( diff --git a/src/app/api/medications/layout/__tests__/route.test.ts b/src/app/api/medications/layout/__tests__/route.test.ts new file mode 100644 index 000000000..e6c075a20 --- /dev/null +++ b/src/app/api/medications/layout/__tests__/route.test.ts @@ -0,0 +1,277 @@ +/** + * v1.16.10 — `/api/medications/layout` route. + * + * Mirrors the insights-layout test deck: GET returns the defaults for + * users who have not saved a blob, PUT round-trips through the + * serializer with preserve-when-absent semantics for `view` / `order`, + * 422s carry the multi-issue envelope, DELETE returns the canonical + * defaults. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; +import { DEFAULT_MEDICATION_LIST_LAYOUT } from "@/lib/medication-list-layout"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; +const callDelete = DELETE as unknown as () => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + __resetAuditDedupMemoForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/medications/layout", () => { + it("returns the defaults when the user has no saved blob", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_MEDICATION_LIST_LAYOUT; + }; + expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT); + }); + + it("does not lazy-write a row on GET", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: null, + } as never); + + await callGet(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns the saved blob through the resolver", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { + version: 1, + view: "table", + order: ["med-b", "med-a"], + }, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-b", "med-a"]); + }); + + it("collapses a malformed stored blob onto the defaults instead of failing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { view: "kanban", order: "nope" }, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("cards"); + expect(body.data.order).toEqual([]); + }); +}); + +describe("PUT /api/medications/layout — preserve-when-absent", () => { + it("persists a full blob and echoes the normalised result", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, view: "table", order: ["med-a", "med-b"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { version: number; view: string; order: string[] }; + }; + expect(body.data).toEqual({ + version: 1, + view: "table", + order: ["med-a", "med-b"], + }); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + where: { id: string }; + data: { medicationListLayoutJson: unknown }; + }; + expect(call.where.id).toBe("user-1"); + expect(call.data.medicationListLayoutJson).toEqual({ + version: 1, + view: "table", + order: ["med-a", "med-b"], + }); + // No stored-blob read needed — both fields were on the body. + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("a view-only PUT preserves the stored manual order", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { + version: 1, + view: "cards", + order: ["med-z", "med-a"], + }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, view: "table" })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-z", "med-a"]); + }); + + it("an order-only PUT preserves the stored view", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { version: 1, view: "table", order: [] }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, order: ["med-1", "med-2"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-1", "med-2"]); + }); + + it("dedupes a repeated id in the submitted order", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, view: "cards", order: ["a", "b", "a"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { order: string[] } }; + expect(body.data.order).toEqual(["a", "b"]); + }); +}); + +describe("PUT /api/medications/layout — 422 multi-issue envelope", () => { + it("rejects an unknown view and a malformed order in one envelope", async () => { + const res = await callPut( + makeReq({ version: 1, view: "kanban", order: [""] }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { issues: Array<{ path: string }> }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + const paths = body.details.issues.map((i) => i.path); + expect(paths.some((p) => p.startsWith("view"))).toBe(true); + expect(paths.some((p) => p.startsWith("order"))).toBe(true); + }); + + it("rejects a wrong version", async () => { + const res = await callPut(makeReq({ version: 2, view: "table" })); + expect(res.status).toBe(422); + }); + + it("writes one deduped audit breadcrumb for repeated 422s", async () => { + const res1 = await callPut(makeReq({ version: 9 })); + const res2 = await callPut(makeReq({ version: 9 })); + expect(res1.status).toBe(422); + expect(res2.status).toBe(422); + + await new Promise((r) => setTimeout(r, 5)); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { + data: { userId: string; action: string }; + }; + expect(call.data.userId).toBe("user-1"); + expect(call.data.action).toBe("medication.layout.validation-failed"); + }); +}); + +describe("DELETE /api/medications/layout", () => { + it("clears the saved blob and returns the defaults", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_MEDICATION_LIST_LAYOUT; + }; + expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { medicationListLayoutJson: unknown }; + }; + expect(call.data.medicationListLayoutJson).toBeDefined(); + }); +}); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts new file mode 100644 index 000000000..1fb39de3d --- /dev/null +++ b/src/app/api/medications/layout/route.ts @@ -0,0 +1,177 @@ +/** + * GET / PUT / DELETE medications list presentation. + * + * GET returns the resolved presentation (view + manual order, defaults + * merged in if the user hasn't customised yet). PUT updates it with + * preserve-when-absent semantics — a view-only PUT keeps the stored + * order and vice versa, matching the `heroVisible` contract on + * `/api/dashboard/widgets`. DELETE resets to default. + * + * Mirrors the shape and semantics of `/api/insights/layout`; the blob + * lives on its own `User` column (`medication_list_layout_json`) per + * the per-surface-column convention. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + buildPayloadDiagnostic, + safeJson, + returnAllZodIssues, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma, toJson } from "@/lib/db"; +import { + resolveMedicationListLayout, + serializeMedicationListLayout, + DEFAULT_MEDICATION_LIST_LAYOUT, + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, + type MedicationListLayout, +} from "@/lib/medication-list-layout"; +import { Prisma } from "@/generated/prisma/client"; +import { z } from "zod/v4"; +import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { shouldEmitAuditRow } from "@/lib/audit-dedup"; +import type { NextRequest } from "next/server"; + +const layoutSchema = z.object({ + version: z.literal(1), + // Both fields are optional so a client can PUT exactly the field it + // changed; the handler merges the absent one from the stored blob + // (preserve-when-absent, like `heroVisible` on the dashboard layout). + view: z.enum(MEDICATION_LIST_VIEWS).optional(), + // Medication ids are opaque here — display-only ordering, unknown / + // deleted ids are ignored at apply time, so the schema bounds size + // and shape but does not assert ownership. + order: z + .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) + .max(MEDICATION_ORDER_MAX_ENTRIES) + .optional(), +}); + +async function buildMedicationListLayout( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { medicationListLayoutJson: true }, + }); + return resolveMedicationListLayout(row?.medicationListLayoutJson); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + // 5-minute TTL matches the dashboard-widgets / insights-layout + // caches; the blob changes only on a view toggle or an order save, + // which invalidates via `invalidateUserMedicationListLayout()`. + const layout = await cached( + caches.medicationListLayout as ServerCache, + user.id, + () => buildMedicationListLayout(user.id), + annotate, + ); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = layoutSchema.safeParse(body); + if (!parsed.success) { + // Multi-issue 422 envelope matches the dashboard-widgets / insights + // layout routes; the wide-event line carries the redacted payload + // shape for operator debugging without leaking the raw body. + const issues = sanitiseZodIssues(parsed.error.issues); + const payloadDiagnostic = buildPayloadDiagnostic( + redactSensitiveFields(body), + ); + annotate({ + action: { name: "medication.layout.validation-failed" }, + meta: { + issue_count: issues.length, + ...payloadDiagnostic, + zod_issues: issues, + }, + }); + if ( + shouldEmitAuditRow( + user.id, + "medication.layout.validation-failed", + Date.now(), + ) + ) { + // Best-effort breadcrumb — never block the 422 on a write miss. + // `message` strips from the audit row so a Zod code that embeds + // the offending value cannot leak through the audit surface. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "medication.layout.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); + } + return returnAllZodIssues(parsed.error, 422); + } + + // Preserve-when-absent: a PUT carrying only `view` must not wipe the + // stored manual order, and an order-only PUT must not reset the view. + // One stored-blob read covers both fallbacks. + let mergedView = parsed.data.view; + let mergedOrder = parsed.data.order; + if (mergedView === undefined || mergedOrder === undefined) { + const existing = await buildMedicationListLayout(user.id); + if (mergedView === undefined) mergedView = existing.view; + if (mergedOrder === undefined) mergedOrder = existing.order; + } + const normalized = serializeMedicationListLayout({ + view: mergedView, + order: mergedOrder, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { medicationListLayoutJson: toJson(normalized) }, + }); + + annotate({ + action: { name: "medication.layout.update" }, + meta: { view: normalized.view, order_count: normalized.order.length }, + }); + + invalidateUserMedicationListLayout(user.id); + + return apiSuccess(normalized); +}); + +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { medicationListLayoutJson: Prisma.JsonNull }, + }); + + annotate({ action: { name: "medication.layout.reset" } }); + + invalidateUserMedicationListLayout(user.id); + + return apiSuccess(DEFAULT_MEDICATION_LIST_LAYOUT); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f8da7dde9..219c633f4 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -54,8 +54,15 @@ async function buildMedicationsList( todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS, ); - const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] = - await Promise.all([ + const [ + medications, + latestIntakes, + todayEvents, + resolvedEvents, + eraFloors, + usableStock, + inventoryCounts, + ] = await Promise.all([ prisma.medication.findMany({ where: { userId }, include: { schedules: true }, @@ -117,6 +124,28 @@ async function buildMedicationsList( where: { medication: { userId }, supersededByRevisionId: null }, _max: { validUntil: true }, }), + // v1.16.10 — usable stock per medication (one batched aggregate, + // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE + // containers with units left — the same usable-container filter + // the GLP-1 details endpoint applies. Feeds the list payload's + // `stockUnitsRemaining` / `stockDosesRemaining` for the table view. + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { + userId, + state: { in: ["ACTIVE", "IN_USE"] }, + unitsRemaining: { gt: 0 }, + }, + _sum: { unitsRemaining: true }, + }), + // …and the any-state item count, so a medication whose containers + // are all used up / expired reads as stock 0 (tracking is ON, the + // supply ran out) instead of null (tracking off). + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { userId }, + _count: { id: true }, + }), ]); const resolvedSlotsByMedId = new Map(); @@ -152,6 +181,15 @@ async function buildMedicationsList( ), ); + const usableUnitsByMedId = new Map(); + for (const entry of usableStock) { + usableUnitsByMedId.set( + entry.medicationId, + entry._sum.unitsRemaining ?? 0, + ); + } + const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); + let categoryMap: Record = {}; try { categoryMap = await getMedicationCategories(medications.map((m) => m.id)); @@ -185,6 +223,17 @@ async function buildMedicationsList( resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); + // v1.16.10 — dose-derived stock for the table view. NULL when the + // medication has no inventory items at all (tracking off); 0 when + // tracking is on but every container is used up / expired. + const tracksInventory = trackedMedIds.has(m.id); + const stockUnitsRemaining = tracksInventory + ? (usableUnitsByMedId.get(m.id) ?? 0) + : null; + const stockDosesRemaining = + stockUnitsRemaining === null + ? null + : Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose)); return { ...m, category: categoryMap[m.id] ?? "OTHER", @@ -192,6 +241,8 @@ async function buildMedicationsList( todayEventCount: todayEventCountByMedId[m.id] ?? 0, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, + stockUnitsRemaining, + stockDosesRemaining, }; }); } diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx new file mode 100644 index 000000000..409997fb9 --- /dev/null +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -0,0 +1,165 @@ +/** + * v1.16.10 — /medications view-toggle wiring. + * + * Pins that the page renders the view the PERSISTED preference names + * (cards vs table from `GET /api/medications/layout`, seeded into the + * query cache), that the header toggle announces the active segment, + * and that the manual medication order applies to BOTH views. The PUT + * side of the persistence contract is pinned in + * `src/lib/queries/__tests__/use-medication-list-layout.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { timezone: "Europe/Berlin" }, + }), +})); + +// The create wizard and the dialogs are closed in every assertion here; +// mock them away so the page render stays scoped to the list surface. +vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({ + MedicationWizardDialog: () => null, +})); +vi.mock("@/components/medications/log-intake-dialog", () => ({ + LogIntakeDialog: () => null, +})); +vi.mock("@/components/medications/medication-reorder-dialog", () => ({ + MedicationReorderDialog: () => null, +})); + +import MedicationsPage from "@/app/medications/page"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +const MEDS = [ + { + id: "m1", + name: "Ramipril", + dose: "5 mg", + category: "OTHER", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: "s1", ...pastWindow }], + }, + { + id: "m2", + name: "Aspirin", + dose: "100 mg", + category: "OTHER", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: "s2", ...pastWindow }], + }, +]; + +function renderPage(layout: MedicationListLayout): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + client.setQueryData(queryKeys.medications(), MEDS); + client.setQueryData(queryKeys.medicationListLayout(), layout); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/medications — view renders from the persisted preference", () => { + it("renders the card grid for view=cards and announces the active segment", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + + expect(html).not.toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/, + ); + expect(html).toMatch( + /data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/, + ); + }); + + it("renders the table for view=table", () => { + const html = renderPage({ version: 1, view: "table", order: [] }); + + expect(html).toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/, + ); + }); + + it("offers the manual-order editor from the header", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + expect(html).toContain('aria-label="Adjust order"'); + }); +}); + +describe("/medications — the manual order applies to BOTH views", () => { + // Alphabetical default would put Aspirin first; the saved order pins + // Ramipril to the top. Both views must follow it. + const layout: MedicationListLayout = { + version: 1, + view: "cards", + order: ["m1", "m2"], + }; + + it("orders the cards by the saved order", () => { + const html = renderPage(layout); + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + }); + + it("orders the table rows by the saved order", () => { + const html = renderPage({ ...layout, view: "table" }); + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + }); + + it("falls back to the alphabetical default without a saved order", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril")); + }); +}); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index f061a6599..f948b5e80 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -10,6 +10,12 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; +import { + MedicationTable, + MedicationTableSkeleton, +} from "@/components/medications/medication-table"; +import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; +import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -20,8 +26,10 @@ import { } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react"; +import { ArrowDownUp, CheckCircle2, Loader2, Pill, Plus } from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; +import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; +import { applyMedicationOrder } from "@/lib/medications/medication-order"; interface Schedule { id: string; @@ -75,6 +83,14 @@ interface Medication { atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ rxNormCode?: string | null; + /** + * v1.16.10 — usable inventory units left across the medication's + * containers (ACTIVE / IN_USE with units remaining). Null = inventory + * tracking off (no items ever registered). + */ + stockUnitsRemaining?: number | null; + /** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */ + stockDosesRemaining?: number | null; schedules: Schedule[]; } @@ -133,6 +149,16 @@ export default function MedicationsPage() { // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = useState(false); + // v1.16.10 — manual-order editor (shared by both views). + const [reorderOpen, setReorderOpen] = useState(false); + + // v1.16.10 — the persisted list presentation: cards vs table plus the + // manual medication order, server-side per user + // (`GET`/`PUT /api/medications/layout`). The toggle writes + // optimistically; the reorder dialog saves explicitly. + const { layout, isLayoutLoading, setView } = useMedicationListLayout( + isAuthenticated, + ); useEffect(() => { if (shouldOpenFromUrl) { @@ -211,18 +237,25 @@ export default function MedicationsPage() { ); } - const byName = (a: Medication, b: Medication) => - a.name.localeCompare(b.name, "de", { sensitivity: "base" }); - // Defensive against stale service-worker responses or any future API // shape change: only filter when we actually have an array. const medsArray = Array.isArray(medications) ? medications : []; - const activeMeds = medsArray.filter((m) => m.active).sort(byName); - const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName); + // v1.16.10 — the user-defined manual order applies to BOTH views + // (cards and table). Medications not in the saved order keep the + // alphabetical default, appended after the ordered block. + const activeMeds = applyMedicationOrder( + medsArray.filter((m) => m.active), + layout.order, + ); + const inactiveMeds = applyMedicationOrder( + medsArray.filter((m) => !m.active), + layout.order, + ); + const tableView = layout.view === "table"; return (
-
+

{t("medications.title")} @@ -233,48 +266,75 @@ export default function MedicationsPage() { {t("medications.subtitle")}

- {/* v1.14.0 — the "Add" button is now a choice: log an intake - (incl. a backdated one) against an existing medication, or - create a new medication. v1.12.2 — match the dashboard Add - button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so - both primary "add" entry points clear the WCAG 2.5.5 44px mobile - minimum identically. */} - - - - - - setLogIntakeOpen(true)} - disabled={activeMeds.length === 0} +
+ {/* v1.16.10 — card ⇄ table view toggle, persisted server-side + per user; plus the manual-order editor shared by both + views. The reorder affordance only earns its slot once + there is something to reorder. */} + + {medsArray.length > 1 && ( + + )} + {/* v1.14.0 — the "Add" button is now a choice: log an intake + (incl. a backdated one) against an existing medication, or + create a new medication. v1.12.2 — match the dashboard Add + button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so + both primary "add" entry points clear the WCAG 2.5.5 44px mobile + minimum identically. */} + + + + + + setLogIntakeOpen(true)} + disabled={activeMeds.length === 0} + > + + {t("medications.addChoice.logIntake")} + + + + {t("medications.addChoice.newMedication")} + + + +
- {isLoading ? ( - // v1.11.3 C4 — card skeletons instead of a bare centred spinner so - // the page reserves the loaded layout and does not jump when the - // medications resolve. + {isLoading || isLayoutLoading ? ( + // v1.11.3 C4 — skeletons instead of a bare centred spinner so the + // page reserves the loaded layout and does not jump when the + // medications resolve. v1.16.10 — the skeleton follows the + // persisted view (table rows vs card grid) so resolving the data + // never swaps the footprint; while the view preference itself is + // still loading the default cards skeleton stands in.
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} + {tableView && !isLayoutLoading ? ( + + ) : ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )}
) : isError ? (
@@ -307,6 +367,14 @@ export default function MedicationsPage() { } /> + ) : tableView ? ( + // v1.16.10 — the compact table view. Same payloads, same status + // gating, same mutation hooks as the cards; one row per + // medication, inactive rows pinned after the active block. + ) : (
{/* Active medications */} @@ -391,6 +459,22 @@ export default function MedicationsPage() { date+time that supports backdating. Submits to the existing per-medication intake route (same slot upsert as a live "Taken" tap) so the one-row-per-dose-slot invariant holds. */} + {/* v1.16.10 — manual-order editor. The list is the page's current + effective order (active block first, inactive after), so the + dialog opens showing exactly what both views render. */} + {reorderOpen && ( + ({ + id: m.id, + name: m.name, + dose: m.dose, + active: m.active, + }))} + /> + )} + {logIntakeOpen && ( ` semantics with `scope="col"` headers and a sticky + * first column (the responsive horizontal-scroll pattern); + * - every column renders from the SAME payloads the cards consume + * (the list row's `nextDueAt` / `stockDosesRemaining`, the batched + * compliance-summary cache entry the cards share); + * - the action buttons are per-row labelled ("Take – {name}") and the + * row rides the cards' shared intake hook (source-level guard); + * - tri-state sorting semantics incl. `aria-sort` and null-last + * ordering, with `null` sort = the manual order the page passed in; + * - inactive rows pin after the active block, muted, without actions. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MedicationTable, + MedicationTableSkeleton, + nextSortState, + sortMedicationRows, + type TableMedication, +} from "@/components/medications/medication-table"; + +function makeClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); +} + +function render(node: React.ReactNode, client: QueryClient) { + return renderToStaticMarkup( + + {node} + , + ); +} + +// Seed the SAME batched summary key the cards read — the table's +// compliance column must come from this shared cache entry. +function seedCompliance( + client: QueryClient, + medId: string, + rate7: number, + rate30: number, +) { + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: rate7, streak: 0 }, + compliance30: { rate: rate30 }, + }, + ]); +} + +// A schedule whose window has already passed for today (01:00–02:00) so +// the row shows the next-intake value rather than the take-now pill, +// regardless of host clock. +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +function med(partial: Partial & { id: string; name: string }): TableMedication { + return { + dose: "5 mg", + active: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: `s-${partial.id}`, ...pastWindow }], + ...partial, + }; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function tomorrowNoonIso(): string { + const due = new Date(); + due.setDate(due.getDate() + 1); + due.setHours(12, 0, 0, 0); + return due.toISOString(); +} + +describe(" — structure + shared payloads", () => { + it("renders real table semantics: scope=col headers, sticky first column, sr caption", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + expect(html).toContain(" { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain("Ramipril"); + expect(html).toContain("5 mg"); + // Server-computed next due — the same `nextDueAt` the cards render. + expect(html).toContain("Tomorrow,"); + // Dose-derived stock from the list payload. + expect(html).toContain("12 doses"); + }); + + it("renders the compliance column from the cards' shared batched summary cache", () => { + const client = makeClient(); + seedCompliance(client, "m1", 92, 87); + const html = render( + , + client, + ); + + expect(html).toContain("92%"); + expect(html).toContain("87%"); + // The cadence-window labels (7-/30-day defaults). + expect(html).toContain("7 d"); + expect(html).toContain("30 d"); + }); + + it("labels the action buttons per row and keeps the 44px tap floor", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain('aria-label="Take – Ramipril"'); + expect(html).toContain('aria-label="Skip – Ramipril"'); + // 44-px mobile floor on the action buttons (shrinks at sm). + expect(html).toContain("size-11 sm:size-9"); + }); + + it("hides stock for an untracked medication and tints a low stock", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain("2 doses"); + expect(html).toContain("text-warning"); + }); + + it("pins inactive rows after the active block, muted and without actions", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + // Active row first despite alphabetical order saying otherwise. + expect(html.indexOf("Zyrtec")).toBeLessThan(html.indexOf("Amoxicillin")); + expect(html).toContain("opacity-60"); + expect(html).toContain("Inactive"); + // No take/skip affordance on the inactive row. + expect(html).not.toContain('aria-label="Take – Amoxicillin"'); + }); + + it("renders a GLP-1 medication like any other row", () => { + const client = makeClient(); + seedCompliance(client, "g1", 100, 100); + const html = render( + , + client, + ); + + expect(html).toContain("Mounjaro"); + expect(html).toContain('aria-label="Take – Mounjaro"'); + expect(html).toContain("4 doses"); + }); +}); + +describe(" — manual order + aria-sort", () => { + const rows = [ + med({ id: "m1", name: "Ramipril", nextDueAt: tomorrowNoonIso() }), + med({ id: "m2", name: "Aspirin", nextDueAt: null }), + ]; + + it("renders rows in the order the page passed in when no sort is active (manual order)", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + // The page already applied the manual order; with sort=null the + // table must not re-sort (Ramipril stays before Aspirin). + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + // Every sortable header is unsorted. + expect((html.match(/aria-sort="none"/g) ?? []).length).toBe(4); + }); + + it("sorts by name ascending with the matching aria-sort", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril")); + expect(html).toContain('aria-sort="ascending"'); + }); + + it("sorts by name descending with the matching aria-sort", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + expect(html).toContain('aria-sort="descending"'); + }); + + it("names the sort affordance on every sortable header", () => { + const client = makeClient(); + const html = render( + , + client, + ); + expect(html).toContain('aria-label="Sort by Name"'); + expect(html).toContain('aria-label="Sort by Next dose"'); + expect(html).toContain('aria-label="Sort by Adherence"'); + expect(html).toContain('aria-label="Sort by Stock"'); + }); +}); + +describe("nextSortState — tri-state cycle", () => { + it("cycles none → asc → desc → none per column", () => { + const asc = nextSortState(null, "name"); + expect(asc).toEqual({ column: "name", direction: "asc" }); + const desc = nextSortState(asc, "name"); + expect(desc).toEqual({ column: "name", direction: "desc" }); + expect(nextSortState(desc, "name")).toBeNull(); + }); + + it("switching column resets to ascending", () => { + expect( + nextSortState({ column: "name", direction: "desc" }, "nextDue"), + ).toEqual({ column: "nextDue", direction: "asc" }); + }); +}); + +describe("sortMedicationRows — null-last semantics", () => { + const a = med({ id: "a", name: "Alpha", nextDueAt: null, stockDosesRemaining: null }); + const b = med({ + id: "b", + name: "Beta", + nextDueAt: "2026-06-03T10:00:00Z", + stockDosesRemaining: 2, + }); + const c = med({ + id: "c", + name: "Gamma", + nextDueAt: "2026-06-02T18:00:00Z", + stockDosesRemaining: 9, + }); + + it("returns the input order untouched for a null sort", () => { + expect( + sortMedicationRows([b, a, c], null).map((m) => m.id), + ).toEqual(["b", "a", "c"]); + }); + + it("sorts by next due ascending, rows without a due date last", () => { + expect( + sortMedicationRows([a, b, c], { + column: "nextDue", + direction: "asc", + }).map((m) => m.id), + ).toEqual(["c", "b", "a"]); + }); + + it("keeps rows without a due date last in descending order too", () => { + expect( + sortMedicationRows([a, b, c], { + column: "nextDue", + direction: "desc", + }).map((m) => m.id), + ).toEqual(["b", "c", "a"]); + }); + + it("sorts by compliance via the shared summary rates, unknown rows last", () => { + const rates = new Map([ + ["b", 50], + ["c", 95], + ]); + expect( + sortMedicationRows( + [a, b, c], + { column: "compliance", direction: "desc" }, + rates, + ).map((m) => m.id), + ).toEqual(["c", "b", "a"]); + }); + + it("sorts by stock, untracked medications last", () => { + expect( + sortMedicationRows([a, b, c], { + column: "stock", + direction: "asc", + }).map((m) => m.id), + ).toEqual(["b", "c", "a"]); + }); +}); + +describe(" — reserved footprint", () => { + it("mirrors the loaded table shell (container, table, action slots) and hides from AT", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('aria-hidden="true"'); + expect(html).toContain(" { + const source = readFileSync( + join(process.cwd(), "src/components/medications/medication-table.tsx"), + "utf8", + ); + + it("rides the cards' shared intake hook instead of its own POST", () => { + expect(source).toContain( + 'from "@/components/medications/use-medication-intake"', + ); + expect(source).not.toContain("/intake"); + }); + + it("reads compliance through the cards' batched summary hook", () => { + expect(source).toContain("useMedicationComplianceSummary"); + }); + + it("gates the status pill on the server next-due exactly like the cards", () => { + expect(source).toContain("reduceCurrentWindowStatus"); + expect(source).toContain("nextDue:"); + expect(source).toContain("resolveDisplayedSlotInstant"); + }); +}); diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 7f9e41be9..b937e315d 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -19,6 +19,13 @@ interface MedicationStatusPillProps { * local days since that take. */ takenEarlyDaysAgo?: number | null; + /** + * v1.16.10 — compact variant for the table view's Status column: + * suppresses the trailing window-range span so the cell stays one + * short phrase. Tier logic, colours and glyphs are byte-identical + * with the card pill — only the suffix is dropped. + */ + compact?: boolean; } /** @@ -34,6 +41,7 @@ export function MedicationStatusPill({ windowStart, windowEnd, takenEarlyDaysAgo = null, + compact = false, }: MedicationStatusPillProps) { const { t, locale } = useTranslations(); @@ -46,10 +54,12 @@ export function MedicationStatusPill({ ? t("medications.lastDoseYesterday") : t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })} - - {" "} - — {formatTimeWindowRange(windowStart, windowEnd, locale)} - + {!compact && ( + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + + )}

); } @@ -85,10 +95,12 @@ export function MedicationStatusPill({ ? t("medications.overdue") : t("medications.veryOverdue")} - - {" "} - — {formatTimeWindowRange(windowStart, windowEnd, locale)} - + {!compact && ( + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + + )}

); } diff --git a/src/components/medications/medication-reorder-dialog.tsx b/src/components/medications/medication-reorder-dialog.tsx new file mode 100644 index 000000000..5dbb98baa --- /dev/null +++ b/src/components/medications/medication-reorder-dialog.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, GripVertical, Loader2 } from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { reorderById } from "@/lib/insights-layout-reorder"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useTranslations } from "@/lib/i18n/context"; +import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout"; + +/** + * v1.16.10 — manual medication ordering, shared by BOTH /medications + * views (cards + table). Reuses the dashboard / insights reorder + * vocabulary: a vertical row list with a drag handle (@dnd-kit, pointer + * + keyboard sensors) AND per-row up/down arrow buttons as the primary + * keyboard surface, flushed by an explicit Save. The swap-and-renumber + * logic is the shared `reorderById` helper the inline Insights edit + * 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). + */ + +export interface ReorderMedication { + id: string; + name: string; + dose: string; + active: boolean; +} + +interface MedicationReorderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The full list in its current effective order (active block first). */ + medications: ReorderMedication[]; +} + +export function MedicationReorderDialog({ + open, + onOpenChange, + medications, +}: MedicationReorderDialogProps) { + const { t } = useTranslations(); + 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, + }), + ); + + 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); + + 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)); + } + + async function save() { + setSaving(true); + const ok = await runSaveMedicationListOrder({ + order: ids, + queryClient, + t, + }); + setSaving(false); + if (ok) { + setDraft(null); + onOpenChange(false); + } + } + + function close(next: boolean) { + if (!next) setDraft(null); + onOpenChange(next); + } + + return ( + + + + {t("medications.reorderTitle")} + + {t("medications.reorderDescription")} + + + +
+ + + {rows.map((med, index) => ( + + ))} + + + {rows.length > 0 && ( +

+ {t("dashboard.dragHandleHint")} +

+ )} +
+ + + + + +
+
+ ); +} + +interface SortableMedicationRowProps { + medication: ReorderMedication; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + labels: { + moveUp: string; + moveDown: string; + dragHandle: string; + inactive: string; + }; + onMove: (id: string, delta: -1 | 1) => void; +} + +function SortableMedicationRow({ + medication, + index, + total, + dragHintId, + disabled, + labels, + onMove, +}: SortableMedicationRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: medication.id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ + + + {medication.name} + + + {medication.dose} + {!medication.active && <> · {labels.inactive}} + + + + +
+ ); +} diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx new file mode 100644 index 000000000..70d55b341 --- /dev/null +++ b/src/components/medications/medication-table.tsx @@ -0,0 +1,678 @@ +"use client"; + +import { useEffect, useReducer, useState } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowDown, + ArrowUp, + Check, + ChevronsUpDown, + Loader2, + SkipForward, +} from "lucide-react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; +import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; +import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; +import { useMedicationIntake } from "@/components/medications/use-medication-intake"; +import { + reduceCurrentWindowStatus, + toZonedDate, +} from "@/lib/medications/window-status"; +import { formatTime } from "@/lib/format"; +import { formatTimeWindowRange } from "@/lib/time-window-format"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { + useMedicationComplianceSummary, + useMedicationComplianceSummaryAll, +} from "@/lib/queries/use-medication-compliance-summary"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; + +/** + * v1.16.10 — the compact table view for /medications (issue #316 item 3). + * + * One row per medication, the same data sources as the cards by + * construction: the rows render straight off the `GET /api/medications` + * list payload (next-due, stock), the Therapietreue column reads the + * SAME batched compliance summary the cards share + * (`useMedicationComplianceSummary`), the status cell mirrors the card + * body's precedence over the SAME `reduceCurrentWindowStatus` / + * server-`nextDueAt` gate, and the action buttons fire the SAME + * `useMedicationIntake` mutation hook with the same displayed-slot + * instant. Nothing here recomputes dueness beyond what the cards do. + * + * Responsive: horizontal scroll (the shared `` wrapper) with a + * sticky first column so the medication name stays anchored while the + * row scrolls. Action buttons keep the 44-px tap-target floor. + */ + +interface TableSchedule { + id: string; + windowStart: string; + windowEnd: string; + label: string | null; + dose: string | null; + daysOfWeek: string | null; + timesOfDay?: string[]; + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; +} + +export interface TableMedication { + id: string; + name: string; + dose: string; + treatmentClass?: string; + active: boolean; + pausedAt: string | null; + lastTakenAt: string | null; + todayEventCount?: number; + nextDueAt?: string | null; + nextDueOverdue?: boolean; + /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ + stockDosesRemaining?: number | null; + schedules: TableSchedule[]; +} + +export type MedicationTableSortColumn = + | "name" + | "nextDue" + | "compliance" + | "stock"; + +export interface MedicationTableSort { + column: MedicationTableSortColumn; + direction: "asc" | "desc"; +} + +/** + * Tri-state header click cycle: none → ascending → descending → none. + * `null` = the manual (user-defined) order the page passed in. + */ +export function nextSortState( + current: MedicationTableSort | null, + column: MedicationTableSortColumn, +): MedicationTableSort | null { + if (!current || current.column !== column) { + return { column, direction: "asc" }; + } + if (current.direction === "asc") return { column, direction: "desc" }; + return null; +} + +/** + * Client-side sort over the loaded list. Rows without a value for the + * chosen column (no next due, no compliance row yet, inventory tracking + * off) sort last in BOTH directions — an unknown must never outrank a + * known value. `null` sort returns the input order untouched (the + * manual order). + */ +export function sortMedicationRows( + rows: readonly T[], + sort: MedicationTableSort | null, + shortRateById?: ReadonlyMap, +): T[] { + if (!sort) return [...rows]; + const dir = sort.direction === "asc" ? 1 : -1; + const value = (m: T): number | string | null => { + switch (sort.column) { + case "name": + return m.name; + case "nextDue": { + if (!m.nextDueAt) return null; + const ms = new Date(m.nextDueAt).getTime(); + return Number.isFinite(ms) ? ms : null; + } + case "compliance": + return shortRateById?.get(m.id) ?? null; + case "stock": + return m.stockDosesRemaining ?? null; + } + }; + return [...rows].sort((a, b) => { + const va = value(a); + const vb = value(b); + if (va === null && vb === null) return 0; + if (va === null) return 1; + if (vb === null) return -1; + if (typeof va === "string" && typeof vb === "string") { + return va.localeCompare(vb, "de", { sensitivity: "base" }) * dir; + } + return ((va as number) - (vb as number)) * dir; + }); +} + +const LOW_STOCK_DOSES = 4; + +interface MedicationTableProps { + /** Active medications, already in the page's manual order. */ + activeMedications: TableMedication[]; + /** Inactive medications, already in the page's manual order; pinned after the active block. */ + inactiveMedications: TableMedication[]; + /** SSR-pinnable initial sort (tests); the header buttons own it after mount. */ + initialSort?: MedicationTableSort | null; +} + +export function MedicationTable({ + activeMedications, + inactiveMedications, + initialSort = null, +}: MedicationTableProps) { + const { t } = useTranslations(); + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; + const [sort, setSort] = useState(initialSort); + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + + // Same reminder-thresholds source as the cards so the status pill + // tiers identically on both presentations. + const { data: thresholds } = useQuery({ + queryKey: queryKeys.settingsReminderThresholds(), + queryFn: async () => { + try { + return await apiGet<{ lateMinutes: number; missedMinutes: number }>( + "/api/settings/reminder-thresholds", + ); + } catch { + return null; + } + }, + staleTime: 5 * 60 * 1000, + }); + + // The whole batched compliance array — the Therapietreue sort needs + // every row at once. Same key + fetcher as the per-row hook. + const { data: complianceRows } = useMedicationComplianceSummaryAll(); + const shortRateById = new Map( + (complianceRows ?? []).map((row) => [ + row.medicationId, + row.complianceDisplay?.short.rate ?? row.compliance7?.rate ?? 0, + ]), + ); + + // Re-render once a minute so the status pill tracks wall-clock + // progress — the cards tick on the same cadence. + useEffect(() => { + const interval = setInterval(forceUpdate, 60_000); + return () => clearInterval(interval); + }, []); + + const sortedActive = sortMedicationRows(activeMedications, sort, shortRateById); + const sortedInactive = sortMedicationRows( + inactiveMedications, + sort, + shortRateById, + ); + + const lateMinutes = thresholds?.lateMinutes ?? 120; + const missedMinutes = thresholds?.missedMinutes ?? 240; + + const columns: Array<{ + key: MedicationTableSortColumn | "status" | "actions"; + label: string; + sortable: boolean; + className?: string; + }> = [ + { + key: "name", + label: t("medications.tableColName"), + sortable: true, + // Sticky first column: the name stays anchored while the row + // scrolls horizontally on narrow viewports. + className: "bg-card sticky left-0 z-10", + }, + { key: "status", label: t("medications.tableColStatus"), sortable: false }, + { key: "nextDue", label: t("medications.tableColNextDose"), sortable: true }, + { + key: "compliance", + label: t("medications.tableColCompliance"), + sortable: true, + }, + { key: "stock", label: t("medications.tableColStock"), sortable: true }, + { key: "actions", label: t("medications.tableColActions"), sortable: false }, + ]; + + function ariaSort( + column: MedicationTableSortColumn, + ): "ascending" | "descending" | "none" { + if (!sort || sort.column !== column) return "none"; + return sort.direction === "asc" ? "ascending" : "descending"; + } + + return ( +
+
+ + + + {columns.map((col) => + col.sortable ? ( + + + + ) : ( + + {col.label} + + ), + )} + + + + {sortedActive.map((med) => ( + + ))} + {sortedInactive.map((med) => ( + + ))} + +
{t("medications.tableCaption")}
+
+ ); +} + +interface MedicationTableRowItemProps { + medication: TableMedication; + userTz: string; + lateMinutes: number; + missedMinutes: number; +} + +function MedicationTableRowItem({ + medication, + userTz, + lateMinutes, + missedMinutes, +}: MedicationTableRowItemProps) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const weekdayLabel = useWeekdayLabel(); + + // SAME mutation path as the cards — the shared take/skip hook with + // failure toast + Undo. No card-specific follow-up here; the + // injection-site prompt stays a card affordance. + const { intakeLoading, recordIntake } = useMedicationIntake({ medication }); + + // SAME batched compliance source as the cards. + const { data: compliance } = useMedicationComplianceSummary(medication.id); + const display = compliance?.complianceDisplay; + const shortDays = display?.shortDays ?? 7; + const longDays = display?.longDays ?? 30; + const rateShort = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; + const rateLong = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; + const doseStatus = display?.currentDose.status ?? "upcoming"; + + const sortedSchedules = [...medication.schedules].sort( + (a, b) => + a.windowStart.localeCompare(b.windowStart) || + a.windowEnd.localeCompare(b.windowEnd), + ); + const now = new Date(); + const nowZoned = toZonedDate(now, userTz); + const nextDueMs = medication.nextDueAt + ? new Date(medication.nextDueAt).getTime() + : NaN; + const nextAt = Number.isFinite(nextDueMs) ? nextDueMs : undefined; + + // The SAME served-next-due-gated pill reduction the cards run — + // including the v1.16.6 `nextDue` gate so a rolling cadence whose + // next dose is tomorrow can never paint an overdue pill today. + const currentWindowStatus = reduceCurrentWindowStatus({ + schedules: sortedSchedules, + nowBerlin: nowZoned, + lateMinutes, + missedMinutes, + active: medication.active, + lastTakenAt: medication.lastTakenAt, + todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, + nextDue: + medication.nextDueAt === undefined + ? undefined + : nextAt !== undefined + ? { + at: new Date(nextAt), + overdue: medication.nextDueOverdue === true, + } + : null, + }); + + // Same displayed-slot threading as the cards so the server records + // THIS dose rather than snapping "now" to the nearest slot. + const displayedSlot = resolveDisplayedSlotInstant({ + currentWindowStatus, + nextDueAt: medication.nextDueAt, + now, + timeZone: userTz, + }); + + // Status cell — the card body's exact precedence: last-dose context + // outranks the overdue escalation, which outranks the window pill. + const overdueLabel = + medication.active && doseStatus === "missed" + ? t("medications.veryOverdue") + : medication.active && doseStatus === "overdue" + ? t("medications.overdue") + : null; + + const statusCell = + currentWindowStatus.status && + currentWindowStatus.takenEarlyDaysAgo != null ? ( + + ) : overdueLabel ? ( + + {overdueLabel} + + ) : currentWindowStatus.status ? ( + + ) : ( + – + ); + + // Next-intake cell — the card's value logic, compacted: overdue slot + // in calm amber, in-window shows the matched take-window range, a + // future slot shows the relative day + canonical next-due time. + const nextSchedule = sortedSchedules[0] ?? null; + let nextCell: React.ReactNode = ( + – + ); + if (medication.active) { + if (medication.nextDueOverdue && nextAt) { + nextCell = ( + + {t("medications.nextIntakeOverdue", { + time: formatTime(new Date(nextAt).toISOString()), + })} + + ); + } else if (currentWindowStatus.status === "in_window") { + nextCell = ( + + {formatTimeWindowRange( + currentWindowStatus.window!.start, + currentWindowStatus.window!.end, + locale, + )} + + ); + } else if (nextAt) { + const nextDate = toZonedDate(new Date(nextAt), userTz); + const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`; + const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; + const tomorrow = new Date(nowZoned); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; + const diffDays = Math.round( + (nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000), + ); + const dayLabel = + nextStr === todayStr + ? t("medications.today") + : nextStr === tomorrowStr + ? t("medications.tomorrow") + : diffDays <= 5 + ? weekdayLabel(nextDate.getDay()) + : fmt.dateWithWeekday(nextDate); + nextCell = ( + + {dayLabel}, {formatTime(new Date(nextAt).toISOString())} + + ); + } else if (nextSchedule) { + nextCell = ( + + {formatTimeWindowRange( + nextSchedule.windowStart, + nextSchedule.windowEnd, + locale, + )} + + ); + } + } + + // Therapietreue cell — the same two cadence-scaled windows the card + // bars show, compacted to label + mini bar + percentage per row. + const complianceCell = compliance ? ( +
+ {( + [ + [shortDays, rateShort], + [longDays, rateLong], + ] as const + ).map(([days, rate]) => ( +
+ + {t("medications.tableComplianceDays", { days })} + + + + {fmt.number(Math.round(rate))}% + +
+ ))} +
+ ) : ( + + ); + + const stock = medication.stockDosesRemaining; + const stockCell = + stock === null || stock === undefined ? ( + – + ) : ( + + {stock === 1 + ? t("medications.tableStockDoseOne") + : t("medications.tableStockDoses", { count: stock })} + + ); + + return ( + + {/* Sticky name column — opaque background so scrolled content + passes underneath, not through. */} + + + + {medication.name} + + + {medication.dose} + {!medication.active && <> · {t("common.inactive")}} + + + + {statusCell} + {nextCell} + {medication.active ? complianceCell : –} + {stockCell} + + {medication.active ? ( +
+ + +
+ ) : ( + – + )} +
+
+ ); +} + +/** + * Loading placeholder for the table view. Mirrors the loaded table's + * shell (same container, same column count, same row height) so the + * page reserves the footprint and does not jump when the rows resolve — + * the table-view counterpart of `MedicationCardSkeleton`. + */ +export function MedicationTableSkeleton({ rows = 4 }: { rows?: number }) { + return ( + + ); +} diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx new file mode 100644 index 000000000..e9e08d109 --- /dev/null +++ b/src/components/medications/medication-view-toggle.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { LayoutGrid, Table as TableIcon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { MedicationListView } from "@/lib/medication-list-layout"; + +/** + * v1.16.10 — icon-only card/table view toggle for the /medications page + * header. A small two-segment control: the active segment carries the + * filled surface, the inactive one stays ghost. `aria-pressed` announces + * the state per segment; the group is named for screen readers. Segments + * keep the 44-px mobile tap floor (`size-11`), shrinking to the standard + * 36-px control height on desktop like the neighbouring Add button. + */ +interface MedicationViewToggleProps { + view: MedicationListView; + onChange: (view: MedicationListView) => void; +} + +export function MedicationViewToggle({ + view, + onChange, +}: MedicationViewToggleProps) { + const { t } = useTranslations(); + + const segments = [ + { + value: "cards" as const, + label: t("medications.viewCards"), + icon: LayoutGrid, + }, + { + value: "table" as const, + label: t("medications.viewTable"), + icon: TableIcon, + }, + ]; + + return ( +
+ {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/__tests__/medication-list-layout.test.ts b/src/lib/__tests__/medication-list-layout.test.ts new file mode 100644 index 000000000..ac9e0313d --- /dev/null +++ b/src/lib/__tests__/medication-list-layout.test.ts @@ -0,0 +1,92 @@ +/** + * v1.16.10 — medications list presentation blob (resolver + serializer). + * + * The resolver must never throw on a stored row (malformed / legacy / + * partial blobs collapse onto the defaults field-by-field); the + * serializer dedupes and caps the manual order so the persisted blob + * stays bounded and canonical. + */ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_MEDICATION_LIST_LAYOUT, + MEDICATION_ORDER_MAX_ENTRIES, + resolveMedicationListLayout, + serializeMedicationListLayout, +} from "@/lib/medication-list-layout"; + +describe("resolveMedicationListLayout", () => { + it("returns the defaults for a null / missing column", () => { + expect(resolveMedicationListLayout(null)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + expect(resolveMedicationListLayout(undefined)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + }); + + it("returns the defaults for a non-object blob", () => { + expect(resolveMedicationListLayout("cards")).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + expect(resolveMedicationListLayout(42)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + }); + + it("resolves a complete stored blob verbatim", () => { + expect( + resolveMedicationListLayout({ + version: 1, + view: "table", + order: ["med-b", "med-a"], + }), + ).toEqual({ version: 1, view: "table", order: ["med-b", "med-a"] }); + }); + + it("falls back per field: unknown view → cards, malformed order → empty", () => { + expect( + resolveMedicationListLayout({ view: "kanban", order: "med-a" }), + ).toEqual({ version: 1, view: "cards", order: [] }); + }); + + it("drops non-string and oversized order entries, keeping the rest", () => { + const resolved = resolveMedicationListLayout({ + view: "table", + order: ["med-a", 7, null, "x".repeat(65), "med-b"], + }); + expect(resolved.order).toEqual(["med-a", "med-b"]); + }); + + it("dedupes a stored order (first occurrence wins)", () => { + const resolved = resolveMedicationListLayout({ + view: "cards", + order: ["med-a", "med-b", "med-a"], + }); + expect(resolved.order).toEqual(["med-a", "med-b"]); + }); +}); + +describe("serializeMedicationListLayout", () => { + it("emits the canonical v1 blob", () => { + expect( + serializeMedicationListLayout({ view: "table", order: ["med-a"] }), + ).toEqual({ version: 1, view: "table", order: ["med-a"] }); + }); + + it("dedupes the order before persisting", () => { + expect( + serializeMedicationListLayout({ + view: "cards", + order: ["a", "b", "a", "c", "b"], + }).order, + ).toEqual(["a", "b", "c"]); + }); + + it("caps the order at the documented maximum", () => { + const order = Array.from({ length: 500 }, (_, i) => `med-${i}`); + expect( + serializeMedicationListLayout({ view: "cards", order }).order.length, + ).toBe(MEDICATION_ORDER_MAX_ENTRIES); + }); +}); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 140769403..80be52ea2 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -246,6 +246,16 @@ export function invalidateUserInsightsLayout(userId: string): void { caches.insightsLayout.deleteByPrefix(userId); } +/** + * v1.16.10 — invalidate the medications list-presentation cache for a + * single user. Called from the medications-layout PUT / DELETE + * endpoints when the user toggles the view or saves a manual order so + * the next /medications mount paints the new presentation. + */ +export function invalidateUserMedicationListLayout(userId: string): void { + caches.medicationListLayout.deleteByPrefix(userId); +} + /** * v1.16.9 — full per-user cache eviction for the wipe / restore paths. * A data wipe or a backup restore replaces (or removes) every row the @@ -265,6 +275,7 @@ export function invalidateUserData(userId: string): void { caches.workouts.deleteByPrefix(`${userId}|`); caches.dashboardWidgets.deleteByPrefix(userId); caches.insightsLayout.deleteByPrefix(userId); + caches.medicationListLayout.deleteByPrefix(userId); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index c19601b2a..580fd462b 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -610,6 +610,18 @@ export const caches = { maxEntries: 500, ttlMs: 300_000, }), + /** + * v1.16.10 — per-user medications list presentation (view choice + + * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same + * per-user bucket size). Read on every /medications mount; changes + * only on a view toggle / order save, which invalidates via + * `invalidateUserMedicationListLayout()`. + */ + medicationListLayout: new ServerCache({ + name: "medicationListLayout", + maxEntries: 500, + ttlMs: 300_000, + }), } as const; /** diff --git a/src/lib/medication-list-layout.ts b/src/lib/medication-list-layout.ts new file mode 100644 index 000000000..7aa5ae60e --- /dev/null +++ b/src/lib/medication-list-layout.ts @@ -0,0 +1,113 @@ +/** + * Medications list presentation — persisted in + * `User.medicationListLayoutJson`. + * + * Single source of truth for the /medications view choice (cards vs the + * compact table) and the user-defined manual medication order shared by + * BOTH views. Null / missing column = the defaults below; the GET + * endpoint never lazy-writes a row, so the column only carries data once + * the user has explicitly toggled the view or saved an order. + * + * Mirrors `insights-layout.ts` / `dashboard-layout.ts` — same separate + * per-surface column on `User`, same resolver / serializer semantics, + * same preserve-when-absent PUT contract (see the route). + */ + +/** The two list presentations /medications can render. */ +export const MEDICATION_LIST_VIEWS = ["cards", "table"] as const; + +export type MedicationListView = (typeof MEDICATION_LIST_VIEWS)[number]; + +export interface MedicationListLayout { + version: 1; + /** Which presentation the list renders in. Default: cards. */ + view: MedicationListView; + /** + * User-defined manual medication order (medication ids, first = top). + * Applied to BOTH views; medications not in the list append after the + * ordered block in the alphabetical default order. Ids that no longer + * resolve to a medication are ignored at apply time (a deleted + * medication must not 422 the stored layout), so the list is bounded + * but not ownership-validated — it is display-only. + */ + order: string[]; +} + +/** + * Upper bound on the persisted order list. Generous — the medications + * list itself has no hard cap, but the blob must stay small; beyond + * this many entries the tail falls back to the alphabetical default. + */ +export const MEDICATION_ORDER_MAX_ENTRIES = 200; + +/** Per-entry id length bound (cuids are 25 chars; leave headroom). */ +export const MEDICATION_ORDER_ID_MAX_LENGTH = 64; + +export const DEFAULT_MEDICATION_LIST_LAYOUT: MedicationListLayout = { + version: 1, + view: "cards", + order: [], +}; + +function isMedicationListView(value: unknown): value is MedicationListView { + return ( + typeof value === "string" && + (MEDICATION_LIST_VIEWS as readonly string[]).includes(value) + ); +} + +/** + * Tolerant read-side resolver: any malformed / legacy / partial blob + * collapses onto the defaults field-by-field, so a GET can never fail on + * a stored row and a new field added later defaults cleanly for blobs + * persisted before it existed. + */ +export function resolveMedicationListLayout( + raw: unknown, +): MedicationListLayout { + if (raw === null || raw === undefined || typeof raw !== "object") { + return { ...DEFAULT_MEDICATION_LIST_LAYOUT, order: [] }; + } + const blob = raw as { view?: unknown; order?: unknown }; + const view = isMedicationListView(blob.view) + ? blob.view + : DEFAULT_MEDICATION_LIST_LAYOUT.view; + const order = Array.isArray(blob.order) + ? dedupeOrder( + blob.order.filter( + (id): id is string => + typeof id === "string" && + id.length > 0 && + id.length <= MEDICATION_ORDER_ID_MAX_LENGTH, + ), + ) + : []; + return { version: 1, view, order }; +} + +/** + * Write-side normaliser: dedupes the order list (first occurrence wins) + * and caps it. The route merges absent fields from the stored blob + * BEFORE calling this, so the result is always a complete layout. + */ +export function serializeMedicationListLayout(input: { + view: MedicationListView; + order: string[]; +}): MedicationListLayout { + return { + version: 1, + view: input.view, + order: dedupeOrder(input.order).slice(0, MEDICATION_ORDER_MAX_ENTRIES), + }; +} + +function dedupeOrder(order: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const id of order) { + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} diff --git a/src/lib/medications/__tests__/medication-order.test.ts b/src/lib/medications/__tests__/medication-order.test.ts new file mode 100644 index 000000000..27bcb0af5 --- /dev/null +++ b/src/lib/medications/__tests__/medication-order.test.ts @@ -0,0 +1,52 @@ +/** + * v1.16.10 — the manual medication order shared by BOTH /medications + * views. Ordered ids first (in saved order), everything else appends in + * the alphabetical default; unknown ids are ignored. + */ +import { describe, expect, it } from "vitest"; + +import { applyMedicationOrder } from "@/lib/medications/medication-order"; + +const meds = [ + { id: "m1", name: "Ramipril" }, + { id: "m2", name: "Aspirin" }, + { id: "m3", name: "Mounjaro" }, +]; + +describe("applyMedicationOrder", () => { + it("returns the alphabetical default for an empty order", () => { + expect(applyMedicationOrder(meds, []).map((m) => m.name)).toEqual([ + "Aspirin", + "Mounjaro", + "Ramipril", + ]); + }); + + it("applies the saved order verbatim when every id is named", () => { + expect( + applyMedicationOrder(meds, ["m3", "m1", "m2"]).map((m) => m.id), + ).toEqual(["m3", "m1", "m2"]); + }); + + it("appends unnamed medications alphabetically after the ordered block", () => { + // A medication created after the last order save must surface, not + // vanish — it lands after the ordered block in the default order. + expect(applyMedicationOrder(meds, ["m3"]).map((m) => m.id)).toEqual([ + "m3", + "m2", // Aspirin + "m1", // Ramipril + ]); + }); + + it("ignores ids that no longer resolve to a medication", () => { + expect( + applyMedicationOrder(meds, ["deleted-med", "m2"]).map((m) => m.id), + ).toEqual(["m2", "m3", "m1"]); + }); + + it("does not mutate the input array", () => { + const input = [...meds]; + applyMedicationOrder(input, ["m3", "m1"]); + expect(input).toEqual(meds); + }); +}); diff --git a/src/lib/medications/medication-order.ts b/src/lib/medications/medication-order.ts new file mode 100644 index 000000000..0419c1f08 --- /dev/null +++ b/src/lib/medications/medication-order.ts @@ -0,0 +1,37 @@ +/** + * v1.16.10 — the user-defined manual medication order, applied to BOTH + * /medications views (cards and table). + * + * The persisted layout carries an ordered list of medication ids + * (`MedicationListLayout.order`). Medications named there render first, + * in that order; every other medication (newly created since the last + * save, or never ordered) appends after the ordered block in the + * alphabetical default the list always used. Ids that no longer resolve + * to a medication are simply ignored — a deleted medication must not + * corrupt the order. + */ + +/** The default tiebreak the list used before manual ordering existed. */ +export function compareMedicationNames( + a: { name: string }, + b: { name: string }, +): number { + return a.name.localeCompare(b.name, "de", { sensitivity: "base" }); +} + +export function applyMedicationOrder( + medications: readonly T[], + order: readonly string[], +): T[] { + const position = new Map(); + order.forEach((id, index) => { + if (!position.has(id)) position.set(id, index); + }); + const ordered = medications + .filter((m) => position.has(m.id)) + .sort((a, b) => position.get(a.id)! - position.get(b.id)!); + const rest = medications + .filter((m) => !position.has(m.id)) + .sort(compareMedicationNames); + return [...ordered, ...rest]; +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 52791dff5..d3ffcfc0a 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -18,6 +18,11 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, +} from "@/lib/medication-list-layout"; import { scheduleRevisionCreateSchema, scheduleRevisionUpdateSchema, @@ -228,11 +233,25 @@ const medicationListEntry = medicationResource .describe( "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", ), + stockUnitsRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", + ), + stockDosesRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", + ), }) .meta({ id: "MedicationListEntry", description: - "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", + "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", }); const medicationDetailEntry = medicationResource @@ -677,6 +696,32 @@ medicationExtractionSchema.meta({ "Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.", }); +// v1.16.10 — medications list presentation (cards/table view + manual +// order), persisted per user in its own `User` column following the +// dashboard-widgets / insights-layout per-surface convention. +const medicationListLayoutSchema = z + .object({ + version: z.literal(1), + view: z + .enum(MEDICATION_LIST_VIEWS) + .optional() + .describe( + 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.', + ), + order: z + .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) + .max(MEDICATION_ORDER_MAX_ENTRIES) + .optional() + .describe( + "User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses.", + ), + }) + .meta({ + id: "MedicationListLayout", + description: + "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.", + }); + export const medicationPaths: NonNullable = { "/api/medications": { get: { @@ -724,6 +769,75 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/layout": { + get: { + tags: ["Medications"], + summary: "Read the calling user's medications list presentation", + description: + "Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.", + responses: { + "200": { + description: "The resolved presentation (custom or default).", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Update the calling user's medications list presentation", + description: + "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.", + requestBody: { + required: true, + content: { + "application/json": { schema: medicationListLayoutSchema }, + }, + }, + responses: { + "200": { + description: + "Presentation saved; the normalised blob is echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutSaved", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Reset the calling user's medications list presentation", + description: + "Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.", + responses: { + "200": { + description: "Presentation reset; the defaults are returned.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutReset", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/{id}": { get: { tags: ["Medications"], diff --git a/src/lib/queries/__tests__/use-medication-list-layout.test.ts b/src/lib/queries/__tests__/use-medication-list-layout.test.ts new file mode 100644 index 000000000..58ba26c33 --- /dev/null +++ b/src/lib/queries/__tests__/use-medication-list-layout.test.ts @@ -0,0 +1,150 @@ +/** + * v1.16.10 — toggle / reorder persistence wiring for the medications + * list presentation. Pins the PUT payload shape (field-scoped, version + * 1), the optimistic cache flip + rollback-on-failure for the view + * toggle, and the cache update on a saved order — without a React + * render, via the dependency-injected `run*` orchestration functions + * (the `use-medication-intake` testing convention). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { apiPut } from "@/lib/api/api-fetch"; +import { toast } from "sonner"; +import { + runSetMedicationListView, + runSaveMedicationListOrder, +} from "@/lib/queries/use-medication-list-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const t = (key: string) => key; + +function makeClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runSetMedicationListView", () => { + it("PUTs the field-scoped body and lands the server echo in the cache", async () => { + const queryClient = makeClient(); + queryClient.setQueryData( + queryKeys.medicationListLayout(), + { version: 1, view: "cards", order: ["med-a"] }, + ); + const saved: MedicationListLayout = { + version: 1, + view: "table", + order: ["med-a"], + }; + vi.mocked(apiPut).mockResolvedValue(saved as never); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + // Field-scoped: only `view` rides the body — the server preserves + // the stored order (preserve-when-absent), so the client must not + // resend (and thereby race) it. + expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", { + version: 1, + view: "table", + }); + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(saved); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("flips the cache optimistically before the PUT resolves", async () => { + const queryClient = makeClient(); + queryClient.setQueryData( + queryKeys.medicationListLayout(), + { version: 1, view: "cards", order: [] }, + ); + let observedDuringFlight: MedicationListLayout | undefined; + vi.mocked(apiPut).mockImplementation((async () => { + observedDuringFlight = queryClient.getQueryData( + queryKeys.medicationListLayout(), + ); + return { version: 1, view: "table", order: [] }; + }) as never); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + expect(observedDuringFlight?.view).toBe("table"); + }); + + it("rolls the cache back and surfaces a toast when the PUT fails", async () => { + const queryClient = makeClient(); + const previous: MedicationListLayout = { + version: 1, + view: "cards", + order: ["med-a"], + }; + queryClient.setQueryData(queryKeys.medicationListLayout(), previous); + vi.mocked(apiPut).mockRejectedValue(new Error("boom")); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(previous); + expect(toast.error).toHaveBeenCalledWith("medications.viewSaveFailed"); + }); +}); + +describe("runSaveMedicationListOrder", () => { + it("PUTs the order-only body, caches the echo, and reports success", async () => { + const queryClient = makeClient(); + const saved: MedicationListLayout = { + version: 1, + view: "table", + order: ["med-b", "med-a"], + }; + vi.mocked(apiPut).mockResolvedValue(saved as never); + + const ok = await runSaveMedicationListOrder({ + order: ["med-b", "med-a"], + queryClient, + t, + }); + + expect(ok).toBe(true); + expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", { + version: 1, + order: ["med-b", "med-a"], + }); + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(saved); + expect(toast.success).toHaveBeenCalledWith("medications.reorderSaved"); + }); + + it("returns false and surfaces a toast when the PUT fails", async () => { + const queryClient = makeClient(); + vi.mocked(apiPut).mockRejectedValue(new Error("boom")); + + const ok = await runSaveMedicationListOrder({ + order: ["med-a"], + queryClient, + t, + }); + + expect(ok).toBe(false); + expect(toast.error).toHaveBeenCalledWith( + "medications.reorderSaveFailed", + ); + }); +}); diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts index ed9fb1d1d..16990a9dc 100644 --- a/src/lib/queries/use-medication-compliance-summary.ts +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -43,6 +43,19 @@ export interface MedicationComplianceSummaryEntry { * lets the cards swap the skeleton for a quiet same-footprint fallback * with a manual retry. */ +/** + * The ONE fetcher behind the shared summary key. Both hooks below (and + * any future consumer) must ride this exact function so the key can + * never be registered with diverging fetch shapes. + */ +function fetchComplianceSummary(): Promise< + MedicationComplianceSummaryEntry[] +> { + return apiGet( + "/api/medications/compliance", + ); +} + export function useMedicationComplianceSummary(medicationId: string): { data: MedicationComplianceSummaryEntry | null | undefined; isError: boolean; @@ -50,8 +63,7 @@ export function useMedicationComplianceSummary(medicationId: string): { } { const { data, isError, refetch } = useQuery({ queryKey: queryKeys.medicationComplianceSummary(), - queryFn: () => - apiGet("/api/medications/compliance"), + queryFn: fetchComplianceSummary, // Dose actions invalidate the key explicitly through // `medicationDependentKeys`; a shorter window would only re-fire the // batch on every list visit. Matches the reminder-thresholds query @@ -62,3 +74,20 @@ export function useMedicationComplianceSummary(medicationId: string): { }); return { data, isError, refetch }; } + +/** + * v1.16.10 — the whole batched array, for consumers that need every + * row at once (the table view's Therapietreue column sort). Same key, + * same fetcher, no `select` — the per-row hook and this one share one + * cache entry by construction. + */ +export function useMedicationComplianceSummaryAll(): { + data: MedicationComplianceSummaryEntry[] | undefined; +} { + const { data } = useQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: fetchComplianceSummary, + staleTime: 5 * 60 * 1000, + }); + return { data }; +} diff --git a/src/lib/queries/use-medication-list-layout.ts b/src/lib/queries/use-medication-list-layout.ts new file mode 100644 index 000000000..b21bf6eee --- /dev/null +++ b/src/lib/queries/use-medication-list-layout.ts @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { + DEFAULT_MEDICATION_LIST_LAYOUT, + type MedicationListLayout, + type MedicationListView, +} from "@/lib/medication-list-layout"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** + * v1.16.10 — the /medications presentation preference (cards/table view + * + manual order), persisted per user through + * `GET`/`PUT /api/medications/layout`. + * + * The PUT is field-scoped: the server preserves whichever of `view` / + * `order` the body omits, so the two writers below each send only the + * field they own and can never wipe the other one. + * + * The orchestration lives in dependency-injected `run*` functions (the + * repo convention from `use-medication-intake.ts`) so the optimistic + * write + rollback contract is unit-testable without a React render. + */ + +/** + * Flip the view optimistically (the toggle must feel instant), then + * persist. On failure the cache rolls back to the previous layout and + * a calm toast surfaces — the user is back on the view they came from. + */ +export async function runSetMedicationListView(deps: { + view: MedicationListView; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { view, queryClient, t } = deps; + const key = queryKeys.medicationListLayout(); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_MEDICATION_LIST_LAYOUT), + view, + }); + try { + const saved = await apiPut( + "/api/medications/layout", + { version: 1, view }, + ); + if (saved) queryClient.setQueryData(key, saved); + } catch { + queryClient.setQueryData(key, previous); + toast.error(t("medications.viewSaveFailed")); + } +} + +/** + * Persist a manual medication order (the reorder dialog's Save). Not + * optimistic — the dialog stays open until the PUT resolves, mirroring + * the dashboard layout section's explicit Save. Returns whether the + * save landed so the dialog knows to close. + */ +export async function runSaveMedicationListOrder(deps: { + order: string[]; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { order, queryClient, t } = deps; + try { + const saved = await apiPut( + "/api/medications/layout", + { version: 1, order }, + ); + if (saved) { + queryClient.setQueryData(queryKeys.medicationListLayout(), saved); + } + toast.success(t("medications.reorderSaved")); + return true; + } catch { + toast.error(t("medications.reorderSaveFailed")); + return false; + } +} + +export function useMedicationListLayout(enabled: boolean = true): { + /** Resolved layout — defaults until the GET lands. */ + layout: MedicationListLayout; + /** True while the preference GET is in flight (first load only). */ + isLayoutLoading: boolean; + setView: (view: MedicationListView) => Promise; + saveOrder: (order: string[]) => Promise; +} { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.medicationListLayout(), + queryFn: () => apiGet("/api/medications/layout"), + staleTime: 5 * 60 * 1000, + enabled, + }); + + return { + layout: data ?? DEFAULT_MEDICATION_LIST_LAYOUT, + isLayoutLoading: enabled && isLoading, + setView: (view) => runSetMedicationListView({ view, queryClient, t }), + saveOrder: (order) => + runSaveMedicationListOrder({ order, queryClient, t }), + }; +} diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 99ecee37f..8a5ae44ca 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -28,6 +28,14 @@ export const medicationKeys = { */ medicationComplianceSummary: () => ["medications", "compliance-summary"] as const, + /** + * v1.16.10 — the medications list presentation (cards/table view + + * manual order, `GET /api/medications/layout`). Deliberately OUTSIDE + * the `["medications"]` prefix: an intake mutation must not refetch a + * pure UI preference. Writes update the slot directly through the + * layout PUT's response. + */ + medicationListLayout: () => ["medication-list-layout"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => From 7eeab2ae983d0a3770de294690d75a851ecbaaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 18:48:30 +0200 Subject: [PATCH 04/13] fix(medications): stock claims are atomic and only a dose becoming taken consumes Two hardening passes on the consumption stamp: - consumeForIntake claims the stamp with a single conditional UPDATE (NULL-stamp predicate + row lock) instead of read-then-write, so a double tap racing a sync replay decrements exactly once; the loser sees count 0. restoreForIntake clears the stamp the same way, so concurrent restores cannot refund twice. - Every seam consumes only on the pending-to-taken transition. A sync replay that re-posts an already-taken history row, or an edit that corrects the timestamp of an old dose, never touches today's stock. --- src/app/api/ingest/medication/route.ts | 27 +++-- .../intake/[eventId]/__tests__/route.test.ts | 34 ++++++ .../[id]/intake/[eventId]/route.ts | 46 +++++-- .../intake/bulk/__tests__/route.test.ts | 37 ++++++ src/app/api/medications/intake/bulk/route.ts | 20 ++-- src/app/api/medications/intake/route.ts | 50 +++++--- src/app/api/telegram/webhook/route.ts | 27 +++-- .../inventory/__tests__/consumption.test.ts | 112 ++++++++++++++++++ src/lib/medications/inventory/consumption.ts | 82 +++++++------ 9 files changed, 345 insertions(+), 90 deletions(-) diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 4b01a61c1..30e6c77b7 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -191,6 +191,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); let event; + // v1.16.10 — only the genuine pending→taken transition consumes + // inventory units (the slot upsert's `consumedTransition` flag). A + // re-post converging onto an already-taken slot row — including a + // pre-v1.16.10 row that predates the consumption stamp — must not + // decrement stock again. A fresh ad-hoc create is always a take. + let consumed = true; if (attribution.slotInstant) { // Scheduled dose — converge onto the one canonical slot row (the // pending REMINDER row when one exists) through the shared upsert: @@ -209,6 +215,7 @@ export const POST = apiHandler(async (request: NextRequest) => { attributionSource: "AUTO", }); event = applied.row; + consumed = applied.consumedTransition; } else { // Ad-hoc / PRN / off-window — standalone row under the documented // contract (`scheduledFor = takenAt`). @@ -227,14 +234,18 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.16.10 — the external ingest records a take; consume inventory // units. The stamp on the row keeps a retried POST exactly-once (the - // idempotency probe above already short-circuits the common replay). - await consumeForIntake({ - client: prisma, - userId: apiToken.userId, - medicationId: medication.id, - eventId: event.id, - intakeAt: effectiveTakenAt, - }); + // idempotency probe above already short-circuits the common replay) + // and the transition gate keeps a slot-converged re-post from + // double-decrementing through a pre-stamp row. + if (consumed) { + await consumeForIntake({ + client: prisma, + userId: apiToken.userId, + medicationId: medication.id, + eventId: event.id, + intakeAt: effectiveTakenAt, + }); + } await auditLog("medication.ingest.external", { userId: apiToken.userId, diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index b832029b5..e5f6bab07 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -623,6 +623,40 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => { expect(restoreForIntake).not.toHaveBeenCalled(); }); + it("an in-place time correction on an already-taken row never consumes (pre-stamp legacy rows must not retro-charge)", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + // PUT lookup — a row already taken BEFORE the edit, stamp NULL + // (pre-v1.16.10: its stock moved through the legacy hook at take + // time). The edit only corrects the takenAt minute. + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + inventoryConsumption: null, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + scheduledFor: at(7, 0), + takenAt: at(7, 10), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: at(7, 10).toISOString() }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + it("a taken → skipped edit restores the row's stamp and never consumes", async () => { const { consumeForIntake, restoreForIntake } = await import( "@/lib/medications/inventory/consumption" diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index c6440447f..c3c2a857e 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -225,6 +225,14 @@ export const PUT = apiHandler( const slotMoved = targetScheduledFor.getTime() !== event.scheduledFor.getTime(); + // v1.16.10 — transition gate for the inventory hooks below. Only an + // edit moving the row INTO taken may consume; a row that was + // already taken keeps its stamp frozen (a stamped row would no-op + // anyway, and a pre-v1.16.10 taken row — NULL stamp, stock already + // moved by the legacy hook at take time — must never retro-consume + // on a time correction). + const wasTaken = event.takenAt !== null && !event.skipped; + let updated; if (!slotMoved) { // The slot is unchanged — a bare in-place update can never collide with @@ -245,19 +253,22 @@ export const PUT = apiHandler( syncVersion: { increment: 1 }, }, }); - // v1.16.10 — inventory follows the post-edit state: a row that is - // (now) taken consumes (the stamp keeps an already-consumed row - // untouched — the stamp freezes what the original take pulled, - // even when `unitsPerDose` changed since); a row edited out of - // taken (skip flip, takenAt cleared) refunds its stamp. + // v1.16.10 — inventory follows the post-edit state: an edit that + // moves the row INTO taken consumes; a row that stays taken keeps + // its stamp untouched (the stamp freezes what the original take + // pulled, even when `unitsPerDose` changed since, and a pre-stamp + // legacy row must not retro-consume); a row edited out of taken + // (skip flip, takenAt cleared) refunds its stamp. if (nextTakenAt !== null && !nextSkipped) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: id, - eventId, - intakeAt: nextTakenAt, - }); + if (!wasTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId, + intakeAt: nextTakenAt, + }); + } } else { await restoreForIntake({ client: prisma, @@ -304,7 +315,16 @@ export const PUT = apiHandler( doseTaken: event.doseTaken, }); updated = applied.row; - if (nextTakenAt !== null && !nextSkipped) { + // A taken source WITHOUT a stamp is a pre-v1.16.10 row whose + // stock moved through the legacy hook: the refund above was a + // no-op, so consuming on the converged row would double-charge — + // skip it. Every other taken outcome nets the move to exactly one + // consumption. + if ( + nextTakenAt !== null && + !nextSkipped && + (!wasTaken || event.inventoryConsumption !== null) + ) { await consumeForIntake({ client: prisma, userId: user.id, diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 61c827638..f35456c20 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -926,6 +926,43 @@ describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption", expect(restoreForIntake).not.toHaveBeenCalled(); }); + it("a taken re-post onto an already-taken slot (journal replay) never reaches consume", async () => { + // The replayed row pre-dates the consumption stamp (NULL) — exactly + // the shape a full client journal re-sync re-posts for historical + // doses. The upsert reports no pending→taken transition, so the + // consume hook must not run: the stamp cannot gate a pre-v1.16.10 + // row and the replay must not drain today's stock. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-taken", + takenAt: new Date("2026-06-15T05:01:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T05:01:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-taken", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + it("an explicit skip entry restores the slot row's stamp instead of consuming", async () => { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index aa231b9ea..b923db783 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -422,11 +422,16 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } - // v1.16.10 — inventory: a taken write consumes (the stamp on the - // row makes a replayed sync batch exactly-once), an explicit skip - // refunds a previously-taken row's stamp. Pending echoes carry - // neither flag and never touch stock. - if (!applied.noDowngradeNoOp && isExplicitTaken) { + // v1.16.10 — inventory: only the genuine pending→taken + // transition consumes (the upsert's `consumedTransition` flag — + // the ONLY transition that may move stock). The stamp keeps a + // replayed batch exactly-once for post-v1.16.10 rows; the + // transition gate additionally protects pre-stamp rows: a full + // journal replay re-posting historical taken doses must not + // drain today's stock through their NULL stamps. An explicit + // skip refunds a previously-taken row's stamp. Pending echoes + // carry neither flag and never touch stock. + if (applied.consumedTransition && isExplicitTaken) { await consumeForIntake({ client: prisma, userId: user.id, @@ -509,8 +514,9 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } - // v1.16.10 — same inventory rule as the canonical-slot branch. - if (!applied.noDowngradeNoOp && isExplicitTaken) { + // v1.16.10 — same inventory rule as the canonical-slot branch: + // gate on the upsert's pending→taken transition flag. + if (applied.consumedTransition && isExplicitTaken) { await consumeForIntake({ client: prisma, userId: user.id, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 2ffc077f3..002c91589 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -523,6 +523,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const slotMoved = targetScheduledFor.getTime() !== existing.scheduledFor.getTime(); + // v1.16.10 — transition gate for the inventory consume below. Only + // a row moving INTO taken may consume; a re-take of an already- + // taken row (e.g. a time correction) leaves its stamp frozen, and a + // pre-v1.16.10 taken row (NULL stamp, stock already moved by the + // legacy hook at take time) must never retro-consume. + const wasTaken = existing.takenAt !== null && !existing.skipped; if (!slotMoved) { [updated] = await prisma.$transaction([ prisma.medicationIntakeEvent.update({ @@ -544,17 +550,18 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { snoozedUntil: null }, }), ]); - // v1.16.10 — the toggle recorded a take; consume inventory units. - // The stamp on the row makes a re-toggle of an already-taken slot - // a no-op, so the count only moves on the genuine pending→taken - // (or skipped→taken) transition. - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: existing.medicationId, - eventId: intakeId, - intakeAt: resolvedTakenAt, - }); + // v1.16.10 — the toggle recorded a take; consume inventory units + // on the genuine pending→taken (or skipped→taken) transition. The + // stamp additionally keeps a racing replay exactly-once. + if (!wasTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: intakeId, + intakeAt: resolvedTakenAt, + }); + } } else { // The take re-attributed to a different slot. Tombstone the source row // and route the dose through the shared canonical-slot upsert, which @@ -600,14 +607,19 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { snoozedUntil: null }, }); // v1.16.10 — consume on the converged row (the old row's stamp - // was refunded above, so the move nets one consumption). - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: existing.medicationId, - eventId: applied.row.id, - intakeAt: resolvedTakenAt, - }); + // was refunded above, so the move nets one consumption). A taken + // source WITHOUT a stamp is a pre-v1.16.10 row whose stock moved + // through the legacy hook: the refund above was a no-op, so + // consuming on the target would double-charge — skip it. + if (!wasTaken || existing.inventoryConsumption !== null) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: applied.row.id, + intakeAt: resolvedTakenAt, + }); + } } } else if (status === "skipped") { updated = await prisma.medicationIntakeEvent.update({ diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index e5e5620ef..9d480846d 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -275,6 +275,10 @@ async function markMedicationTaken( const written = await prisma.$transaction(async (tx) => { let slotScheduledFor: Date; let eventId: string; + // v1.16.10 — only a genuine pending→taken transition may consume + // inventory units; a redelivered button press converging onto an + // already-taken slot row (including a pre-stamp row) must not. + let consumedTransition = true; if (canonicalSlot) { const applied = await applyCanonicalSlotWrite({ client: tx, @@ -291,6 +295,7 @@ async function markMedicationTaken( }); slotScheduledFor = applied.row.scheduledFor; eventId = applied.row.id; + consumedTransition = applied.consumedTransition; } else { // Ad-hoc / PRN — standalone row under the documented contract // (`scheduledFor = takenAt`). @@ -312,20 +317,24 @@ async function markMedicationTaken( where: { id: medication.id }, data: { snoozedUntil: null }, }); - return { slotScheduledFor, eventId }; + return { slotScheduledFor, eventId, consumedTransition }; }); scheduledFor = written.slotScheduledFor; // v1.16.10 — the button confirmed a take; consume inventory units. // Runs after the intake transaction committed (the dose record must // never hinge on the stock write); the stamp keeps a Telegram - // redelivery exactly-once. - await consumeForIntake({ - client: prisma, - userId, - medicationId: medication.id, - eventId: written.eventId, - intakeAt: takenAt, - }); + // redelivery exactly-once and the transition gate keeps a re-press + // on an already-taken slot from decrementing through a pre-stamp + // row. + if (written.consumedTransition) { + await consumeForIntake({ + client: prisma, + userId, + medicationId: medication.id, + eventId: written.eventId, + intakeAt: takenAt, + }); + } // The confirmation must reflect on the next read (today feed, card // pill, compliance) rather than wait out the cache TTL. invalidateUserMedications(userId, { evict: true }); diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts index 8929d7bf6..d61163d4a 100644 --- a/src/lib/medications/inventory/__tests__/consumption.test.ts +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -90,6 +90,55 @@ function makeClient(state: FakeState) { (e) => e.id === args.where.id && e.userId === args.where.userId, ) ?? null, ), + // The hooks' atomic claim — evaluate the conditional WHERE the way + // Postgres would (the gate fields plus the stamp predicate: + // `Prisma.AnyNull` matches a NULL stamp, a JSON value matches by + // structural equality) and apply the data to every matching row. + updateMany: vi.fn( + async (args: { + where: { + id?: string; + userId?: string; + medicationId?: string; + deletedAt?: null; + takenAt?: { not: null }; + skipped?: boolean; + inventoryConsumption?: { equals: unknown }; + }; + data: Record; + }) => { + const w = args.where; + const matches = state.events.filter((e) => { + if (w.id !== undefined && e.id !== w.id) return false; + if (w.userId !== undefined && e.userId !== w.userId) return false; + if (w.medicationId !== undefined && e.medicationId !== w.medicationId) + return false; + if ("deletedAt" in w && e.deletedAt !== null) return false; + if (w.takenAt !== undefined && e.takenAt === null) return false; + if (w.skipped !== undefined && e.skipped !== w.skipped) return false; + if (w.inventoryConsumption !== undefined) { + const eq = w.inventoryConsumption.equals; + if (eq === Prisma.AnyNull) { + if (e.inventoryConsumption !== null) return false; + } else if ( + JSON.stringify(e.inventoryConsumption) !== JSON.stringify(eq) + ) { + return false; + } + } + return true; + }); + for (const event of matches) { + if ("inventoryConsumption" in args.data) { + event.inventoryConsumption = + args.data.inventoryConsumption === Prisma.DbNull + ? null + : args.data.inventoryConsumption; + } + } + return { count: matches.length }; + }, + ), update: vi.fn( async (args: { where: { id: string }; @@ -653,3 +702,66 @@ describe("restoreForIntake", () => { ]); }); }); + +describe("claim atomicity (concurrent duplicate calls)", () => { + it("two interleaved consumes decrement exactly once — the claim, not a read, is the gate", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 4, + firstUseAt: new Date(NOW.getTime() - MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 29 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + + // Both calls pass any plain read of the (still NULL) stamp; only the + // conditional claim UPDATE — atomic per statement, like Postgres + // under the row lock — lets exactly one through. + await Promise.all([ + consumeForIntake(consumeArgs(client)), + consumeForIntake(consumeArgs(client)), + ]); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + ]); + }); + + it("two interleaved restores refund exactly once", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 1 }], + }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 3, + firstUseAt: new Date(NOW.getTime() - MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 29 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + + const args = { + client: client as never, + userId: "user-1", + eventId: "evt-1", + }; + await Promise.all([restoreForIntake(args), restoreForIntake(args)]); + + expect(state.items[0].unitsRemaining).toBe(4); + expect(state.events[0].inventoryConsumption).toBeNull(); + }); +}); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts index c80a38d2b..8ba4e8d57 100644 --- a/src/lib/medications/inventory/consumption.ts +++ b/src/lib/medications/inventory/consumption.ts @@ -135,26 +135,30 @@ export async function consumeForIntake(input: { const { client, userId, medicationId, eventId, intakeAt } = input; try { await runAtomically(client, async (tx) => { - const event = await tx.medicationIntakeEvent.findFirst({ - where: { id: eventId, userId }, - select: { - id: true, - medicationId: true, - takenAt: true, - skipped: true, - deletedAt: true, - inventoryConsumption: true, + // Atomic claim — gate check and stamp reservation in ONE + // conditional UPDATE. A plain read-then-write gate is not safe + // under READ COMMITTED: two concurrent consume calls for the same + // event (a double tap racing an iOS sync replay) both read the + // NULL stamp and both decrement. The claim takes the event's row + // lock and re-evaluates the predicate after a blocking peer + // commits, so exactly one caller wins; the loser sees count 0 and + // returns. The placeholder `[]` is overwritten with the real + // stamp below inside the same transaction — and rolls back with + // it if the consumption fails, so a failed consume never leaves a + // row stamped. + const claimed = await tx.medicationIntakeEvent.updateMany({ + where: { + id: eventId, + userId, + medicationId, + deletedAt: null, + takenAt: { not: null }, + skipped: false, + inventoryConsumption: { equals: Prisma.AnyNull }, }, + data: { inventoryConsumption: [] }, }); - if ( - !event || - event.deletedAt !== null || - event.takenAt === null || - event.skipped || - event.inventoryConsumption !== null - ) { - return; - } + if (claimed.count === 0) return; const medication = await tx.medication.findFirst({ where: { id: medicationId, userId }, @@ -249,7 +253,7 @@ export async function consumeForIntake(input: { // with no tracked stock must not retro-consume containers // registered later when a sync replay revisits the row. await tx.medicationIntakeEvent.update({ - where: { id: event.id }, + where: { id: eventId }, data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue }, }); @@ -302,17 +306,32 @@ export async function restoreForIntake(input: { inventoryConsumption: true, }, }); - if (!event) return; - const stamp = parseStamp(event.inventoryConsumption); + if (!event || event.inventoryConsumption === null) return; + const stampValue = event.inventoryConsumption; + + // Atomic claim — clear the stamp ONLY while it still holds the + // exact value just read (jsonb structural equality). The claim + // takes the event's row lock, so two concurrent restores cannot + // both refund: the loser blocks, re-evaluates against the + // now-NULL stamp, sees count 0 and returns. The refund below then + // applies the captured stamp inside the same transaction; a + // mid-refund failure rolls the clear back with it. + const claimed = await tx.medicationIntakeEvent.updateMany({ + where: { + id: eventId, + userId, + inventoryConsumption: { + equals: stampValue as Prisma.InputJsonValue, + }, + }, + data: { inventoryConsumption: Prisma.DbNull }, + }); + if (claimed.count === 0) return; + + const stamp = parseStamp(stampValue); if (stamp === null || stamp.length === 0) { - // Clear an empty/malformed stamp so a later re-take can - // consume afresh; a NULL stamp is already clear. - if (event.inventoryConsumption !== null) { - await tx.medicationIntakeEvent.update({ - where: { id: event.id }, - data: { inventoryConsumption: Prisma.DbNull }, - }); - } + // Empty / malformed stamp — the claim already cleared it so a + // later re-take can consume afresh; nothing to refund. return; } @@ -351,11 +370,6 @@ export async function restoreForIntake(input: { itemIds.push(item.id); } - await tx.medicationIntakeEvent.update({ - where: { id: event.id }, - data: { inventoryConsumption: Prisma.DbNull }, - }); - annotate({ action: { name: "medication.inventory.restored" }, meta: { From 8a5a26b49aac8ae53f30e821e1b1f8ff2e4fa10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 19:03:32 +0200 Subject: [PATCH 05/13] feat(dashboard): the daily-overview band becomes opt-in The hero renders only when the layout carries the literal heroVisible: true. Legacy blobs without the field, malformed values, and fresh accounts all keep it off; the Settings toggle persists an explicit opt-in through the existing layout PUT. --- .../__tests__/dashboard-hero-wiring.test.ts | 8 ++-- .../dashboard/widgets/__tests__/route.test.ts | 4 +- src/app/api/dashboard/widgets/route.ts | 2 +- src/app/page.tsx | 4 +- .../dashboard-layout-section.test.tsx | 22 +++++------ .../settings/dashboard-layout-section.tsx | 2 +- src/lib/__tests__/dashboard-layout.test.ts | 39 ++++++++++--------- src/lib/dashboard-layout.ts | 20 +++++----- 8 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 9e26a1939..898830436 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -7,9 +7,9 @@ import { join } from "node:path"; * * The hero band mounts between the page header and the tile strip: * - * - it gates on the layout's `heroVisible` flag (anything but the - * literal `false` renders — the resolver's clamp) AND on snapshot - * mode (the verdict needs the snapshot payload); + * - it gates on the layout's `heroVisible` flag (only the literal + * `true` renders — the hero is opt-in) AND on snapshot mode (the + * verdict needs the snapshot payload); * - while `primaryLoading` is true (same `!mounted ||` pin the tile * silhouettes use) a footprint-identical skeleton holds the band, * so the hero and the tiles swap to content in the SAME render @@ -30,7 +30,7 @@ describe("dashboard hero — page wiring", () => { it("gates the hero on snapshot mode + the layout's heroVisible flag", () => { expect(src).toMatch( - /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/, + /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/, ); }); diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 66458f54e..775c2d510 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -477,7 +477,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); }); - it("defaults heroVisible to true for a first save with no stored layout", async () => { + it("defaults heroVisible to false for a first save with no stored layout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dashboardWidgetsJson: null, } as never); @@ -495,7 +495,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { .calls[0]?.[0] as unknown as { data: { dashboardWidgetsJson: { heroVisible: boolean } }; }; - expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true); + expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); }); it("422s on a non-boolean heroVisible", async () => { diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 5896c6322..7795a07b2 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -283,7 +283,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; } if (mergedHeroVisible === undefined) { - mergedHeroVisible = existingLayout.heroVisible !== false; + mergedHeroVisible = existingLayout.heroVisible === true; } } const normalized = serializeDashboardLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 48e4dce3d..56d967b76 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -709,13 +709,13 @@ export default function DashboardPage() { // hero band (it derives from the snapshot's server-computed // `greetingHour`, so the old client-side Intl hour walk retired // with it). The hero gates on the layout's `heroVisible` flag - // (default on; anything but the literal `false` renders) and holds + // (opt-in; only the literal `true` renders) and holds // a footprint-identical skeleton while the snapshot is in flight, // so the band swaps in place — no growth, no greeting flash. When // the hero does NOT render (toggle off, or snapshot flag off) the // header takes the greeting back — it must never disappear with the // band. - const heroVisible = snapshotEnabled && layout.heroVisible !== false; + const heroVisible = snapshotEnabled && layout.heroVisible === true; const heroSnapshot = snapshotQuery.data; return ( diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 73ab9f58f..386955c60 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -299,17 +299,7 @@ describe(" — hero visibility switch", () => { expect(en).toContain("Daily overview"); }); - it("reflects heroVisible: default layout → checked, false → unchecked", () => { - const checkedHtml = render( - , - ); - const checked = checkedHtml.match( - /]*data-slot="hero-visible-switch"[^>]*>/, - ); - expect(checked).not.toBeNull(); - expect(checked![0]).toContain('data-state="checked"'); - - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false }; + it("reflects heroVisible: default layout → unchecked, true → checked", () => { const uncheckedHtml = render( , ); @@ -318,6 +308,16 @@ describe(" — hero visibility switch", () => { ); expect(unchecked).not.toBeNull(); expect(unchecked![0]).toContain('data-state="unchecked"'); + + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + const checkedHtml = render( + , + ); + const checked = checkedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(checked).not.toBeNull(); + expect(checked![0]).toContain('data-state="checked"'); }); it("persists through the existing PUT draft flow (source pins)", () => { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 0699eb892..d93498f9d 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -408,7 +408,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setHeroVisible(v)} disabled={saveMutation.isPending} data-slot="hero-visible-switch" diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 964ede10a..827b8498a 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -380,59 +380,60 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => { /** * Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs * on the layout blob like the B8 comparison baseline. Resolver contract: - * anything that is not the literal `false` clamps back to `true` - * (default-on for legacy blobs; a stale client cannot poison the field - * with a non-boolean). Serializer persists the resolved boolean - * explicitly so a re-read never has to guess the default. + * anything that is not the literal `true` clamps back to `false` + * (the hero is opt-in; legacy blobs keep it off and a stale client + * cannot poison the field with a non-boolean). Serializer persists the + * resolved boolean explicitly so a re-read never has to guess the + * default. */ describe("resolveDashboardLayout() — heroVisible", () => { - it("defaults to true for legacy layouts (no field saved)", () => { + it("defaults to false for legacy layouts (no field saved)", () => { const legacy = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], }; - expect(resolveDashboardLayout(legacy).heroVisible).toBe(true); + expect(resolveDashboardLayout(legacy).heroVisible).toBe(false); }); - it("respects an explicit heroVisible: false", () => { + it("respects an explicit heroVisible: true", () => { const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], - heroVisible: false, + heroVisible: true, }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(false); + expect(resolveDashboardLayout(saved).heroVisible).toBe(true); }); - it("clamps non-boolean values back to true", () => { - for (const poisoned of ["false", 0, null, 1, {}]) { + it("clamps non-boolean values back to false", () => { + for (const poisoned of ["true", 0, null, 1, {}]) { const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], heroVisible: poisoned, }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(true); + expect(resolveDashboardLayout(saved).heroVisible).toBe(false); } }); - it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => { - expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true); + it("defaults heroVisible to false in DEFAULT_DASHBOARD_LAYOUT", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(false); }); - it("preserves heroVisible: false through serialize → resolve round-trip", () => { + it("preserves heroVisible: true through serialize → resolve round-trip", () => { const layout: DashboardLayout = { ...DEFAULT_DASHBOARD_LAYOUT, - heroVisible: false, + heroVisible: true, }; const resolved = resolveDashboardLayout(serializeDashboardLayout(layout)); - expect(resolved.heroVisible).toBe(false); + expect(resolved.heroVisible).toBe(true); }); - it("serializer derives true when the field is missing on input", () => { + it("serializer derives false when the field is missing on input", () => { const layout: DashboardLayout = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], }; - expect(serializeDashboardLayout(layout).heroVisible).toBe(true); + expect(serializeDashboardLayout(layout).heroVisible).toBe(false); }); }); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 06c7f3317..3c58e3cf7 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -324,9 +324,9 @@ export interface DashboardLayout { /** * Dashboard hero (daily verdict) visibility. Piggy-backs on the * layout blob like the B8 comparison baseline — a UI affordance, not - * an analytical attribute, so no Prisma migration. The resolver - * clamps anything that is not the literal `false` back to `true` - * (legacy blobs without the field render the hero by default; a + * an analytical attribute, so no Prisma migration. Opt-in: the + * resolver clamps anything that is not the literal `true` back to + * `false` (legacy blobs without the field keep the hero off; a * stale client cannot poison it with a non-boolean), and the * serializer persists the resolved boolean explicitly. */ @@ -342,9 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1; */ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { version: DASHBOARD_LAYOUT_VERSION, - // Hero (daily verdict) is on by default; users opt out via + // Hero (daily verdict) is off by default; users opt in via // Settings → Dashboard. - heroVisible: true, + heroVisible: false, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -462,10 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // client cannot poison the dashboard with values the renderer // doesn't know how to draw. chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs), - // Hero visibility — anything that is not the literal `false` - // clamps to `true` (default-on for legacy blobs and malformed - // values alike). - heroVisible: candidate.heroVisible !== false, + // Hero visibility — anything that is not the literal `true` + // clamps to `false` (default-off for legacy blobs and malformed + // values alike; the hero is opt-in). + heroVisible: candidate.heroVisible === true, }; } @@ -495,6 +495,6 @@ export function serializeDashboardLayout( chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs), // Persist hero visibility explicitly (same clamp as the resolver) // so a re-read never has to guess the default. - heroVisible: layout.heroVisible !== false, + heroVisible: layout.heroVisible === true, }; } From baec7101381bf80ddb92c2715d83bbd3b04041ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 19:07:53 +0200 Subject: [PATCH 06/13] fix(coach): the launcher stops blinking out on chart hovers and sits evenly in the corner The chart-tooltip auto-hide made the button vanish on every hover tooltip anywhere on the page, which read as a glitch; a tooltip under the cursor never reaches the lower-right corner anyway. The observer chain behind it goes with it. The right inset now mirrors the desktop bottom inset. --- .../__tests__/chart-tooltip-observer.test.ts | 70 ------- .../__tests__/layout-coach-fab.test.tsx | 5 +- .../insights/chart-tooltip-observer.ts | 181 ------------------ src/components/insights/layout-coach-fab.tsx | 31 +-- .../insights/use-chart-tooltip-active.ts | 27 --- 5 files changed, 11 insertions(+), 303 deletions(-) delete mode 100644 src/components/insights/__tests__/chart-tooltip-observer.test.ts delete mode 100644 src/components/insights/chart-tooltip-observer.ts delete mode 100644 src/components/insights/use-chart-tooltip-active.ts diff --git a/src/components/insights/__tests__/chart-tooltip-observer.test.ts b/src/components/insights/__tests__/chart-tooltip-observer.test.ts deleted file mode 100644 index 4a1bd3793..000000000 --- a/src/components/insights/__tests__/chart-tooltip-observer.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - __resetChartTooltipObserverForTests, - getChartTooltipActive, - getChartTooltipActiveServer, - subscribeChartTooltipActive, -} from "../chart-tooltip-observer"; - -/** - * v1.4.33 (F15) — chart-tooltip observer contract tests. - * - * The full DOM-side behaviour (MutationObserver watching `[class*= - * "recharts-tooltip-wrapper"]` visibility flips) lives behind a guard - * that no-ops when `document` is undefined — which is the case in the - * default vitest node environment. These tests pin the SSR-safe path - * and the subscribe/snapshot contract that `useSyncExternalStore` - * leans on. - * - * The integration with a real Recharts tooltip is exercised via the - * Playwright suite (mobile insight chart hover → FAB fades). - */ - -describe("chart-tooltip-observer", () => { - beforeEach(() => { - __resetChartTooltipObserverForTests(); - }); - - afterEach(() => { - __resetChartTooltipObserverForTests(); - }); - - it("getChartTooltipActiveServer always returns false", () => { - // The SSR snapshot must never claim a tooltip is active on the - // server — there's no DOM to inspect, and a stale `true` would - // render the FAB invisible on the initial pass. - expect(getChartTooltipActiveServer()).toBe(false); - }); - - it("getChartTooltipActive returns false when no wrappers exist", () => { - expect(getChartTooltipActive()).toBe(false); - }); - - it("subscribeChartTooltipActive returns an unsubscribe function", () => { - const unsubscribe = subscribeChartTooltipActive(() => undefined); - expect(typeof unsubscribe).toBe("function"); - unsubscribe(); - }); - - it("no-ops cleanly on a server-side render (no document available)", () => { - // Document is undefined in the vitest node environment — the - // module's `start()` early-returns. Subscribing should still - // succeed; the listener simply never fires. - let calls = 0; - const unsubscribe = subscribeChartTooltipActive(() => { - calls += 1; - }); - expect(getChartTooltipActive()).toBe(false); - expect(calls).toBe(0); - unsubscribe(); - }); - - it("supports multiple subscribers without throwing", () => { - const a = subscribeChartTooltipActive(() => undefined); - const b = subscribeChartTooltipActive(() => undefined); - expect(getChartTooltipActive()).toBe(false); - a(); - b(); - }); -}); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 445b7c909..f2f545df5 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -58,8 +58,9 @@ describe("", () => { // Fixed bottom-right, above the mobile bottom-nav band. The plain // desktop offset kicks in at `md:` — the same breakpoint where the // bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air - // in the 768-1023px band. - expect(html).toContain("fixed right-4"); + // in the 768-1023px band. The right inset mirrors the desktop + // bottom inset so the corner gap is even. + expect(html).toContain("fixed right-6"); expect(html).toContain( "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]", ); diff --git a/src/components/insights/chart-tooltip-observer.ts b/src/components/insights/chart-tooltip-observer.ts deleted file mode 100644 index 18f3edc89..000000000 --- a/src/components/insights/chart-tooltip-observer.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * v1.4.33 (F15) — chart-tooltip active observer. - * - * The mobile Coach FAB at `bottom-right` of every routed `/insights` - * sub-page used to overlay the Recharts tooltip bubble when a user - * tapped a data point near the bottom-right of the chart. We can't - * route a `tooltipActive` boolean directly from the chart to the FAB - * because the chart sits inside the routed page subtree while the FAB - * is mounted at the layout level — and the chart component itself is - * out of this work-item's touch surface. - * - * Instead, listen at the DOM level. Recharts paints every active - * tooltip into a div whose class always carries `recharts-tooltip- - * wrapper`. The `` upstream component flips the - * inline `visibility` from `hidden` (idle) to `visible` (cursor over - * a point or long-press on touch). A single shared `MutationObserver` - * watches every such wrapper for `style`-attribute changes and feeds - * a tiny external store; consumers subscribe via `useSyncExternalStore` - * to avoid prop-drilling or react-context coupling between unrelated - * subtrees. - * - * The store is module-scoped so the listener stays a singleton — the - * observer connects on the first subscriber and disconnects on the - * last unmount. SSR-safe: every browser API touch is gated on - * `typeof document`. - */ - -let activeCount = 0; -const listeners = new Set<() => void>(); - -let observer: MutationObserver | null = null; -let listening = false; - -// Track wrappers we've already seen so we don't double-count their -// visibility transitions. A `WeakSet` lets the GC reclaim removed -// wrapper elements naturally. -const knownWrappers = new WeakSet(); - -function isVisible(wrapper: HTMLElement): boolean { - // Recharts surfaces `visibility: visible` via inline style when the - // tooltip should paint, and clears it otherwise. We mirror that exact - // contract — `getComputedStyle()` is not required because the inline - // style is always set. - return wrapper.style.visibility === "visible"; -} - -function emit() { - for (const fn of listeners) fn(); -} - -function syncWrapperState(wrapper: HTMLElement, nextVisible: boolean) { - const flagged = wrapper.dataset.coachTooltipActive === "1"; - if (nextVisible && !flagged) { - wrapper.dataset.coachTooltipActive = "1"; - activeCount += 1; - emit(); - } else if (!nextVisible && flagged) { - delete wrapper.dataset.coachTooltipActive; - activeCount = Math.max(0, activeCount - 1); - emit(); - } -} - -function adoptWrapper(wrapper: HTMLElement) { - if (knownWrappers.has(wrapper)) return; - knownWrappers.add(wrapper); - // Seed the active-count with whatever state the wrapper carried on - // first sight so a tooltip already painted before our observer - // attached still counts. - syncWrapperState(wrapper, isVisible(wrapper)); -} - -function scanForWrappers(root: ParentNode) { - // `querySelectorAll` returns every existing wrapper inside the - // mutated subtree on every mutation — cheap because the DOM - // typically holds 0–1 wrappers at a time. - const wrappers = root.querySelectorAll( - '[class*="recharts-tooltip-wrapper"]', - ); - for (const wrapper of wrappers) adoptWrapper(wrapper); -} - -function handleMutations(mutations: MutationRecord[]) { - for (const mutation of mutations) { - if (mutation.type === "childList") { - for (const node of mutation.addedNodes) { - if (!(node instanceof HTMLElement)) continue; - if (node.matches?.('[class*="recharts-tooltip-wrapper"]')) { - adoptWrapper(node); - } else { - scanForWrappers(node); - } - } - for (const node of mutation.removedNodes) { - if (!(node instanceof HTMLElement)) continue; - // Removed wrappers cannot contribute to active count anymore; - // drop the flag so a re-added wrapper re-counts cleanly. - if (node.dataset?.coachTooltipActive === "1") { - delete node.dataset.coachTooltipActive; - activeCount = Math.max(0, activeCount - 1); - emit(); - } - } - } else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) { - const target = mutation.target; - if (target.matches('[class*="recharts-tooltip-wrapper"]')) { - syncWrapperState(target, isVisible(target)); - } - } - } -} - -function start() { - if (listening) return; - if (typeof document === "undefined") return; - listening = true; - observer = new MutationObserver(handleMutations); - observer.observe(document.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ["style"], - }); - // Seed with wrappers that already exist in the DOM at subscribe-time. - scanForWrappers(document.body); -} - -function stop() { - if (!listening) return; - listening = false; - observer?.disconnect(); - observer = null; - // Reset the counter — re-seeding on the next subscriber re-scans - // the DOM anyway. - activeCount = 0; -} - -/** - * Subscribe-API used by `useSyncExternalStore`. Connects the singleton - * observer on the first subscriber and disconnects on the last. - */ -export function subscribeChartTooltipActive(listener: () => void): () => void { - listeners.add(listener); - if (listeners.size === 1) start(); - return () => { - listeners.delete(listener); - if (listeners.size === 0) stop(); - }; -} - -/** - * Snapshot getter for `useSyncExternalStore`. Returns `true` while at - * least one Recharts tooltip wrapper is currently visible. - */ -export function getChartTooltipActive(): boolean { - return activeCount > 0; -} - -/** - * SSR snapshot — no chart tooltip can be active on the server, so we - * always return `false`. Keeps the hook safe on the initial render - * before hydration. - */ -export function getChartTooltipActiveServer(): boolean { - return false; -} - -/** - * Test-only reset hook. Vitest tears the DOM down between tests but - * the module-scoped counter survives the teardown; exposing a reset - * keeps cross-test state from bleeding. - */ -export function __resetChartTooltipObserverForTests(): void { - activeCount = 0; - listeners.clear(); - if (observer) { - observer.disconnect(); - observer = null; - } - listening = false; -} diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 0ce143008..bfade4a0d 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -13,7 +13,6 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; import { queryKeys } from "@/lib/query-keys"; -import { useChartTooltipActive } from "./use-chart-tooltip-active"; import { apiGet } from "@/lib/api/api-fetch"; /** @@ -30,10 +29,11 @@ import { apiGet } from "@/lib/api/api-fetch"; * The FAB hides inside the Coach view itself (`/insights/coach`) — a * launcher pointing at the page the user is already on is noise. * - * v1.4.33 (F15) — keeps the chart-tooltip auto-hide so it never - * overlays a Recharts tooltip on the lower right; it also yields while - * a data-list selection bar is mounted (CSS `:has()` gate) so it never - * covers the bar's delete action. + * It yields while a data-list selection bar is mounted (CSS `:has()` + * gate) so it never covers the bar's delete action. The v1.4.33 + * chart-tooltip auto-hide is gone: blinking out on every chart hover + * read as a glitch, and a hover tooltip under the cursor never reaches + * the lower-right corner anyway. */ const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen"; @@ -63,7 +63,6 @@ export function LayoutCoachFab() { const launch = useCoachLaunch(); const flags = useFeatureFlags(); const disableCoach = useDisableCoach(); - const tooltipActive = useChartTooltipActive(); // Local "seen on this device" stamp — the nudge timestamp the user // last dismissed by opening the chat. Lazy initialiser per the @@ -109,16 +108,6 @@ export function LayoutCoachFab() { prevUnreadRef.current = unread; }, [unread, t]); - // While a chart tooltip hides the FAB it is `aria-hidden` + - // `tabIndex={-1}` — but an element that ALREADY holds focus keeps it. - // Drop the focus so a hidden control is never the active element. - const buttonRef = useRef(null); - useEffect(() => { - if (tooltipActive && document.activeElement === buttonRef.current) { - buttonRef.current?.blur(); - } - }, [tooltipActive]); - // Visiting the Coach page itself counts as reading the nudge on this // device — persist the stamp so the dot stays gone after leaving. useEffect(() => { @@ -154,23 +143,21 @@ export function LayoutCoachFab() { return ( <> - )} {/* v1.14.0 — the "Add" button is now a choice: log an intake From 5142823b7edda7d67a44854ad24717bd0d9301ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 19:35:11 +0200 Subject: [PATCH 10/13] fix(ui): the filter pills open their menu on screen again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill trigger paints its own label spans and mounts no SelectValue, and Radix's item-aligned placement silently skips all positioning when the value node is absent — the open menu laid out below the height-locked app shell, fully off-viewport, which reads as a dead control. The mood and type/source pills on the mood and measurements lists were affected since the unified filter rail landed in v1.16.1. Popper placement has no value-node dependency and anchors the menu under the pill like the date-range popover next to it. --- ...t.tsx => medication-order-editor.test.tsx} | 0 .../ui/__tests__/filter-bar.test.tsx | 20 +++++++++++++++++++ src/components/ui/filter-bar.tsx | 9 ++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) rename src/components/medications/__tests__/{medication-reorder-dialog.test.tsx => medication-order-editor.test.tsx} (100%) diff --git a/src/components/medications/__tests__/medication-reorder-dialog.test.tsx b/src/components/medications/__tests__/medication-order-editor.test.tsx similarity index 100% rename from src/components/medications/__tests__/medication-reorder-dialog.test.tsx rename to src/components/medications/__tests__/medication-order-editor.test.tsx diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx index 5830b2e0f..bde2d54db 100644 --- a/src/components/ui/__tests__/filter-bar.test.tsx +++ b/src/components/ui/__tests__/filter-bar.test.tsx @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; @@ -141,3 +143,21 @@ describe("FilterBar shell", () => { expect(html).toContain('aria-label="Filter entfernen: Quelle"'); }); }); + +describe("FilterBarSelect — menu positioning (source pin)", () => { + // The pill trigger paints its own label spans and mounts no + // ``. Radix's default item-aligned placement silently + // skips ALL positioning when the value node is absent — the open + // menu then lays out below the height-locked app shell, fully + // off-viewport, which users report as "clicking does nothing". SSR + // markup cannot exercise an open portal, so this pins the source: + // the content must request popper placement, which has no + // value-node dependency. + it("requests popper placement on the select content", () => { + const src = readFileSync( + join(process.cwd(), "src/components/ui/filter-bar.tsx"), + "utf8", + ); + expect(src).toMatch(//); + }); +}); diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index 599ecdadd..4df8852b8 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -175,7 +175,14 @@ export function FilterBarSelect({ {label} )} - + {/* `position="popper"` is load-bearing: this trigger paints its + own label spans and mounts no `SelectValue`, and Radix's + default item-aligned placement silently skips ALL positioning + when the value node is absent — the open menu then lays out + below the height-locked app shell, fully off-viewport. Popper + placement has no value-node dependency and anchors the menu + under the pill like the date-range popover next to it. */} + {allLabel} {options.map((opt) => ( From 02642a964e87af66459c437ba502f2fdcbca8d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 19:35:23 +0200 Subject: [PATCH 11/13] feat(medications): list customization moves into its own settings section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /settings/medications now hosts the view preference (cards or table) and the manual-order editor inline, following the dashboard and insights pattern. The page header's gear icon links there instead of opening a dialog; the quick view toggle stays next to the add button. The order editor keeps its grouped active/inactive semantics — a saved order can never interleave the groups. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../__tests__/list-view-toggle.test.tsx | 13 +- src/app/medications/page.tsx | 69 +++------ src/app/settings/[section]/page.tsx | 2 + .../medication-order-editor.test.tsx | 73 ++++----- ...dialog.tsx => medication-order-editor.tsx} | 141 ++++++++---------- .../__tests__/medications-section.test.tsx | 122 +++++++++++++++ .../__tests__/settings-shell.test.tsx | 11 ++ .../settings/medications-section.tsx | 129 ++++++++++++++++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 5 + src/components/settings/settings-shell.tsx | 6 + 17 files changed, 441 insertions(+), 180 deletions(-) rename src/components/medications/{medication-reorder-dialog.tsx => medication-order-editor.tsx} (79%) create mode 100644 src/components/settings/__tests__/medications-section.test.tsx create mode 100644 src/components/settings/medications-section.tsx diff --git a/messages/de.json b/messages/de.json index 9930facb5..3dcafb78a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Erlaubte Stellen", "allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.", "viewToggleLabel": "Ansicht", + "viewToggleHint": "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", "viewCards": "Kartenansicht", "viewTable": "Tabellenansicht", "viewSaveFailed": "Ansicht konnte nicht gespeichert werden", @@ -1737,7 +1738,8 @@ "reorderTitle": "Reihenfolge anpassen", "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.", "reorderSaved": "Reihenfolge gespeichert", - "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden" + "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden", + "customize": "Medikamente anpassen" }, "charts": { "loadingLabel": "Diagramm wird geladen…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Insights", "description": "Reihenfolge der Übersicht und der Navigations-Pills." + }, + "medications": { + "title": "Medikamente", + "description": "Listenansicht und Reihenfolge der Medikamente." } }, "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index f5927bd8b..a9ea8f561 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Allowed sites", "allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.", "viewToggleLabel": "View", + "viewToggleHint": "Choose whether your medications appear as cards or as a table.", "viewCards": "Card view", "viewTable": "Table view", "viewSaveFailed": "View could not be saved", @@ -1737,7 +1738,8 @@ "reorderTitle": "Adjust order", "reorderDescription": "Set the order of your medications – it applies to both the card and the table view.", "reorderSaved": "Order saved", - "reorderSaveFailed": "Order could not be saved" + "reorderSaveFailed": "Order could not be saved", + "customize": "Customize medications" }, "charts": { "loadingLabel": "Loading chart…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Insights", "description": "Overview and navigation-pill order." + }, + "medications": { + "title": "Medications", + "description": "List view and medication order." } }, "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index 514b42a15..63018e39e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Sitios permitidos", "allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen.", "viewToggleLabel": "Vista", + "viewToggleHint": "Elige si tus medicamentos se muestran como tarjetas o como tabla.", "viewCards": "Vista de tarjetas", "viewTable": "Vista de tabla", "viewSaveFailed": "No se pudo guardar la vista", @@ -1737,7 +1738,8 @@ "reorderTitle": "Ajustar orden", "reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.", "reorderSaved": "Orden guardado", - "reorderSaveFailed": "No se pudo guardar el orden" + "reorderSaveFailed": "No se pudo guardar el orden", + "customize": "Personalizar medicamentos" }, "charts": { "loadingLabel": "Cargando la gráfica…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Análisis", "description": "Orden de la vista general y de las pestañas de navegación." + }, + "medications": { + "title": "Medicamentos", + "description": "Vista de lista y orden de los medicamentos." } }, "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index 68b82779e..9e24dfa09 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Sites autorisés", "allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés.", "viewToggleLabel": "Affichage", + "viewToggleHint": "Choisis si tes médicaments s'affichent sous forme de cartes ou de tableau.", "viewCards": "Vue cartes", "viewTable": "Vue tableau", "viewSaveFailed": "L'affichage n'a pas pu être enregistré", @@ -1737,7 +1738,8 @@ "reorderTitle": "Modifier l'ordre", "reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.", "reorderSaved": "Ordre enregistré", - "reorderSaveFailed": "L'ordre n'a pas pu être enregistré" + "reorderSaveFailed": "L'ordre n'a pas pu être enregistré", + "customize": "Personnaliser les médicaments" }, "charts": { "loadingLabel": "Chargement du graphique…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Analyses", "description": "Ordre de la vue d'ensemble et des onglets de navigation." + }, + "medications": { + "title": "Médicaments", + "description": "Vue de la liste et ordre des médicaments." } }, "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index 7290f0b4e..692bf94fe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Siti consentiti", "allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti.", "viewToggleLabel": "Vista", + "viewToggleHint": "Scegli se i tuoi farmaci vengono mostrati come schede o come tabella.", "viewCards": "Vista a schede", "viewTable": "Vista tabella", "viewSaveFailed": "Impossibile salvare la vista", @@ -1737,7 +1738,8 @@ "reorderTitle": "Modifica ordine", "reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.", "reorderSaved": "Ordine salvato", - "reorderSaveFailed": "Impossibile salvare l’ordine" + "reorderSaveFailed": "Impossibile salvare l’ordine", + "customize": "Personalizza i farmaci" }, "charts": { "loadingLabel": "Caricamento del grafico…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Approfondimenti", "description": "Ordine della panoramica e delle schede di navigazione." + }, + "medications": { + "title": "Farmaci", + "description": "Vista elenco e ordine dei farmaci." } }, "profile": "Profilo", diff --git a/messages/pl.json b/messages/pl.json index 21e72f180..03223cb51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1718,6 +1718,7 @@ "allowedSitesLabel": "Dozwolone miejsca", "allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane.", "viewToggleLabel": "Widok", + "viewToggleHint": "Wybierz, czy leki mają być wyświetlane jako karty, czy jako tabela.", "viewCards": "Widok kart", "viewTable": "Widok tabeli", "viewSaveFailed": "Nie udało się zapisać widoku", @@ -1737,7 +1738,8 @@ "reorderTitle": "Zmień kolejność", "reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.", "reorderSaved": "Kolejność zapisana", - "reorderSaveFailed": "Nie udało się zapisać kolejności" + "reorderSaveFailed": "Nie udało się zapisać kolejności", + "customize": "Dostosuj leki" }, "charts": { "loadingLabel": "Ładowanie wykresu…", @@ -3767,6 +3769,10 @@ "insights": { "title": "Analizy", "description": "Kolejność przeglądu i pigułek nawigacyjnych." + }, + "medications": { + "title": "Leki", + "description": "Widok listy i kolejność leków." } }, "profile": "Profil", diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx index 409997fb9..305737dd9 100644 --- a/src/app/medications/__tests__/list-view-toggle.test.tsx +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -33,9 +33,6 @@ vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({ vi.mock("@/components/medications/log-intake-dialog", () => ({ LogIntakeDialog: () => null, })); -vi.mock("@/components/medications/medication-reorder-dialog", () => ({ - MedicationReorderDialog: () => null, -})); import MedicationsPage from "@/app/medications/page"; import { I18nProvider } from "@/lib/i18n/context"; @@ -133,9 +130,15 @@ describe("/medications — view renders from the persisted preference", () => { ); }); - it("offers the manual-order editor from the header", () => { + it("links the header customize glyph to /settings/medications", () => { + // v1.16.10 — same entry-point logic as the dashboard and insights + // headers: the Settings2 ghost icon is a plain link to the page's + // settings section (view preference + manual order live there). const html = renderPage({ version: 1, view: "cards", order: [] }); - expect(html).toContain('aria-label="Adjust order"'); + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain('aria-label="Customize medications"'); + // The in-page order dialog is gone — no dialog trigger remains. + expect(html).not.toContain('aria-label="Adjust order"'); }); }); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index a0a611642..dcd1a4e54 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; @@ -15,7 +16,6 @@ import { MedicationTableSkeleton, } from "@/components/medications/medication-table"; import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; -import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -149,16 +149,13 @@ export default function MedicationsPage() { // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = useState(false); - // v1.16.10 — manual-order editor (shared by both views). - const [reorderOpen, setReorderOpen] = useState(false); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user // (`GET`/`PUT /api/medications/layout`). The toggle writes - // optimistically; the reorder dialog saves explicitly. - const { layout, isLayoutLoading, setView } = useMedicationListLayout( - isAuthenticated, - ); + // optimistically; the order editor lives at /settings/medications. + const { layout, isLayoutLoading, setView } = + useMedicationListLayout(isAuthenticated); useEffect(() => { if (shouldOpenFromUrl) { @@ -268,25 +265,27 @@ export default function MedicationsPage() {
{/* v1.16.10 — card ⇄ table view toggle, persisted server-side - per user; plus the manual-order editor shared by both - views. The editor opens behind the same Settings2 glyph - the dashboard and insights headers use for their - customize entry points, in the same slot (left of the - add button). It only earns its slot once there is - something to reorder. */} + per user. The Settings2 glyph next to it is the customize + shortcut to /settings/medications (view preference + the + manual-order editor), exactly like the dashboard and + insights headers link to their settings sections, in the + same slot (left of the add button) and with the same + responsive 44-px mobile tap floor. */} - {medsArray.length > 1 && ( - - )} +