fix(medications): every taken dose moves the stock, in units that match the product

Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 17:42:31 +02:00
1 parent dacdcccfd2
commit 7d3557a3fd
53 files changed
+4118 -641

No files matched your search

@@ -0,0 +1,173 @@
/**
* v1.16.10 — `<AddInventoryDialog>` register-flow coverage.
*
* Pins the multi-unit quantity input: the segmented Dosen | Einheiten
* control only surfaces when a dose spans more than one unit, the live
* conversion line renders next to the quantity, the POST always carries
* UNITS, and the container-type select defaults from the delivery form.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`,
* no `@testing-library/react`) plus source-string structural assertions
* for the interactive plumbing an SSR mount can't exercise.
*/
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The Radix Dialog / Select portal at runtime, so their bodies never
// materialise in static markup. Collapse the primitives to plain
// wrappers (same trick as the LogInjectionSiteDialog suite).
vi.mock("@/components/ui/dialog", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Dialog: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
DialogHeader: Pass,
DialogTitle: Pass,
};
});
vi.mock("@/components/ui/select", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Select: ({
value,
children,
}: {
value: string;
children?: React.ReactNode;
}) => <div data-mock-select-value={value}>{children}</div>,
SelectContent: Pass,
SelectItem: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-select-item">{children}</div>
),
SelectTrigger: Pass,
SelectValue: () => null,
};
});
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({ data: undefined, isLoading: false })),
useQueryClient: () => ({
invalidateQueries: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const { AddInventoryDialog } = await import(
"../sections/inventory-section"
);
function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const src = readFileSync(
resolve(__dirname, "../sections/inventory-section.tsx"),
"utf8",
);
describe("<AddInventoryDialog>", () => {
it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => {
const multi = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={4}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(multi).toContain('data-slot="inventory-quantity-mode"');
expect(multi).toContain(">Dosen<");
expect(multi).toContain(">Einheiten<");
const single = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={1}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(single).not.toContain('data-slot="inventory-quantity-mode"');
});
it("renders the live conversion next to the prefilled quantity (units mode → dose equivalent)", () => {
// Prefill 28 units at 4 units per dose → "≈ 7 Dosen".
const html = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={4}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(html).toContain('data-slot="inventory-quantity-conversion"');
expect(html).toContain("≈ 7 Dosen");
});
it("defaults the container type from the caller (delivery-form mapping upstream)", () => {
const html = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={null}
unitsPerDose={1}
initialContainerType="PEN"
onClose={() => {}}
/>,
);
expect(html).toContain('data-mock-select-value="PEN"');
// All six container kinds are offered.
for (const label of [
"Pen",
"Ampulle",
"Tablettenpackung",
"Inhalator",
"Flasche",
"Sonstiges",
]) {
expect(html).toContain(label);
}
});
it("always submits UNITS: dose-mode input multiplies by unitsPerDose before the POST", () => {
// Interactive mode-switching is out of SSR reach; the structural
// contract lives in the source: the submitted value is the converted
// unit count, and the wire field stays `dosesTotal`.
expect(src).toMatch(
/const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/,
);
expect(src).toMatch(/dosesTotal: units,/);
expect(src).toMatch(/containerType,/);
});
it("bounds the quantity to 1000 units (dose mode scales the max down)", () => {
expect(src).toContain("units >= 1 && units <= 1000");
expect(src).toContain('Math.floor(1000 / unitsPerDose)');
});
it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => {
expect(src).toMatch(
/if \(deliveryForm === "INJECTION"\) return "PEN";\s*\n\s*if \(deliveryForm === "ORAL"\) return "BLISTER";\s*\n\s*return "OTHER";/,
);
});
});
@@ -106,3 +106,27 @@ describe("estimateRunwayDays", () => {
).toBe(28);
});
});
describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => {
it("runs off the dose-derived count: floor(units / unitsPerDose) before dividing by daily consumption", () => {
// 9 units at 2 units per dose → floor(9 / 2) = 4 doses; a daily
// single-dose schedule then reads 4 days of runway. The call sites
// (Übersicht supply row, Bestand summary) derive the dose count this
// way before handing it to the estimator.
const unitsRemaining = 9;
const unitsPerDose = 2;
const dosesRemaining = Math.floor(unitsRemaining / unitsPerDose);
expect(
estimateRunwayDays(dosesRemaining, [schedule({ timesOfDay: ["08:00"] })]),
).toBe(4);
});
it("a partial dose's worth of units never counts as a day", () => {
// 1 unit at 2 units per dose is not a dose → no runway.
expect(
estimateRunwayDays(Math.floor(1 / 2), [
schedule({ timesOfDay: ["08:00"] }),
]),
).toBeNull();
});
});
@@ -125,6 +125,8 @@ export interface MedicationDetailSnapshot {
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
/** v1.16.10 — inventory units one dose consumes (default 1). */
unitsPerDose?: number | null;
trackInjectionSites?: boolean;
allowedInjectionSites?: string[];
active: boolean;
@@ -179,6 +181,7 @@ function snapshotToWizardPayload(
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
unitsPerDose: med.unitsPerDose ?? 1,
trackInjectionSites: med.trackInjectionSites ?? false,
allowedInjectionSites: med.allowedInjectionSites ?? [],
notificationsEnabled: med.notificationsEnabled,
@@ -314,8 +317,8 @@ export function MedicationDetailTabs({
return await apiGet<{
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
dosesTotal: number;
dosesRemaining: number;
unitsTotal: number;
unitsRemaining: number;
}>;
} | null>(`/api/medications/${id}/inventory`);
} catch {
@@ -328,11 +331,18 @@ export function MedicationDetailTabs({
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
const dosesRemaining = liveItems.reduce(
(sum, i) => sum + i.dosesRemaining,
// v1.16.10 — items count UNITS; the supply readout and the runway are
// dose-derived (floor over the pooled units — consumption spills
// across containers, so the pool is the honest dose count). The raw
// unit tally renders as secondary text when a dose spans > 1 unit.
const perDose = Math.max(1, medication.unitsPerDose ?? 1);
const unitsRemaining = liveItems.reduce(
(sum, i) => sum + i.unitsRemaining,
0,
);
const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0);
const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0);
const dosesRemaining = Math.floor(unitsRemaining / perDose);
const dosesTotal = Math.floor(unitsTotal / perDose);
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
@@ -422,6 +432,16 @@ export function MedicationDetailTabs({
remaining: dosesRemaining,
total: dosesTotal,
})}
{perDose > 1 && (
<span className="text-muted-foreground">
{" ("}
{t("medications.detail.bestand.unitsDetail", {
remaining: unitsRemaining,
total: unitsTotal,
})}
{")"}
</span>
)}
{runwayDays !== null && (
<span className="text-muted-foreground">
{" · "}
@@ -562,6 +582,8 @@ export function MedicationDetailTabs({
<InventorySection
medicationId={id}
dosesPerUnit={medication.dosesPerUnit}
unitsPerDose={medication.unitsPerDose}
deliveryForm={medication.deliveryForm}
/>
</TabsContent>
@@ -6,20 +6,29 @@
* v1.16.1 — the tab grows its write affordances:
*
* - REGISTER: a "+" flow (header button + the empty-state CTA) that
* records a new pen / pack via `POST /api/medications/[id]/inventory`
* — quantity in doses/units, optional printed expiry. The quantity
* prefills from the medication's `dosesPerUnit` when configured.
* records a new pack / container via
* `POST /api/medications/[id]/inventory` — quantity in units,
* optional printed expiry. The quantity prefills from the
* medication's `dosesPerUnit` when configured.
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
* `dosesRemaining`) for stock corrections and manual withdrawals —
* the count is set absolutely, clamped server-side to the item's
* capacity, and the canonical state machine derives the next state
* (0 ⇒ used up).
*
* v1.16.10 — items count UNITS (tablets / ampoules / puffs);
* `Medication.unitsPerDose` maps units to doses. Every dose-facing
* readout divides by it (floor), with the raw unit count as secondary
* text when the factor is > 1. The register flow gains a container-type
* select (pen / ampoule / tablet pack / …, defaulted from the delivery
* form) and, for multi-unit doses, a segmented Dosen | Einheiten
* quantity input with live conversion — the wire always carries units.
*
* Reads stay on `GET /api/medications/[id]/inventory` (the same
* per-item list the pen/vial CRUD route serves): a calm summary — the
* per-item list the inventory CRUD route serves): a calm summary — the
* doses remaining across every non-terminal item — plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count
* too); the richer pen math is implicit in the per-item doses figures.
* with its container type and state badge. Shown for ALL medications
* (pill packs count too).
*/
import { useState, type FormEvent } from "react";
@@ -39,6 +48,13 @@ import {
} from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -46,11 +62,22 @@ import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
const CONTAINER_TYPES = [
"PEN",
"AMPOULE",
"BLISTER",
"INHALER",
"BOTTLE",
"OTHER",
] as const;
type ContainerType = (typeof CONTAINER_TYPES)[number];
interface InventoryItem {
id: string;
state: InventoryState;
dosesTotal: number;
dosesRemaining: number;
containerType: ContainerType;
unitsTotal: number;
unitsRemaining: number;
}
interface InventoryResponse {
@@ -65,18 +92,33 @@ const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive
USED_UP: "outline",
};
/** Default container kind for the register flow, from the delivery form. */
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
if (deliveryForm === "ORAL") return "BLISTER";
return "OTHER";
}
export function InventorySection({
medicationId,
dosesPerUnit,
unitsPerDose,
deliveryForm,
}: {
medicationId: string;
/** Prefills the register flow's quantity when the medication tracks it. */
dosesPerUnit?: number | null;
/** Units one dose consumes; dose-derived readouts divide by it. */
unitsPerDose?: number | null;
/** Defaults the register flow's container type. */
deliveryForm?: string;
}) {
const { t } = useTranslations();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const perDose = Math.max(1, unitsPerDose ?? 1);
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
@@ -102,17 +144,22 @@ export function InventorySection({
}
const items = Array.isArray(data?.items) ? data.items : [];
// Active supply: doses left across every non-terminal (not used-up) item.
// Active supply: units left across every non-terminal (not used-up) item.
const live = items.filter((i) => i.state !== "USED_UP");
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
const remainingUnits = live.reduce((sum, i) => sum + i.unitsRemaining, 0);
const totalUnits = live.reduce((sum, i) => sum + i.unitsTotal, 0);
// Dose-derived headline (floor — a partial dose is not a dose).
const remaining = Math.floor(remainingUnits / perDose);
const total = Math.floor(totalUnits / perDose);
const dialogs = (
<>
{addOpen && (
<AddInventoryDialog
medicationId={medicationId}
defaultDosesTotal={dosesPerUnit ?? null}
defaultUnitsTotal={dosesPerUnit ?? null}
unitsPerDose={perDose}
initialContainerType={defaultContainerType(deliveryForm)}
onClose={() => setAddOpen(false)}
/>
)}
@@ -155,6 +202,14 @@ export function InventorySection({
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
{perDose > 1 && (
<span className="text-muted-foreground block text-xs font-normal">
{t("medications.detail.bestand.unitsDetail", {
remaining: remainingUnits,
total: totalUnits,
})}
</span>
)}
</p>
<Button
variant="outline"
@@ -180,10 +235,26 @@ export function InventorySection({
data-slot="inventory-item-row"
>
<span className="text-foreground text-sm">
{t("medications.detail.bestand.doses", {
remaining: item.dosesRemaining,
total: item.dosesTotal,
})}
<span className="block">
{t(
`medications.detail.bestand.containerType.${item.containerType}`,
)}
</span>
<span className="text-muted-foreground block text-xs">
{t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
{perDose > 1 && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: item.unitsRemaining,
total: item.unitsTotal,
})}
</>
)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
@@ -209,32 +280,47 @@ export function InventorySection({
}
/**
* Register a new pen / pack. Two fields (quantity + optional printed
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
* the medication's `dosesPerUnit` so the common case is one tap.
* Register a new pack / container. Quantity (units), container type and
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
* prefills from the medication's `dosesPerUnit` so the common case is
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
* Dosen | Einheiten control converts live — the POST always carries
* units.
*/
function AddInventoryDialog({
export function AddInventoryDialog({
medicationId,
defaultDosesTotal,
defaultUnitsTotal,
unitsPerDose,
initialContainerType,
onClose,
}: {
medicationId: string;
defaultDosesTotal: number | null;
defaultUnitsTotal: number | null;
unitsPerDose: number;
initialContainerType: ContainerType;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dosesTotal, setDosesTotal] = useState(
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
? String(defaultDosesTotal)
const [quantity, setQuantity] = useState(
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
? String(defaultUnitsTotal)
: "",
);
// The unit the typed quantity is read in. Only surfaced when a dose
// spans several units; the stored value is ALWAYS units.
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
const [containerType, setContainerType] =
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
const [busy, setBusy] = useState(false);
const parsed = Number(dosesTotal);
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
const units =
effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
async function submit(e: FormEvent) {
e.preventDefault();
@@ -242,7 +328,9 @@ function AddInventoryDialog({
setBusy(true);
try {
await apiPost(`/api/medications/${medicationId}/inventory`, {
dosesTotal: parsed,
// Wire field name is `dosesTotal`; it carries UNITS.
dosesTotal: units,
containerType,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
@@ -269,6 +357,33 @@ function AddInventoryDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
@@ -276,19 +391,60 @@ function AddInventoryDialog({
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={100}
max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
step={1}
required
autoComplete="off"
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
<p
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
)}
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
@@ -336,7 +492,7 @@ function AddInventoryDialog({
}
/**
* Stock correction for one item: sets the remaining-dose count
* Stock correction for one item: sets the remaining-unit count
* absolutely (covers both "I miscounted" and a manual withdrawal). The
* server clamps to the item's capacity and re-derives the state; 0
* marks the item used up.
@@ -352,12 +508,12 @@ function AdjustInventoryDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [value, setValue] = useState(String(item.dosesRemaining));
const [value, setValue] = useState(String(item.unitsRemaining));
const [busy, setBusy] = useState(false);
const parsed = Number(value);
const valid =
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal;
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
async function submit(e: FormEvent) {
e.preventDefault();
@@ -365,6 +521,7 @@ function AdjustInventoryDialog({
setBusy(true);
try {
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
// Wire field name is `dosesRemaining`; it carries UNITS.
dosesRemaining: parsed,
});
await queryClient.invalidateQueries({
@@ -403,7 +560,7 @@ function AdjustInventoryDialog({
type="number"
inputMode="numeric"
min={0}
max={item.dosesTotal}
max={item.unitsTotal}
step={1}
required
autoComplete="off"
@@ -416,7 +573,7 @@ function AdjustInventoryDialog({
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.dosesTotal,
total: item.unitsTotal,
})}
</p>
</div>
@@ -383,6 +383,50 @@ describe("buildCreateBody", () => {
expect(body.schedules[0].windowEnd).toBe("09:00");
});
it("carries unitsPerDose onto the body and round-trips it on edit-hydrate (v1.16.10)", () => {
const p: WizardPayload = {
...withCadence("daily"),
treatmentRow: "bloodPressure",
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
unitsPerDose: "2",
};
expect(buildCreateBody(p).unitsPerDose).toBe(2);
// The default "1" still ships (an edit back to 1 must reach the server).
expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1);
// An empty field omits it (server default applies).
expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined();
const hydrated = hydrateWizardPayload({
id: "m1",
name: "Foo",
dose: "5 mg",
category: "BLOOD_PRESSURE",
unitsPerDose: 2,
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [],
});
expect(hydrated.unitsPerDose).toBe("2");
// Absent on the snapshot → the default 1.
const hydratedDefault = hydrateWizardPayload({
id: "m1",
name: "Foo",
dose: "5 mg",
category: "BLOOD_PRESSURE",
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [],
});
expect(hydratedDefault.unitsPerDose).toBe("1");
});
it("emits DIABETES category for the diabetes row", () => {
const p: WizardPayload = {
...withCadence("daily"),
@@ -147,13 +147,14 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
value={payload.dosesPerUnit}
onChange={(e) =>
applyPartial({
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
// v1.16.10 — the cap rose to 1000, so four digits.
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 4),
})
}
placeholder={t(
"medications.wizard.steps.step3.dosesPerUnitPlaceholder",
)}
maxLength={3}
maxLength={4}
autoComplete="off"
/>
<p className="text-muted-foreground text-xs">
@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
</div>
</div>
{/* v1.16.10 — units consumed per dose. The inventory counts units
(tablets / ampoules); a multi-unit dose (2 × 2 mg for 4 mg)
decrements several per take and every dose-level readout
divides by this factor. */}
<div className="space-y-2">
<Label htmlFor="wizard-units-per-dose" className="text-sm">
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
</Label>
<Input
id="wizard-units-per-dose"
type="text"
inputMode="numeric"
value={payload.unitsPerDose}
onChange={(e) =>
applyPartial({
unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
})
}
placeholder="1"
maxLength={3}
autoComplete="off"
/>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
</p>
</div>
{/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor.
Shown only for an INJECTION delivery form. Default off; enabling
it surfaces the post-dose site prompt and the rotation
@@ -219,6 +219,11 @@ export interface WizardPayload {
* inventory tracking off (maps to `undefined` / NULL on the wire).
*/
dosesPerUnit: string;
/**
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets for
* a 4 mg dose). Empty string / "1" both map to the server default 1.
*/
unitsPerDose: string;
/**
* v1.8.5 — per-medication injection-site tracking opt-in. Only
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
@@ -283,6 +288,7 @@ export function emptyWizardPayload(): WizardPayload {
treatmentRow: null,
deliveryForm: "ORAL",
dosesPerUnit: "",
unitsPerDose: "1",
trackInjectionSites: false,
allowedInjectionSites: [],
mode: draft.mode,
@@ -528,6 +534,8 @@ export interface CreateMedicationBody {
deliveryForm: MedicationDeliveryForm;
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
dosesPerUnit?: number;
/** v1.16.10 — inventory units per dose. Omitted at the default 1. */
unitsPerDose?: number;
/** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */
@@ -673,6 +681,7 @@ export function buildCreateBody(
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
@@ -683,6 +692,12 @@ export function buildCreateBody(
parsedDosesPerUnit >= 1 && {
dosesPerUnit: parsedDosesPerUnit,
}),
// v1.16.10 — always sent when valid (an edit back to 1 must reach
// the server; the create default matches the schema default).
...(Number.isFinite(parsedUnitsPerDose) &&
parsedUnitsPerDose >= 1 && {
unitsPerDose: parsedUnitsPerDose,
}),
// v1.8.5 — injection-site tracking is only meaningful for an
// INJECTION delivery form. Always send the boolean for an injection
// (so edits can deactivate it); send the allowed list only when
@@ -903,6 +918,8 @@ export interface MedicationPayload {
deliveryForm?: string;
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
dosesPerUnit?: number | null;
/** v1.16.10 — inventory units per dose (default 1). */
unitsPerDose?: number | null;
/** v1.8.5 — injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
@@ -1014,6 +1031,10 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
? String(initial.dosesPerUnit)
: "",
unitsPerDose:
typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1
? String(initial.unitsPerDose)
: "1",
trackInjectionSites: initial.trackInjectionSites ?? false,
allowedInjectionSites: (initial.allowedInjectionSites ??
[]) as InjectionSiteKey[],