mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #317 from MBombeck/release/v1.16.10
Release v1.16.10 — the stock follows every dose, and the list takes your order
This commit is contained in:
110 files changed
+9484
-1159
No files matched your search
@@ -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
|
||||
|
||||
+630
-7
@@ -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
|
||||
|
||||
+58
-12
@@ -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",
|
||||
|
||||
+58
-12
@@ -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",
|
||||
|
||||
+58
-12
@@ -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",
|
||||
|
||||
+58
-12
@@ -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",
|
||||
|
||||
+58
-12
@@ -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",
|
||||
|
||||
+59
-13
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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": ["<medicationId>", ...] }
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB;
|
||||
+50
-5
@@ -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": ["<medicationId>", ...] }
|
||||
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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<ReturnType<typeof consumeOneDose>> = 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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
});
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Response>;
|
||||
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callDelete = DELETE as unknown as () => Promise<Response>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<MedicationListLayout> {
|
||||
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<MedicationListLayout>,
|
||||
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);
|
||||
});
|
||||
@@ -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<string, ResolvedSlotMark[]>();
|
||||
@@ -152,6 +181,15 @@ async function buildMedicationsList(
|
||||
),
|
||||
);
|
||||
|
||||
const usableUnitsByMedId = new Map<string, number>();
|
||||
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<string, string> = {};
|
||||
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
|
||||
|
||||
@@ -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:<medId>' answers the callback without creating an intake event", async () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+30
-26
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>
|
||||
<MedicationsPage />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<table");
|
||||
expect(html).toContain('data-slot="card"');
|
||||
// Toggle present, group named, the cards segment pressed.
|
||||
expect(html).toContain('aria-label="View"');
|
||||
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("renders the table for view=table", () => {
|
||||
const html = renderPage({ version: 1, view: "table", order: [] });
|
||||
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-table"[^>]*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"));
|
||||
});
|
||||
});
|
||||
+112
-46
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
@@ -233,48 +263,76 @@ export default function MedicationsPage() {
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 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. */}
|
||||
<MedicationViewToggle view={layout.view} onChange={setView} />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Settings2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* 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. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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.
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={t("medications.title")}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={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.
|
||||
<div role="status" aria-busy="true" aria-label={t("medications.title")}>
|
||||
{tableView && !isLayoutLoading ? (
|
||||
<MedicationTableSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
|
||||
@@ -307,6 +365,14 @@ export default function MedicationsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : 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.
|
||||
<MedicationTable
|
||||
activeMedications={activeMeds}
|
||||
inactiveMedications={inactiveMeds}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active medications */}
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
|
||||
@@ -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: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
|
||||
@@ -494,6 +494,77 @@ describe("<DashboardHero> — 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("<DashboardHero> — typographic hierarchy", () => {
|
||||
it("the greeting leads: larger + heavier than the verdict line", () => {
|
||||
const html = render(baseSnapshot());
|
||||
const greeting = html.match(
|
||||
/<p[^>]*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(
|
||||
/<p[^>]*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("<DashboardHero> — source pins", () => {
|
||||
|
||||
@@ -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 `<ScoreRing>` 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({
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* 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. */}
|
||||
<p
|
||||
data-slot="dashboard-hero-greeting"
|
||||
className="text-muted-foreground text-sm"
|
||||
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
{welcomeText}
|
||||
</p>
|
||||
@@ -216,7 +245,7 @@ export function DashboardHero({
|
||||
<p
|
||||
data-slot="dashboard-hero-verdict"
|
||||
data-verdict-variant={verdict.variant}
|
||||
className="text-foreground text-base font-medium"
|
||||
className="text-foreground/90 text-base font-medium"
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -58,8 +58,9 @@ describe("<LayoutCoachFab>", () => {
|
||||
// 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)]",
|
||||
);
|
||||
|
||||
@@ -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 `<TooltipBoundingBox>` 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<HTMLElement>();
|
||||
|
||||
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<HTMLElement>(
|
||||
'[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;
|
||||
}
|
||||
@@ -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-<key>-from` / `--ring-<key>-to` / `--tile-<key>`) with a Dracula
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(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 (
|
||||
<>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset on mobile; plain bottom offset once the
|
||||
// bottom-nav hides (`md:hidden` on the nav, so `md:` here —
|
||||
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
|
||||
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
|
||||
// The right inset mirrors the desktop bottom inset so the
|
||||
// corner gap is even.
|
||||
"fixed right-6 z-40 size-12 rounded-full shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6",
|
||||
// Dark glyph on the purple/pink gradient — white sat at
|
||||
// ≈2.3:1 against the gradient midpoint; the background token
|
||||
@@ -180,7 +167,6 @@ export function LayoutCoachFab() {
|
||||
// The default ring alone is hard to see against the gradient;
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
// Fade out while a Recharts tooltip is open (see header note).
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
@@ -197,7 +183,6 @@ export function LayoutCoachFab() {
|
||||
"[body:has([data-testid=onboarding-tour])_&]:pointer-events-none",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:opacity-0",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:invisible",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-5" aria-hidden="true" />
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import {
|
||||
getChartTooltipActive,
|
||||
getChartTooltipActiveServer,
|
||||
subscribeChartTooltipActive,
|
||||
} from "./chart-tooltip-observer";
|
||||
|
||||
/**
|
||||
* v1.4.33 (F15) — read the live "is a Recharts tooltip painted right
|
||||
* now" boolean. Backed by a singleton `MutationObserver` that watches
|
||||
* every `.recharts-tooltip-wrapper` on the page for inline-style
|
||||
* `visibility` changes; see `chart-tooltip-observer.ts` for the
|
||||
* full contract.
|
||||
*
|
||||
* Returns `false` on the server so the FAB renders fully on the SSR
|
||||
* pass; the client takes over after hydration.
|
||||
*/
|
||||
export function useChartTooltipActive(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeChartTooltipActive,
|
||||
getChartTooltipActive,
|
||||
getChartTooltipActiveServer,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* v1.16.10 — `<AddInventoryDialog>` register-flow coverage.
|
||||
*
|
||||
* Pins the multi-unit quantity input: the segmented Dosen | Einheiten
|
||||
* control only surfaces when a dose spans more than one unit, the live
|
||||
* conversion line renders next to the quantity, the POST always carries
|
||||
* UNITS, and the container-type select defaults from the delivery form.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`,
|
||||
* no `@testing-library/react`) plus source-string structural assertions
|
||||
* for the interactive plumbing an SSR mount can't exercise.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The Radix Dialog / Select portal at runtime, so their bodies never
|
||||
// materialise in static markup. Collapse the primitives to plain
|
||||
// wrappers (same trick as the LogInjectionSiteDialog suite).
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: Pass,
|
||||
DialogDescription: Pass,
|
||||
DialogFooter: Pass,
|
||||
DialogHeader: Pass,
|
||||
DialogTitle: Pass,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Select: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children?: React.ReactNode;
|
||||
}) => <div data-mock-select-value={value}>{children}</div>,
|
||||
SelectContent: Pass,
|
||||
SelectItem: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-select-item">{children}</div>
|
||||
),
|
||||
SelectTrigger: Pass,
|
||||
SelectValue: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { AddInventoryDialog } = await import(
|
||||
"../sections/inventory-section"
|
||||
);
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const src = readFileSync(
|
||||
resolve(__dirname, "../sections/inventory-section.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("<AddInventoryDialog>", () => {
|
||||
it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => {
|
||||
const multi = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={4}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(multi).toContain('data-slot="inventory-quantity-mode"');
|
||||
expect(multi).toContain(">Dosen<");
|
||||
expect(multi).toContain(">Einheiten<");
|
||||
|
||||
const single = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={1}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(single).not.toContain('data-slot="inventory-quantity-mode"');
|
||||
});
|
||||
|
||||
it("renders the live conversion next to the prefilled quantity (units mode → dose equivalent)", () => {
|
||||
// Prefill 28 units at 4 units per dose → "≈ 7 Dosen".
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={4}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="inventory-quantity-conversion"');
|
||||
expect(html).toContain("≈ 7 Dosen");
|
||||
});
|
||||
|
||||
it("defaults the container type from the caller (delivery-form mapping upstream)", () => {
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={null}
|
||||
unitsPerDose={1}
|
||||
initialContainerType="PEN"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-mock-select-value="PEN"');
|
||||
// All six container kinds are offered.
|
||||
for (const label of [
|
||||
"Pen",
|
||||
"Ampulle",
|
||||
"Tablettenpackung",
|
||||
"Inhalator",
|
||||
"Flasche",
|
||||
"Sonstiges",
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("always submits UNITS: dose-mode input multiplies by unitsPerDose before the POST", () => {
|
||||
// Interactive mode-switching is out of SSR reach; the structural
|
||||
// contract lives in the source: the submitted value is the converted
|
||||
// unit count on the v1.16.10 symmetric wire field `unitsTotal`.
|
||||
expect(src).toMatch(
|
||||
/const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/,
|
||||
);
|
||||
expect(src).toMatch(/unitsTotal: units,/);
|
||||
expect(src).toMatch(/containerType,/);
|
||||
});
|
||||
|
||||
it("renders '< 1 Dosis' instead of '≈ 0 Dosen' for a sub-dose unit count", () => {
|
||||
// 3 units at 4 units per dose is less than one dose — the
|
||||
// conversion line must say so rather than rounding to zero.
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={3}
|
||||
unitsPerDose={4}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("< 1 Dosis");
|
||||
expect(html).not.toContain("≈ 0 Dosen");
|
||||
});
|
||||
|
||||
it("bounds the quantity to 1000 units (dose mode scales the max down)", () => {
|
||||
expect(src).toContain("units >= 1 && units <= 1000");
|
||||
expect(src).toContain('Math.floor(1000 / unitsPerDose)');
|
||||
});
|
||||
|
||||
it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => {
|
||||
expect(src).toMatch(
|
||||
/if \(deliveryForm === "INJECTION"\) return "PEN";\s*\n\s*if \(deliveryForm === "ORAL"\) return "BLISTER";\s*\n\s*return "OTHER";/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* v1.16.10 — Bestand summary availability semantics.
|
||||
*
|
||||
* Renders `<InventorySection>` with a seeded query cache and pins the
|
||||
* release-QA scenario: a single EXPIRED pen with units left shows ZERO
|
||||
* available doses in the headline, with the expired units as a muted
|
||||
* suffix — matching the list payload and the GLP-1 endpoint instead of
|
||||
* counting expired stock as supply.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { InventorySection } = await import("../sections/inventory-section");
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function seedItems(items: unknown[]) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { items, meta: { total: items.length } },
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("<InventorySection> — summary availability", () => {
|
||||
it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => {
|
||||
seedItems([
|
||||
{
|
||||
id: "i1",
|
||||
state: "EXPIRED",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("0 von 0 Dosen übrig");
|
||||
expect(html).toContain('data-slot="inventory-expired-suffix"');
|
||||
expect(html).toContain("+ 4 Einheiten abgelaufen");
|
||||
// The item row itself stays visible with its state badge.
|
||||
expect(html).toContain("Abgelaufen");
|
||||
});
|
||||
|
||||
it("usable stock counts; expired suffix absent when nothing expired", () => {
|
||||
seedItems([
|
||||
{
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
},
|
||||
{
|
||||
id: "i2",
|
||||
state: "USED_UP",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 0,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("4 von 4 Dosen übrig");
|
||||
expect(html).not.toContain('data-slot="inventory-expired-suffix"');
|
||||
});
|
||||
|
||||
it("mixed usable + expired: headline counts only the usable pool", () => {
|
||||
seedItems([
|
||||
{
|
||||
id: "i1",
|
||||
state: "IN_USE",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 2,
|
||||
},
|
||||
{
|
||||
id: "i2",
|
||||
state: "EXPIRED",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 3,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("2 von 4 Dosen übrig");
|
||||
expect(html).toContain("+ 3 Einheiten abgelaufen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.16.10 — the inline manual-order editor mirrors the page's grouping.
|
||||
*
|
||||
* Both /medications views pin inactive medications after the active
|
||||
* block, so the editor drafts TWO sections (Aktiv / Inaktiv) with a
|
||||
* muted heading each: drag and arrows work within a section, and the
|
||||
* saved order is active ids first, inactive ids after — by
|
||||
* construction, not by validation. The editor renders inline in the
|
||||
* Medikamente settings section (`/settings/medications`); it started
|
||||
* life as a dialog on the /medications page and kept its contract when
|
||||
* it moved.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`)
|
||||
* plus pure-helper tests for the interactive plumbing an SSR mount
|
||||
* can't exercise, plus source-string structural assertions.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/charts/reduced-motion", () => ({
|
||||
prefersReducedMotion: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-medication-list-layout", () => ({
|
||||
runSaveMedicationListOrder: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { MedicationOrderEditor, buildSavedOrder, moveWithinSection } =
|
||||
await import("../medication-order-editor");
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const MEDS = [
|
||||
{ id: "a1", name: "Ramipril", dose: "5 mg", active: true },
|
||||
{ id: "a2", name: "Mounjaro", dose: "7.5 mg", active: true },
|
||||
{ id: "i1", name: "Amoxicillin", dose: "500 mg", active: false },
|
||||
{ id: "i2", name: "Ibuprofen", dose: "400 mg", active: false },
|
||||
];
|
||||
|
||||
describe("moveWithinSection — moves stay inside the group", () => {
|
||||
it("swaps neighbours within the section (the arrow / drag contract)", () => {
|
||||
expect(moveWithinSection(["a1", "a2"], "a2", -1)).toEqual(["a2", "a1"]);
|
||||
expect(moveWithinSection(["i1", "i2"], "i1", 1)).toEqual(["i2", "i1"]);
|
||||
});
|
||||
|
||||
it("cannot move past either section edge — the group boundary is unreachable", () => {
|
||||
expect(moveWithinSection(["a1", "a2"], "a1", -1)).toEqual(["a1", "a2"]);
|
||||
expect(moveWithinSection(["a1", "a2"], "a2", 1)).toEqual(["a1", "a2"]);
|
||||
// The first inactive row moving "up" stays where it is — it cannot
|
||||
// climb into the active block.
|
||||
expect(moveWithinSection(["i1", "i2"], "i1", -1)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSavedOrder — active ids always precede inactive ids", () => {
|
||||
it("composes active block first regardless of within-group churn", () => {
|
||||
expect(buildSavedOrder(["a2", "a1"], ["i2", "i1"])).toEqual([
|
||||
"a2",
|
||||
"a1",
|
||||
"i2",
|
||||
"i1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("an inactive med can never land above an active one in the SAVED order", () => {
|
||||
// Drive the editor's own move primitive as far up as it allows and
|
||||
// compose the save payload exactly like save() does.
|
||||
let inactive = ["i1", "i2"];
|
||||
inactive = moveWithinSection(inactive, "i2", -1); // i2 to the top of its group
|
||||
inactive = moveWithinSection(inactive, "i2", -1); // edge — no-op
|
||||
const saved = buildSavedOrder(["a1", "a2"], inactive);
|
||||
expect(saved).toEqual(["a1", "a2", "i2", "i1"]);
|
||||
const firstInactiveIdx = Math.min(saved.indexOf("i1"), saved.indexOf("i2"));
|
||||
const lastActiveIdx = Math.max(saved.indexOf("a1"), saved.indexOf("a2"));
|
||||
expect(firstInactiveIdx).toBeGreaterThan(lastActiveIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationOrderEditor> — two-section rendering", () => {
|
||||
it("renders an Aktiv and an Inaktiv section with the rows grouped", () => {
|
||||
const html = render(<MedicationOrderEditor medications={MEDS} />);
|
||||
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"');
|
||||
// Muted group headings.
|
||||
expect(html).toContain(">Aktiv</p>");
|
||||
expect(html).toContain(">Inaktiv</p>");
|
||||
// Every active row renders before every inactive row.
|
||||
const lastActive = Math.max(
|
||||
html.indexOf("Ramipril"),
|
||||
html.indexOf("Mounjaro"),
|
||||
);
|
||||
const firstInactive = Math.min(
|
||||
html.indexOf("Amoxicillin"),
|
||||
html.indexOf("Ibuprofen"),
|
||||
);
|
||||
expect(lastActive).toBeLessThan(firstInactive);
|
||||
});
|
||||
|
||||
it("omits the Inaktiv section when every medication is active", () => {
|
||||
const html = render(
|
||||
<MedicationOrderEditor medications={MEDS.filter((m) => m.active)} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="medication-reorder-section-active"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="medication-reorder-section-inactive"',
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the Save / Cancel pair until a draft exists (clean editor = no footer)", () => {
|
||||
const html = render(<MedicationOrderEditor medications={MEDS} />);
|
||||
expect(html).not.toContain(">Speichern<");
|
||||
expect(html).not.toContain(">Abbrechen<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication-order-editor — structural guards (source)", () => {
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/medications/medication-order-editor.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("saves through buildSavedOrder (active ids, then inactive ids)", () => {
|
||||
expect(src).toContain("buildSavedOrder(activeIds, inactiveIds)");
|
||||
});
|
||||
|
||||
it("each section owns its own DndContext, so a drag cannot cross groups", () => {
|
||||
// One <DndContext> inside the per-section component, none at the
|
||||
// editor level spanning both lists.
|
||||
const sectionBody = src.slice(src.indexOf("function ReorderSection"));
|
||||
expect(sectionBody).toContain("<DndContext");
|
||||
expect(src.slice(0, src.indexOf("function ReorderSection"))).not.toContain(
|
||||
"<DndContext",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* v1.16.10 — the compact /medications table view.
|
||||
*
|
||||
* Pins:
|
||||
* - real `<table>` semantics with `scope="col"` headers and a sticky
|
||||
* first column (the responsive horizontal-scroll pattern);
|
||||
* - every column renders from the SAME payloads the cards consume
|
||||
* (the list row's `nextDueAt` / `stockDosesRemaining`, the batched
|
||||
* compliance-summary cache entry the cards share);
|
||||
* - the action buttons are per-row labelled ("Take – {name}") and the
|
||||
* row rides the cards' shared intake hook (source-level guard);
|
||||
* - tri-state sorting semantics incl. `aria-sort` and null-last
|
||||
* ordering, with `null` sort = the manual order the page passed in;
|
||||
* - inactive rows pin after the active block, muted, without actions.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MedicationTable,
|
||||
MedicationTableSkeleton,
|
||||
nextSortState,
|
||||
sortMedicationRows,
|
||||
type TableMedication,
|
||||
} from "@/components/medications/medication-table";
|
||||
|
||||
function makeClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Seed the SAME batched summary key the cards read — the table's
|
||||
// compliance column must come from this shared cache entry.
|
||||
function seedCompliance(
|
||||
client: QueryClient,
|
||||
medId: string,
|
||||
rate7: number,
|
||||
rate30: number,
|
||||
) {
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: rate7, streak: 0 },
|
||||
compliance30: { rate: rate30 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// A schedule whose window has already passed for today (01:00–02:00) so
|
||||
// the row shows the next-intake value rather than the take-now pill,
|
||||
// regardless of host clock.
|
||||
const pastWindow = {
|
||||
windowStart: "01:00",
|
||||
windowEnd: "02:00",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
dose: null,
|
||||
};
|
||||
|
||||
function med(partial: Partial<TableMedication> & { id: string; name: string }): TableMedication {
|
||||
return {
|
||||
dose: "5 mg",
|
||||
active: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
stockDosesRemaining: null,
|
||||
schedules: [{ id: `s-${partial.id}`, ...pastWindow }],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T12:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function tomorrowNoonIso(): string {
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + 1);
|
||||
due.setHours(12, 0, 0, 0);
|
||||
return due.toISOString();
|
||||
}
|
||||
|
||||
describe("<MedicationTable> — structure + shared payloads", () => {
|
||||
it("renders real table semantics: scope=col headers, sticky first column, sr caption", () => {
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("<table");
|
||||
expect((html.match(/scope="col"/g) ?? []).length).toBe(6);
|
||||
// All six column headers, localised.
|
||||
for (const label of [
|
||||
"Name",
|
||||
"Status",
|
||||
"Next dose",
|
||||
"Adherence",
|
||||
"Stock",
|
||||
"Actions",
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
// Sticky first column for the horizontal-scroll mobile pattern.
|
||||
expect(html).toContain("sticky left-0");
|
||||
// Screen-reader caption names the table.
|
||||
expect(html).toContain("Medications as a table");
|
||||
});
|
||||
|
||||
it("renders one row per medication from the list payload: name, dose, next due, stock", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({
|
||||
id: "m1",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
nextDueAt: tomorrowNoonIso(),
|
||||
stockDosesRemaining: 12,
|
||||
}),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Ramipril");
|
||||
expect(html).toContain("5 mg");
|
||||
// Server-computed next due — the same `nextDueAt` the cards render.
|
||||
expect(html).toContain("Tomorrow,");
|
||||
// Dose-derived stock from the list payload.
|
||||
expect(html).toContain("12 doses");
|
||||
});
|
||||
|
||||
it("renders the compliance column from the cards' shared batched summary cache", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 92, 87);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("92%");
|
||||
expect(html).toContain("87%");
|
||||
// The cadence-window labels (7-/30-day defaults).
|
||||
expect(html).toContain("7 d");
|
||||
expect(html).toContain("30 d");
|
||||
});
|
||||
|
||||
it("labels the action buttons per row and keeps the 44px tap floor", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="Take – Ramipril"');
|
||||
expect(html).toContain('aria-label="Skip – Ramipril"');
|
||||
// 44-px mobile floor on the action buttons (shrinks at sm).
|
||||
expect(html).toContain("size-11 sm:size-9");
|
||||
});
|
||||
|
||||
it("hides stock for an untracked medication and tints a low stock", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
seedCompliance(client, "m2", 90, 88);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({ id: "m1", name: "Aspirin", stockDosesRemaining: null }),
|
||||
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 2 }),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("2 doses");
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("pins inactive rows after the active block, muted and without actions", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[med({ id: "m1", name: "Zyrtec" })]}
|
||||
inactiveMedications={[
|
||||
med({ id: "m2", name: "Amoxicillin", active: false }),
|
||||
]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
// Active row first despite alphabetical order saying otherwise.
|
||||
expect(html.indexOf("Zyrtec")).toBeLessThan(html.indexOf("Amoxicillin"));
|
||||
expect(html).toContain("opacity-60");
|
||||
expect(html).toContain("Inactive");
|
||||
// No take/skip affordance on the inactive row.
|
||||
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
|
||||
});
|
||||
|
||||
it("renders a GLP-1 medication like any other row", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "g1", 100, 100);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({
|
||||
id: "g1",
|
||||
name: "Mounjaro",
|
||||
treatmentClass: "GLP1",
|
||||
nextDueAt: tomorrowNoonIso(),
|
||||
stockDosesRemaining: 4,
|
||||
}),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Mounjaro");
|
||||
expect(html).toContain('aria-label="Take – Mounjaro"');
|
||||
expect(html).toContain("4 doses");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationTable> — manual order + aria-sort", () => {
|
||||
const rows = [
|
||||
med({ id: "m1", name: "Ramipril", nextDueAt: tomorrowNoonIso() }),
|
||||
med({ id: "m2", name: "Aspirin", nextDueAt: null }),
|
||||
];
|
||||
|
||||
it("renders rows in the order the page passed in when no sort is active (manual order)", () => {
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationTable activeMedications={rows} inactiveMedications={[]} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// The page already applied the manual order; with sort=null the
|
||||
// table must not re-sort (Ramipril stays before Aspirin).
|
||||
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
|
||||
// Every sortable header is unsorted.
|
||||
expect((html.match(/aria-sort="none"/g) ?? []).length).toBe(4);
|
||||
});
|
||||
|
||||
it("sorts by name ascending with the matching aria-sort", () => {
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={rows}
|
||||
inactiveMedications={[]}
|
||||
initialSort={{ column: "name", direction: "asc" }}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril"));
|
||||
expect(html).toContain('aria-sort="ascending"');
|
||||
});
|
||||
|
||||
it("sorts by name descending with the matching aria-sort", () => {
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={rows}
|
||||
inactiveMedications={[]}
|
||||
initialSort={{ column: "name", direction: "desc" }}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
|
||||
expect(html).toContain('aria-sort="descending"');
|
||||
});
|
||||
|
||||
it("names the sort affordance on every sortable header", () => {
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationTable activeMedications={rows} inactiveMedications={[]} />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain('aria-label="Sort by Name"');
|
||||
expect(html).toContain('aria-label="Sort by Next dose"');
|
||||
expect(html).toContain('aria-label="Sort by Adherence"');
|
||||
expect(html).toContain('aria-label="Sort by Stock"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextSortState — tri-state cycle", () => {
|
||||
it("cycles none → asc → desc → none per column", () => {
|
||||
const asc = nextSortState(null, "name");
|
||||
expect(asc).toEqual({ column: "name", direction: "asc" });
|
||||
const desc = nextSortState(asc, "name");
|
||||
expect(desc).toEqual({ column: "name", direction: "desc" });
|
||||
expect(nextSortState(desc, "name")).toBeNull();
|
||||
});
|
||||
|
||||
it("switching column resets to ascending", () => {
|
||||
expect(
|
||||
nextSortState({ column: "name", direction: "desc" }, "nextDue"),
|
||||
).toEqual({ column: "nextDue", direction: "asc" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortMedicationRows — null-last semantics", () => {
|
||||
const a = med({ id: "a", name: "Alpha", nextDueAt: null, stockDosesRemaining: null });
|
||||
const b = med({
|
||||
id: "b",
|
||||
name: "Beta",
|
||||
nextDueAt: "2026-06-03T10:00:00Z",
|
||||
stockDosesRemaining: 2,
|
||||
});
|
||||
const c = med({
|
||||
id: "c",
|
||||
name: "Gamma",
|
||||
nextDueAt: "2026-06-02T18:00:00Z",
|
||||
stockDosesRemaining: 9,
|
||||
});
|
||||
|
||||
it("returns the input order untouched for a null sort", () => {
|
||||
expect(
|
||||
sortMedicationRows([b, a, c], null).map((m) => m.id),
|
||||
).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("sorts by next due ascending, rows without a due date last", () => {
|
||||
expect(
|
||||
sortMedicationRows([a, b, c], {
|
||||
column: "nextDue",
|
||||
direction: "asc",
|
||||
}).map((m) => m.id),
|
||||
).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("keeps rows without a due date last in descending order too", () => {
|
||||
expect(
|
||||
sortMedicationRows([a, b, c], {
|
||||
column: "nextDue",
|
||||
direction: "desc",
|
||||
}).map((m) => m.id),
|
||||
).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("sorts by compliance via the shared summary rates, unknown rows last", () => {
|
||||
const rates = new Map([
|
||||
["b", 50],
|
||||
["c", 95],
|
||||
]);
|
||||
expect(
|
||||
sortMedicationRows(
|
||||
[a, b, c],
|
||||
{ column: "compliance", direction: "desc" },
|
||||
rates,
|
||||
).map((m) => m.id),
|
||||
).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts by stock, untracked medications last", () => {
|
||||
expect(
|
||||
sortMedicationRows([a, b, c], {
|
||||
column: "stock",
|
||||
direction: "asc",
|
||||
}).map((m) => m.id),
|
||||
).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("collates names by the active locale, not a hardcoded one", () => {
|
||||
// Swedish sorts Ö after Z; German folds Ö next to O. The same two
|
||||
// rows must order differently under the two locales.
|
||||
const oe = med({ id: "oe", name: "Övriga" });
|
||||
const z = med({ id: "z", name: "Zyrtec" });
|
||||
const sort = { column: "name", direction: "asc" } as const;
|
||||
expect(
|
||||
sortMedicationRows([z, oe], sort, undefined, "sv").map((m) => m.id),
|
||||
).toEqual(["z", "oe"]);
|
||||
expect(
|
||||
sortMedicationRows([z, oe], sort, undefined, "de").map((m) => m.id),
|
||||
).toEqual(["oe", "z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationTableSkeleton> — reserved footprint", () => {
|
||||
it("mirrors the loaded table shell (container, table, action slots) and hides from AT", () => {
|
||||
const html = renderToStaticMarkup(<MedicationTableSkeleton />);
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toContain("size-11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication-table — shared mutation/status sources (source guard)", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/medications/medication-table.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("rides the cards' shared intake hook instead of its own POST", () => {
|
||||
expect(source).toContain(
|
||||
'from "@/components/medications/use-medication-intake"',
|
||||
);
|
||||
expect(source).not.toContain("/intake");
|
||||
});
|
||||
|
||||
it("reads compliance through the cards' batched summary hook", () => {
|
||||
expect(source).toContain("useMedicationComplianceSummary");
|
||||
});
|
||||
|
||||
it("gates the status pill on the server next-due exactly like the cards", () => {
|
||||
expect(source).toContain("reduceCurrentWindowStatus");
|
||||
expect(source).toContain("nextDue:");
|
||||
expect(source).toContain("resolveDisplayedSlotInstant");
|
||||
});
|
||||
});
|
||||
@@ -106,3 +106,27 @@ describe("estimateRunwayDays", () => {
|
||||
).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => {
|
||||
it("runs off the dose-derived count: floor(units / unitsPerDose) before dividing by daily consumption", () => {
|
||||
// 9 units at 2 units per dose → floor(9 / 2) = 4 doses; a daily
|
||||
// single-dose schedule then reads 4 days of runway. The call sites
|
||||
// (Übersicht supply row, Bestand summary) derive the dose count this
|
||||
// way before handing it to the estimator.
|
||||
const unitsRemaining = 9;
|
||||
const unitsPerDose = 2;
|
||||
const dosesRemaining = Math.floor(unitsRemaining / unitsPerDose);
|
||||
expect(
|
||||
estimateRunwayDays(dosesRemaining, [schedule({ timesOfDay: ["08:00"] })]),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("a partial dose's worth of units never counts as a day", () => {
|
||||
// 1 unit at 2 units per dose is not a dose → no runway.
|
||||
expect(
|
||||
estimateRunwayDays(Math.floor(1 / 2), [
|
||||
schedule({ timesOfDay: ["08:00"] }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,13 @@ interface MedicationStatusPillProps {
|
||||
* local days since that take.
|
||||
*/
|
||||
takenEarlyDaysAgo?: number | null;
|
||||
/**
|
||||
* v1.16.10 — compact variant for the table view's Status column:
|
||||
* suppresses the trailing window-range span so the cell stays one
|
||||
* short phrase. Tier logic, colours and glyphs are byte-identical
|
||||
* with the card pill — only the suffix is dropped.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +41,7 @@ export function MedicationStatusPill({
|
||||
windowStart,
|
||||
windowEnd,
|
||||
takenEarlyDaysAgo = null,
|
||||
compact = false,
|
||||
}: MedicationStatusPillProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
@@ -46,10 +54,12 @@ export function MedicationStatusPill({
|
||||
? t("medications.lastDoseYesterday")
|
||||
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -85,10 +95,12 @@ export function MedicationStatusPill({
|
||||
? t("medications.overdue")
|
||||
: t("medications.veryOverdue")}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import { PhaseConfigSheet } from "@/components/medications/sections/phase-config
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -125,6 +126,8 @@ export interface MedicationDetailSnapshot {
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.16.10 — inventory units one dose consumes (default 1). */
|
||||
unitsPerDose?: number | null;
|
||||
trackInjectionSites?: boolean;
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
@@ -179,6 +182,7 @@ function snapshotToWizardPayload(
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
unitsPerDose: med.unitsPerDose ?? 1,
|
||||
trackInjectionSites: med.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: med.allowedInjectionSites ?? [],
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
@@ -314,8 +318,8 @@ export function MedicationDetailTabs({
|
||||
return await apiGet<{
|
||||
items: Array<{
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
}>;
|
||||
} | null>(`/api/medications/${id}/inventory`);
|
||||
} catch {
|
||||
@@ -327,12 +331,21 @@ export function MedicationDetailTabs({
|
||||
});
|
||||
|
||||
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
|
||||
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
|
||||
const dosesRemaining = liveItems.reduce(
|
||||
(sum, i) => sum + i.dosesRemaining,
|
||||
0,
|
||||
);
|
||||
const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0);
|
||||
// v1.16.10 — items count UNITS; the supply readout and the runway are
|
||||
// dose-derived (floor over the pooled units — consumption spills
|
||||
// across containers, so the pool is the honest dose count). The raw
|
||||
// unit tally renders as secondary text when a dose spans > 1 unit.
|
||||
// Availability rides the shared summary helper (ACTIVE / IN_USE with
|
||||
// units only — the list / GLP-1 semantic); expired stock surfaces as
|
||||
// a separate muted suffix and never feeds the runway.
|
||||
const perDose = Math.max(1, medication.unitsPerDose ?? 1);
|
||||
const {
|
||||
unitsRemaining,
|
||||
unitsTotal,
|
||||
dosesRemaining,
|
||||
dosesTotal,
|
||||
expiredUnits,
|
||||
} = summariseSupply(inventoryItems, perDose);
|
||||
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
|
||||
|
||||
return (
|
||||
@@ -422,6 +435,24 @@ export function MedicationDetailTabs({
|
||||
remaining: dosesRemaining,
|
||||
total: dosesTotal,
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" ("}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: unitsRemaining,
|
||||
total: unitsTotal,
|
||||
})}
|
||||
{")"}
|
||||
</span>
|
||||
)}
|
||||
{expiredUnits > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.expiredSuffix", {
|
||||
units: expiredUnits,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{runwayDays !== null && (
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
@@ -562,6 +593,8 @@ export function MedicationDetailTabs({
|
||||
<InventorySection
|
||||
medicationId={id}
|
||||
dosesPerUnit={medication.dosesPerUnit}
|
||||
unitsPerDose={medication.unitsPerDose}
|
||||
deliveryForm={medication.deliveryForm}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowDown, ArrowUp, GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
|
||||
|
||||
/**
|
||||
* v1.16.10 — manual medication ordering, shared by BOTH /medications
|
||||
* views (cards + table). Reuses the dashboard / insights reorder
|
||||
* vocabulary: a vertical row list with a drag handle (@dnd-kit, pointer
|
||||
* + keyboard sensors) AND per-row up/down arrow buttons as the primary
|
||||
* keyboard surface, flushed by an explicit Save. The swap-and-renumber
|
||||
* logic is the shared `reorderById` helper the inline Insights edit
|
||||
* mode rides (the standalone extraction of the dashboard's
|
||||
* `reorderWidgets` contract), so the surfaces cannot drift.
|
||||
*
|
||||
* Hosted inline by the Medikamente settings section
|
||||
* (`/settings/medications`), exactly like the dashboard layout editor
|
||||
* lives under `/settings/dashboard` — the editor used to be a dialog on
|
||||
* the /medications page; the page header now links here instead.
|
||||
*
|
||||
* The editor mirrors the page's grouping: an Aktiv section and an
|
||||
* Inaktiv section (muted heading), each with its own drag context and
|
||||
* arrow bounds, so a row can only move WITHIN its group — both views
|
||||
* pin inactive medications after the active block, and an order that
|
||||
* crossed the boundary could never render. Save persists the id order
|
||||
* (active ids first, then inactive ids) through
|
||||
* `PUT /api/medications/layout` (order-only, the stored view is
|
||||
* preserved server-side). The Save / Cancel pair only appears once a
|
||||
* draft exists, following the dashboard layout section's draft state
|
||||
* machine.
|
||||
*/
|
||||
|
||||
export interface ReorderMedication {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface MedicationOrderEditorProps {
|
||||
/** The full list in its current effective order (active block first). */
|
||||
medications: ReorderMedication[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-order composition: active ids always precede inactive ids,
|
||||
* matching the render grouping of both list views. Exported so tests
|
||||
* can pin that an inactive id cannot land above an active one.
|
||||
*/
|
||||
export function buildSavedOrder(
|
||||
activeIds: readonly string[],
|
||||
inactiveIds: readonly string[],
|
||||
): string[] {
|
||||
return [...activeIds, ...inactiveIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-button move, bounded to the section: swapping past either end
|
||||
* returns the input untouched, so the boundary between the groups is
|
||||
* unreachable by construction.
|
||||
*/
|
||||
export function moveWithinSection(
|
||||
ids: readonly string[],
|
||||
id: string,
|
||||
delta: -1 | 1,
|
||||
): string[] {
|
||||
const idx = ids.indexOf(id);
|
||||
const targetIdx = idx + delta;
|
||||
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return [...ids];
|
||||
return reorderById(
|
||||
ids.map((rowId, order) => ({ id: rowId, order })),
|
||||
id,
|
||||
ids[targetIdx],
|
||||
).map((r) => r.id);
|
||||
}
|
||||
|
||||
export function MedicationOrderEditor({
|
||||
medications,
|
||||
}: MedicationOrderEditorProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const dragHintId = useId();
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Per-section draft id order — null means "the order the host passed
|
||||
// in". Created on the first move so an untouched editor renders the
|
||||
// persisted order without churn; Cancel simply clears the drafts.
|
||||
const [draftActive, setDraftActive] = useState<string[] | null>(null);
|
||||
const [draftInactive, setDraftInactive] = useState<string[] | null>(null);
|
||||
|
||||
const byId = new Map(medications.map((m) => [m.id, m]));
|
||||
const activeIds =
|
||||
draftActive ?? medications.filter((m) => m.active).map((m) => m.id);
|
||||
const inactiveIds =
|
||||
draftInactive ?? medications.filter((m) => !m.active).map((m) => m.id);
|
||||
|
||||
const labels = {
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
moveDown: t("dashboard.moveDown"),
|
||||
dragHandle: t("dashboard.dragHandle"),
|
||||
inactive: t("common.inactive"),
|
||||
};
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const ok = await runSaveMedicationListOrder({
|
||||
order: buildSavedOrder(activeIds, inactiveIds),
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
setSaving(false);
|
||||
if (ok) {
|
||||
setDraftActive(null);
|
||||
setDraftInactive(null);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraftActive(null);
|
||||
setDraftInactive(null);
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draftActive !== null || draftInactive !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="medication-order-editor">
|
||||
{medications.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
{activeIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.active")}
|
||||
ids={activeIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftActive}
|
||||
dataSlot="medication-reorder-section-active"
|
||||
/>
|
||||
)}
|
||||
{inactiveIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.inactive")}
|
||||
ids={inactiveIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftInactive}
|
||||
dataSlot="medication-reorder-section-inactive"
|
||||
/>
|
||||
)}
|
||||
{activeIds.length + inactiveIds.length > 0 && (
|
||||
<p id={dragHintId} className="text-muted-foreground sr-only">
|
||||
{t("dashboard.dragHandleHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving}>
|
||||
{saving && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReorderSectionProps {
|
||||
heading: string;
|
||||
ids: string[];
|
||||
byId: ReadonlyMap<string, ReorderMedication>;
|
||||
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 (
|
||||
<div className="space-y-2" data-slot={dataSlot}>
|
||||
<p className="text-muted-foreground text-xs font-medium">{heading}</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{rows.map((med, index) => (
|
||||
<SortableMedicationRow
|
||||
key={med.id}
|
||||
medication={med}
|
||||
index={index}
|
||||
total={rows.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={disabled}
|
||||
labels={labels}
|
||||
onMove={move}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="medication-reorder-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${medication.name}`}
|
||||
aria-describedby={dragHintId}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="medication-drag-handle"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 text-sm">
|
||||
<span className="block truncate font-medium" title={medication.name}>
|
||||
{medication.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{medication.dose}
|
||||
{!medication.active && <> · {labels.inactive}</>}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(medication.id, -1)}
|
||||
disabled={index === 0 || disabled}
|
||||
aria-label={`${labels.moveUp} — ${medication.name}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(medication.id, 1)}
|
||||
disabled={index === total - 1 || disabled}
|
||||
aria-label={`${labels.moveDown} — ${medication.name}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<Table>` 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<T extends TableMedication>(
|
||||
rows: readonly T[],
|
||||
sort: MedicationTableSort | null,
|
||||
shortRateById?: ReadonlyMap<string, number>,
|
||||
/** 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<MedicationTableSort | null>(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<string, number>(
|
||||
(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 (
|
||||
<div className="bg-card border-border rounded-xl border">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("medications.tableCaption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) =>
|
||||
col.sortable ? (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
scope="col"
|
||||
aria-sort={ariaSort(col.key as MedicationTableSortColumn)}
|
||||
className={col.className}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSort((current) =>
|
||||
nextSortState(
|
||||
current,
|
||||
col.key as MedicationTableSortColumn,
|
||||
),
|
||||
)
|
||||
}
|
||||
aria-label={t("medications.tableSortBy", {
|
||||
column: col.label,
|
||||
})}
|
||||
className="hover:text-foreground focus-visible:ring-ring inline-flex min-h-8 items-center gap-1 rounded font-medium focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{col.label}
|
||||
{sort?.column === col.key ? (
|
||||
sort.direction === "asc" ? (
|
||||
<ArrowUp className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
className="text-muted-foreground/60 size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
) : (
|
||||
<TableHead key={col.key} scope="col" className={col.className}>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedActive.map((med) => (
|
||||
<MedicationTableRowItem
|
||||
key={med.id}
|
||||
medication={med}
|
||||
userTz={userTz}
|
||||
lateMinutes={lateMinutes}
|
||||
missedMinutes={missedMinutes}
|
||||
/>
|
||||
))}
|
||||
{sortedInactive.map((med) => (
|
||||
<MedicationTableRowItem
|
||||
key={med.id}
|
||||
medication={med}
|
||||
userTz={userTz}
|
||||
lateMinutes={lateMinutes}
|
||||
missedMinutes={missedMinutes}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<MedicationStatusPill
|
||||
compact
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.window!.start}
|
||||
windowEnd={currentWindowStatus.window!.end}
|
||||
takenEarlyDaysAgo={currentWindowStatus.takenEarlyDaysAgo}
|
||||
/>
|
||||
) : overdueLabel ? (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{overdueLabel}
|
||||
</span>
|
||||
) : currentWindowStatus.status ? (
|
||||
<MedicationStatusPill
|
||||
compact
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.window!.start}
|
||||
windowEnd={currentWindowStatus.window!.end}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">–</span>
|
||||
);
|
||||
|
||||
// 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 = (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
);
|
||||
if (medication.active) {
|
||||
if (medication.nextDueOverdue && nextAt) {
|
||||
nextCell = (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("medications.nextIntakeOverdue", {
|
||||
time: formatTime(new Date(nextAt).toISOString()),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
} else if (currentWindowStatus.status === "in_window") {
|
||||
nextCell = (
|
||||
<span>
|
||||
{formatTimeWindowRange(
|
||||
currentWindowStatus.window!.start,
|
||||
currentWindowStatus.window!.end,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} 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 = (
|
||||
<span>
|
||||
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
|
||||
</span>
|
||||
);
|
||||
} else if (nextSchedule) {
|
||||
nextCell = (
|
||||
<span>
|
||||
{formatTimeWindowRange(
|
||||
nextSchedule.windowStart,
|
||||
nextSchedule.windowEnd,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Therapietreue cell — the same two cadence-scaled windows the card
|
||||
// bars show, compacted to label + mini bar + percentage per row.
|
||||
const complianceCell = compliance ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{(
|
||||
[
|
||||
[shortDays, rateShort],
|
||||
[longDays, rateLong],
|
||||
] as const
|
||||
).map(([days, rate]) => (
|
||||
<div key={days} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground w-8 shrink-0 tabular-nums">
|
||||
{t("medications.tableComplianceDays", { days })}
|
||||
</span>
|
||||
<Progress
|
||||
value={rate}
|
||||
className="h-1.5 w-14"
|
||||
aria-label={t("medications.complianceWindow", { days })}
|
||||
/>
|
||||
<span className="w-9 text-right font-medium tabular-nums">
|
||||
{fmt.number(Math.round(rate))}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1" aria-hidden="true">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const stock = medication.stockDosesRemaining;
|
||||
const stockCell =
|
||||
stock === null || stock === undefined ? (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
stock === 0
|
||||
? "text-destructive font-medium"
|
||||
: stock < LOW_STOCK_DOSES
|
||||
? "text-warning font-medium"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
{stock === 1
|
||||
? t("medications.tableStockDoseOne")
|
||||
: t("medications.tableStockDoses", { count: stock })}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow className={cn(!medication.active && "opacity-60")}>
|
||||
{/* Sticky name column — opaque background so scrolled content
|
||||
passes underneath, not through. */}
|
||||
<TableCell className="bg-card sticky left-0 z-10">
|
||||
<Link
|
||||
href={`/medications/${medication.id}`}
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
className="focus-visible:ring-ring block min-w-0 rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-foreground block max-w-40 truncate text-sm font-medium sm:max-w-56">
|
||||
{medication.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{medication.dose}
|
||||
{!medication.active && <> · {t("common.inactive")}</>}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{statusCell}</TableCell>
|
||||
<TableCell className="text-sm">{nextCell}</TableCell>
|
||||
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => recordIntake(false, displayedSlot)}
|
||||
disabled={!!intakeLoading}
|
||||
aria-label={t("medications.takeActionFor", {
|
||||
name: medication.name,
|
||||
})}
|
||||
>
|
||||
{intakeLoading === "take" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => recordIntake(true, displayedSlot)}
|
||||
disabled={!!intakeLoading}
|
||||
aria-label={t("medications.skipActionFor", {
|
||||
name: medication.name,
|
||||
})}
|
||||
>
|
||||
{intakeLoading === "skip" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<SkipForward className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="bg-card border-border rounded-xl border" aria-hidden="true">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1.5">
|
||||
<Skeleton className="size-11 rounded-md sm:size-9" />
|
||||
<Skeleton className="size-11 rounded-md sm:size-9" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("medications.viewToggleLabel")}
|
||||
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
|
||||
>
|
||||
{segments.map(({ value, label, icon: Icon }) => {
|
||||
const active = view === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!active) onChange(value);
|
||||
}}
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-slot={`medications-view-${value}`}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
|
||||
active
|
||||
? "bg-secondary text-secondary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<InventoryState, "secondary" | "outline" | "destructive
|
||||
USED_UP: "outline",
|
||||
};
|
||||
|
||||
/** Default container kind for the register flow, from the delivery form. */
|
||||
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
|
||||
if (deliveryForm === "INJECTION") return "PEN";
|
||||
if (deliveryForm === "ORAL") return "BLISTER";
|
||||
return "OTHER";
|
||||
}
|
||||
|
||||
export function InventorySection({
|
||||
medicationId,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
deliveryForm,
|
||||
}: {
|
||||
medicationId: string;
|
||||
/** Prefills the register flow's quantity when the medication tracks it. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** Units one dose consumes; dose-derived readouts divide by it. */
|
||||
unitsPerDose?: number | null;
|
||||
/** Defaults the register flow's container type. */
|
||||
deliveryForm?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
const perDose = Math.max(1, unitsPerDose ?? 1);
|
||||
|
||||
const { data, isLoading } = useQuery<InventoryResponse>({
|
||||
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 && (
|
||||
<AddInventoryDialog
|
||||
medicationId={medicationId}
|
||||
defaultDosesTotal={dosesPerUnit ?? null}
|
||||
defaultUnitsTotal={dosesPerUnit ?? null}
|
||||
unitsPerDose={perDose}
|
||||
initialContainerType={defaultContainerType(deliveryForm)}
|
||||
onClose={() => setAddOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -155,6 +208,24 @@ export function InventorySection({
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.bestand.summary", { remaining, total })}
|
||||
{perDose > 1 && (
|
||||
<span className="text-muted-foreground block text-xs font-normal">
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: remainingUnits,
|
||||
total: totalUnits,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{expiredUnits > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground block text-xs font-normal"
|
||||
data-slot="inventory-expired-suffix"
|
||||
>
|
||||
{t("medications.detail.bestand.expiredSuffix", {
|
||||
units: expiredUnits,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -180,10 +251,26 @@ export function InventorySection({
|
||||
data-slot="inventory-item-row"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: item.dosesRemaining,
|
||||
total: item.dosesTotal,
|
||||
})}
|
||||
<span className="block">
|
||||
{t(
|
||||
`medications.detail.bestand.containerType.${item.containerType}`,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: Math.floor(item.unitsRemaining / perDose),
|
||||
total: Math.floor(item.unitsTotal / perDose),
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
<>
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: item.unitsRemaining,
|
||||
total: item.unitsTotal,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
@@ -209,32 +296,47 @@ export function InventorySection({
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new pen / pack. Two fields (quantity + optional printed
|
||||
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
|
||||
* the medication's `dosesPerUnit` so the common case is one tap.
|
||||
* Register a new pack / container. Quantity (units), container type and
|
||||
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
|
||||
* prefills from the medication's `dosesPerUnit` so the common case is
|
||||
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
|
||||
* Dosen | Einheiten control converts live — the POST always carries
|
||||
* units.
|
||||
*/
|
||||
function AddInventoryDialog({
|
||||
export function AddInventoryDialog({
|
||||
medicationId,
|
||||
defaultDosesTotal,
|
||||
defaultUnitsTotal,
|
||||
unitsPerDose,
|
||||
initialContainerType,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string;
|
||||
defaultDosesTotal: number | null;
|
||||
defaultUnitsTotal: number | null;
|
||||
unitsPerDose: number;
|
||||
initialContainerType: ContainerType;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dosesTotal, setDosesTotal] = useState(
|
||||
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
|
||||
? String(defaultDosesTotal)
|
||||
const [quantity, setQuantity] = useState(
|
||||
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
|
||||
? String(defaultUnitsTotal)
|
||||
: "",
|
||||
);
|
||||
// The unit the typed quantity is read in. Only surfaced when a dose
|
||||
// spans several units; the stored value is ALWAYS units.
|
||||
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
|
||||
const [containerType, setContainerType] =
|
||||
useState<ContainerType>(initialContainerType);
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(dosesTotal);
|
||||
const parsed = Number(quantity);
|
||||
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
|
||||
const units =
|
||||
effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
|
||||
const quantityValid =
|
||||
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
|
||||
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -242,7 +344,9 @@ function AddInventoryDialog({
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost(`/api/medications/${medicationId}/inventory`, {
|
||||
dosesTotal: parsed,
|
||||
// The wire field carries UNITS (v1.16.10 symmetric naming).
|
||||
unitsTotal: units,
|
||||
containerType,
|
||||
printedExpiry: expiry
|
||||
? new Date(`${expiry}T00:00:00`).toISOString()
|
||||
: null,
|
||||
@@ -269,6 +373,33 @@ function AddInventoryDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-container-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.containerTypeLabel")}
|
||||
</label>
|
||||
<Select
|
||||
value={containerType}
|
||||
onValueChange={(v) => setContainerType(v as ContainerType)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inventory-add-container-type"
|
||||
className="w-full"
|
||||
data-slot="inventory-container-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTAINER_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`medications.detail.bestand.containerType.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
@@ -276,19 +407,64 @@ function AddInventoryDialog({
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
{unitsPerDose > 1 && (
|
||||
<div
|
||||
className="border-border/60 inline-flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
||||
data-slot="inventory-quantity-mode"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "doses"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("doses")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeDoses")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "units"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("units")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeUnits")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={dosesTotal}
|
||||
onChange={(e) => 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 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="inventory-quantity-conversion"
|
||||
>
|
||||
{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),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
@@ -336,7 +512,7 @@ function AddInventoryDialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock correction for one item: sets the remaining-dose count
|
||||
* Stock correction for one item: sets the remaining-unit count
|
||||
* absolutely (covers both "I miscounted" and a manual withdrawal). The
|
||||
* server clamps to the item's capacity and re-derives the state; 0
|
||||
* marks the item used up.
|
||||
@@ -352,12 +528,12 @@ function AdjustInventoryDialog({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState(String(item.dosesRemaining));
|
||||
const [value, setValue] = useState(String(item.unitsRemaining));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(value);
|
||||
const valid =
|
||||
Number.isInteger(parsed) && parsed >= 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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-units-per-dose" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-units-per-dose"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={payload.unitsPerDose}
|
||||
onChange={(e) =>
|
||||
applyPartial({
|
||||
unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
|
||||
})
|
||||
}
|
||||
placeholder="1"
|
||||
maxLength={3}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -299,17 +299,7 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
|
||||
expect(en).toContain("Daily overview");
|
||||
});
|
||||
|
||||
it("reflects heroVisible: default layout → checked, false → unchecked", () => {
|
||||
const checkedHtml = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
);
|
||||
const checked = checkedHtml.match(
|
||||
/<button[^>]*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(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
);
|
||||
@@ -318,6 +308,16 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
|
||||
);
|
||||
expect(unchecked).not.toBeNull();
|
||||
expect(unchecked![0]).toContain('data-state="unchecked"');
|
||||
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
const checkedHtml = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
);
|
||||
const checked = checkedHtml.match(
|
||||
/<button[^>]*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)", () => {
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MedicationsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryState.layout = { version: 1, view: "cards", order: [] };
|
||||
});
|
||||
|
||||
describe("<MedicationsSection> — 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"));
|
||||
});
|
||||
});
|
||||
@@ -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("<SettingsShell>", () => {
|
||||
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("<SettingsShell>", () => {
|
||||
// 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");
|
||||
|
||||
@@ -408,7 +408,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
<Switch
|
||||
id={heroToggleId}
|
||||
checked={layout.heroVisible !== false}
|
||||
checked={layout.heroVisible === true}
|
||||
onCheckedChange={(v) => setHeroVisible(v)}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="hero-visible-switch"
|
||||
|
||||
@@ -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<MedicationListEntry[]>("/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 (
|
||||
<section
|
||||
aria-labelledby="settings-section-medications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1 id="settings-section-medications-title" className="sr-only">
|
||||
{t("settings.sections.medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.medications.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* View preference — cards vs table. The shared header toggle
|
||||
writes optimistically, so there is no Save button here. */}
|
||||
<div
|
||||
id="medications-view"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGrid className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("medications.viewToggleLabel")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<p className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("medications.viewToggleHint")}
|
||||
</p>
|
||||
{isLayoutLoading ? (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<MedicationViewToggle view={layout.view} onChange={setView} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual order — applies to both list views. */}
|
||||
<div
|
||||
id="medications-order"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("medications.reorderTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.reorderDescription")}
|
||||
</p>
|
||||
{isLoading || isLayoutLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MedicationOrderEditor medications={ordered} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Pill,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -37,6 +38,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
insights: TrendingUp,
|
||||
medications: Pill,
|
||||
thresholds: SlidersHorizontal,
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// `<SelectValue>`. 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(/<SelectContent\s+position="popper"\s+align="start">/);
|
||||
});
|
||||
});
|
||||
@@ -175,7 +175,14 @@ export function FilterBarSelect({
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* `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. */}
|
||||
<SelectContent position="popper" align="start">
|
||||
<SelectItem value={allValue}>{allLabel}</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ function fakeMedication(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+12
@@ -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<unknown>({
|
||||
name: "medicationListLayout",
|
||||
maxEntries: 500,
|
||||
ttlMs: 300_000,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
+10
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]]));
|
||||
|
||||
@@ -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 ?? (<T>(_label: string, fn: () => Promise<T>) => 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
|
||||
|
||||
@@ -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<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of order) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) => {
|
||||
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<string, unknown>;
|
||||
}) => {
|
||||
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<FakeItem>;
|
||||
}) => {
|
||||
const item = state.items.find((i) => i.id === args.where.id)!;
|
||||
Object.assign(item, args.data);
|
||||
return item;
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function takenEvent(overrides: Partial<FakeEvent> = {}): 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> = {}): 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<typeof makeClient>) {
|
||||
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<FakeEvent>)],
|
||||
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<void>) =>
|
||||
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<typeof makeClient>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Pick<PrismaClient, "$transaction">>;
|
||||
|
||||
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<T>(
|
||||
client: ConsumptionClient,
|
||||
fn: (tx: TxClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user