mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user