mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(medications): accept units per dose like 1.5 and let the editor enter them
A medication's units per dose, a schedule's override and a restored backup now accept any value above 0 and at most 100 with at most four decimal places, which is exactly what the Decimal(10,4) column stores. Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3, 3/4 passed, so one and a half tablets could not be recorded. A restore now refuses a value no write route would accept. The medication editor keeps its buttons and adds an Other field that reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not read, and previews the value it will save. The supply tab's packaging dialog uses the same reader; it refused every non-whole value before, so a medication at half a tablet could not have its packaging saved. Cards and the supply tab show a whole number plus a fraction as 1½. The rule, the input reader and the display format live in one module without imports so the editor and the validators share them. Refs #1034
This commit is contained in:
1 parent
e03cf7c2c8
commit
1e040605be
33 files changed
+1030
-276
No files matched your search
@@ -345,3 +345,29 @@ describe("slot-aware units math (#219 parity)", () => {
|
||||
expect(estimateUnitsRunwayDays(10, [], 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #1034 — a whole number plus a fraction (1½) and a free decimal (1.2)
|
||||
// run through the same arithmetic as a whole tablet.
|
||||
describe("units runway with a mixed units-per-dose value (#1034)", () => {
|
||||
const morning = schedule({ timesOfDay: ["08:00"] });
|
||||
const evening = schedule({ timesOfDay: ["20:00"] });
|
||||
|
||||
it("burns 1½ units per dose at the medication level", () => {
|
||||
// Two doses a day at 1.5 units = 3 units/day; 30 units last 10 days.
|
||||
expect(estimateDailyUnitsCount([morning, evening], 1.5)).toBeCloseTo(3);
|
||||
expect(estimateUnitsRunwayDays(30, [morning, evening], 1.5)).toBe(10);
|
||||
expect(effectiveUnitsPerDose([morning, evening], 1.5)).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("honours a 2¼ per-slot override beside the inherited value", () => {
|
||||
const heavy = schedule({ timesOfDay: ["20:00"], unitsPerDose: 2.25 });
|
||||
// 1 + 2.25 = 3.25 units/day; 13 units last 4 days.
|
||||
expect(estimateDailyUnitsCount([morning, heavy], 1)).toBeCloseTo(3.25);
|
||||
expect(estimateUnitsRunwayDays(13, [morning, heavy], 1)).toBe(4);
|
||||
});
|
||||
|
||||
it("floors a partial day under a free decimal dose", () => {
|
||||
// 1.2 units/day; 10 units cover 8 whole days (8.33…).
|
||||
expect(estimateUnitsRunwayDays(10, [morning], 1.2)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* #1034 — the units-per-dose control: curated buttons plus an Other field,
|
||||
* and the mixed glyph wherever a stored value is shown.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
|
||||
* The field's open state is derived from the value on mount, which is the
|
||||
* part a static render can pin: a stored value no button holds must open
|
||||
* the field showing it, never fall back to a button.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
import { UnitsPerDoseField } from "../units-per-dose-field";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { InventorySection } = await import("../sections/inventory-section");
|
||||
|
||||
function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function field(value: string, inherit?: { label: string }) {
|
||||
return (
|
||||
<UnitsPerDoseField
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
labelId="upd-label"
|
||||
inputId="upd-other"
|
||||
dataSlot="upd"
|
||||
inherit={inherit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pressed(html: string): string[] {
|
||||
return [...html.matchAll(/aria-pressed="true"[^>]*>([^<]*)</g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
}
|
||||
|
||||
describe("<UnitsPerDoseField>", () => {
|
||||
it("a button value selects its button and keeps the Other field closed", () => {
|
||||
const html = render(field("0.5"));
|
||||
expect(pressed(html)).toEqual(["½"]);
|
||||
expect(html).not.toContain('id="upd-other"');
|
||||
expect(html).toContain("Other…");
|
||||
});
|
||||
|
||||
it("a stored mixed value opens the Other field with it and a glyph preview", () => {
|
||||
const html = render(field("1.5"));
|
||||
expect(pressed(html)).toEqual(["Other…"]);
|
||||
expect(html).toContain('id="upd-other"');
|
||||
expect(html).toContain('value="1.5"');
|
||||
expect(html).toContain('inputMode="decimal"');
|
||||
expect(html).toContain("Counts as 1½ per dose");
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("reads a decimal comma and previews the value in the reader's locale", () => {
|
||||
const html = render(field("1,2"), "de");
|
||||
expect(html).toContain("Zählt als 1,2 pro Dosis");
|
||||
});
|
||||
|
||||
it("text that does not read shows an alert and marks the field invalid", () => {
|
||||
const html = render(field("1,5x"));
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('aria-invalid="true"');
|
||||
expect(html).toContain("Enter a number above 0 and up to 100");
|
||||
expect(html).not.toContain("Counts as");
|
||||
});
|
||||
|
||||
it("the per-slot variant selects Default for an empty value", () => {
|
||||
const html = render(field("", { label: "Default" }));
|
||||
expect(pressed(html)).toEqual(["Default"]);
|
||||
expect(html).not.toContain('id="upd-other"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InventorySection> with a mixed units-per-dose value", () => {
|
||||
it("shows the packaging line as a glyph and counts doses by 1.5", () => {
|
||||
const items = [
|
||||
{
|
||||
id: "i1",
|
||||
state: "IN_USE" as const,
|
||||
containerType: "BLISTER",
|
||||
unitsTotal: 30,
|
||||
unitsRemaining: 30,
|
||||
},
|
||||
];
|
||||
useQueryMock.mockReturnValue({
|
||||
data: {
|
||||
items,
|
||||
summary: summariseSupply(items, 1.5),
|
||||
meta: { total: 1 },
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1.5} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("1 Dosis = 1½ Einheiten");
|
||||
expect(html).toContain("20 von 20 Dosen übrig");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,20 @@
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional-dosing UI mapping.
|
||||
* Units-per-dose buttons ↔ server rule.
|
||||
*
|
||||
* 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.
|
||||
* The editor's buttons must never offer a value the server's validator
|
||||
* would 422, and a value outside the buttons is recognised as such so the
|
||||
* editor opens the "Other" field for it instead of dropping it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
UNITS_PER_DOSE_FRACTIONS,
|
||||
isSupportedUnitsPerDose,
|
||||
} from "@/lib/validations/medication";
|
||||
import { isSupportedUnitsPerDose } from "@/lib/medications/units-per-dose";
|
||||
import {
|
||||
UNITS_PER_DOSE_OPTIONS,
|
||||
UNITS_PER_DOSE_FRACTION_VALUES,
|
||||
formatUnitsPerDose,
|
||||
formatUnitCount,
|
||||
unitsPerDoseOptionsFor,
|
||||
isCuratedUnitsPerDose,
|
||||
} 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(),
|
||||
);
|
||||
});
|
||||
|
||||
describe("units-per-dose buttons ↔ validator alignment", () => {
|
||||
it("offers only server-accepted values", () => {
|
||||
for (const opt of UNITS_PER_DOSE_OPTIONS) {
|
||||
expect(isSupportedUnitsPerDose(opt.value)).toBe(true);
|
||||
@@ -35,21 +22,33 @@ describe("units-per-dose selector ↔ validator alignment", () => {
|
||||
expect(Number(opt.raw)).toBe(opt.value);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels the fractions with their glyph", () => {
|
||||
expect(UNITS_PER_DOSE_OPTIONS.map((o) => o.label)).toEqual([
|
||||
"¼",
|
||||
"⅓",
|
||||
"½",
|
||||
"⅔",
|
||||
"¾",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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("¾");
|
||||
describe("isCuratedUnitsPerDose", () => {
|
||||
it("recognises a button value", () => {
|
||||
expect(isCuratedUnitsPerDose("0.5")).toBe(true);
|
||||
expect(isCuratedUnitsPerDose("2")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders whole / uncurated values as the plain number", () => {
|
||||
expect(formatUnitsPerDose(1)).toBe("1");
|
||||
expect(formatUnitsPerDose(2)).toBe("2");
|
||||
expect(formatUnitsPerDose(10)).toBe("10");
|
||||
it("does not claim a value only the Other field can hold", () => {
|
||||
expect(isCuratedUnitsPerDose("1.5")).toBe(false);
|
||||
expect(isCuratedUnitsPerDose("10")).toBe(false);
|
||||
expect(isCuratedUnitsPerDose("1,5")).toBe(false);
|
||||
expect(isCuratedUnitsPerDose("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,20 +62,3 @@ describe("formatUnitCount — display rounding", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatUnitsPerDose } from "@/components/medications/units-per-dose";
|
||||
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
|
||||
import { getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
@@ -198,7 +198,7 @@ export function Glp1MedicationCard({
|
||||
highlighted = false,
|
||||
}: Glp1MedicationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
|
||||
// last-resort fallback for logged-out mounts and legacy fixtures.
|
||||
const { user } = useAuth();
|
||||
@@ -506,7 +506,7 @@ export function Glp1MedicationCard({
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("medications.perSlotUnits", {
|
||||
units: formatUnitsPerDose(schedule.unitsPerDose),
|
||||
units: formatUnitsPerDose(schedule.unitsPerDose, locale),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatUnitsPerDose } from "@/components/medications/units-per-dose";
|
||||
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
|
||||
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
|
||||
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
@@ -471,7 +471,7 @@ export function MedicationCard({
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("medications.perSlotUnits", {
|
||||
units: formatUnitsPerDose(s.unitsPerDose),
|
||||
units: formatUnitsPerDose(s.unitsPerDose, locale),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
formatUnitsPerDose,
|
||||
parseUnitsPerDoseInput,
|
||||
} from "@/lib/medications/units-per-dose";
|
||||
import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types";
|
||||
import {
|
||||
CONTAINER_TYPES,
|
||||
@@ -526,20 +530,21 @@ export function PackagingDialog({
|
||||
dosesPerUnit: number | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose));
|
||||
// #1034 — opens on the value as the reader writes it ("1,5" in German)
|
||||
// and reads back through the same parser as the wizard's Other field.
|
||||
const [perDoseValue, setPerDoseValue] = useState(() =>
|
||||
formatUnitsPerDose(unitsPerDose, locale),
|
||||
);
|
||||
const [packValue, setPackValue] = useState(
|
||||
dosesPerUnit === null ? "" : String(dosesPerUnit),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsedPerDose = Number(perDoseValue);
|
||||
const perDoseValid =
|
||||
Number.isInteger(parsedPerDose) &&
|
||||
parsedPerDose >= 1 &&
|
||||
parsedPerDose <= 100;
|
||||
const parsedPerDose = parseUnitsPerDoseInput(perDoseValue);
|
||||
const perDoseValid = parsedPerDose !== null;
|
||||
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
|
||||
const packValid =
|
||||
parsedPack === null ||
|
||||
@@ -616,15 +621,16 @@ export function PackagingDialog({
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-units-per-dose"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
maxLength={12}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={perDoseValue}
|
||||
onChange={(e) => setPerDoseValue(e.target.value)}
|
||||
aria-invalid={
|
||||
(perDoseValue.trim() !== "" && !perDoseValid) || undefined
|
||||
}
|
||||
aria-describedby="packaging-units-per-dose-helper"
|
||||
/>
|
||||
<p
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { SupplySummary } from "@/lib/medications/inventory/summary";
|
||||
import {
|
||||
@@ -334,7 +335,7 @@ 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.packagingUnitsPerDose", {
|
||||
units: perDose,
|
||||
units: formatUnitsPerDose(perDose, locale),
|
||||
})}
|
||||
<span className="text-muted-foreground block text-xs font-normal">
|
||||
{dosesPerUnit != null
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
UNITS_PER_DOSE_OPTIONS,
|
||||
isCuratedUnitsPerDose,
|
||||
} from "@/components/medications/units-per-dose";
|
||||
import {
|
||||
formatUnitsPerDose,
|
||||
parseUnitsPerDoseInput,
|
||||
} from "@/lib/medications/units-per-dose";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Units per dose: the curated buttons (¼ ⅓ ½ ⅔ ¾ 1 2 3 4) plus an "Other"
|
||||
* entry that opens a text field for anything else (#1034): "1.5", "1,5",
|
||||
* "1 1/2", "1½", "0.8". The field writes what was typed into the payload;
|
||||
* the wizard reads it with `parseUnitsPerDoseInput` and holds the step
|
||||
* while it does not read. A value no button holds (a stored 1.5, or a
|
||||
* legacy 10) opens with the field showing it, so an edit never drops it.
|
||||
*
|
||||
* `inherit` adds a leading button that clears the value (the per-slot
|
||||
* override's "use the medication's value").
|
||||
*/
|
||||
export function UnitsPerDoseField({
|
||||
value,
|
||||
onChange,
|
||||
labelId,
|
||||
inputId,
|
||||
dataSlot,
|
||||
inherit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
/** Id of the visible label naming the whole control. */
|
||||
labelId: string;
|
||||
/** Id for the Other text field. */
|
||||
inputId: string;
|
||||
dataSlot: string;
|
||||
inherit?: { label: string };
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [otherOpen, setOtherOpen] = useState(
|
||||
() => value.trim() !== "" && !isCuratedUnitsPerDose(value),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const typed = value.trim();
|
||||
const parsed = typed === "" ? null : parseUnitsPerDoseInput(typed);
|
||||
const invalid = otherOpen && typed !== "" && parsed === null;
|
||||
const messageId = `${inputId}-message`;
|
||||
|
||||
function choose(next: string) {
|
||||
setOtherOpen(false);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function openOther() {
|
||||
setOtherOpen(true);
|
||||
// The field mounts on this render; focus it on the next frame.
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={dataSlot}>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{inherit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={!otherOpen && value === "" ? "default" : "outline"}
|
||||
aria-pressed={!otherOpen && value === ""}
|
||||
className="min-w-10"
|
||||
onClick={() => choose("")}
|
||||
>
|
||||
{inherit.label}
|
||||
</Button>
|
||||
)}
|
||||
{UNITS_PER_DOSE_OPTIONS.map((opt) => {
|
||||
const selected = !otherOpen && value === 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={() => choose(opt.raw)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={otherOpen ? "default" : "outline"}
|
||||
aria-pressed={otherOpen}
|
||||
aria-controls={inputId}
|
||||
data-slot={`${dataSlot}-other`}
|
||||
onClick={openOther}
|
||||
>
|
||||
{t("medications.unitsPerDoseField.other")}
|
||||
</Button>
|
||||
</div>
|
||||
{otherOpen && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
maxLength={12}
|
||||
className="max-w-40 tabular-nums"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("medications.unitsPerDoseField.placeholder")}
|
||||
aria-label={t("medications.unitsPerDoseField.inputLabel")}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={typed !== "" ? messageId : undefined}
|
||||
/>
|
||||
{invalid ? (
|
||||
<p
|
||||
id={messageId}
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-slot={`${dataSlot}-invalid`}
|
||||
>
|
||||
{t("medications.unitsPerDoseField.invalid")}
|
||||
</p>
|
||||
) : parsed !== null ? (
|
||||
<p
|
||||
id={messageId}
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot={`${dataSlot}-preview`}
|
||||
>
|
||||
{t("medications.unitsPerDoseField.preview", {
|
||||
value: formatUnitsPerDose(parsed, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional-dosing UI mapping.
|
||||
* Units-per-dose buttons for the web editor.
|
||||
*
|
||||
* 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.
|
||||
* The editor offers a CURATED set as buttons: the split-pill fractions
|
||||
* (¼ ⅓ ½ ⅔ ¾) plus a few whole numbers. Anything else (1½, 2¼, 0.8) goes
|
||||
* through the "Other" field, parsed by `parseUnitsPerDoseInput`. The payload
|
||||
* and the API carry the DECIMAL value; the button shows the glyph. The
|
||||
* fractions come from the same table the server rule and the formatter read
|
||||
* (`@/lib/medications/units-per-dose`), so a button can never offer a value
|
||||
* the server would refuse.
|
||||
*/
|
||||
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: "¾" },
|
||||
];
|
||||
import {
|
||||
UNITS_PER_DOSE_FRACTIONS,
|
||||
formatUnitsPerDose,
|
||||
} from "@/lib/medications/units-per-dose";
|
||||
|
||||
/** The curated whole-number doses shown alongside the fractions. */
|
||||
const WHOLE_OPTIONS = [1, 2, 3, 4] as const;
|
||||
@@ -32,9 +26,9 @@ export interface UnitsPerDoseOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Curated selector options: fractions first (½ reads naturally before 1), then wholes. */
|
||||
/** Curated button options: fractions first (½ reads naturally before 1), then wholes. */
|
||||
export const UNITS_PER_DOSE_OPTIONS: UnitsPerDoseOption[] = [
|
||||
...FRACTION_GLYPHS.map((f) => ({
|
||||
...UNITS_PER_DOSE_FRACTIONS.map((f) => ({
|
||||
value: f.value,
|
||||
raw: String(f.value),
|
||||
label: f.glyph,
|
||||
@@ -42,19 +36,14 @@ export const UNITS_PER_DOSE_OPTIONS: UnitsPerDoseOption[] = [
|
||||
...WHOLE_OPTIONS.map((n) => ({
|
||||
value: n,
|
||||
raw: String(n),
|
||||
label: String(n),
|
||||
label: formatUnitsPerDose(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,
|
||||
);
|
||||
/** True when a payload string is exactly one of the curated buttons. */
|
||||
export function isCuratedUnitsPerDose(raw: string): boolean {
|
||||
return UNITS_PER_DOSE_OPTIONS.some((o) => o.raw === raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a unit count for display — drops the float noise a ⅓-dose
|
||||
@@ -64,32 +53,3 @@ export const UNITS_PER_DOSE_FRACTION_VALUES = FRACTION_GLYPHS.map(
|
||||
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;
|
||||
}
|
||||
@@ -481,6 +481,68 @@ describe("buildCreateBody", () => {
|
||||
expect(hydrated.schedules[0].unitsPerDose).toBe("0.5");
|
||||
});
|
||||
|
||||
it("reads a typed units-per-dose value with a comma, a slash or a glyph (#1034)", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
treatmentRow: "bloodPressure",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
};
|
||||
// parseFloat("1,5") is 1; the body must carry 1.5.
|
||||
expect(buildCreateBody({ ...p, unitsPerDose: "1,5" }).unitsPerDose).toBe(
|
||||
1.5,
|
||||
);
|
||||
expect(buildCreateBody({ ...p, unitsPerDose: "1 1/2" }).unitsPerDose).toBe(
|
||||
1.5,
|
||||
);
|
||||
expect(buildCreateBody({ ...p, unitsPerDose: "2¼" }).unitsPerDose).toBe(
|
||||
2.25,
|
||||
);
|
||||
// The per-slot override goes through the same reader.
|
||||
expect(
|
||||
buildCreateBody({ ...p, scheduleUnitsPerDose: "1,5" }).schedules[0]
|
||||
.unitsPerDose,
|
||||
).toBe(1.5);
|
||||
// Edit-hydrate keeps a mixed value.
|
||||
const hydrated = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Foo",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
unitsPerDose: 1.5,
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [],
|
||||
});
|
||||
expect(hydrated.unitsPerDose).toBe("1.5");
|
||||
});
|
||||
|
||||
it("holds the dose and times steps while a typed units-per-dose value is invalid (#1034)", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
name: "Foo",
|
||||
doseAmount: "5",
|
||||
treatmentRow: "bloodPressure",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
};
|
||||
expect(validateStep({ ...p, unitsPerDose: "1,5" }, 3)).toBe(true);
|
||||
expect(validateStep({ ...p, unitsPerDose: "abc" }, 3)).toBe(false);
|
||||
expect(validateStep({ ...p, unitsPerDose: "0" }, 3)).toBe(false);
|
||||
expect(validateStep({ ...p, unitsPerDose: "101" }, 3)).toBe(false);
|
||||
expect(validateStep({ ...p, unitsPerDose: "" }, 3)).toBe(false);
|
||||
// Step 7: blank means "inherit"; anything typed must be valid.
|
||||
expect(validateStep({ ...p, scheduleUnitsPerDose: "" }, 7)).toBe(true);
|
||||
expect(validateStep({ ...p, scheduleUnitsPerDose: "1 1/2" }, 7)).toBe(true);
|
||||
expect(validateStep({ ...p, scheduleUnitsPerDose: "1,2,3" }, 7)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits DIABETES category for the diabetes row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
|
||||
@@ -21,7 +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 { UnitsPerDoseField } from "@/components/medications/units-per-dose-field";
|
||||
import { DOSE_UNIT_KEYS } from "@/lib/medications/dose-units";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -153,35 +153,19 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
<Label id="wizard-units-per-dose-label" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
|
||||
</Label>
|
||||
{/* 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>
|
||||
{/* v1.16.12 (#316) — curated fraction / whole-number buttons, the
|
||||
most error-resistant input for the common doses. #1034 — plus an
|
||||
Other field for a whole number and a fraction (1½) or any other
|
||||
value up to four decimals; the step holds while it does not read.
|
||||
The decimal value is what the payload + API carry; the button
|
||||
shows the glyph. */}
|
||||
<UnitsPerDoseField
|
||||
value={payload.unitsPerDose}
|
||||
onChange={(next) => applyPartial({ unitsPerDose: next })}
|
||||
labelId="wizard-units-per-dose-label"
|
||||
inputId="wizard-units-per-dose-other"
|
||||
dataSlot="wizard-units-per-dose"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
|
||||
</p>
|
||||
|
||||
@@ -5,10 +5,9 @@ import { Moon, Sun, Sunrise, Sunset } from "lucide-react";
|
||||
import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
|
||||
import { DoseWindowEditor } from "@/components/medications/scheduling/dose-window-editor";
|
||||
import type { DoseWindowScale } from "@/components/medications/scheduling/dose-window";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UNITS_PER_DOSE_OPTIONS } from "@/components/medications/units-per-dose";
|
||||
import { UnitsPerDoseField } from "@/components/medications/units-per-dose-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { StepProps } from "./step1-name";
|
||||
@@ -141,43 +140,16 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
|
||||
<Label id="wizard-schedule-units-label" className="text-sm">
|
||||
{t("medications.wizard.steps.step7.unitsOverrideLabel")}
|
||||
</Label>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="wizard-schedule-units-label"
|
||||
data-slot="wizard-schedule-units"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
payload.scheduleUnitsPerDose === "" ? "default" : "outline"
|
||||
}
|
||||
aria-pressed={payload.scheduleUnitsPerDose === ""}
|
||||
className="min-w-10"
|
||||
onClick={() => applyPartial({ scheduleUnitsPerDose: "" })}
|
||||
>
|
||||
{t("medications.wizard.steps.step7.unitsOverrideInherit")}
|
||||
</Button>
|
||||
{UNITS_PER_DOSE_OPTIONS.map((opt) => {
|
||||
const selected = payload.scheduleUnitsPerDose === 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({ scheduleUnitsPerDose: opt.raw })
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<UnitsPerDoseField
|
||||
value={payload.scheduleUnitsPerDose}
|
||||
onChange={(next) => applyPartial({ scheduleUnitsPerDose: next })}
|
||||
labelId="wizard-schedule-units-label"
|
||||
inputId="wizard-schedule-units-other"
|
||||
dataSlot="wizard-schedule-units"
|
||||
inherit={{
|
||||
label: t("medications.wizard.steps.step7.unitsOverrideInherit"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type MedicationDeliveryForm,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { parseUnitsPerDoseInput } from "@/lib/medications/units-per-dose";
|
||||
import {
|
||||
isExplicitRange,
|
||||
type DoseWindowEntry,
|
||||
@@ -189,7 +190,9 @@ export interface ScheduleDraft {
|
||||
/**
|
||||
* #219 — per-schedule units-consumed-per-dose (empty = inherit the
|
||||
* medication `unitsPerDose`). Decrements the right tablet count for a
|
||||
* morning-whole / noon-half plan.
|
||||
* morning-whole / noon-half plan. Holds what the editor shows: a button
|
||||
* value ("0.5") or the text typed into the Other field ("1,5", "1 1/2"),
|
||||
* read by `parseUnitsPerDoseInput` when the body is built.
|
||||
*/
|
||||
unitsPerDose?: string;
|
||||
/**
|
||||
@@ -233,7 +236,8 @@ export interface WizardPayload {
|
||||
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.
|
||||
* a 4 mg dose). A button value or the text typed into the Other field
|
||||
* ("1,5", "1 1/2", #1034), read by `parseUnitsPerDoseInput`.
|
||||
*/
|
||||
unitsPerDose: string;
|
||||
/**
|
||||
@@ -499,7 +503,10 @@ export function validateStep(payload: WizardPayload, step: number): boolean {
|
||||
case 2:
|
||||
return payload.treatmentRow !== null;
|
||||
case 3:
|
||||
return payload.doseAmount.trim().length > 0;
|
||||
return (
|
||||
payload.doseAmount.trim().length > 0 &&
|
||||
parseUnitsPerDoseInput(payload.unitsPerDose) !== null
|
||||
);
|
||||
case 4:
|
||||
if (payload.startsOn === null) return false;
|
||||
if (payload.endsOn === null) return true;
|
||||
@@ -532,7 +539,12 @@ export function validateStep(payload: WizardPayload, step: number): boolean {
|
||||
}
|
||||
case 7: {
|
||||
const times = payload.timesOfDay.filter((t) => TIME_RE.test(t));
|
||||
return times.length >= 1;
|
||||
// Blank inherits the medication-level value; anything typed must read.
|
||||
const slotUnits = payload.scheduleUnitsPerDose.trim();
|
||||
return (
|
||||
times.length >= 1 &&
|
||||
(slotUnits === "" || parseUnitsPerDoseInput(slotUnits) !== null)
|
||||
);
|
||||
}
|
||||
case 8:
|
||||
if (payload.startsOn === null) return false;
|
||||
@@ -704,8 +716,8 @@ export function encodeScheduleDraft(
|
||||
// omitted so the schedule inherits the medication-level value.
|
||||
if (draft.dose && draft.dose.trim() !== "") out.dose = draft.dose.trim();
|
||||
if (draft.unitsPerDose && draft.unitsPerDose.trim() !== "") {
|
||||
const parsed = Number.parseFloat(draft.unitsPerDose);
|
||||
if (Number.isFinite(parsed) && parsed > 0) out.unitsPerDose = parsed;
|
||||
const parsed = parseUnitsPerDoseInput(draft.unitsPerDose);
|
||||
if (parsed !== null) out.unitsPerDose = parsed;
|
||||
}
|
||||
if (isPrn) out.scheduleType = "PRN";
|
||||
// v1.15.18 — emit only the explicit windows that still name a live dose
|
||||
@@ -787,9 +799,10 @@ export function buildCreateBody(
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 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);
|
||||
// #1034 — the shared reader, not `parseFloat`: "1,5" is 1.5 (parseFloat
|
||||
// reads 1), "1 1/2" and "1½" are 1.5, and a value the server would
|
||||
// refuse is null. Step 3 cannot be left while it is null.
|
||||
const parsedUnitsPerDose = parseUnitsPerDoseInput(committed.unitsPerDose);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
@@ -802,10 +815,9 @@ 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 > 0 && {
|
||||
unitsPerDose: parsedUnitsPerDose,
|
||||
}),
|
||||
...(parsedUnitsPerDose !== null && {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user