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:
Marc-André Bombeck committed 2026-06-13 16:07:50 +02:00
1 parent 5ce3e81972
commit f4dec8c28f
30 files changed
+510 -136

No files matched your search

+37 -47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+3 -3
View File
@@ -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);
},
);
+2
View File
@@ -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,
});
},
+6 -2
View File
@@ -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,
+3 -3
View File
@@ -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) {
+7 -2
View File
@@ -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,
+18 -9
View File
@@ -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,
+22
View File
@@ -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
+3 -1
View File
@@ -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,
+4 -7
View File
@@ -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", () => {
+38 -13
View File
@@ -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(),
})