mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications): accept units per dose like 1.5 and let the editor enter them
A medication's units per dose, a schedule's override and a restored backup now accept any value above 0 and at most 100 with at most four decimal places, which is exactly what the Decimal(10,4) column stores. Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3, 3/4 passed, so one and a half tablets could not be recorded. A restore now refuses a value no write route would accept. The medication editor keeps its buttons and adds an Other field that reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not read, and previews the value it will save. The supply tab's packaging dialog uses the same reader; it refused every non-whole value before, so a medication at half a tablet could not have its packaging saved. Cards and the supply tab show a whole number plus a fraction as 1½. The rule, the input reader and the display format live in one module without imports so the editor and the validators share them. Refs #1034
This commit is contained in:
1 parent
e03cf7c2c8
commit
1e040605be
33 files changed
+1030
-276
No files matched your search
+23
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user