diff --git a/CHANGELOG.md b/CHANGELOG.md index 08947ecb5..f71a8c7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order + +### Added + +- **A medication can declare how many units one dose consumes.** Two 2 mg tablets for a 4 mg dose register as one dose taken and decrement two units, the supply dialog accepts quantities in doses or units with live conversion, containers carry a type (pen, ampoule, blister pack, inhaler, bottle) and hold up to 1000 units — a 200-dose inhaler canister finally fits. (#316) +- **The medication list has a table view and an order of your choosing.** A toggle beside the add button switches between cards and a compact, per-column sortable table; both views share a manual order, and the whole arrangement lives in its own settings section behind the same gear icon the dashboard and insights pages use. The choice persists per account. (#316) + +### Changed + +- **The daily-overview band is opt-in now.** New accounts and accounts that never chose it start without the band and keep the plain greeting; it switches on under dashboard settings. +- **Expired containers count as available nowhere.** Every surface — overview row, supply tab, list, table, GLP-1 card and the runway estimate — agrees on the same figure and shows expired stock separately instead of mixing it in. +- The wellness rings sit exactly on the theme palette now — readiness green, recovery cyan, sleep purple, stress orange, strain pink — instead of neighbouring tones, and the light theme follows the same five hue families. +- The inventory endpoints speak one wire dialect: request fields renamed to `unitsTotal` / `unitsRemaining` to match the responses they always returned. + +### Fixed + +- **Every taken dose now moves the tracked stock.** Only one of the six intake paths decremented the supply before — web, the status toggle, the phone sync, the external API and the Telegram button all consume now, the next container opens automatically, and undoing, skipping or deleting a dose refunds exactly what it consumed. Each dose carries its consumption stamp, so a double tap racing a sync replay decrements once, and a re-posted history never drains the shelf. Doses recorded before this release stay untouched — the count starts moving with the next dose, or correct a container once under the supply tab. (#316) +- **A dashboard full of readings no longer claims "not enough data".** While the score warms up the band says it is being computed, and a first reading of a new data type — a fresh wearable metric, a first glucose value — can no longer knock the score out, because the gate checks only the types the score actually reads. +- **The score stops flickering on busy accounts.** The pre-aggregation rebuild deleted its window before inserting the replacement without a transaction — a concurrent sync write could abort the insert and leave whole types empty until the next pass. Delete and insert run atomically now, and the boot-time backfill shares the fold horizon, so an account with years of history stops re-backfilling on every deploy. +- **The filter pills on the mood and measurements lists open their menu on screen again.** The menu opened off-viewport below the page since v1.16.1 — selecting a mood, type or source read as a dead control. +- **The coach button stops blinking out on chart hovers.** It no longer hides whenever a chart tooltip is open anywhere on the page, and it sits evenly in the corner. +- The pre-item GLP-1 supply ledger reads again: accounts that tracked pens through the older ledger see their count on the card and in the coach until they register containers, which then take over. + ## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 541c54915..64d8de8a3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.9 + version: 1.16.10 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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: @@ -2289,6 +2344,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 `unitsTotal` 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 (`unitsRemaining`, clamped to the item's + capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as + `medication.inventory.update`." + parameters: + - 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 +4726,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 @@ -4743,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: @@ -4779,8 +5007,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 +5174,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: + unitsTotal: + type: integer + minimum: 1 + maximum: 1000 + description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the + medication's `unitsPerDose`. + containerType: + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; + defaults to OTHER. + 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: + - unitsTotal + description: Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item + starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose. + UpdateMedicationInventoryItemRequest: + type: object + 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" + unitsRemaining: + description: Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical + state machine re-derives the state (0 ⇒ USED_UP). + type: integer + 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 +8570,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: @@ -8366,12 +8679,30 @@ 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 - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8391,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: @@ -8449,6 +8782,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 +8882,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8562,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: @@ -8702,6 +9094,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: @@ -12729,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: @@ -12887,6 +13502,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 +13600,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled diff --git a/messages/de.json b/messages/de.json index 008dd5f5f..3dcafb78a 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,41 @@ "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", + "quantityUnderOneDose": "< 1 Dosis", + "expiredSuffix": "+ {units} Einheiten abgelaufen" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "viewToggleHint": "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", + "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", + "customize": "Medikamente anpassen" }, "charts": { "loadingLabel": "Diagramm wird geladen…", @@ -3727,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 891a06406..a9ea8f561 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,41 @@ "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", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} units expired" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "viewToggleHint": "Choose whether your medications appear as cards or as a table.", + "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", + "customize": "Customize medications" }, "charts": { "loadingLabel": "Loading chart…", @@ -3727,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 06537b0b8..63018e39e 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,41 @@ "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", + "quantityUnderOneDose": "< 1 dosis", + "expiredSuffix": "+ {units} unidades caducadas" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "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", + "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", + "customize": "Personalizar medicamentos" }, "charts": { "loadingLabel": "Cargando la gráfica…", @@ -3727,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 445d9e1f3..9e24dfa09 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,41 @@ "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", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} unités périmées" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "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é", + "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é", + "customize": "Personnaliser les médicaments" }, "charts": { "loadingLabel": "Chargement du graphique…", @@ -3727,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 a71112349..692bf94fe 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,41 @@ "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", + "quantityUnderOneDose": "< 1 dose", + "expiredSuffix": "+ {units} unità scadute" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "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", + "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", + "customize": "Personalizza i farmaci" }, "charts": { "loadingLabel": "Caricamento del grafico…", @@ -3727,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 18ac910b5..03223cb51 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}", @@ -772,7 +773,7 @@ "complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu", "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", - "complianceDoses": "{count} dawek", + "complianceDoses": "Dawki: {count}", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", @@ -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,41 @@ "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": "Dawki: ≈ {doses}", + "quantityUnderOneDose": "< 1 dawka", + "expiredSuffix": "+ przeterminowane jednostki: {units}" }, "erweitert": { "group": { @@ -1697,7 +1716,30 @@ "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", + "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", + "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": "Dawki: {count}", + "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", + "customize": "Dostosuj leki" }, "charts": { "loadingLabel": "Ładowanie wykresu…", @@ -3727,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/package.json b/package.json index cbbc776ae..4361dcb1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.9", + "version": "1.16.10", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", 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/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 439ea78ad..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. @@ -1261,6 +1269,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 +1613,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 +1729,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/__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/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..30e6c77b7 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"; @@ -190,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: @@ -208,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`). @@ -224,6 +232,21 @@ 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) + // 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, 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..6f74d2026 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,12 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => id: "med-1", userId: "user-1", doseChanges: [], + inventoryItems: [], inventoryEvents: [], intakeEvents: [], schedules: [], dosesPerUnit: null, + unitsPerDose: 1, } as never); const res = await GET(getReq(), { params: Promise.resolve({ id: "med-1" }), @@ -428,3 +430,143 @@ 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 }, + ], + // Items exist, so the legacy ledger below must be IGNORED — a + // stale delta history cannot override the per-item truth. + inventoryEvents: [{ delta: 9 }], + } as never); + + const res = await GET(getReq(), { + 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 and no ledger rows 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: [], + inventoryEvents: [], + } 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(); + }); + + // W1 — the legacy delta ledger the POST below still accepts must stay + // readable: an account that only ever posted `{inventory: {delta}}` + // sees its pen count on GET instead of a silent null. + it("falls back to the legacy ledger when the user has zero inventory items", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: 4, + unitsPerDose: 1, + inventoryItems: [], + // Running sum: +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses. + inventoryEvents: [{ delta: 2 }, { delta: -1 }], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inventory: { + pensRemaining: number; + dosesRemaining: number; + weeksOfSupply: number; + lowStock: boolean; + }; + }; + }; + expect(body.data.inventory).toEqual({ + pensRemaining: 1, + dosesRemaining: 4, + weeksOfSupply: 4, + lowStock: false, + }); + }); + + it("ignores the ledger fallback when dosesPerUnit is not configured", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: null, + unitsPerDose: 1, + inventoryItems: [], + inventoryEvents: [{ delta: 2 }], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { inventory: unknown } }; + // Pre-item contract: without `dosesPerUnit` the ledger never + // produced an inventory block either. + expect(body.data.inventory).toBeNull(); + }); +}); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index a951fc643..1ede74ba7 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -54,6 +54,13 @@ export const GET = apiHandler( where: { id }, include: { doseChanges: { orderBy: { effectiveFrom: "asc" } }, + // v1.16.10 — the inventory block reads the per-item entities + // (the same rows the Bestand tab and the intake consumption + // hook move). The legacy MedicationInventoryEvent running-sum + // ledger stays as a READ fallback for accounts that only ever + // posted deltas: when zero items exist, the ledger-derived + // numbers surface instead of a silent null. + inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, @@ -69,25 +76,56 @@ 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) { + 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 = usable.length; + const dosesRemaining = Math.floor( + unitsRemaining / Math.max(1, medication.unitsPerDose), + ); + const weeksOfSupply = dosesRemaining; + const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; + inventory = { + pensRemaining, + dosesRemaining, + weeksOfSupply, + lowStock, + }; + } else if ( + medication.dosesPerUnit && + medication.inventoryEvents.length > 0 + ) { + // Read-side fallback for ledger-only accounts (pre-v1.16.10 the + // POST below was the only inventory writer). Items win whenever + // any exist — the ledger never overrides them and no item rows + // are fabricated from it. Math is the pre-item contract verbatim: + // running sum of deltas counts pens, `dosesPerUnit` maps pens to + // doses, weekly cadence approximates the supply horizon. const pens = medication.inventoryEvents.reduce( (sum, ev) => sum + ev.delta, 0, ); const pensRemaining = Math.max(0, pens); const dosesRemaining = pensRemaining * medication.dosesPerUnit; - // Approximate weeks-of-supply assuming weekly cadence (the - // canonical GLP-1 case). The Coach snapshot does the same. const weeksOfSupply = dosesRemaining; const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; inventory = { @@ -202,6 +240,14 @@ export const POST = apiHandler( // The schema's XOR refinement guarantees `inventory` is present // when `doseChange` is not, so the non-null assertion is safe. + // + // DEPRECATED branch (v1.16.10): this writes the legacy + // MedicationInventoryEvent running-sum ledger. The per-item + // endpoints (`POST /api/medications/[id]/inventory`, + // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; + // GET above and the Coach snapshot read the ledger only while the + // medication has zero inventory items. New callers must register + // containers instead of posting deltas. const { delta, reason } = parsed.data.inventory!; const created = await prisma.medicationInventoryEvent.create({ data: { medicationId: id, delta, reason }, diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index cc1223c6c..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 @@ -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,207 @@ 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("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" + ); + 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..c3c2a857e 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"; @@ -221,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 @@ -241,6 +253,29 @@ export const PUT = apiHandler( syncVersion: { increment: 1 }, }, }); + // 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) { + if (!wasTaken) { + 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 +283,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 +315,24 @@ export const PUT = apiHandler( doseTaken: event.doseTaken, }); updated = applied.row; + // 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, + medicationId: id, + eventId: applied.row.id, + intakeAt: nextTakenAt, + }); + } } await auditLog("medication.intake.update", { @@ -335,6 +397,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..992409555 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -2,8 +2,8 @@ * v1.4.43 W6 — multi-issue 422 envelope on PATCH * /api/medications/[id]/inventory/[itemId]. * - * v1.16.1 — stock-correction contract: `dosesRemaining` sets the count - * absolutely, clamps to `dosesTotal`, and the canonical state machine + * v1.16.1 — stock-correction contract: `unitsRemaining` sets the count + * absolutely, clamps to `unitsTotal`, and the canonical state machine * derives the next state (0 ⇒ USED_UP). */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -83,7 +83,7 @@ beforeEach(() => { userId: "user-1", firstUseAt: null, state: "ACTIVE", - dosesRemaining: 4, + unitsRemaining: 4, printedExpiry: null, } as never); }); @@ -129,7 +129,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1 }); }); -describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => { +describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => { beforeEach(() => { vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ id: "i1", @@ -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); @@ -151,32 +151,32 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc }); it("sets the remaining count absolutely", async () => { - const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { 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); + const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { 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 () => { - const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX); + it("clamps a raise above the item's capacity to unitsTotal", async () => { + const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { 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 () => { - const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX); + const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX); expect(res.status).toBe(422); }); }); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 684026b20..fe03720e3 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -6,9 +6,10 @@ * - mark-as-used-up (terminal — operator override when a pen is * physically discarded but the dose ledger hasn't reached zero) * - update printed expiry (e.g. carton label correction) - * - stock correction via `dosesRemaining` (v1.16.1 — the Bestand - * tab's adjust / withdraw flow; clamped to `dosesTotal`, state - * re-derived by the canonical state machine) + * - stock correction via `unitsRemaining` (v1.16.1 — the Bestand + * tab's adjust / withdraw flow; clamped to `unitsTotal`, state + * re-derived by the canonical state machine; v1.16.10 renamed + * the request field from `dosesRemaining` to match the response) * - update notes * * DELETE /api/medications/[id]/inventory/[itemId] @@ -74,7 +75,7 @@ export const PATCH = apiHandler( markAsFirstUseAt, markAsUsedUp, printedExpiry, - dosesRemaining, + unitsRemaining, notes, } = parsed.data; @@ -82,7 +83,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) { @@ -96,16 +97,17 @@ export const PATCH = apiHandler( nextPrintedExpiry = printedExpiry; } - if (dosesRemaining !== undefined) { + if (unitsRemaining !== 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(unitsRemaining, existing.unitsTotal); } if (markAsUsedUp === true) { - nextDosesRemaining = 0; + nextUnitsRemaining = 0; nextState = "USED_UP"; } @@ -119,13 +121,13 @@ export const PATCH = apiHandler( // pure and idempotent — running it once more with the composed view // collapses every edge case onto the same decision tree the intake // hook and the daily expire cron already share. USED_UP is terminal - // (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual + // (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual // override remains sticky. nextState = computeInventoryState( { state: nextState, - dosesTotal: existing.dosesTotal, - dosesRemaining: nextDosesRemaining, + unitsTotal: existing.unitsTotal, + unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, printedExpiry: nextPrintedExpiry, }, @@ -137,7 +139,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 +187,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..16617b3fd 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -124,19 +124,19 @@ describe("POST /api/medications/[id]/inventory", () => { vi.mocked(getSession).mockResolvedValue(null); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, }), { params: Promise.resolve({ id: "med-1" }) }, ); expect(res.status).toBe(401); }); - it("returns 422 when dosesTotal is missing or invalid", async () => { + it("returns 422 when unitsTotal is missing or invalid", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 0, + unitsTotal: 0, }), { params: Promise.resolve({ id: "med-1" }) }, ); @@ -154,7 +154,7 @@ describe("POST /api/medications/[id]/inventory", () => { const printed = "2027-06-01T00:00:00Z"; const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, printedExpiry: printed, }), { params: Promise.resolve({ id: "med-1" }) }, @@ -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), @@ -184,7 +185,7 @@ describe("POST /api/medications/[id]/inventory", () => { }); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: 4, + unitsTotal: 4, }), { params: Promise.resolve({ id: "med-1" }) }, ); @@ -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", @@ -356,7 +357,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: "string", + unitsTotal: "string", printedExpiry: "not-iso", }), { params: Promise.resolve({ id: "med-1" }) }, @@ -382,7 +383,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/inventory", { - dosesTotal: "string", + unitsTotal: "string", printedExpiry: "not-iso", purchasedAt: "also-not-iso", notes: 123, diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 41b3a5ebf..db17065b8 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -90,13 +90,18 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data; + const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } = + parsed.data; const created = await prisma.medicationInventoryItem.create({ + // The wire field `unitsTotal` counts UNITS (tablets / ampoules / + // puffs) — v1.16.10 renamed the request field to match the + // response side. data: buildCreateInventoryInput({ userId: user.id, medicationId: id, - dosesTotal, + unitsTotal, + containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, notes: notes ?? null, @@ -109,7 +114,8 @@ export const POST = apiHandler( details: { medicationId: id, itemId: created.id, - dosesTotal, + unitsTotal, + 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/__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/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..f35456c20 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,217 @@ 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("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([ + { + 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..b923db783 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,30 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } + // 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, + 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 +514,23 @@ 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: + // gate on the upsert's pending→taken transition flag. + if (applied.consumedTransition && 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 +561,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..002c91589 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, @@ -519,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({ @@ -540,11 +550,33 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { snoozedUntil: null }, }), ]); + // 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 // 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 +606,20 @@ 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). 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({ @@ -581,6 +627,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/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 2da559eaa..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, }; }); } @@ -245,6 +296,7 @@ export const POST = apiHandler(async (request: NextRequest) => { category, treatmentClass, dosesPerUnit, + unitsPerDose, deliveryForm, trackInjectionSites, allowedInjectionSites, @@ -312,6 +364,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..9d480846d 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,13 @@ 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; + // 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, @@ -285,6 +294,8 @@ async function markMedicationTaken( attributionSource: "AUTO", }); slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; + consumedTransition = applied.consumedTransition; } else { // Ad-hoc / PRN — standalone row under the documented contract // (`scheduledFor = takenAt`). @@ -300,13 +311,30 @@ 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, 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 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 }); @@ -494,8 +522,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 +539,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 +553,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/app/globals.css b/src/app/globals.css index f7f66e45e..739ebc1ca 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -216,25 +216,29 @@ /* * v1.14.0 — wellness-ring per-metric gradients (Dracula dark). * Five DISTINCT single-hue two-stop arcs (from = darker, to = lighter): - * readiness green · recovery teal · sleep indigo-blue · stress amber · - * strain violet. Vivid on the dark `--card`. Each `to` doubles as the - * ring's bloom colour. The Alucard light set lives on `:root.light`. + * readiness green · recovery cyan · sleep purple · stress orange · + * strain pink. Every `to` stop IS the matching Dracula palette colour + * and the `from` stop is a hand-darkened tone of the same hue, so the + * arcs read as Dracula rather than generic chart colours (the v1.14.0 + * set leaned indigo/teal/violet, which sat next to the palette instead + * of on it). Each `to` doubles as the ring's bloom colour. The Alucard + * light set lives on `:root.light`. */ - --ring-readiness-from: #1f9d57; - --ring-readiness-to: #5cf08f; + --ring-readiness-from: #1fa854; + --ring-readiness-to: #50fa7b; --tile-readiness: #50fa7b; - --ring-recovery-from: #0e9e94; - --ring-recovery-to: #4ff0d2; + --ring-recovery-from: #2fa8c4; + --ring-recovery-to: #8be9fd; --tile-recovery: #8be9fd; - --ring-sleep-from: #4f6ef0; - --ring-sleep-to: #8fb4ff; - --tile-sleep: #6f8bff; - --ring-stress-from: #e0922f; - --ring-stress-to: #ffce6b; + --ring-sleep-from: #8655d6; + --ring-sleep-to: #bd93f9; + --tile-sleep: #bd93f9; + --ring-stress-from: #d9842e; + --ring-stress-to: #ffb86c; --tile-stress: #ffb86c; - --ring-strain-from: #8f6bf0; - --ring-strain-to: #c4a8ff; - --tile-strain: #bd93f9; + --ring-strain-from: #d6429b; + --ring-strain-to: #ff79c6; + --tile-strain: #ff79c6; /* Band fallback (anatomy detail view, no per-metric hue). */ --ring-green-from: #1f9d57; --ring-green-to: #5cf08f; @@ -305,24 +309,24 @@ * v1.14.0 — wellness-ring per-metric gradients (Alucard light). DEEPER + * more saturated than the dark set so each hue holds AA-grade presence on * the white `--card` (raw Dracula cyan/green wash out on white). Same five - * distinct hues: readiness green · recovery teal · sleep royal-blue · - * stress amber · strain violet. + * Dracula hue families as the dark set: readiness green · recovery cyan · + * sleep purple · stress orange · strain pink. */ --ring-readiness-from: #0f7a3a; --ring-readiness-to: #18a34e; --tile-readiness: #14720a; - --ring-recovery-from: #07857c; - --ring-recovery-to: #0e9c8f; - --tile-recovery: #0a8f86; - --ring-sleep-from: #2d5fc4; - --ring-sleep-to: #4f7ae0; - --tile-sleep: #2d5fc4; + --ring-recovery-from: #067f96; + --ring-recovery-to: #0d95b0; + --tile-recovery: #08869e; + --ring-sleep-from: #5b2db8; + --ring-sleep-to: #7a45d6; + --tile-sleep: #6233c0; --ring-stress-from: #b06a16; --ring-stress-to: #c47a1e; --tile-stress: #a34d14; - --ring-strain-from: #6b4fd0; - --ring-strain-to: #8a6ff0; - --tile-strain: #644ac9; + --ring-strain-from: #ad1e6f; + --ring-strain-to: #cc2e88; + --tile-strain: #b0277d; --ring-green-from: #0f7a3a; --ring-green-to: #18a34e; --ring-yellow-from: #b06a16; 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..305737dd9 --- /dev/null +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -0,0 +1,168 @@ +/** + * 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, +})); + +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("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('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"'); + }); +}); + +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..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"; @@ -10,6 +11,11 @@ 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 { 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 { CheckCircle2, Loader2, Pill, Plus, Settings2 } 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[]; } @@ -134,6 +150,13 @@ export default function MedicationsPage() { // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = 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 order editor lives at /settings/medications. + const { layout, isLayoutLoading, setView } = + useMedicationListLayout(isAuthenticated); + useEffect(() => { if (shouldOpenFromUrl) { // Drop the query param so a refresh after closing the dialog @@ -211,18 +234,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 +263,76 @@ 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. 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. */} + + + {/* 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. -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} + {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. +
+ {tableView && !isLayoutLoading ? ( + + ) : ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )}
) : isError ? (
@@ -307,6 +365,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 */} 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/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index ad7eb5f22..f092b69ce 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section"; import { ExportSection } from "@/components/settings/export-section"; import { InsightsSection } from "@/components/settings/insights-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; +import { MedicationsSection } from "@/components/settings/medications-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; // v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two @@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record< notifications: NotificationsSection, dashboard: DashboardSection, insights: InsightsSection, + medications: MedicationsSection, thresholds: ThresholdsSection, sources: () => , api: ApiSection, 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/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/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts index e94d457c8..cbbbd48e2 100644 --- a/src/components/insights/derived/ring-hues.ts +++ b/src/components/insights/derived/ring-hues.ts @@ -4,9 +4,10 @@ * The premium signature is a SINGLE-hue, TWO-stop gradient per ring * (dark → light tones of the *same* colour), never a multi-hue rainbow. * Each metric leans toward one intentional, distinct hue (Oura's per-metric - * move): readiness green, recovery teal/turquoise, sleep indigo-blue, stress - * amber, strain violet — five clearly different hues so the strip never reads - * as "a wash of green/cyan". + * move): readiness green, recovery cyan, sleep purple, stress orange, + * strain pink — five clearly different hues so the strip never reads as + * "a wash of green/cyan". In the dark theme every `to` stop sits exactly + * on the matching Dracula palette colour. * * The actual colours live as CSS custom properties in `globals.css` * (`--ring--from` / `--ring--to` / `--tile-`) with a Dracula 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 ( <> + +
+ )} +
+ ); +} + +interface ReorderSectionProps { + heading: string; + ids: string[]; + byId: ReadonlyMap; + dragHintId: string; + disabled: boolean; + labels: { + moveUp: string; + moveDown: string; + dragHandle: string; + inactive: string; + }; + onChange: (ids: string[]) => void; + dataSlot: string; +} + +/** + * One group (Aktiv or Inaktiv) with its OWN DndContext + SortableContext: + * a drag started in one section cannot drop into the other, and the + * arrow buttons stop at the section bounds — the saved order keeps the + * active block first by construction. + */ +function ReorderSection({ + heading, + ids, + byId, + dragHintId, + disabled, + labels, + onChange, + dataSlot, +}: ReorderSectionProps) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const rows = ids + .map((id) => byId.get(id)) + .filter((m): m is ReorderMedication => m !== undefined); + + function applyReorder(fromId: string, toId: string) { + onChange( + reorderById( + ids.map((id, order) => ({ id, order })), + fromId, + toId, + ).map((r) => r.id), + ); + } + + function move(id: string, delta: -1 | 1) { + const next = moveWithinSection(ids, id, delta); + if (next.some((v, i) => v !== ids[i])) onChange(next); + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + applyReorder(String(active.id), String(over.id)); + } + + return ( +
+

{heading}

+ + + {rows.map((med, index) => ( + + ))} + + +
+ ); +} + +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..9cd4658fd --- /dev/null +++ b/src/components/medications/medication-table.tsx @@ -0,0 +1,686 @@ +"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, + /** Active UI locale for name collation; omitted = runtime default. */ + locale?: string, +): 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, locale, { 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, locale } = 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, + locale, + ); + const sortedInactive = sortMedicationRows( + inactiveMedications, + sort, + shortRateById, + locale, + ); + + 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/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 195763ce1..16114f651 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 — + * `unitsRemaining`) 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,18 +48,37 @@ 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"; import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { summariseSupply } from "@/lib/medications/inventory/summary"; 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 +93,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 +145,27 @@ export function InventorySection({ } const items = Array.isArray(data?.items) ? data.items : []; - // Active supply: doses 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); + // Available supply: ACTIVE / IN_USE containers with units left — the + // shared summary helper keeps this row, the Übersicht row, the list + // payload and the GLP-1 endpoint on one predicate. Expired stock is + // never available; it renders as a separate muted suffix. + // Dose-derived headline (floor — a partial dose is not a dose). + const { + unitsRemaining: remainingUnits, + unitsTotal: totalUnits, + dosesRemaining: remaining, + dosesTotal: total, + expiredUnits, + } = summariseSupply(items, perDose); const dialogs = ( <> {addOpen && ( setAddOpen(false)} /> )} @@ -155,6 +208,24 @@ export function InventorySection({

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

+ +
+ )} 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 }) + : // A unit count below one dose must not read "≈ 0 doses" — + // it is simply less than one dose. + Math.floor(parsed / unitsPerDose) === 0 + ? t("medications.detail.bestand.quantityUnderOneDose") + : 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,7 +541,8 @@ function AdjustInventoryDialog({ setBusy(true); try { await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, { - dosesRemaining: parsed, + // The wire field carries UNITS (v1.16.10 symmetric naming). + unitsRemaining: parsed, }); await queryClient.invalidateQueries({ queryKey: queryKeys.medicationInventory(medicationId), @@ -403,7 +580,7 @@ function AdjustInventoryDialog({ type="number" inputMode="numeric" min={0} - max={item.dosesTotal} + max={item.unitsTotal} step={1} required autoComplete="off" @@ -416,7 +593,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/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/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx new file mode 100644 index 000000000..901ed17ef --- /dev/null +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -0,0 +1,122 @@ +/** + * v1.16.10 — the "Medikamente" settings section (`/settings/medications`). + * + * Hosts the /medications list customisation following the dashboard / + * insights pattern: the cards ⇄ table view preference (the shared header + * toggle, optimistic PUT) plus the manual-order editor INLINE (two + * grouped sections, explicit Save). SSR smoke assertions, matching the + * rest of the settings suite — the editor's interactive contract is + * pinned in `medication-order-editor.test.tsx`, the PUT side in + * `use-medication-list-layout.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const MEDS = [ + { id: "a1", name: "Ramipril", dose: "5 mg", active: true }, + { id: "a2", name: "Aspirin", dose: "100 mg", active: true }, + { id: "i1", name: "Amoxicillin", dose: "500 mg", active: false }, +]; + +// Mutable holder so individual tests can inject a layout into the mocked +// `useQuery` without re-mocking the module. +const queryState: { layout: MedicationListLayout } = { + layout: { version: 1, view: "cards", order: [] }, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + if (Array.isArray(queryKey) && queryKey[0] === "medication-list-layout") { + return { data: queryState.layout, isLoading: false }; + } + if (Array.isArray(queryKey) && queryKey[0] === "medications") { + return { data: MEDS, isLoading: false }; + } + return { data: null, isLoading: false }; + }, + useQueryClient: () => ({ setQueryData: vi.fn(), getQueryData: vi.fn() }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/charts/reduced-motion", () => ({ + prefersReducedMotion: () => true, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MedicationsSection } from "../medications-section"; + +function render(locale: "en" | "de" = "en"): string { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + queryState.layout = { version: 1, view: "cards", order: [] }; +}); + +describe(" — SSR smoke", () => { + it("renders the section heading + description via i18n (no raw keys)", () => { + const html = render(); + expect(html).toContain("settings-section-medications-title"); + expect(html).toContain("List view and medication order."); + expect(html).not.toContain("settings.sections."); + expect(html).not.toContain("medications.view"); + expect(html).not.toContain("medications.reorder"); + }); + + it("renders the German copy end-to-end", () => { + const html = render("de"); + expect(html).toContain("Listenansicht und Reihenfolge der Medikamente."); + expect(html).toContain("Reihenfolge anpassen"); + expect(html).toContain( + "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", + ); + }); + + it("hosts the view toggle bound to the persisted layout (cards pressed)", () => { + const html = render(); + expect(html).toMatch( + /data-slot="medications-view-cards"[^>]*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("reflects a persisted table view on the toggle", () => { + queryState.layout = { version: 1, view: "table", order: [] }; + const html = render(); + expect(html).toMatch( + /data-slot="medications-view-table"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/, + ); + }); + + it("renders the order editor INLINE with active rows before inactive", () => { + const html = render(); + expect(html).toContain('data-slot="medication-order-editor"'); + expect(html).toContain('data-slot="medication-reorder-section-active"'); + expect(html).toContain('data-slot="medication-reorder-section-inactive"'); + const lastActive = Math.max( + html.indexOf("Ramipril"), + html.indexOf("Aspirin"), + ); + expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin")); + }); + + it("feeds the editor the saved order (active block re-sorted by layout.order)", () => { + // Alphabetical default would put Aspirin first; the saved order pins + // Ramipril to the top — the editor must open showing exactly what + // both /medications views render. + queryState.layout = { version: 1, view: "cards", order: ["a1", "a2"] }; + const html = render(); + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index dd2f00220..3a857c10a 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -50,12 +50,16 @@ describe("SETTINGS_SECTION_SLUGS", () => { // between `thresholds` (Targets) and `ai`. // v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the // overview-arrange + pill-sort customise surface for `/insights`. + // v1.16.10 — `medications` sits between `insights` and `thresholds`: + // the view-preference + manual-order customise surface for + // `/medications`, reached from the page header's Settings2 glyph. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", "notifications", "dashboard", "insights", + "medications", "thresholds", "sources", "ai", @@ -149,6 +153,10 @@ describe("", () => { expect(html).toContain("Notifications"); expect(html).toContain("Dashboard"); expect(html).toContain("Insights"); + // v1.16.10 — the /medications customise surface is its own nav entry + // between Insights and Targets. + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain("Medications"); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. expect(html).toContain("API & Tokens"); @@ -181,6 +189,9 @@ describe("", () => { // per-metric overrides moved out into their own "Persönliche Zielwerte" // section, which is the new entry below the Dashboard one. expect(html).toContain("Dashboard"); + // v1.16.10 — the /medications customise surface ("Medikamente"). + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain("Medikamente"); // v1.8.7.1 — Targets and Sources are two separate German nav // entries again: "Zielwerte" and "Quellen". expect(html).toContain("Zielwerte"); 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/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx new file mode 100644 index 000000000..7e4830cf8 --- /dev/null +++ b/src/components/settings/medications-section.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { LayoutGrid, Loader2, Pill } from "lucide-react"; + +import { + MedicationOrderEditor, + type ReorderMedication, +} from "@/components/medications/medication-order-editor"; +import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { applyMedicationOrder } from "@/lib/medications/medication-order"; +import { queryKeys } from "@/lib/query-keys"; +import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; + +/** + * v1.16.10 — the "Medikamente" settings section (analogous to the + * Dashboard and Insights sections). Hosts two blocks: + * 1. The list VIEW preference — cards vs table, written optimistically + * through the same `PUT /api/medications/layout` the page header + * toggle uses (the toggle component is shared). + * 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv) + * with drag + arrow reordering, flushed by an explicit Save, + * persisted as active-ids-then-inactive-ids on the same layout row. + * + * Both write through the same `/api/medications/layout` contract the + * /medications page reads, so a save here repaints both list views. + */ + +/** The slice of the medications list the order editor needs. */ +interface MedicationListEntry { + id: string; + name: string; + dose: string; + active: boolean; +} + +export function MedicationsSection() { + const { t } = useTranslations(); + const { layout, isLayoutLoading, setView } = useMedicationListLayout(); + + const { data: medications, isLoading } = useQuery({ + queryKey: queryKeys.medications(), + queryFn: async () => { + return apiGet("/api/medications"); + }, + }); + + // Defensive against stale service-worker responses or any future API + // shape change: only map when we actually have an array. The editor + // receives the page's current effective order (active block first, + // inactive after) so it opens showing exactly what both views render. + const medsArray = Array.isArray(medications) ? medications : []; + const ordered: ReorderMedication[] = [ + ...applyMedicationOrder( + medsArray.filter((m) => m.active), + layout.order, + ), + ...applyMedicationOrder( + medsArray.filter((m) => !m.active), + layout.order, + ), + ].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active })); + + return ( +
+
+

