mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(medications): fractional dosing via decimal units per dose (#316)
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.
This commit is contained in:
1 parent
5ce3e81972
commit
f4dec8c28f
30 files changed
+510
-136
No files matched your search
+37
-47
@@ -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:
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
+1
-1
@@ -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 ?",
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
@@ -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);
|
||||
+12
-4
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: <T,>(item: T) => item,
|
||||
}));
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" ("}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: unitsRemaining,
|
||||
total: unitsTotal,
|
||||
remaining: formatUnitCount(unitsRemaining),
|
||||
total: formatUnitCount(unitsTotal),
|
||||
})}
|
||||
{")"}
|
||||
</span>
|
||||
|
||||
@@ -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<InventoryResponse>({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
@@ -152,6 +155,10 @@ export function InventorySection({
|
||||
return apiGet<InventoryResponse>(`/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({
|
||||
<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 && (
|
||||
{perDose !== 1 && (
|
||||
<span className="text-muted-foreground block text-xs font-normal">
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: remainingUnits,
|
||||
total: totalUnits,
|
||||
remaining: formatUnitCount(remainingUnits),
|
||||
total: formatUnitCount(totalUnits),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -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({
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal}
|
||||
step={1}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional-dosing UI mapping.
|
||||
*
|
||||
* The wizard offers a CURATED set of units-per-dose values: the sub-unit
|
||||
* fractions (¼ ⅓ ½ ⅔ ¾, for split pills) plus a few whole numbers
|
||||
* (multi-tablet doses). The payload + API carry the DECIMAL value; the
|
||||
* UI renders the glyph. Kept beside the validator's
|
||||
* {@link UNITS_PER_DOSE_FRACTIONS} (one source of truth) — a test asserts
|
||||
* the fraction values here match the validator exactly, so the buttons
|
||||
* can never offer a value the server would 422.
|
||||
*
|
||||
* Thirds are inexact in decimal (⅓ → 0.3333, ⅔ → 0.6667); the column is
|
||||
* Decimal(10,4) and the runway floor absorbs the sub-0.0001 drift.
|
||||
*/
|
||||
const FRACTION_GLYPHS: ReadonlyArray<{ value: number; glyph: string }> = [
|
||||
{ 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;
|
||||
}
|
||||
@@ -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. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-units-per-dose" className="text-sm">
|
||||
<Label id="wizard-units-per-dose-label" 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"
|
||||
/>
|
||||
{/* v1.16.12 (#316) — curated fraction / whole-number selector
|
||||
instead of a free-text field: split-pill doses (½ tablet) are
|
||||
now expressible, and a button set is the most error-resistant
|
||||
input (no ambiguous decimal separators, no out-of-set values
|
||||
the server would reject). The decimal value is what the
|
||||
payload + API carry; the button shows the glyph. */}
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="wizard-units-per-dose-label"
|
||||
data-slot="wizard-units-per-dose"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{unitsPerDoseOptionsFor(payload.unitsPerDose).map((opt) => {
|
||||
const selected = payload.unitsPerDose === opt.raw;
|
||||
return (
|
||||
<Button
|
||||
key={opt.raw}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
aria-pressed={selected}
|
||||
className="min-w-10 tabular-nums"
|
||||
onClick={() => applyPartial({ unitsPerDose: opt.raw })}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
|
||||
</p>
|
||||
|
||||
@@ -700,7 +700,9 @@ export function buildCreateBody(
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
|
||||
// v1.16.12 — fractional dosing: parse as a float (½ → 0.5) and gate at
|
||||
// > 0, not the old integer `>= 1`, so a split-pill dose survives.
|
||||
const parsedUnitsPerDose = Number.parseFloat(committed.unitsPerDose);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
@@ -714,7 +716,7 @@ export function buildCreateBody(
|
||||
// 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 && {
|
||||
parsedUnitsPerDose > 0 && {
|
||||
unitsPerDose: parsedUnitsPerDose,
|
||||
}),
|
||||
// v1.8.5 — injection-site tracking is only meaningful for an
|
||||
@@ -1064,7 +1066,7 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
unitsPerDose:
|
||||
typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1
|
||||
typeof initial.unitsPerDose === "number" && initial.unitsPerDose > 0
|
||||
? String(initial.unitsPerDose)
|
||||
: "1",
|
||||
trackInjectionSites: initial.trackInjectionSites ?? false,
|
||||
|
||||
@@ -394,15 +394,15 @@ export async function buildGlp1SnapshotBlock(
|
||||
const usable = med.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,
|
||||
);
|
||||
pensRemaining = usable.length;
|
||||
dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, med.unitsPerDose),
|
||||
unitsRemaining / (Number(med.unitsPerDose) || 1),
|
||||
);
|
||||
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
|
||||
} else if (med.dosesPerUnit && med.inventoryEvents.length > 0) {
|
||||
|
||||
@@ -290,8 +290,13 @@ export async function runMedicationLowStockTick(
|
||||
summary.medicationsEvaluated += 1;
|
||||
|
||||
const evaluation = evaluateMedicationRunway(
|
||||
med.inventoryItems,
|
||||
med.unitsPerDose,
|
||||
// v1.16.12 — Decimal columns → JS numbers for the runway math.
|
||||
med.inventoryItems.map((it) => ({
|
||||
state: it.state,
|
||||
unitsTotal: Number(it.unitsTotal),
|
||||
unitsRemaining: Number(it.unitsRemaining),
|
||||
})),
|
||||
Number(med.unitsPerDose),
|
||||
med.schedules,
|
||||
);
|
||||
const decision = decideLowStockAction({
|
||||
|
||||
@@ -283,6 +283,41 @@ describe("consumeForIntake", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes a FRACTION of a unit for a split-pill dose, stamps the fraction, and refunds it exactly on undo (v1.16.12 #316)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 0.5,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsTotal: 30,
|
||||
unitsRemaining: 30,
|
||||
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));
|
||||
|
||||
// 30 tablets dosed at ½ each leaves 29.5 — the fraction is NOT floored.
|
||||
expect(state.items[0].unitsRemaining).toBe(29.5);
|
||||
expect(state.items[0].state).toBe("IN_USE");
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 0.5 },
|
||||
]);
|
||||
|
||||
// Undo refunds exactly the 0.5 it consumed and clears the stamp.
|
||||
await restoreForIntake({
|
||||
client: client as never,
|
||||
userId: "user-1",
|
||||
eventId: "evt-1",
|
||||
});
|
||||
expect(state.items[0].unitsRemaining).toBe(30);
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the IN_USE container over fresher ACTIVE stock", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
|
||||
@@ -94,7 +94,10 @@ function parseStamp(value: Prisma.JsonValue | null): InventoryConsumptionEntry[]
|
||||
) {
|
||||
entries.push({
|
||||
itemId: (raw as { itemId: string }).itemId,
|
||||
units: Math.floor((raw as { units: number }).units),
|
||||
// v1.16.12 — the stamp carries FRACTIONAL units (½ tablet per
|
||||
// dose), so it is no longer floored: refunding a split-pill dose
|
||||
// must return the exact 0.5 it consumed.
|
||||
units: (raw as { units: number }).units,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,7 +168,13 @@ export async function consumeForIntake(input: {
|
||||
select: { unitsPerDose: true },
|
||||
});
|
||||
if (!medication) return;
|
||||
const unitsPerDose = Math.max(1, medication.unitsPerDose);
|
||||
// v1.16.12 — Decimal column; a dose may consume a FRACTION of a
|
||||
// unit (½ / ¼ tablet for a split pill). Convert to a JS number for
|
||||
// the arithmetic below (unit counts stay well within double
|
||||
// precision) and gate at > 0, NOT at ≥ 1 — the old `Math.max(1, …)`
|
||||
// clamp would silently turn a half-tablet dose back into a whole one.
|
||||
const unitsPerDose = Number(medication.unitsPerDose);
|
||||
if (!(unitsPerDose > 0)) return;
|
||||
|
||||
// Candidate containers, in consumption order: the open container
|
||||
// first (earliest in-use deadline), then FEFO over unopened
|
||||
@@ -211,12 +220,12 @@ export async function consumeForIntake(input: {
|
||||
|
||||
for (const item of [...inUse, ...active]) {
|
||||
if (owed <= 0) break;
|
||||
const take = Math.min(owed, item.unitsRemaining);
|
||||
const take = Math.min(owed, Number(item.unitsRemaining));
|
||||
if (take <= 0) continue;
|
||||
|
||||
const wasUnopened = item.firstUseAt === null;
|
||||
const nextFirstUseAt = item.firstUseAt ?? intakeAt;
|
||||
const nextUnitsRemaining = item.unitsRemaining - take;
|
||||
const nextUnitsRemaining = Number(item.unitsRemaining) - take;
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
item.printedExpiry,
|
||||
@@ -224,7 +233,7 @@ export async function consumeForIntake(input: {
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
@@ -345,14 +354,14 @@ export async function restoreForIntake(input: {
|
||||
if (!item) continue; // container deleted since — nothing to refund.
|
||||
|
||||
const nextUnitsRemaining = Math.min(
|
||||
item.unitsTotal,
|
||||
item.unitsRemaining + entry.units,
|
||||
Number(item.unitsTotal),
|
||||
Number(item.unitsRemaining) + entry.units,
|
||||
);
|
||||
const refundedHere = nextUnitsRemaining - item.unitsRemaining;
|
||||
const refundedHere = nextUnitsRemaining - Number(item.unitsRemaining);
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: item.firstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
|
||||
@@ -50,6 +50,28 @@ export async function expireStaleInUseItems(input: {
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.12 (#316) — Prisma serialises a Decimal column to a JSON STRING,
|
||||
* but the API contract (and the iOS client) read an inventory item's
|
||||
* `unitsTotal` / `unitsRemaining` as numbers. Convert at every response
|
||||
* boundary that returns an item, so a fractional remainder (29.5 after a
|
||||
* half-tablet dose) lands as a JSON number, never `"29.5"`.
|
||||
*/
|
||||
export function serializeInventoryItem<
|
||||
T extends { unitsTotal: unknown; unitsRemaining: unknown },
|
||||
>(
|
||||
item: T,
|
||||
): Omit<T, "unitsTotal" | "unitsRemaining"> & {
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
} {
|
||||
return {
|
||||
...item,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: Number(item.unitsRemaining),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for the POST /[medId]/inventory route. Composes the
|
||||
* Prisma-create with the computed `expiresAt` so the caller doesn't
|
||||
|
||||
@@ -47,7 +47,9 @@ export function summariseSupply(
|
||||
items: readonly SupplyItem[],
|
||||
unitsPerDose: number,
|
||||
): SupplySummary {
|
||||
const perDose = Math.max(1, unitsPerDose);
|
||||
// v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet
|
||||
// per dose) must stay fractional, else the dose-derived counts halve.
|
||||
const perDose = unitsPerDose > 0 ? unitsPerDose : 1;
|
||||
const available = items.filter(isAvailableSupply);
|
||||
const unitsRemaining = available.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
|
||||
@@ -149,9 +149,8 @@ export const medicationResource = z
|
||||
),
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
"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: z.boolean(),
|
||||
notificationsEnabled: z.boolean(),
|
||||
@@ -250,7 +249,7 @@ const medicationListEntry = medicationResource
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.",
|
||||
"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.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -290,15 +289,13 @@ const medicationInventoryItemResource = z
|
||||
),
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"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: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
|
||||
"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: z.iso
|
||||
.datetime({ offset: true })
|
||||
|
||||
@@ -155,6 +155,43 @@ describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100",
|
||||
updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// v1.16.12 (#316) — fractional dosing: a curated set of split-pill
|
||||
// fractions is accepted alongside the whole numbers; an arbitrary
|
||||
// decimal is NOT (the UI only ever sends a curated value).
|
||||
it("accepts every curated unitsPerDose fraction on create + update", () => {
|
||||
for (const v of [0.25, 0.3333, 0.5, 0.6667, 0.75]) {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: v,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: v }).success).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an uncurated fractional unitsPerDose", () => {
|
||||
for (const v of [0.333, 0.1, 0.4, 1.5, 2.5]) {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: v,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a fractional remaining-stock correction (½-tablet leftover)", () => {
|
||||
expect(
|
||||
updateInventoryItemSchema.safeParse({ unitsRemaining: 29.5 }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createInventoryItemSchema.safeParse({ unitsTotal: 1.5 }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => {
|
||||
|
||||
@@ -98,6 +98,36 @@ export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
|
||||
export type MedicationTreatmentClass =
|
||||
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit
|
||||
* fraction of a tablet (split pills). The UI offers a CURATED set of
|
||||
* common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds
|
||||
* are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4)
|
||||
* column and the runway floor absorb the sub-0.0001 drift. Whole numbers
|
||||
* 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid
|
||||
* alongside the fractions. One source of truth — the UI selector and the
|
||||
* tests import this set so the allowed values can never drift from the
|
||||
* validator.
|
||||
*/
|
||||
export const UNITS_PER_DOSE_FRACTIONS = [
|
||||
0.25, 0.3333, 0.5, 0.6667, 0.75,
|
||||
] as const;
|
||||
export const UNITS_PER_DOSE_MAX_WHOLE = 100;
|
||||
|
||||
export function isSupportedUnitsPerDose(value: number): boolean {
|
||||
if (
|
||||
(UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE
|
||||
);
|
||||
}
|
||||
|
||||
const UNITS_PER_DOSE_MESSAGE =
|
||||
"unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)";
|
||||
|
||||
/**
|
||||
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
|
||||
* the injection-site picker surfaces for any `INJECTION` dose, and a
|
||||
@@ -489,12 +519,10 @@ export const createMedicationSchema = z
|
||||
*/
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
"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.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
@@ -592,15 +620,14 @@ export const updateMedicationSchema = z
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1. */
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1.
|
||||
* v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
"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.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
@@ -917,11 +944,10 @@ export const createInventoryItemSchema = z
|
||||
* `Medication.unitsPerDose`. */
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"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`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
@@ -979,12 +1005,11 @@ export const updateInventoryItemSchema = z
|
||||
*/
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
"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).",
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user