From f4dec8c28f25e07a8f6a46990d1ead1d04474e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 13 Jun 2026 16:07:50 +0200 Subject: [PATCH] feat(medications): fractional dosing via decimal units per dose (#316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dose can now consume a fraction of a unit — a half, third or quarter tablet for a split pill. The wizard offers a curated fraction set beside the whole numbers (the most error-resistant input); units-per-dose and the per-container counts widen from Int to Decimal, so a 30-tablet pack dosed at a half reads 29.5 after one dose and the days-left projection follows. The consumption ledger stamps and refunds the exact fraction, and existing whole-number doses are untouched. OpenAPI regenerated — the three unit fields move from integer to number — and the units-per-dose hint is updated across the six locales. Also anchors a first-use date relative to now in an inventory route test that had aged past its 30-day in-use window. --- docs/api/openapi.yaml | 84 ++++++++-------- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../migration.sql | 15 +++ prisma/schema.prisma | 16 +++- src/app/api/medications/[id]/glp1/route.ts | 6 +- .../[itemId]/__tests__/route.test.ts | 4 + .../[id]/inventory/[itemId]/route.ts | 12 ++- .../api/medications/[id]/inventory/route.ts | 12 ++- src/app/api/medications/[id]/route.ts | 2 + src/app/api/medications/route.ts | 8 +- .../__tests__/units-per-dose.test.ts | 82 ++++++++++++++++ .../detail/MedicationDetailTabs.tsx | 14 ++- .../sections/inventory-section.tsx | 28 ++++-- src/components/medications/units-per-dose.ts | 95 +++++++++++++++++++ .../medications/wizard/steps/Step3Dose.tsx | 46 ++++++--- .../medications/wizard/wizard-payload.ts | 8 +- src/lib/ai/coach/glp1-snapshot.ts | 6 +- src/lib/jobs/medication-low-stock.ts | 9 +- .../inventory/__tests__/consumption.test.ts | 35 +++++++ src/lib/medications/inventory/consumption.ts | 27 ++++-- src/lib/medications/inventory/service.ts | 22 +++++ src/lib/medications/inventory/summary.ts | 4 +- src/lib/openapi/routes/medications.ts | 11 +-- .../__tests__/medication-inventory.test.ts | 37 ++++++++ src/lib/validations/medication.ts | 51 +++++++--- 30 files changed, 510 insertions(+), 136 deletions(-) create mode 100644 prisma/migrations/0154_v11612_fractional_units_per_dose/migration.sql create mode 100644 src/components/medications/__tests__/units-per-dose.test.ts create mode 100644 src/components/medications/units-per-dose.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 21b1ac6cc..99531be46 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.11 + version: 1.16.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -5141,11 +5141,10 @@ components: 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 + description: Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a + supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements + this many units per taken dose; dose-derived readouts divide unit counts by it. + type: number deliveryForm: type: string enum: @@ -5429,11 +5428,10 @@ components: 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 + description: Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ + / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events + keep their recorded consumption. + type: number deliveryForm: type: string enum: @@ -5605,11 +5603,11 @@ components: type: object properties: unitsTotal: - type: integer + type: number minimum: 1 maximum: 1000 - description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the - medication's `unitsPerDose`. + description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill + packs). 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. @@ -5661,9 +5659,9 @@ components: 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 + description: Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's + `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP). + type: number minimum: 0 maximum: 1000 notes: @@ -9049,12 +9047,10 @@ components: - 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. + type: number + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a + split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. 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: @@ -9178,8 +9174,9 @@ components: 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." + description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a + fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives + the table view's Bestand column. Read-only — aggregated, not stored." required: - id - name @@ -9268,12 +9265,10 @@ components: - 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. + type: number + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a + split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. 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: @@ -9652,17 +9647,14 @@ components: 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`. + type: number + description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a + split-pill remainder reads cleanly. 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. + type: number + description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). 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 @@ -14615,12 +14607,10 @@ components: - 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. + type: number + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a + split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. 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: diff --git a/messages/de.json b/messages/de.json index 078258bfa..125069d45 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.", "short": "Dosis", "unitsPerDoseLabel": "Einheiten pro Dosis", - "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis" + "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis – oder ½ Tablette bei geteilter Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", diff --git a/messages/en.json b/messages/en.json index dff86db90..b1fc9b123 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.", "short": "Dose", "unitsPerDoseLabel": "Units per dose", - "unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose" + "unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose, or ½ a tablet for a split dose" }, "step4": { "title": "Over what period are you taking it?", diff --git a/messages/es.json b/messages/es.json index 67341a39b..fe35ca751 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.", "short": "Dosis", "unitsPerDoseLabel": "Unidades por dosis", - "unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg" + "unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg, o ½ comprimido para una dosis partida" }, "step4": { "title": "¿Durante qué periodo lo tomas?", diff --git a/messages/fr.json b/messages/fr.json index f92ef14a5..c7b9df470 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.", "short": "Dose", "unitsPerDoseLabel": "Unités par dose", - "unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg" + "unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg, ou ½ comprimé pour une dose fractionnée" }, "step4": { "title": "Sur quelle période le prenez-vous ?", diff --git a/messages/it.json b/messages/it.json index edd4bbb1f..ff1b9d097 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.", "short": "Dose", "unitsPerDoseLabel": "Unità per dose", - "unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg" + "unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg, oppure ½ compressa per una dose divisa" }, "step4": { "title": "Per quanto tempo lo assumi?", diff --git a/messages/pl.json b/messages/pl.json index 7511fb7ce..73ccf0242 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1120,7 +1120,7 @@ "injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.", "short": "Dawka", "unitsPerDoseLabel": "Jednostki na dawkę", - "unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg" + "unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg lub ½ tabletki przy dzielonej dawce" }, "step4": { "title": "Przez jaki okres go przyjmujesz?", diff --git a/prisma/migrations/0154_v11612_fractional_units_per_dose/migration.sql b/prisma/migrations/0154_v11612_fractional_units_per_dose/migration.sql new file mode 100644 index 000000000..95984881c --- /dev/null +++ b/prisma/migrations/0154_v11612_fractional_units_per_dose/migration.sql @@ -0,0 +1,15 @@ +-- v1.16.12 (#316) — fractional dosing. +-- +-- A dose can consume a FRACTION of a unit (½ / ⅓ / ¼ of a tablet for a +-- split pill), so the unit↔dose factor and the per-container stock must +-- hold non-integer values: 30 tablets dosed at ½ each reads 29.5 after +-- one dose. Widen the three integer unit columns to NUMERIC; every +-- existing integer value casts losslessly. Additive and reversible in +-- value terms (all current rows are whole numbers). + +ALTER TABLE "medications" + ALTER COLUMN "units_per_dose" TYPE numeric(10,4) USING "units_per_dose"::numeric(10,4); + +ALTER TABLE "medication_inventory_items" + ALTER COLUMN "doses_total" TYPE numeric(12,4) USING "doses_total"::numeric(12,4), + ALTER COLUMN "doses_remaining" TYPE numeric(12,4) USING "doses_remaining"::numeric(12,4); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 80ae7c70c..3a1ebb4dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1282,7 +1282,12 @@ model Medication { /// 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") + /// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a + /// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated + /// set of common fractions; the column stores their decimal value + /// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers. + /// Existing integer rows cast losslessly. + unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4) active Boolean @default(true) notificationsEnabled Boolean @default(true) @map("notifications_enabled") pausedAt DateTime? @map("paused_at") @@ -1783,11 +1788,14 @@ model MedicationInventoryItem { /// `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") + /// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a + /// fractional remainder (30 tablets dosed at ½ each reads 29.5 after + /// one dose). Existing integer rows cast losslessly. + unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4) /// Remaining units; decremented by the intake consumption hook /// (v1.16.10 — renamed from `dosesRemaining`; column stays - /// `doses_remaining`). - unitsRemaining Int @map("doses_remaining") + /// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing. + unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4) /// NULL until the pen is first opened. Sets the 30-day in-use /// clock per EMA EPAR §6.3. firstUseAt DateTime? @map("first_use_at") diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 1ede74ba7..0fe699b56 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -92,15 +92,15 @@ export const GET = apiHandler( const usable = medication.inventoryItems.filter( (item) => (item.state === "ACTIVE" || item.state === "IN_USE") && - item.unitsRemaining > 0, + Number(item.unitsRemaining) > 0, ); const unitsRemaining = usable.reduce( - (sum, item) => sum + item.unitsRemaining, + (sum, item) => sum + Number(item.unitsRemaining), 0, ); const pensRemaining = usable.length; const dosesRemaining = Math.floor( - unitsRemaining / Math.max(1, medication.unitsPerDose), + unitsRemaining / (Number(medication.unitsPerDose) || 1), ); const weeksOfSupply = dosesRemaining; const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 992409555..94f1aa934 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -27,6 +27,10 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/medications/inventory/service", () => ({ computeExpiresAt: vi.fn().mockReturnValue(null), buildPatchInventoryUpdate: vi.fn().mockReturnValue({}), + // v1.16.12 — the route serialises its Decimal unit columns to numbers + // on the way out; a passthrough keeps these update-logic assertions + // focused on the Prisma call, not the response shape. + serializeInventoryItem: (item: T) => item, })); vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index fe03720e3..99ce9346d 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -35,6 +35,7 @@ import { computeExpiresAt, computeInventoryState, } from "@/lib/medications/inventory/state-machine"; +import { serializeInventoryItem } from "@/lib/medications/inventory/service"; type RouteParams = { params: Promise<{ id: string; itemId: string }> }; @@ -83,7 +84,10 @@ export const PATCH = apiHandler( // commutative — applying them in any order produces the same row. let nextFirstUseAt = existing.firstUseAt; let nextState = existing.state; - let nextUnitsRemaining = existing.unitsRemaining; + // v1.16.12 — Decimal column; work in JS numbers locally (the + // arithmetic below stays well within double precision) and let + // Prisma re-widen on write. + let nextUnitsRemaining: number = Number(existing.unitsRemaining); let nextPrintedExpiry = existing.printedExpiry; if (markAsFirstUseAt) { @@ -103,7 +107,7 @@ export const PATCH = apiHandler( // 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); + nextUnitsRemaining = Math.min(unitsRemaining, Number(existing.unitsTotal)); } if (markAsUsedUp === true) { @@ -126,7 +130,7 @@ export const PATCH = apiHandler( nextState = computeInventoryState( { state: nextState, - unitsTotal: existing.unitsTotal, + unitsTotal: Number(existing.unitsTotal), unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, printedExpiry: nextPrintedExpiry, @@ -166,7 +170,7 @@ export const PATCH = apiHandler( meta: { medication_id: id, prev: existing.state, next: updated.state }, }); - return apiSuccess(updated); + return apiSuccess(serializeInventoryItem(updated)); }, ); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index db17065b8..79bbc9b95 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -28,7 +28,10 @@ import { } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createInventoryItemSchema } from "@/lib/validations/medication"; -import { buildCreateInventoryInput } from "@/lib/medications/inventory/service"; +import { + buildCreateInventoryInput, + serializeInventoryItem, +} from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; type RouteParams = { params: Promise<{ id: string }> }; @@ -54,7 +57,10 @@ export const GET = apiHandler( meta: { medication_id: id, total: items.length }, }); - return apiSuccess({ items, meta: { total: items.length } }); + return apiSuccess({ + items: items.map(serializeInventoryItem), + meta: { total: items.length }, + }); }, ); @@ -128,6 +134,6 @@ export const POST = apiHandler( meta: { medication_id: id }, }); - return apiSuccess(created, 201); + return apiSuccess(serializeInventoryItem(created), 201); }, ); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 596a358ee..054eaacb0 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -178,6 +178,7 @@ export const GET = apiHandler( return apiSuccess({ ...medication, + unitsPerDose: Number(medication.unitsPerDose), category, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, @@ -710,6 +711,7 @@ export const PUT = apiHandler( return apiSuccess({ ...medication, + unitsPerDose: Number(medication.unitsPerDose), category: normalizedCategory, }); }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 74273246a..258cda7d0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -185,7 +185,7 @@ async function buildMedicationsList( for (const entry of usableStock) { usableUnitsByMedId.set( entry.medicationId, - entry._sum.unitsRemaining ?? 0, + Number(entry._sum.unitsRemaining ?? 0), ); } const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); @@ -233,9 +233,12 @@ async function buildMedicationsList( const stockDosesRemaining = stockUnitsRemaining === null ? null - : Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose)); + : Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1)); return { ...m, + // v1.16.12 — Decimal → number so the wire stays a JSON number, not + // the string Prisma would otherwise serialise a Decimal to. + unitsPerDose: Number(m.unitsPerDose), category: categoryMap[m.id] ?? "OTHER", lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, todayEventCount: todayEventCountByMedId[m.id] ?? 0, @@ -489,6 +492,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess( { ...medication, + unitsPerDose: Number(medication.unitsPerDose), category: normalizedCategory, }, 201, diff --git a/src/components/medications/__tests__/units-per-dose.test.ts b/src/components/medications/__tests__/units-per-dose.test.ts new file mode 100644 index 000000000..b8766ee1c --- /dev/null +++ b/src/components/medications/__tests__/units-per-dose.test.ts @@ -0,0 +1,82 @@ +/** + * v1.16.12 (#316) — fractional-dosing UI mapping. + * + * The wizard's fraction buttons must never offer a value the server's + * validator would 422. This pins that contract: the selector's fraction + * values equal {@link UNITS_PER_DOSE_FRACTIONS}, every offered option + * passes {@link isSupportedUnitsPerDose}, the glyph formatting round-trips, + * and a legacy / non-curated value is preserved as an extra option. + */ +import { describe, expect, it } from "vitest"; + +import { + UNITS_PER_DOSE_FRACTIONS, + isSupportedUnitsPerDose, +} from "@/lib/validations/medication"; +import { + UNITS_PER_DOSE_OPTIONS, + UNITS_PER_DOSE_FRACTION_VALUES, + formatUnitsPerDose, + formatUnitCount, + unitsPerDoseOptionsFor, +} from "@/components/medications/units-per-dose"; + +describe("units-per-dose selector ↔ validator alignment", () => { + it("offers exactly the validator's fraction set", () => { + expect([...UNITS_PER_DOSE_FRACTION_VALUES].sort()).toEqual( + [...UNITS_PER_DOSE_FRACTIONS].sort(), + ); + }); + + it("offers only server-accepted values", () => { + for (const opt of UNITS_PER_DOSE_OPTIONS) { + expect(isSupportedUnitsPerDose(opt.value)).toBe(true); + // The payload string round-trips to the same number. + expect(Number(opt.raw)).toBe(opt.value); + } + }); +}); + +describe("formatUnitsPerDose", () => { + it("renders curated fractions as glyphs", () => { + expect(formatUnitsPerDose(0.25)).toBe("¼"); + expect(formatUnitsPerDose(0.3333)).toBe("⅓"); + expect(formatUnitsPerDose(0.5)).toBe("½"); + expect(formatUnitsPerDose(0.6667)).toBe("⅔"); + expect(formatUnitsPerDose(0.75)).toBe("¾"); + }); + + it("renders whole / uncurated values as the plain number", () => { + expect(formatUnitsPerDose(1)).toBe("1"); + expect(formatUnitsPerDose(2)).toBe("2"); + expect(formatUnitsPerDose(10)).toBe("10"); + }); +}); + +describe("formatUnitCount — display rounding", () => { + it("passes whole and half counts through unchanged", () => { + expect(formatUnitCount(30)).toBe(30); + expect(formatUnitCount(29.5)).toBe(29.5); + }); + + it("rounds the float noise a third-dose leaves", () => { + expect(formatUnitCount(29.6667)).toBe(29.67); + }); +}); + +describe("unitsPerDoseOptionsFor", () => { + it("returns the curated set for a curated current value", () => { + expect(unitsPerDoseOptionsFor("0.5")).toBe(UNITS_PER_DOSE_OPTIONS); + expect(unitsPerDoseOptionsFor("2")).toBe(UNITS_PER_DOSE_OPTIONS); + }); + + it("appends a legacy / non-curated current value so an edit never drops it", () => { + const opts = unitsPerDoseOptionsFor("10"); + expect(opts).toHaveLength(UNITS_PER_DOSE_OPTIONS.length + 1); + expect(opts.at(-1)).toEqual({ value: 10, raw: "10", label: "10" }); + }); + + it("ignores an empty / invalid current value", () => { + expect(unitsPerDoseOptionsFor("")).toBe(UNITS_PER_DOSE_OPTIONS); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index 41ba790a6..42fc719d9 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -76,6 +76,7 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { formatUnitCount } from "@/components/medications/units-per-dose"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; @@ -353,7 +354,12 @@ export function MedicationDetailTabs({ // 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); + // v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½ + // tablet per dose) stays fractional instead of halving the dose counts. + const perDose = + medication.unitsPerDose && medication.unitsPerDose > 0 + ? medication.unitsPerDose + : 1; const { unitsRemaining, unitsTotal, @@ -461,12 +467,12 @@ export function MedicationDetailTabs({ remaining: dosesRemaining, total: dosesTotal, })} - {perDose > 1 && ( + {perDose !== 1 && ( {" ("} {t("medications.detail.bestand.unitsDetail", { - remaining: unitsRemaining, - total: unitsTotal, + remaining: formatUnitCount(unitsRemaining), + total: formatUnitCount(unitsTotal), })} {")"} diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 5e87ec361..b36e11b92 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -64,6 +64,7 @@ import { import { SettingsGroup } from "@/components/medications/settings-group"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { formatUnitCount } from "@/components/medications/units-per-dose"; import { apiDelete, apiGet, @@ -144,7 +145,9 @@ export function InventorySection({ } } - const perDose = Math.max(1, unitsPerDose ?? 1); + // v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet + // per dose) must stay fractional or the dose-derived counts halve. + const perDose = unitsPerDose && unitsPerDose > 0 ? unitsPerDose : 1; const { data, isLoading } = useQuery({ queryKey: queryKeys.medicationInventory(medicationId), @@ -152,6 +155,10 @@ export function InventorySection({ return apiGet(`/api/medications/${medicationId}/inventory`); }, staleTime: 30_000, + // v1.16.12 (#316) — fresh on every mount so reopening the supply tab + // reflects stock changed elsewhere (a dose on another device, a + // refill) without a manual reload. + refetchOnMount: "always", }); // v1.16.11 — the low-stock alert threshold, for the cross-link row @@ -262,11 +269,11 @@ export function InventorySection({

{t("medications.detail.bestand.summary", { remaining, total })} - {perDose > 1 && ( + {perDose !== 1 && ( {t("medications.detail.bestand.unitsDetail", { - remaining: remainingUnits, - total: totalUnits, + remaining: formatUnitCount(remainingUnits), + total: formatUnitCount(totalUnits), })} )} @@ -367,12 +374,12 @@ export function InventorySection({ remaining: Math.floor(item.unitsRemaining / perDose), total: Math.floor(item.unitsTotal / perDose), })} - {perDose > 1 && ( + {perDose !== 1 && ( <> {" · "} {t("medications.detail.bestand.unitsDetail", { - remaining: item.unitsRemaining, - total: item.unitsTotal, + remaining: formatUnitCount(item.unitsRemaining), + total: formatUnitCount(item.unitsTotal), })} )} @@ -648,8 +655,9 @@ function AdjustInventoryDialog({ const [busy, setBusy] = useState(false); const parsed = Number(value); + // v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5). const valid = - Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal; + Number.isFinite(parsed) && parsed >= 0 && parsed <= item.unitsTotal; async function submit(e: FormEvent) { e.preventDefault(); @@ -694,10 +702,10 @@ function AdjustInventoryDialog({ = [ + { value: 0.25, glyph: "¼" }, + { value: 0.3333, glyph: "⅓" }, + { value: 0.5, glyph: "½" }, + { value: 0.6667, glyph: "⅔" }, + { value: 0.75, glyph: "¾" }, +]; + +/** The curated whole-number doses shown alongside the fractions. */ +const WHOLE_OPTIONS = [1, 2, 3, 4] as const; + +export interface UnitsPerDoseOption { + /** Numeric value (e.g. 0.5). */ + value: number; + /** The string stored in the wizard payload + sent to the API. */ + raw: string; + /** Display glyph / number (e.g. "½", "2"). */ + label: string; +} + +/** Curated selector options: fractions first (½ reads naturally before 1), then wholes. */ +export const UNITS_PER_DOSE_OPTIONS: UnitsPerDoseOption[] = [ + ...FRACTION_GLYPHS.map((f) => ({ + value: f.value, + raw: String(f.value), + label: f.glyph, + })), + ...WHOLE_OPTIONS.map((n) => ({ + value: n, + raw: String(n), + label: String(n), + })), +]; + +/** + * The exact fraction decimals the UI offers — exported for the alignment + * test, which asserts (in Node, where server imports are fine) that this + * equals the validator's `UNITS_PER_DOSE_FRACTIONS`. This module itself + * stays free of any server import so it can ship in the client bundle. + */ +export const UNITS_PER_DOSE_FRACTION_VALUES = FRACTION_GLYPHS.map( + (f) => f.value, +); + +/** + * Round a unit count for display — drops the float noise a ⅓-dose + * (0.3333/dose) leaves in a running remainder (29.6667 → 29.67). Whole + * and ½ counts pass through unchanged. + */ +export function formatUnitCount(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Render a stored units-per-dose decimal: the fraction glyph (¼ … ¾) when + * it matches a curated fraction, else the plain number ("2", "10"). + */ +export function formatUnitsPerDose(value: number): string { + const hit = FRACTION_GLYPHS.find((f) => Math.abs(f.value - value) < 0.001); + return hit ? hit.glyph : String(value); +} + +/** + * The selector options for a given current value: the curated set, plus + * the current value appended when it is a legacy / non-curated positive + * number (e.g. an existing medication set to 10 units per dose), so an + * edit never silently drops it. + */ +export function unitsPerDoseOptionsFor(current: string): UnitsPerDoseOption[] { + if (UNITS_PER_DOSE_OPTIONS.some((o) => o.raw === current)) { + return UNITS_PER_DOSE_OPTIONS; + } + const n = Number(current); + if (Number.isFinite(n) && n > 0) { + return [ + ...UNITS_PER_DOSE_OPTIONS, + { value: n, raw: current, label: formatUnitsPerDose(n) }, + ]; + } + return UNITS_PER_DOSE_OPTIONS; +} diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx index c58cb8ff0..265ba910d 100644 --- a/src/components/medications/wizard/steps/Step3Dose.tsx +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -21,6 +21,7 @@ import { type InjectionSiteKey, } from "@/lib/medications/injection-sites"; import type { MedicationDeliveryForm } from "@/lib/validations/medication"; +import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose"; import { useTranslations } from "@/lib/i18n/context"; import type { StepProps } from "./Step1Name"; @@ -168,23 +169,38 @@ export function Step3Dose({ payload, applyPartial }: StepProps) { decrements several per take and every dose-level readout divides by this factor. */}

-