+ {t("settings.sections.medications.title")} +

+

+ {t("settings.sections.medications.description")} +

+
+ + {/* View preference — cards vs table. The shared header toggle + writes optimistically, so there is no Save button here. */} +
+
+ +

+ {t("medications.viewToggleLabel")} +

+
+
+

+ {t("medications.viewToggleHint")} +

+ {isLayoutLoading ? ( + + ) : ( + + )} +
+
+ + {/* Manual order — applies to both list views. */} +
+
+ +

+ {t("medications.reorderTitle")} +

+
+

+ {t("medications.reorderDescription")} +

+ {isLoading || isLayoutLoading ? ( +
+ + {t("common.loading")} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 1c72d8a01..e3c6bff7c 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -19,6 +19,7 @@ import { Layers, LayoutDashboard, Link2, + Pill, Settings2, SlidersHorizontal, Sparkles, @@ -37,6 +38,7 @@ const SLUG_ICON: Record = { notifications: Bell, dashboard: LayoutDashboard, insights: TrendingUp, + medications: Pill, thresholds: SlidersHorizontal, sources: Layers, ai: Sparkles, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index d476fa92d..9a92818fb 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -13,12 +13,17 @@ // `/api/auth/me/source-priority`) and reading them as one page made the // surface long; splitting them back keeps each concern self-contained. // Both slugs are served by the dynamic `[section]` route below. +// v1.16.10 — `medications` sits after `insights`: the customise surface +// for the /medications list (view preference + manual order), following +// the dashboard / insights pattern of a dedicated settings section +// reached from the page header's Settings2 glyph. export const SETTINGS_SECTION_SLUGS = [ "account", "integrations", "notifications", "dashboard", "insights", + "medications", "thresholds", "sources", "ai", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1ca71948b..bf979d8fc 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -25,6 +25,7 @@ import { Layers, LayoutDashboard, Link2, + Pill, Settings2, SlidersHorizontal, Sparkles, @@ -100,6 +101,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.insights.title", icon: TrendingUp, }, + { + slug: "medications", + titleKey: "settings.sections.medications.title", + icon: Pill, + }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", 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) => ( 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/__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/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index a15dfd158..0e507c45e 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -30,6 +30,8 @@ function fakeMedication(overrides: Partial> = {}) { note: null, }, ], + unitsPerDose: 1, + inventoryItems: [], inventoryEvents: [], intakeEvents: [], ...overrides, @@ -134,4 +136,68 @@ 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, + }); + }); + + // W1 — ledger-only accounts (the pre-item delta writer) keep their + // pen count in the Coach prompt instead of a silent omission. + it("falls back to the legacy ledger when the medication has zero items", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + dosesPerUnit: 4, + inventoryItems: [], + // +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses. + inventoryEvents: [{ delta: 2 }, { delta: -1 }], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1"); + expect(out?.medications[0].penInventory).toEqual({ + pensRemaining: 1, + dosesRemaining: 4, + weeksOfSupplyApprox: 4, + }); + }); + + it("ignores the ledger whenever items exist (items win)", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + dosesPerUnit: 4, + unitsPerDose: 1, + inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }], + // Stale delta history must not override the per-item truth. + inventoryEvents: [{ delta: 9 }], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1"); + expect(out?.medications[0].penInventory).toEqual({ + pensRemaining: 1, + dosesRemaining: 4, + weeksOfSupplyApprox: 4, + }); + }); + + it("omits pen inventory when neither items nor ledger rows exist", async () => { + prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]); + const out = await buildGlp1SnapshotBlock("user-1"); + expect(out?.medications[0].penInventory).toBeNull(); + }); }); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 917995eae..8d324d04d 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -266,6 +266,12 @@ export async function buildGlp1SnapshotBlock( include: { schedules: true, doseChanges: { orderBy: { effectiveFrom: "asc" } }, + // v1.16.10 — pen inventory reads the per-item entities (the rows + // the intake consumption hook moves). The legacy event ledger + // stays as a READ fallback for ledger-only accounts: when a + // medication has zero items, the running-sum numbers surface so + // the Coach still knows the pen count. + inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, @@ -368,22 +374,43 @@ 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). + // `pensRemaining` counts usable containers; `dosesRemaining` pools + // the units and divides by `unitsPerDose` so the Coach can answer + // "how long until my next refill?". Ledger-only accounts (the + // pre-item delta writer) fall back to the running sum — items win + // whenever any exist. 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); + const dosesPerWeek = + schedule?.cadence === "weekly" + ? 1 + : schedule?.cadence === "daily" + ? 7 + : 1; + 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), + ); + weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek); + } else if (med.dosesPerUnit && med.inventoryEvents.length > 0) { + // Legacy-ledger fallback — the pre-item contract verbatim: the + // delta sum counts pens, `dosesPerUnit` maps pens to doses. + const pens = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0); + pensRemaining = Math.max(0, pens); dosesRemaining = pensRemaining * med.dosesPerUnit; - const dosesPerWeek = - schedule?.cadence === "weekly" - ? 1 - : schedule?.cadence === "daily" - ? 7 - : 1; weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek); } 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/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, }; } 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/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__/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/__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/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..d61163d4a --- /dev/null +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -0,0 +1,767 @@ +/** + * 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, + ), + // 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 }; + 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 }, + ]); + }); +}); + +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/__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/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts new file mode 100644 index 000000000..480fee16b --- /dev/null +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -0,0 +1,115 @@ +/** + * v1.16.10 — shared supply-summary math (`summariseSupply`). + * + * Pins the release-QA disagreement scenario: a single EXPIRED pen with + * units left must read as ZERO available supply on every surface + * (Übersicht supply row, Bestand summary, list payload, GLP-1 endpoint) + * while the expired units stay visible separately. Both detail-page + * surfaces render from this helper, so the predicate cannot drift from + * the list / GLP-1 one (ACTIVE / IN_USE with units only). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + isAvailableSupply, + summariseSupply, +} from "@/lib/medications/inventory/summary"; + +describe("summariseSupply — expired stock is visible but never available", () => { + it("one expired pen with units ⇒ 0 available, expired units surfaced separately", () => { + const out = summariseSupply( + [{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }], + 1, + ); + expect(out).toEqual({ + unitsRemaining: 0, + unitsTotal: 0, + dosesRemaining: 0, + dosesTotal: 0, + expiredUnits: 4, + }); + }); + + it("pools only ACTIVE / IN_USE containers with units left", () => { + const out = summariseSupply( + [ + { 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 }, + ], + 2, + ); + // 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses. + expect(out).toEqual({ + unitsRemaining: 7, + unitsTotal: 8, + dosesRemaining: 3, + dosesTotal: 4, + expiredUnits: 4, + }); + }); + + it("a drained (used-up) container counts nowhere", () => { + const out = summariseSupply( + [{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }], + 1, + ); + expect(out).toEqual({ + unitsRemaining: 0, + unitsTotal: 0, + dosesRemaining: 0, + dosesTotal: 0, + expiredUnits: 0, + }); + }); + + it("matches the list-route availability predicate per state", () => { + expect( + isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }), + ).toBe(true); + expect( + isAvailableSupply({ state: "IN_USE", unitsTotal: 4, unitsRemaining: 1 }), + ).toBe(true); + expect( + isAvailableSupply({ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }), + ).toBe(false); + expect( + isAvailableSupply({ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }), + ).toBe(false); + expect( + isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 0 }), + ).toBe(false); + }); +}); + +describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => { + const read = (rel: string) => + readFileSync(join(process.cwd(), rel), "utf8"); + + it("the Übersicht supply row sums through summariseSupply and feeds the runway with the available-only figure", () => { + const src = read( + "src/components/medications/detail/MedicationDetailTabs.tsx", + ); + expect(src).toContain('from "@/lib/medications/inventory/summary"'); + expect(src).toMatch(/summariseSupply\(inventoryItems, perDose\)/); + expect(src).toMatch( + /estimateRunwayDays\(dosesRemaining, medication\.schedules\)/, + ); + // No hand-rolled "everything but USED_UP" filter remains. + expect(src).not.toContain('state !== "USED_UP"'); + expect(src).toContain("medications.detail.bestand.expiredSuffix"); + }); + + it("the Bestand summary sums through summariseSupply and surfaces the expired suffix", () => { + const src = read( + "src/components/medications/sections/inventory-section.tsx", + ); + expect(src).toContain('from "@/lib/medications/inventory/summary"'); + expect(src).toMatch(/summariseSupply\(items, perDose\)/); + expect(src).not.toContain('state !== "USED_UP"'); + expect(src).toContain("medications.detail.bestand.expiredSuffix"); + }); +}); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts new file mode 100644 index 000000000..8ba4e8d57 --- /dev/null +++ b/src/lib/medications/inventory/consumption.ts @@ -0,0 +1,391 @@ +/** + * 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) => { + // 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 (claimed.count === 0) 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: eventId }, + 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 || 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) { + // Empty / malformed stamp — the claim already cleared it so a + // later re-take can consume afresh; nothing to refund. + 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); + } + + 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/medications/inventory/summary.ts b/src/lib/medications/inventory/summary.ts new file mode 100644 index 000000000..3c473a763 --- /dev/null +++ b/src/lib/medications/inventory/summary.ts @@ -0,0 +1,67 @@ +/** + * v1.16.10 — shared supply-summary math for every stock readout. + * + * One predicate decides what counts as AVAILABLE supply: ACTIVE or + * IN_USE containers with units left — the same filter the medications + * list route (`stockUnitsRemaining`), the GLP-1 detail endpoint and the + * Coach snapshot run. EXPIRED stock is visible but never available: + * it surfaces separately (`expiredUnits`) so the UI can show a muted + * "expired" suffix without folding it into the headline or the runway + * estimate. USED_UP containers count nowhere. + * + * The Übersicht supply row and the Bestand summary both render from + * this helper, so the surfaces cannot disagree about what "remaining" + * means. + */ + +export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + +export interface SupplyItem { + state: SupplyItemState; + unitsTotal: number; + unitsRemaining: number; +} + +export interface SupplySummary { + /** Pooled units across available (ACTIVE / IN_USE, units left) containers. */ + unitsRemaining: number; + /** Pooled capacity across the same available containers. */ + unitsTotal: number; + /** Dose-derived headline: floor(unitsRemaining / unitsPerDose). */ + dosesRemaining: number; + /** Dose-derived capacity: floor(unitsTotal / unitsPerDose). */ + dosesTotal: number; + /** Units still sitting in EXPIRED containers — visible, never available. */ + expiredUnits: number; +} + +/** The list-route / GLP-1 availability predicate, verbatim. */ +export function isAvailableSupply(item: SupplyItem): boolean { + return ( + (item.state === "ACTIVE" || item.state === "IN_USE") && + item.unitsRemaining > 0 + ); +} + +export function summariseSupply( + items: readonly SupplyItem[], + unitsPerDose: number, +): SupplySummary { + const perDose = Math.max(1, unitsPerDose); + const available = items.filter(isAvailableSupply); + const unitsRemaining = available.reduce( + (sum, item) => sum + item.unitsRemaining, + 0, + ); + const unitsTotal = available.reduce((sum, item) => sum + item.unitsTotal, 0); + const expiredUnits = items + .filter((item) => item.state === "EXPIRED") + .reduce((sum, item) => sum + item.unitsRemaining, 0); + return { + unitsRemaining, + unitsTotal, + dosesRemaining: Math.floor(unitsRemaining / perDose), + dosesTotal: Math.floor(unitsTotal / perDose), + expiredUnits, + }; +} 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 2bec317a1..9622960dd 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -11,10 +11,18 @@ 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"; +import { + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, +} from "@/lib/medication-list-layout"; import { scheduleRevisionCreateSchema, scheduleRevisionUpdateSchema, @@ -139,6 +147,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 @@ -219,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 @@ -236,6 +264,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(), @@ -613,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: { @@ -660,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"], @@ -792,6 +970,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 `unitsTotal` 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 (`unitsRemaining`, 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/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) => 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" 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..b5258cf1d --- /dev/null +++ b/src/lib/validations/__tests__/medication-inventory.test.ts @@ -0,0 +1,167 @@ +/** + * 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), the new + * `unitsPerDose` (1–100) and `containerType` fields, and the symmetric + * request field names (`unitsTotal` / `unitsRemaining` — the response + * always carried them). 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({ unitsTotal: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001 units", () => { + const r = createInventoryItemSchema.safeParse({ unitsTotal: 1001 }); + expect(r.success).toBe(false); + }); + + it("rejects zero", () => { + const r = createInventoryItemSchema.safeParse({ unitsTotal: 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({ + unitsTotal: 4, + containerType: ct, + }); + expect(r.success).toBe(true); + } + const r = createInventoryItemSchema.safeParse({ unitsTotal: 4 }); + expect(r.success).toBe(true); + expect(r.success && r.data.containerType).toBeUndefined(); + }); + + it("rejects an unknown container type", () => { + const r = createInventoryItemSchema.safeParse({ + unitsTotal: 4, + containerType: "BAG", + }); + expect(r.success).toBe(false); + }); +}); + +describe("updateInventoryItemSchema — stock correction cap 1000", () => { + it("accepts 1000", () => { + const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001", () => { + const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 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..6b9b93759 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,105 @@ 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) and renames the wire field to + * `unitsTotal` — it counts units, mapped to doses via + * `Medication.unitsPerDose`. */ + unitsTotal: z + .number() + .int() + .min(1) + .max(1000) + .describe( + "Units the container ships with (tablets / ampoules / puffs; 1–1000). 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). `unitsTotal` 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 and + * renames the wire field to `unitsRemaining` (it always counted + * units), matching the response side. + */ + unitsRemaining: 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 +948,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; @@ -884,6 +974,14 @@ export const glp1DoseChangePostSchema = z.object({ note: z.string().max(MAX_NOTE_CHARS).nullable().optional(), }); +/** + * DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds + * the legacy `MedicationInventoryEvent` running-sum ledger. The per-item + * endpoints (`POST /api/medications/[id]/inventory`, + * `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads + * fall back to the ledger only while a medication has zero inventory + * items. New callers must register containers instead of posting deltas. + */ export const glp1InventoryPostSchema = z.object({ delta: z .number() @@ -891,7 +989,10 @@ export const glp1InventoryPostSchema = z.object({ .finite() .min(-100) .max(100) - .refine((n) => n !== 0, { message: "delta must be non-zero" }), + .refine((n) => n !== 0, { message: "delta must be non-zero" }) + .describe( + "Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.", + ), reason: z.string().min(1).max(MAX_REASON_CHARS), });