From 7c4b54e3ea51736643509ffd3b0be13590cb8f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 18:56:43 +0200 Subject: [PATCH] fix(medications): let the custom dose-window toggle stay on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-dose "custom window" switch could never be turned on. Switching it on seeded the range from the default band and emitted immediately, but the serialiser drops a range equal to the default band, so the persisted value round-tripped straight back to empty, the controlled `checked` recomputed false, and the switch snapped off. The explicit range fields only appear once the switch is on, so there was no way to edit the range into a non-default one first — a deadlock. Track the opened-switch intent in local editor state, decoupled from serialisation. A toggled-on row renders its default band immediately and the switch stays on; an explicit range persists only once the user edits it away from the default. Toggling off drops any stored range back to the default derivation. --- .../scheduling/__tests__/dose-window.test.ts | 15 ++++++ .../scheduling/dose-window-editor.tsx | 46 +++++++++++++++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/components/medications/scheduling/__tests__/dose-window.test.ts b/src/components/medications/scheduling/__tests__/dose-window.test.ts index 0e0737a7a..4e3508de6 100644 --- a/src/components/medications/scheduling/__tests__/dose-window.test.ts +++ b/src/components/medications/scheduling/__tests__/dose-window.test.ts @@ -158,6 +158,21 @@ describe("dose-window helpers", () => { ]; expect(rowsToEntries(rows)).toEqual([]); }); + + // v1.25.13 regression — a row toggled custom but still sitting at the + // default band serialises to `[]`. This is WHY tracks + // the "opened" switch state locally instead of reconstructing it from the + // persisted value: a value round-trip would drop the row and snap the + // switch back off, so the toggle could never turn on until an explicit + // range existed — which the editor won't reveal until the toggle is on. + it("round-trips a default-band custom row to empty (the toggle-deadlock trap)", () => { + const seeded = buildRows(["08:00"], []); // custom:false, band 07:00–09:00 + const openedAtDefault: DoseWindowRow[] = seeded.map((r) => ({ + ...r, + custom: true, + })); + expect(rowsToEntries(openedAtDefault)).toEqual([]); + }); }); describe("isOrderedRange", () => { diff --git a/src/components/medications/scheduling/dose-window-editor.tsx b/src/components/medications/scheduling/dose-window-editor.tsx index acfe00943..0a39d305f 100644 --- a/src/components/medications/scheduling/dose-window-editor.tsx +++ b/src/components/medications/scheduling/dose-window-editor.tsx @@ -18,7 +18,7 @@ * section chrome the other Zeitplan / Erinnerung rows use. */ -import { useCallback, useId, useMemo } from "react"; +import { useCallback, useId, useMemo, useState } from "react"; import { TimeField } from "@/components/ui/time-field"; import { Switch } from "@/components/ui/switch"; @@ -26,7 +26,6 @@ import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import { buildRows, - defaultBandForTime, isOrderedRange, isValidHhmm, lateTailDays, @@ -68,7 +67,31 @@ export function DoseWindowEditor({ const generatedId = useId(); const prefix = idPrefix ?? generatedId; - const rows = useMemo(() => buildRows(timesOfDay, value), [timesOfDay, value]); + // v1.25.13 — the switch reflects the user's INTENT to customise, held in + // local state, because it cannot be reconstructed from `value` alone: the + // persisted contract stores ONLY ranges that differ from the default band + // (`rowsToEntries` drops a default-equal range so the column stays minimal). + // The former editor seeded the range from the default band on toggle-ON and + // emitted immediately, so serialisation dropped it right back to `[]`, the + // controlled `checked={row.custom}` recomputed `false`, and the switch + // snapped off — the toggle could never turn on without first editing a range + // the editor wouldn't reveal until the toggle was on (a deadlock). Tracking + // the opened set here decouples the toggle from serialisation: the row shows + // its default band, and an explicit range only persists once it differs. + const [opened, setOpened] = useState>( + () => new Set(), + ); + + const rows = useMemo(() => { + const base = buildRows(timesOfDay, value); + if (opened.size === 0) return base; + // A row the user opened reads as custom even while its range still equals + // the default band (nothing persisted yet); a row already carrying a + // stored explicit range is custom via `value` regardless. + return base.map((r) => + opened.has(r.timeOfDay) ? { ...r, custom: true } : r, + ); + }, [timesOfDay, value, opened]); // Rebuild the persisted set from the next row state and bubble it up. const emit = useCallback( @@ -89,12 +112,17 @@ export function DoseWindowEditor({ const toggleCustom = useCallback( (row: DoseWindowRow, custom: boolean) => { - if (custom) { - // Switching ON seeds the range from the current default band so - // the user starts from the same band the engine would derive. - const def = defaultBandForTime(row.timeOfDay); - setRow(row.timeOfDay, { custom: true, start: def.start, end: def.end }); - } else { + setOpened((prev) => { + const next = new Set(prev); + if (custom) next.add(row.timeOfDay); + else next.delete(row.timeOfDay); + return next; + }); + // Switching OFF drops any persisted explicit range for the slot (back to + // the default derivation). Switching ON needs no emit — the row already + // renders the default band and the opened set keeps the switch on; a + // range only persists once the user edits it away from the default. + if (!custom) { setRow(row.timeOfDay, { custom: false }); } },