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:
Marc-André Bombeck committed 2026-09-25 09:58:55 +02:00
1 parent e03cf7c2c8
commit 1e040605be
33 files changed
+1030 -276

No files matched your search

+23 -16
View File
@@ -23029,9 +23029,10 @@ components:
minimum: 1
maximum: 1000
unitsPerDose:
description: Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a
supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements
this many units per taken dose; dose-derived readouts divide unit counts by it.
description: "Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places. A whole number
(2 tablets of 2 mg for a 4 mg dose), a split pill (0.5, thirds as 0.3333 / 0.6667), a whole number plus a
fraction (1.5, 2.25) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many
units per taken dose; dose-derived readouts divide unit counts by it."
type: number
reorderLeadDays:
description: Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to
@@ -23184,10 +23185,10 @@ components:
type: string
maxLength: 50
unitsPerDose:
description: Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓
/ ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets
one medication decrement a different tablet count at different times of day (a whole tablet in the morning,
a half at noon).
description: Per-schedule inventory units consumed per dose (#219). Above 0 and at most 100 with at most 4 decimal
places (1, 0.5, 1.5). Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one
medication decrement a different tablet count at different times of day (a whole tablet in the morning, a
half at noon).
type: number
daysOfWeek:
description: Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field
@@ -23366,9 +23367,9 @@ components:
maximum: 1000
- type: "null"
unitsPerDose:
description: Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔
/ ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events
keep their recorded consumption.
description: "Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places (1, 0.5, 1.5,
2.25). The intake consumption hook decrements this many units per taken dose; already-stamped intake events
keep their recorded consumption."
type: number
reorderLeadDays:
description: Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level
@@ -42577,8 +42578,10 @@ components:
unitsPerDose:
type: number
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above
0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333)
or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken
dose; dose-derived readouts divide unit counts by it.
reorderLeadDays:
anyOf:
- type: integer
@@ -42833,8 +42836,10 @@ components:
unitsPerDose:
type: number
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above
0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333)
or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken
dose; dose-derived readouts divide unit counts by it.
reorderLeadDays:
anyOf:
- type: integer
@@ -72142,8 +72147,10 @@ components:
unitsPerDose:
type: number
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above
0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333)
or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken
dose; dose-derived readouts divide unit counts by it.
reorderLeadDays:
anyOf:
- type: integer
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Bearbeiten",
"packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.",
"packagingUnitsPerDoseLabel": "Einheiten pro Dosis",
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis (1–100). Bereits erfasste Einnahmen bleiben unverändert.",
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis oder 1,5 für anderthalb (über 0, bis 100). Bereits erfasste Einnahmen bleiben unverändert.",
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
"packagingSuccess": "Packungsangaben gespeichert",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen",
"reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis",
"reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis"
},
"unitsPerDoseField": {
"other": "Andere…",
"inputLabel": "Einheiten pro Dosis, anderer Wert",
"placeholder": "z. B. 1,5 oder 1 1/2",
"invalid": "Gib eine Zahl über 0 und bis 100 ein, zum Beispiel 1,5 oder 1 1/2.",
"preview": "Zählt als {value} pro Dosis"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Edit",
"packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.",
"packagingUnitsPerDoseLabel": "Units per dose",
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose (1–100). Doses already recorded stay unchanged.",
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose, or 1.5 for one and a half (above 0, up to 100). Doses already recorded stay unchanged.",
"packagingDefaultPackLabel": "Default pack size (units)",
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
"packagingSuccess": "Packaging details saved",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged",
"reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose",
"reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose"
},
"unitsPerDoseField": {
"other": "Other…",
"inputLabel": "Units per dose, other value",
"placeholder": "e.g. 1.5 or 1 1/2",
"invalid": "Enter a number above 0 and up to 100, for example 1.5 or 1 1/2.",
"preview": "Counts as {value} per dose"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Editar",
"packagingDescription": "Define cuántas unidades consume una dosis y el tamaño habitual de un envase nuevo, p. ej. tras un cambio de fabricante con otro tamaño de blíster.",
"packagingUnitsPerDoseLabel": "Unidades por dosis",
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg (1–100). Las tomas ya registradas no cambian.",
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg, o 1,5 para uno y medio (más de 0, hasta 100). Las tomas ya registradas no cambian.",
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
"packagingSuccess": "Datos del envase guardados",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "el archivo no registra nada sobre esa dosis: nadie respondió y nada se anotó",
"reasonStatusReminderEvent": "la fila trata de un recordatorio postergado, que no dice nada sobre la dosis",
"reasonStatusNotificationNotSent": "la fila registra que no se envió ninguna notificación, lo cual habla de la aplicación y no de la dosis"
},
"unitsPerDoseField": {
"other": "Otro…",
"inputLabel": "Unidades por dosis, otro valor",
"placeholder": "p. ej., 1,5 o 1 1/2",
"invalid": "Introduce un número mayor que 0 y hasta 100, por ejemplo 1,5 o 1 1/2.",
"preview": "Cuenta como {value} por dosis"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Modifier",
"packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.",
"packagingUnitsPerDoseLabel": "Unités par dose",
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg (1–100). Les prises déjà enregistrées restent inchangées.",
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg, ou 1,5 pour un et demi (plus de 0, jusqu’à 100). Les prises déjà enregistrées restent inchangées.",
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
"packagingSuccess": "Conditionnement enregistré",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "le fichier ne consigne rien sur cette dose : personne n'a répondu et rien n'a été noté",
"reasonStatusReminderEvent": "la ligne concerne un rappel reporté, ce qui ne dit rien de la dose",
"reasonStatusNotificationNotSent": "la ligne consigne qu'aucune notification n'a été envoyée, ce qui parle de l'application et non de la dose"
},
"unitsPerDoseField": {
"other": "Autre…",
"inputLabel": "Unités par dose, autre valeur",
"placeholder": "p. ex. 1,5 ou 1 1/2",
"invalid": "Saisissez un nombre supérieur à 0 et jusqu’à 100, par exemple 1,5 ou 1 1/2.",
"preview": "Compte comme {value} par dose"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Modifica",
"packagingDescription": "Imposta quante unità consuma una dose e la dimensione abituale di una nuova confezione, ad es. dopo un cambio di produttore con blister diverso.",
"packagingUnitsPerDoseLabel": "Unità per dose",
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg (1–100). Le assunzioni già registrate restano invariate.",
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg, oppure 1,5 per una e mezza (oltre 0, fino a 100). Le assunzioni già registrate restano invariate.",
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
"packagingSuccess": "Dati confezione salvati",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "il file non registra nulla su quella dose: nessuno ha risposto e niente è stato annotato",
"reasonStatusReminderEvent": "la riga riguarda un promemoria postposto, che non dice nulla sulla dose",
"reasonStatusNotificationNotSent": "la riga registra che non è stata inviata alcuna notifica, il che riguarda l'app e non la dose"
},
"unitsPerDoseField": {
"other": "Altro…",
"inputLabel": "Unità per dose, altro valore",
"placeholder": "ad es. 1,5 o 1 1/2",
"invalid": "Inserisci un numero maggiore di 0 e fino a 100, ad esempio 1,5 o 1 1/2.",
"preview": "Conta come {value} per dose"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "편집",
"packagingDescription": "1회분에 몇 개를 쓰는지, 새 포장이 보통 몇 개인지 설정해요 — 예를 들어 제조사가 바뀌어 포장 크기가 달라졌을 때요.",
"packagingUnitsPerDoseLabel": "1회분당 개수",
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개 (1–100). 이미 기록된 복용은 그대로예요.",
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개, 한 알 반이면 1.5 (0 초과, 100 이하). 이미 기록된 복용은 그대로예요.",
"packagingDefaultPackLabel": "기본 포장 크기 (개)",
"packagingDefaultPackHelper": "새 포장을 등록할 때 수량을 미리 채워요 (1–1000). 해당 없으면 비워 두세요.",
"packagingSuccess": "포장 정보를 저장했어요",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "파일에 그 복용에 대한 기록이 없어요 — 응답도 없었고 기록된 것도 없어요",
"reasonStatusReminderEvent": "행이 미뤄 둔 알림에 관한 내용이라 복용에 대해 알려주는 게 없어요",
"reasonStatusNotificationNotSent": "행은 알림이 발송되지 않았다는 기록이라 복용이 아니라 앱에 관한 내용이에요"
},
"unitsPerDoseField": {
"other": "기타…",
"inputLabel": "1회분 단위 수, 다른 값",
"placeholder": "예: 1.5 또는 1 1/2",
"invalid": "0보다 크고 100 이하인 숫자를 입력하세요. 예: 1.5 또는 1 1/2",
"preview": "1회분당 {value}(으)로 계산돼요"
}
},
"charts": {
+8 -1
View File
@@ -2016,7 +2016,7 @@
"packagingEdit": "Edytuj",
"packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.",
"packagingUnitsPerDoseLabel": "Jednostki na dawkę",
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg (1–100). Zarejestrowane dawki pozostają bez zmian.",
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg lub 1,5 dla półtorej (powyżej 0, do 100). Zarejestrowane dawki pozostają bez zmian.",
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
"packagingSuccess": "Zapisano dane opakowania",
@@ -2214,6 +2214,13 @@
"reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane",
"reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce",
"reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki"
},
"unitsPerDoseField": {
"other": "Inna…",
"inputLabel": "Jednostki na dawkę, inna wartość",
"placeholder": "np. 1,5 lub 1 1/2",
"invalid": "Wpisz liczbę większą od 0 i nie większą niż 100, na przykład 1,5 lub 1 1/2.",
"preview": "Liczy się jako {value} na dawkę"
}
},
"charts": {
@@ -494,6 +494,42 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
expect(data.unitsPerDose).toBe(2);
});
// #1034 — a whole number plus a fraction reaches the Prisma write
// unchanged, on the medication and on a per-slot override; a value with
// a fifth decimal place is refused before any write.
it("creates a medication with a mixed units-per-dose value", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
unitsPerDose: 1.5,
asNeeded: true,
}),
);
expect(res.status).toBe(201);
expect(lastCreateData().unitsPerDose).toBe(1.5);
});
it("422s a units-per-dose value with more than four decimal places", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
unitsPerDose: 1.23456,
asNeeded: true,
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: Array<string | number> }> };
};
expect(
body.details.issues.some((i) => i.path.includes("unitsPerDose")),
).toBe(true);
});
it("still 422s a scheduled create without any schedule (legacy contract)", async () => {
const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" }));
expect(res.status).toBe(422);
@@ -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>
);
}
+19 -59
View File
@@ -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
@@ -0,0 +1,139 @@
/**
* #1034 — units per dose beyond the curated set.
*
* One rule, shared by every server write path and the web editor: any value
* above 0 and at most 100 with at most four decimal places, which is exactly
* what the `Decimal(10,4)` column stores. The parser reads what a person
* types into the "Other" field; the formatter renders a stored value with a
* fraction glyph when it has one (1½, 2¼, ⅓).
*/
import { describe, expect, it } from "vitest";
import {
UNITS_PER_DOSE_FRACTIONS,
formatUnitsPerDose,
isSupportedUnitsPerDose,
parseUnitsPerDoseInput,
} from "@/lib/medications/units-per-dose";
describe("isSupportedUnitsPerDose", () => {
it("accepts whole numbers 1–100 and the curated fractions", () => {
for (const v of [1, 2, 10, 100, 0.25, 0.3333, 0.5, 0.6667, 0.75]) {
expect(isSupportedUnitsPerDose(v)).toBe(true);
}
});
it("accepts a whole number plus a fraction", () => {
for (const v of [1.5, 2.25, 1.3333, 3.75, 99.5]) {
expect(isSupportedUnitsPerDose(v)).toBe(true);
}
});
it("accepts any decimal with up to four places, as the column stores it", () => {
for (const v of [1.2, 0.8, 2.4, 0.1, 0.0001, 12.3456]) {
expect(isSupportedUnitsPerDose(v)).toBe(true);
}
});
it("refuses zero, negatives, values above 100 and more than four places", () => {
for (const v of [0, -1, -0.5, 100.0001, 100.5, 101, 1.23456, 0.00001]) {
expect(isSupportedUnitsPerDose(v)).toBe(false);
}
});
it("refuses non-finite values", () => {
for (const v of [Number.NaN, Infinity, -Infinity]) {
expect(isSupportedUnitsPerDose(v)).toBe(false);
}
});
it("every curated fraction passes the rule", () => {
for (const f of UNITS_PER_DOSE_FRACTIONS) {
expect(isSupportedUnitsPerDose(f.value)).toBe(true);
}
});
});
describe("parseUnitsPerDoseInput", () => {
it("reads a decimal point and a decimal comma alike", () => {
expect(parseUnitsPerDoseInput("1.5")).toBe(1.5);
expect(parseUnitsPerDoseInput("1,5")).toBe(1.5);
expect(parseUnitsPerDoseInput(" 2,25 ")).toBe(2.25);
expect(parseUnitsPerDoseInput("0.8")).toBe(0.8);
});
it("reads a mixed number written with a slash", () => {
expect(parseUnitsPerDoseInput("1 1/2")).toBe(1.5);
expect(parseUnitsPerDoseInput("2 1/4")).toBe(2.25);
expect(parseUnitsPerDoseInput("3/4")).toBe(0.75);
expect(parseUnitsPerDoseInput("1 1/3")).toBe(1.3333);
expect(parseUnitsPerDoseInput("2/3")).toBe(0.6667);
});
it("reads a fraction glyph, alone or after a whole number", () => {
expect(parseUnitsPerDoseInput("½")).toBe(0.5);
expect(parseUnitsPerDoseInput("1½")).toBe(1.5);
expect(parseUnitsPerDoseInput("1 ½")).toBe(1.5);
expect(parseUnitsPerDoseInput("2⅓")).toBe(2.3333);
});
it("returns null for anything the server would refuse", () => {
for (const raw of [
"",
" ",
"abc",
"0",
"0,0",
"-1",
"101",
"1.23456",
"1/0",
"1 1/2 1",
"1,5,5",
"1.5.5",
"2 abc",
]) {
expect(parseUnitsPerDoseInput(raw)).toBeNull();
}
});
it("does not silently truncate at the comma the way parseFloat would", () => {
// parseFloat("1,5") is 1; the parser must never produce that.
expect(parseUnitsPerDoseInput("1,5")).not.toBe(1);
});
});
describe("formatUnitsPerDose", () => {
it("renders the 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 a whole number plus a fraction as a mixed glyph", () => {
expect(formatUnitsPerDose(1.5)).toBe("1½");
expect(formatUnitsPerDose(2.25)).toBe("2¼");
expect(formatUnitsPerDose(1.3333)).toBe("1⅓");
expect(formatUnitsPerDose(3.75)).toBe("3¾");
});
it("renders whole numbers plainly", () => {
expect(formatUnitsPerDose(1)).toBe("1");
expect(formatUnitsPerDose(10)).toBe("10");
});
it("renders any other decimal in the reader's locale", () => {
expect(formatUnitsPerDose(1.2)).toBe("1.2");
expect(formatUnitsPerDose(1.2, "de")).toBe("1,2");
expect(formatUnitsPerDose(0.8, "en")).toBe("0.8");
expect(formatUnitsPerDose(12.3456, "de")).toBe("12,3456");
});
it("round-trips through the parser for every glyph it emits", () => {
for (const v of [0.5, 1.5, 2.25, 1.3333, 3.75, 7, 1.2]) {
expect(parseUnitsPerDoseInput(formatUnitsPerDose(v))).toBe(v);
}
});
});
@@ -361,6 +361,42 @@ describe("consumeForIntake", () => {
expect(state.events[0].inventoryConsumption).toBeNull();
});
it("consumes 1½ units per dose across two containers and refunds both exactly (#1034)", async () => {
const state: FakeState = {
unitsPerDose: 1.5,
events: [takenEvent()],
items: [
item({
id: "open",
state: "IN_USE",
unitsTotal: 30,
unitsRemaining: 1,
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
}),
item({ id: "next", unitsTotal: 30, unitsRemaining: 30 }),
],
};
const client = makeClient(state);
await consumeForIntake(consumeArgs(client));
// The open container gives its last unit, the next one the half.
expect(state.items.find((i) => i.id === "open")?.unitsRemaining).toBe(0);
expect(state.items.find((i) => i.id === "next")?.unitsRemaining).toBe(29.5);
expect(state.events[0].inventoryConsumption).toEqual([
{ itemId: "open", units: 1 },
{ itemId: "next", units: 0.5 },
]);
await restoreForIntake({
client: client as never,
userId: "user-1",
eventId: "evt-1",
});
expect(state.items.find((i) => i.id === "open")?.unitsRemaining).toBe(1);
expect(state.items.find((i) => i.id === "next")?.unitsRemaining).toBe(30);
});
it("prefers the IN_USE container over fresher ACTIVE stock", async () => {
const state: FakeState = {
unitsPerDose: 1,
+138
View File
@@ -0,0 +1,138 @@
/**
* Units per dose: the one rule, the input parser and the display format.
*
* `Medication.unitsPerDose`, the per-schedule override and the intake stamp
* are all `Decimal(10,4)`. The rule accepts exactly what that column stores
* without rounding: a value above 0 and at most 100 with at most four
* decimal places. That covers whole tablets (2), split tablets (½, ⅓), a
* whole number plus a fraction (1½, 2¼, #1034) and a measured liquid dose
* (0.8 or 2.4 mL when a bottle is tracked in millilitres).
*
* The inventory arithmetic is plain division and subtraction on these
* numbers (`floor(unitsRemaining / unitsPerDose)`, the consumption loop's
* `owed -= take`), and every write back to a Decimal(10,4) column rounds to
* four places, so a free decimal behaves exactly like the thirds that were
* already allowed.
*
* No imports: the web editor bundles this module, and the server validators
* read the same rule from it.
*/
/** Largest units-per-dose value any write path accepts. */
export const UNITS_PER_DOSE_MAX = 100;
/** Decimal places the column keeps. */
const DECIMALS = 4;
const SCALE = 10 ** DECIMALS;
/**
* The fractions with a glyph. The web editor offers them as buttons and the
* formatter renders them; thirds are inexact in decimal and are stored as
* 0.3333 / 0.6667.
*/
export const UNITS_PER_DOSE_FRACTIONS: 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: "¾" },
];
export const UNITS_PER_DOSE_MESSAGE =
"unitsPerDose must be above 0 and at most 100, with at most 4 decimal places (e.g. 1, 0.5, 1.5, 2.25)";
/** Round to the column's four decimal places. */
function roundToColumn(value: number): number {
return Math.round(value * SCALE) / SCALE;
}
export function isSupportedUnitsPerDose(value: number): boolean {
if (!Number.isFinite(value)) return false;
if (value <= 0 || value > UNITS_PER_DOSE_MAX) return false;
// At most four decimal places. The tolerance absorbs binary float noise
// (1.3333 * 10000 = 13332.999999999998), not a fifth digit.
const scaled = value * SCALE;
return Math.abs(scaled - Math.round(scaled)) < 1e-6;
}
const GLYPH_VALUE = new Map(
UNITS_PER_DOSE_FRACTIONS.map((f) => [f.glyph, f.value]),
);
/** "7", "7.5", "7,5" — one number, either decimal separator. */
function parsePlainNumber(raw: string): number | null {
if (!/^\d+(?:[.,]\d+)?$|^[.,]\d+$/.test(raw)) return null;
return Number(raw.replace(",", "."));
}
/** "3/4" — a simple fraction with a non-zero denominator. */
function parseSlashFraction(raw: string): number | null {
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(raw);
if (!m) return null;
const denominator = Number(m[2]);
if (denominator === 0) return null;
return Number(m[1]) / denominator;
}
/**
* Read what a person typed into the "Other" field: "1.5", "1,5" (either
* separator, whatever the locale, since a value up to 100 never needs a
* thousands separator), "1 1/2", "3/4", "1½" or "½". Returns the value
* rounded to the column's four places, or `null` when it is not a number
* the server would accept. Never falls back to `parseFloat`, which reads
* "1,5" as 1.
*/
export function parseUnitsPerDoseInput(raw: string): number | null {
const text = raw.trim();
if (text === "") return null;
let value: number | null = null;
// A trailing glyph, optionally after a whole number: "½", "1½", "1 ½".
const glyph = GLYPH_VALUE.get(text.slice(-1));
if (glyph !== undefined) {
const whole = text.slice(0, -1).trim();
if (whole === "") value = glyph;
else if (/^\d+$/.test(whole)) value = Number(whole) + glyph;
} else {
// "1 1/2" — a whole number, whitespace, a fraction.
const mixed = /^(\d+)\s+(\d+\s*\/\s*\d+)$/.exec(text);
if (mixed) {
const fraction = parseSlashFraction(mixed[2]);
if (fraction !== null) value = Number(mixed[1]) + fraction;
} else {
value = parseSlashFraction(text) ?? parsePlainNumber(text);
}
}
if (value === null) return null;
// A typed fraction such as 1/3 is rounded to the stored 0.3333; a typed
// decimal with a fifth place is refused rather than silently rounded.
const isTypedDecimal = parsePlainNumber(text) !== null;
const candidate = isTypedDecimal ? value : roundToColumn(value);
return isSupportedUnitsPerDose(candidate) ? roundToColumn(candidate) : null;
}
/**
* Render a stored value: "½", "1½", "2¼", "1⅓" when the fractional part has
* a glyph, "2" for a whole number, and otherwise the decimal in the reader's
* locale ("1,2" in German). Without a locale the decimal uses a point.
*/
export function formatUnitsPerDose(value: number, locale?: string): string {
const whole = Math.floor(value + 1e-9);
const fraction = value - whole;
if (fraction < 0.0005) return String(whole);
const hit = UNITS_PER_DOSE_FRACTIONS.find(
(f) => Math.abs(f.value - fraction) < 0.0005,
);
if (hit) return whole > 0 ? `${whole}${hit.glyph}` : hit.glyph;
const rounded = roundToColumn(value);
if (!locale) return String(rounded);
return new Intl.NumberFormat(locale, {
maximumFractionDigits: DECIMALS,
useGrouping: false,
}).format(rounded);
}
@@ -152,7 +152,7 @@ export const medicationResource = z
unitsPerDose: z
.number()
.describe(
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above 0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
),
reorderLeadDays: z
.number()
@@ -611,3 +611,43 @@ describe("backupPayloadSchema — database enum boundaries", () => {
expect(parsed.success).toBe(false);
});
});
// #1034 — a restore applies the same units-per-dose rule as the write
// routes, on the medication and on each schedule, so a hand-edited file
// cannot put a value into the database that no route would accept.
describe("backupPayloadSchema — unitsPerDose", () => {
function withMedication(med: Record<string, unknown>) {
return backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-09-25T07:00:00.000Z",
userId: "u1",
measurements: [],
intakeEvents: [],
medications: [{ name: "Example", dose: "5 mg", ...med }],
});
}
it("accepts the Decimal strings a backup carries, mixed values included", () => {
for (const v of ["1", "0.5", "1.5", "2.25", "1.3333", "1.2"]) {
expect(withMedication({ unitsPerDose: v }).success).toBe(true);
}
});
it("refuses a medication value the write routes would refuse", () => {
for (const v of ["0", "-1", "101", "abc", "1.23456"]) {
expect(withMedication({ unitsPerDose: v }).success).toBe(false);
}
});
it("applies the rule to the per-schedule override, string or number", () => {
const schedule = (unitsPerDose: unknown) => ({
schedules: [{ windowStart: "08:00", windowEnd: "08:00", unitsPerDose }],
});
expect(withMedication(schedule("1.5")).success).toBe(true);
expect(withMedication(schedule(2.25)).success).toBe(true);
expect(withMedication(schedule(null)).success).toBe(true);
expect(withMedication(schedule("0")).success).toBe(false);
expect(withMedication(schedule(150)).success).toBe(false);
expect(withMedication(schedule("x")).success).toBe(false);
});
});
@@ -123,3 +123,22 @@ describe("scheduleSchema doseWindows", () => {
expect(r.success).toBe(true);
});
});
// #1034 — the per-slot override follows the medication-level rule.
describe("scheduleSchema unitsPerDose", () => {
it("accepts a whole number plus a fraction and a free decimal", () => {
for (const v of [1.5, 2.25, 1.3333, 0.8]) {
expect(
scheduleSchema.safeParse(baseSchedule({ unitsPerDose: v })).success,
).toBe(true);
}
});
it("refuses zero, above 100, and more than four decimal places", () => {
for (const v of [0, 100.5, 1.23456]) {
expect(
scheduleSchema.safeParse(baseSchedule({ unitsPerDose: v })).success,
).toBe(false);
}
});
});
@@ -139,7 +139,7 @@ describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100",
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 1.5,
unitsPerDose: 100.5,
}).success,
).toBe(false);
});
@@ -157,8 +157,7 @@ describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100",
});
// v1.16.12 (#316) — fractional dosing: a curated set of split-pill
// fractions is accepted alongside the whole numbers; an arbitrary
// decimal is NOT (the UI only ever sends a curated value).
// fractions is accepted alongside the whole numbers.
it("accepts every curated unitsPerDose fraction on create + update", () => {
for (const v of [0.25, 0.3333, 0.5, 0.6667, 0.75]) {
expect(
@@ -173,13 +172,32 @@ describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100",
}
});
it("rejects an uncurated fractional unitsPerDose", () => {
for (const v of [0.333, 0.1, 0.4, 1.5, 2.5]) {
// #1034 — a whole number plus a fraction (1½, 2¼), and any other value
// the Decimal(10,4) column stores exactly, is accepted on create + update.
it("accepts a mixed or free decimal unitsPerDose with up to four places", () => {
for (const v of [1.5, 2.25, 1.3333, 3.75, 0.1, 0.4, 1.2, 99.5]) {
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: v,
}).success,
).toBe(true);
expect(
updateMedicationSchema.safeParse({ unitsPerDose: v }).success,
).toBe(true);
}
});
it("rejects a unitsPerDose with more than four decimal places", () => {
for (const v of [1.23456, 0.33333, 0.00001]) {
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: v,
}).success,
).toBe(false);
expect(
updateMedicationSchema.safeParse({ unitsPerDose: v }).success,
).toBe(false);
}
});
+21 -2
View File
@@ -82,6 +82,10 @@ import {
organDonorStatusSchema,
} from "@/lib/validations/emergency-profile";
import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy";
import {
UNITS_PER_DOSE_MESSAGE,
isSupportedUnitsPerDose,
} from "@/lib/medications/units-per-dose";
export const BACKUP_SCHEMA_VERSION = "2" as const;
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
@@ -132,6 +136,15 @@ const measurementSchema = z
})
.passthrough();
// #1034 — the restore applies the write routes' units-per-dose rule. A
// backup carries the Decimal column as a string ("1.5"); a hand-authored
// file may carry a number. `Number("")` is 0, so an empty string fails too.
const unitsPerDoseValue = z
.union([z.string(), z.number()])
.refine((v) => isSupportedUnitsPerDose(Number(v)), {
message: UNITS_PER_DOSE_MESSAGE,
});
const medicationScheduleSchema = z
.object({
id: z.string().min(1).optional(),
@@ -141,7 +154,7 @@ const medicationScheduleSchema = z
dose: z.string().nullable().optional(),
// #219 — per-schedule units per dose. Serialised as a Decimal string in a
// DR file (or a number in a hand-authored one); NULL means inherit.
unitsPerDose: z.union([z.string(), z.number()]).nullable().optional(),
unitsPerDose: unitsPerDoseValue.nullable().optional(),
daysOfWeek: z.string().nullable().optional(),
timesOfDay: z.array(z.string()).optional(),
reminderGraceMinutes: z.number().int().nullable().optional(),
@@ -321,7 +334,13 @@ const medicationSchema = z
dose: z.string(),
treatmentClass: z.enum(MedicationCategory).optional(),
dosesPerUnit: z.number().int().nullable().optional(),
unitsPerDose: z.string().min(1).optional(),
unitsPerDose: z
.string()
.min(1)
.refine((v) => isSupportedUnitsPerDose(Number(v)), {
message: UNITS_PER_DOSE_MESSAGE,
})
.optional(),
active: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
pausedAt: isoDateTime.nullable().optional(),
-28
View File
@@ -100,34 +100,6 @@ export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
export type MedicationTreatmentClass =
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
/**
* v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit
* fraction of a tablet (split pills). The UI offers a CURATED set of
* common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds
* are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4)
* column and the runway floor absorb the sub-0.0001 drift. Whole numbers
* 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid
* alongside the fractions. One source of truth — the UI selector and the
* tests import this set so the allowed values can never drift from the
* validator.
*/
export const UNITS_PER_DOSE_FRACTIONS = [
0.25, 0.3333, 0.5, 0.6667, 0.75,
] as const;
export const UNITS_PER_DOSE_MAX_WHOLE = 100;
export function isSupportedUnitsPerDose(value: number): boolean {
if ((UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)) {
return true;
}
return (
Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE
);
}
export const UNITS_PER_DOSE_MESSAGE =
"unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)";
/**
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
* the injection-site picker surfaces for any `INJECTION` dose, and a
@@ -4,14 +4,16 @@ import {
MEDICATION_CATEGORY_VALUES,
MEDICATION_DELIVERY_FORM_VALUES,
MEDICATION_TREATMENT_CLASS_VALUES,
UNITS_PER_DOSE_MESSAGE,
atcCodeField,
injectionSiteEnum,
isSupportedUnitsPerDose,
INJECTION_SITE_VALUES,
rxNormCodeField,
} from "./base";
import { scheduleSchema } from "./schedule";
import {
UNITS_PER_DOSE_MESSAGE,
isSupportedUnitsPerDose,
} from "@/lib/medications/units-per-dose";
import { assertStableExternalId } from "@/lib/validations/external-id";
/**
@@ -113,7 +115,7 @@ export const createMedicationSchema = z
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
"Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places. A whole number (2 tablets of 2 mg for a 4 mg dose), a split pill (0.5, thirds as 0.3333 / 0.6667), a whole number plus a fraction (1.5, 2.25) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
),
/**
* v1.17.0 — optional per-medication reorder lead time in days. The
@@ -269,13 +271,13 @@ export const updateMedicationSchema = z
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
/** v1.16.10 — inventory units one dose consumes. Default 1.
* v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */
* #1034 — any value above 0 and at most 100 with up to 4 decimals. */
unitsPerDose: z
.number()
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
"Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places (1, 0.5, 1.5, 2.25). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
),
/**
* v1.17.0 — per-medication reorder lead time in days (0–60). `null`
+3 -5
View File
@@ -1,13 +1,11 @@
import { z } from "zod/v4";
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base";
import {
RRULE_PROPS,
UNITS_PER_DOSE_MESSAGE,
doseWindowEntrySchema,
isSupportedUnitsPerDose,
timeRegex,
} from "./base";
} from "@/lib/medications/units-per-dose";
export const scheduleSchema = z
.object({
@@ -40,7 +38,7 @@ export const scheduleSchema = z
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓ / ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one medication decrement a different tablet count at different times of day (a whole tablet in the morning, a half at noon).",
"Per-schedule inventory units consumed per dose (#219). Above 0 and at most 100 with at most 4 decimal places (1, 0.5, 1.5). Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one medication decrement a different tablet count at different times of day (a whole tablet in the morning, a half at noon).",
),
daysOfWeek: z
.array(z.number().int().min(0).max(6))