feat(medications): per-slot dose and units in the create wizard

Give each parallel schedule its own optional dose and units-per-dose
input on the times step, so a split plan (a whole tablet in the
morning, a half at noon) is ONE medication with three schedules
instead of two drugs that double-count. Leaving both blank inherits
the medication-level values, so a single-dose plan is unchanged. An
as-needed slot that sits alongside scheduled ones now rides the same
medication as a PRN schedule rather than flipping the whole-medication
as-needed flag. New keys land in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 07:58:48 +02:00
1 parent 88123c53da
commit 64093d2124
8 files changed
+201 -3

No files matched your search

+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Abends",
"night": "Nachts"
},
"overrideHint": "Optional — für diese Uhrzeit eine abweichende Dosis oder Stückzahl festlegen. Leer lassen, um den Standard des Medikaments zu verwenden.",
"doseOverrideLabel": "Dosis für diese Uhrzeit (optional)",
"doseOverridePlaceholder": "z. B. eine halbe Tablette",
"unitsOverrideLabel": "Verbrauchte Einheiten zu dieser Uhrzeit",
"unitsOverrideInherit": "Standard",
"short": "Zeiten"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Stelle speichern",
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
"logInjectionSiteSaveFailed": "Injektionsstelle konnte nicht gespeichert werden. Bitte versuche es erneut.",
"perSlotUnits": "{units} pro Dosis",
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
"allowedSitesLabel": "Erlaubte Stellen",
+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Evening",
"night": "Night"
},
"overrideHint": "Optional — set a different dose or unit count just for this time of day. Leave blank to use the medication's default.",
"doseOverrideLabel": "Dose for this time (optional)",
"doseOverridePlaceholder": "e.g. half a tablet",
"unitsOverrideLabel": "Units consumed at this time",
"unitsOverrideInherit": "Default",
"short": "Times"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Save site",
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
"logInjectionSiteSaveFailed": "Could not save the injection site. Please try again.",
"perSlotUnits": "{units} per dose",
"trackInjectionSitesToggle": "Track injection sites",
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
"allowedSitesLabel": "Allowed sites",
+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Tarde",
"night": "Noche"
},
"overrideHint": "Opcional: define una dosis o un número de unidades distinto solo para esta hora. Déjalo en blanco para usar el valor predeterminado del medicamento.",
"doseOverrideLabel": "Dosis para esta hora (opcional)",
"doseOverridePlaceholder": "p. ej. media pastilla",
"unitsOverrideLabel": "Unidades consumidas a esta hora",
"unitsOverrideInherit": "Predeterminado",
"short": "Horas"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Guardar sitio",
"logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.",
"logInjectionSiteSaveFailed": "No se pudo guardar el sitio de inyección. Inténtalo de nuevo.",
"perSlotUnits": "{units} por dosis",
"trackInjectionSitesToggle": "Registrar sitios de inyección",
"trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.",
"allowedSitesLabel": "Sitios permitidos",
+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Soir",
"night": "Nuit"
},
"overrideHint": "Facultatif — définissez une dose ou un nombre d'unités différent pour cet horaire uniquement. Laissez vide pour utiliser la valeur par défaut du médicament.",
"doseOverrideLabel": "Dose pour cet horaire (facultatif)",
"doseOverridePlaceholder": "ex. un demi-comprimé",
"unitsOverrideLabel": "Unités consommées à cet horaire",
"unitsOverrideInherit": "Par défaut",
"short": "Heures"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Enregistrer le site",
"logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.",
"logInjectionSiteSaveFailed": "Impossible d’enregistrer le site d’injection. Veuillez réessayer.",
"perSlotUnits": "{units} par dose",
"trackInjectionSitesToggle": "Suivre les sites d'injection",
"trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.",
"allowedSitesLabel": "Sites autorisés",
+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Sera",
"night": "Notte"
},
"overrideHint": "Facoltativo: imposta una dose o un numero di unità diverso solo per questo orario. Lascia vuoto per usare il valore predefinito del farmaco.",
"doseOverrideLabel": "Dose per questo orario (facoltativo)",
"doseOverridePlaceholder": "es. mezza compressa",
"unitsOverrideLabel": "Unità consumate a questo orario",
"unitsOverrideInherit": "Predefinito",
"short": "Orari"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Salva il sito",
"logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.",
"logInjectionSiteSaveFailed": "Impossibile salvare il sito di iniezione. Riprova.",
"perSlotUnits": "{units} per dose",
"trackInjectionSitesToggle": "Registra i siti di iniezione",
"trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.",
"allowedSitesLabel": "Siti consentiti",
+6
View File
@@ -1476,6 +1476,11 @@
"evening": "Wieczór",
"night": "Noc"
},
"overrideHint": "Opcjonalnie — ustaw inną dawkę lub liczbę jednostek tylko dla tej pory. Pozostaw puste, aby użyć wartości domyślnej leku.",
"doseOverrideLabel": "Dawka dla tej pory (opcjonalnie)",
"doseOverridePlaceholder": "np. pół tabletki",
"unitsOverrideLabel": "Jednostki zużyte o tej porze",
"unitsOverrideInherit": "Domyślnie",
"short": "Godziny"
},
"step8": {
@@ -2061,6 +2066,7 @@
"logInjectionSiteConfirm": "Zapisz miejsce",
"logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.",
"logInjectionSiteSaveFailed": "Nie udało się zapisać miejsca iniekcji. Spróbuj ponownie.",
"perSlotUnits": "{units} na dawkę",
"trackInjectionSitesToggle": "Rejestruj miejsca iniekcji",
"trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.",
"allowedSitesLabel": "Dozwolone miejsca",
@@ -5,6 +5,10 @@ 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 { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./step1-name";
@@ -105,6 +109,73 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
onChange={(doseWindows) => applyPartial({ doseWindows })}
scale={scaleForCadence(payload)}
/>
{/* #219 — per-schedule dose + units override. Leaving both blank keeps
the medication-level values, so a single-dose plan is unchanged; a
split plan (a whole tablet in the morning, a half at noon) sets a
different value on each schedule of ONE medication. */}
<div
className="border-border/60 space-y-3 rounded-lg border p-3"
data-slot="wizard-schedule-override"
>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step7.overrideHint")}
</p>
<div className="space-y-2">
<Label htmlFor="wizard-schedule-dose" className="text-sm">
{t("medications.wizard.steps.step7.doseOverrideLabel")}
</Label>
<Input
id="wizard-schedule-dose"
type="text"
value={payload.scheduleDose}
onChange={(e) => applyPartial({ scheduleDose: e.target.value })}
placeholder={t("medications.wizard.steps.step7.doseOverridePlaceholder")}
maxLength={50}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<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>
</div>
</div>
</div>
);
}
@@ -183,6 +183,18 @@ export interface ScheduleDraft {
cadence: CadenceValue;
subControls: CadenceSubControls;
timesOfDay: string[];
/**
* #219 — per-schedule dose override (empty = inherit the medication
* `dose`). Lets a "1-0.5-0.5" split ride ONE medication with three
* schedules at different doses instead of three separate drugs.
*/
dose?: string;
/**
* #219 — per-schedule units-consumed-per-dose (empty = inherit the
* medication `unitsPerDose`). Decrements the right tablet count for a
* morning-whole / noon-half plan.
*/
unitsPerDose?: string;
/**
* v1.15.18 — per-dose explicit on-time windows. One entry per dose
* time the user widened beyond the symmetric ±1h default; a time with
@@ -247,6 +259,15 @@ export interface WizardPayload {
subControls: CadenceSubControls;
/** Mirrors `schedules[activeScheduleIndex].timesOfDay`. */
timesOfDay: string[];
/**
* #219 — mirrors `schedules[activeScheduleIndex].dose`. The per-slot
* dose + units inputs on Step 7 read + write these flat slots; the
* active-draft helpers keep them in sync with the schedule list. Empty
* string = inherit the medication-level value.
*/
scheduleDose: string;
/** #219 — mirrors `schedules[activeScheduleIndex].unitsPerDose`. */
scheduleUnitsPerDose: string;
/**
* v1.15.18 — mirrors `schedules[activeScheduleIndex].doseWindows`. The
* per-dose window editor in Step 7 reads + writes this flat slot; the
@@ -298,6 +319,8 @@ export function emptyWizardPayload(): WizardPayload {
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
scheduleDose: draft.dose ?? "",
scheduleUnitsPerDose: draft.unitsPerDose ?? "",
doseWindows: draft.doseWindows ?? [],
startsOn: todayUtc(),
endsOn: null,
@@ -326,6 +349,14 @@ export function commitActiveDraft(payload: WizardPayload): WizardPayload {
cadence: payload.cadence,
subControls: payload.subControls,
timesOfDay: payload.timesOfDay,
// #219 — project the per-slot dose / units flat mirror onto the draft.
// Empty string collapses to undefined so the encode path omits the
// field and the schedule inherits the medication-level value.
dose: payload.scheduleDose.trim() === "" ? undefined : payload.scheduleDose,
unitsPerDose:
payload.scheduleUnitsPerDose.trim() === ""
? undefined
: payload.scheduleUnitsPerDose,
doseWindows: payload.doseWindows,
};
const schedules = payload.schedules.slice();
@@ -351,6 +382,8 @@ export function setActiveSchedule(
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
scheduleDose: draft.dose ?? "",
scheduleUnitsPerDose: draft.unitsPerDose ?? "",
doseWindows: draft.doseWindows ?? [],
};
}
@@ -372,6 +405,8 @@ export function addSchedule(payload: WizardPayload): WizardPayload {
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
scheduleDose: draft.dose ?? "",
scheduleUnitsPerDose: draft.unitsPerDose ?? "",
};
}
@@ -397,6 +432,8 @@ export function removeSchedule(
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
scheduleDose: draft.dose ?? "",
scheduleUnitsPerDose: draft.unitsPerDose ?? "",
};
}
@@ -578,6 +615,17 @@ export interface CreateMedicationBody {
rollingIntervalDays?: number;
daysOfWeek?: number[];
intervalWeeks?: number;
/** #219 — per-schedule dose override. Omitted = inherit `dose`. */
dose?: string;
/** #219 — per-schedule units per dose. Omitted = inherit `unitsPerDose`. */
unitsPerDose?: number;
/**
* #219 — schedule type. PRN routes a per-draft as-needed slot onto the
* SAME medication (an as-needed extra dose alongside scheduled ones)
* instead of flipping the whole-medication `asNeeded` flag. Omitted =
* SCHEDULED.
*/
scheduleType?: "SCHEDULED" | "PRN";
/** v1.15.18 — per-dose explicit on-time windows. Omitted when none. */
doseWindows?: DoseWindowEntry[];
}>;
@@ -626,6 +674,11 @@ export function encodeScheduleDraft(
oneShotMedication: boolean,
): CreateMedicationBody["schedules"][number] {
const isOneShot = oneShotMedication || draft.mode === "oneShot";
// #219 — a per-draft as-needed slot rides the SAME medication as a PRN
// schedule (never due / reminded / scored), so an as-needed extra dose
// sits alongside the scheduled ones instead of splitting into a second
// drug. A PRN slot carries no cadence.
const isPrn = draft.mode === "asNeeded";
const times = sortTimes(
isOneShot ? draft.timesOfDay.slice(0, 1) : draft.timesOfDay,
);
@@ -638,6 +691,14 @@ export function encodeScheduleDraft(
timesOfDay: times,
};
if (draft.id) out.id = draft.id;
// #219 — per-slot dose + units overrides. Empty / whitespace collapses to
// 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;
}
if (isPrn) out.scheduleType = "PRN";
// v1.15.18 — emit only the explicit windows that still name a live dose
// time and actually differ from the default ±1h band (a point-equivalent
// window leaves the column on the default derivation).
@@ -648,7 +709,10 @@ export function encodeScheduleDraft(
);
if (windows.length > 0) out.doseWindows = windows;
}
if (!isOneShot) {
// #219 — a PRN slot carries no cadence (the schedule validator rejects a
// PRN with rrule / rollingIntervalDays), so suppress the recurrence block
// exactly as the one-shot path does.
if (!isOneShot && !isPrn) {
if (draft.cadence.rrule !== null) {
out.rrule = draft.cadence.rrule;
} else if (draft.cadence.rollingIntervalDays !== null) {
@@ -695,7 +759,15 @@ export function buildCreateBody(
// v1.16.11 — an as-needed medication carries NO schedule at all; the
// wizard clears the list client-side (the server 422s otherwise).
const isOneShot = committed.mode === "oneShot";
const isAsNeeded = committed.mode === "asNeeded";
// #219 — an as-needed slot only flips the WHOLE-medication asNeeded flag
// (zero schedules, never due / scored) when EVERY draft is as-needed. When
// an as-needed draft sits alongside scheduled ones it is emitted as a PRN
// schedule on the same medication (see `encodeScheduleDraft`), so a
// scheduled plan can carry an as-needed extra dose without a second drug.
const hasScheduledDraft = committed.schedules.some(
(d) => d.mode !== "asNeeded",
);
const isAsNeeded = !isOneShot && !hasScheduledDraft;
const draftsToEmit = isAsNeeded
? []
: isOneShot
@@ -972,11 +1044,15 @@ export interface MedicationPayload {
windowEnd: string;
label?: string | null;
dose?: string | null;
/** #219 — per-schedule units per dose (edit-hydrate). NULL = inherit. */
unitsPerDose?: number | null;
daysOfWeek?: number[];
intervalWeeks?: number;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
/** #219 — per-schedule as-needed slot (PRN) round-trips as a mode. */
scheduleType?: string | null;
/** v1.15.18 — per-dose explicit on-time windows (edit-hydrate). */
doseWindows?: DoseWindowEntry[] | null;
}>;
@@ -1004,6 +1080,9 @@ interface MedicationScheduleSnapshot {
id?: string;
windowStart: string;
windowEnd: string;
dose?: string | null;
unitsPerDose?: number | null;
scheduleType?: string | null;
daysOfWeek?: number[];
intervalWeeks?: number;
timesOfDay?: string[];
@@ -1023,13 +1102,23 @@ function hydrateScheduleDraft(
: schedule.windowStart
? [schedule.windowStart]
: ["08:00"];
// #219 — a PRN schedule round-trips as an as-needed draft; every other
// type hydrates as recurring (one-shot is driven by the medication flag).
const isPrn = !oneShot && schedule.scheduleType === "PRN";
const draft: ScheduleDraft = {
mode: oneShot ? "oneShot" : "recurring",
mode: oneShot ? "oneShot" : isPrn ? "asNeeded" : "recurring",
cadence: cadence.value,
subControls: cadence.subControls,
timesOfDay: times,
};
if (schedule.id) draft.id = schedule.id;
// #219 — round-trip the per-slot dose + units so an edit keeps the split.
if (typeof schedule.dose === "string" && schedule.dose.trim() !== "") {
draft.dose = schedule.dose;
}
if (typeof schedule.unitsPerDose === "number" && schedule.unitsPerDose > 0) {
draft.unitsPerDose = String(schedule.unitsPerDose);
}
// v1.15.18 — round-trip the persisted per-dose windows so an edit keeps
// the user's explicit ranges instead of resetting them to the default.
if (schedule.doseWindows && schedule.doseWindows.length > 0) {
@@ -1089,6 +1178,8 @@ export function hydrateWizardPayload(
cadence: first.cadence,
subControls: first.subControls,
timesOfDay: first.timesOfDay,
scheduleDose: first.dose ?? "",
scheduleUnitsPerDose: first.unitsPerDose ?? "",
doseWindows: first.doseWindows ?? [],
startsOn: initial.startsOn ?? base.startsOn,
endsOn: initial.endsOn,