From 1e040605be31f1ec9ea4984d8abef452d901e25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 09:58:40 +0200 Subject: [PATCH] feat(medications): accept units per dose like 1.5 and let the editor enter them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/api/openapi.yaml | 39 +++-- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/ko.json | 9 +- messages/pl.json | 9 +- .../api/medications/__tests__/route.test.ts | 36 ++++ .../__tests__/supply-runway.test.ts | 26 +++ .../__tests__/units-per-dose-field.test.tsx | 121 ++++++++++++++ .../__tests__/units-per-dose.test.ts | 78 ++++----- .../medications/glp1-medication-card.tsx | 6 +- .../medications/medication-card.tsx | 4 +- .../sections/inventory-dialogs.tsx | 30 ++-- .../sections/inventory-section.tsx | 3 +- .../medications/units-per-dose-field.tsx | 155 ++++++++++++++++++ src/components/medications/units-per-dose.ts | 78 +++------ .../wizard/__tests__/wizard-payload.test.ts | 62 +++++++ .../medications/wizard/steps/step3-dose.tsx | 44 ++--- .../medications/wizard/steps/step7-times.tsx | 50 ++---- .../medications/wizard/wizard-payload.ts | 38 +++-- .../__tests__/units-per-dose.test.ts | 139 ++++++++++++++++ .../inventory/__tests__/consumption.test.ts | 36 ++++ src/lib/medications/units-per-dose.ts | 138 ++++++++++++++++ src/lib/openapi/routes/medications/schemas.ts | 2 +- src/lib/validations/__tests__/backup.test.ts | 40 +++++ .../__tests__/medication-dose-windows.test.ts | 19 +++ .../__tests__/medication-inventory.test.ts | 28 +++- src/lib/validations/backup.ts | 23 ++- src/lib/validations/medication/base.ts | 28 ---- .../validations/medication/create-update.ts | 12 +- src/lib/validations/medication/schedule.ts | 8 +- 33 files changed, 1030 insertions(+), 276 deletions(-) create mode 100644 src/components/medications/__tests__/units-per-dose-field.test.tsx create mode 100644 src/components/medications/units-per-dose-field.tsx create mode 100644 src/lib/medications/__tests__/units-per-dose.test.ts create mode 100644 src/lib/medications/units-per-dose.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0ad19a9d..45e68d01d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index e4e72dbce..23bff9031 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 0c340894f..9ed4cb301 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index e9d0ca45c..84cb6b412 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 5842e22d8..daec854b9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 0e0cd8098..7e013cc68 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 9e0246818..c2df0a61f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 188ac08d9..f19caa18f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 4f42bd4d0..50c3e9e96 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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 }> }; + }; + 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); diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index fc8deee61..dd16adf8a 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -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); + }); +}); diff --git a/src/components/medications/__tests__/units-per-dose-field.test.tsx b/src/components/medications/__tests__/units-per-dose-field.test.tsx new file mode 100644 index 000000000..9a6bfc953 --- /dev/null +++ b/src/components/medications/__tests__/units-per-dose-field.test.tsx @@ -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( + {node}, + ); +} + +function field(value: string, inherit?: { label: string }) { + return ( + {}} + labelId="upd-label" + inputId="upd-other" + dataSlot="upd" + inherit={inherit} + /> + ); +} + +function pressed(html: string): string[] { + return [...html.matchAll(/aria-pressed="true"[^>]*>([^<]*) m[1], + ); +} + +describe("", () => { + 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(" 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( + , + "de", + ); + expect(html).toContain("1 Dosis = 1½ Einheiten"); + expect(html).toContain("20 von 20 Dosen übrig"); + }); +}); diff --git a/src/components/medications/__tests__/units-per-dose.test.ts b/src/components/medications/__tests__/units-per-dose.test.ts index b8766ee1c..0891582ae 100644 --- a/src/components/medications/__tests__/units-per-dose.test.ts +++ b/src/components/medications/__tests__/units-per-dose.test.ts @@ -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); - }); -}); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index eaf699ee0..b0f22972e 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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), })} )} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 2d37ad872..b59b2ac0b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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), })} )} diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx index 37e586af2..c2f95cec8 100644 --- a/src/components/medications/sections/inventory-dialogs.tsx +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -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({ setPerDoseValue(e.target.value)} + aria-invalid={ + (perDoseValue.trim() !== "" && !perDoseValid) || undefined + } aria-describedby="packaging-units-per-dose-helper" />

{t("medications.detail.bestand.packagingUnitsPerDose", { - units: perDose, + units: formatUnitsPerDose(perDose, locale), })} {dosesPerUnit != null diff --git a/src/components/medications/units-per-dose-field.tsx b/src/components/medications/units-per-dose-field.tsx new file mode 100644 index 000000000..ec97e4636 --- /dev/null +++ b/src/components/medications/units-per-dose-field.tsx @@ -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(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 ( +

+
+ {inherit && ( + + )} + {UNITS_PER_DOSE_OPTIONS.map((opt) => { + const selected = !otherOpen && value === opt.raw; + return ( + + ); + })} + +
+ {otherOpen && ( +
+ 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 ? ( + + ) : parsed !== null ? ( +

+ {t("medications.unitsPerDoseField.preview", { + value: formatUnitsPerDose(parsed, locale), + })} +

+ ) : null} +
+ )} +
+ ); +} diff --git a/src/components/medications/units-per-dose.ts b/src/components/medications/units-per-dose.ts index 1c3c95508..31c876e1a 100644 --- a/src/components/medications/units-per-dose.ts +++ b/src/components/medications/units-per-dose.ts @@ -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; -} diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 6c6897a44..dd7411e25 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -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"), diff --git a/src/components/medications/wizard/steps/step3-dose.tsx b/src/components/medications/wizard/steps/step3-dose.tsx index 4c4ed7683..a473b96dc 100644 --- a/src/components/medications/wizard/steps/step3-dose.tsx +++ b/src/components/medications/wizard/steps/step3-dose.tsx @@ -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) { - {/* 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. */} -
- {unitsPerDoseOptionsFor(payload.unitsPerDose).map((opt) => { - const selected = payload.unitsPerDose === opt.raw; - return ( - - ); - })} -
+ {/* 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. */} + applyPartial({ unitsPerDose: next })} + labelId="wizard-units-per-dose-label" + inputId="wizard-units-per-dose-other" + dataSlot="wizard-units-per-dose" + />

{t("medications.wizard.steps.step3.unitsPerDoseHint")}

diff --git a/src/components/medications/wizard/steps/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx index 78b241790..fd2ae4a4a 100644 --- a/src/components/medications/wizard/steps/step7-times.tsx +++ b/src/components/medications/wizard/steps/step7-times.tsx @@ -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) { -
- - {UNITS_PER_DOSE_OPTIONS.map((opt) => { - const selected = payload.scheduleUnitsPerDose === opt.raw; - return ( - - ); - })} -
+ 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"), + }} + /> diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 45cacaaac..5941ed063 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -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 diff --git a/src/lib/medications/__tests__/units-per-dose.test.ts b/src/lib/medications/__tests__/units-per-dose.test.ts new file mode 100644 index 000000000..3bcd52abc --- /dev/null +++ b/src/lib/medications/__tests__/units-per-dose.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts index 0e899662d..9c2c5ddd4 100644 --- a/src/lib/medications/inventory/__tests__/consumption.test.ts +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -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, diff --git a/src/lib/medications/units-per-dose.ts b/src/lib/medications/units-per-dose.ts new file mode 100644 index 000000000..ba7d012f4 --- /dev/null +++ b/src/lib/medications/units-per-dose.ts @@ -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); +} diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index a544aa571..7ed878d73 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -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() diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 162251544..c200a3d37 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -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) { + 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); + }); +}); diff --git a/src/lib/validations/__tests__/medication-dose-windows.test.ts b/src/lib/validations/__tests__/medication-dose-windows.test.ts index 239aa5fd2..1a818d708 100644 --- a/src/lib/validations/__tests__/medication-dose-windows.test.ts +++ b/src/lib/validations/__tests__/medication-dose-windows.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/validations/__tests__/medication-inventory.test.ts b/src/lib/validations/__tests__/medication-inventory.test.ts index 4f8b8e2f1..0e9cbd289 100644 --- a/src/lib/validations/__tests__/medication-inventory.test.ts +++ b/src/lib/validations/__tests__/medication-inventory.test.ts @@ -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); } }); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 394552477..d7abc0d74 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index b1da554a4..9f37e13f1 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -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 diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts index 119b48c47..b774984ef 100644 --- a/src/lib/validations/medication/create-update.ts +++ b/src/lib/validations/medication/create-update.ts @@ -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` diff --git a/src/lib/validations/medication/schedule.ts b/src/lib/validations/medication/schedule.ts index 5b464a3a2..6aa887d81 100644 --- a/src/lib/validations/medication/schedule.ts +++ b/src/lib/validations/medication/schedule.ts @@ -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))