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

@@ -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,