diff --git a/messages/de.json b/messages/de.json index d768ca862..9e517ee7c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 856b6bd93..9d5cbbf28 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1848d62e2..c82713070 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index b11b10ec5..ccd97dac0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 25df4a7ab..b0b95afe3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index d27c7ecd8..cf7ab2e7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/wizard/steps/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx index 696781be9..14109c771 100644 --- a/src/components/medications/wizard/steps/step7-times.tsx +++ b/src/components/medications/wizard/steps/step7-times.tsx @@ -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. */} +
+

+ {t("medications.wizard.steps.step7.overrideHint")} +

+
+ + applyPartial({ scheduleDose: e.target.value })} + placeholder={t("medications.wizard.steps.step7.doseOverridePlaceholder")} + maxLength={50} + autoComplete="off" + /> +
+
+ +
+ + {UNITS_PER_DOSE_OPTIONS.map((opt) => { + const selected = payload.scheduleUnitsPerDose === opt.raw; + return ( + + ); + })} +
+
+
); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index df549d54e..7413270a9 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -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,