diff --git a/messages/de.json b/messages/de.json index a1f2634f6..8ab6d3dbb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1533,6 +1533,29 @@ "data": "Daten", "danger": "Gefahrenzone" } + }, + "zeitplan": { + "title": "Zeitplan", + "timesTitle": "Einnahmezeiten", + "timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Für die Häufigkeit (täglich, wöchentlich …) nutze „Vollständig bearbeiten“.", + "scheduleLabel": "Zeitplan {index}", + "fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.", + "graceEcho": "Erinnerungsfenster: {minutes} Minuten", + "graceLink": "In „Erinnerung“ ändern", + "save": "Zeitplan speichern", + "saved": "Zeitplan gespeichert", + "failed": "Zeitplan konnte nicht gespeichert werden", + "window": { + "intro": "Lege je Dosis fest, wann eine Einnahme als pünktlich gilt — ein genauer Zeitpunkt (±1 Std.) oder ein eigenes Fenster wie 07:00–09:00.", + "explicitToggle": "Eigenes Fenster", + "startLabel": "Fensterbeginn", + "endLabel": "Fensterende", + "onTimeBand": "Pünktlich: {start}–{end}", + "lateUntil": "verspätet bis {time}", + "lateDays": "verspätet bis {days} Tage danach", + "orderError": "Der Beginn muss vor dem Ende liegen.", + "noTimes": "Noch keine Einnahmezeiten festgelegt." + } } }, "logInjectionSiteTitle": "Injektionsstelle erfassen", diff --git a/messages/en.json b/messages/en.json index 9b8665c32..58d054157 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1533,6 +1533,29 @@ "data": "Data", "danger": "Danger zone" } + }, + "zeitplan": { + "title": "Schedule", + "timesTitle": "Dose times", + "timesNote": "Adjust each dose's time and on-time window. For the frequency (daily, weekly …) use “Full edit”.", + "scheduleLabel": "Schedule {index}", + "fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.", + "graceEcho": "Reminder window: {minutes} minutes", + "graceLink": "Change under “Reminders”", + "save": "Save schedule", + "saved": "Schedule saved", + "failed": "Could not save the schedule", + "window": { + "intro": "For each dose, set when an intake still counts as on time — a precise time (±1h) or your own window such as 07:00–09:00.", + "explicitToggle": "Custom window", + "startLabel": "Window start", + "endLabel": "Window end", + "onTimeBand": "On time: {start}–{end}", + "lateUntil": "late until {time}", + "lateDays": "late until {days} days after", + "orderError": "The start must be before the end.", + "noTimes": "No dose times set yet." + } } }, "logInjectionSiteTitle": "Log injection site", diff --git a/messages/es.json b/messages/es.json index 0416210ad..a07cce72b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1533,6 +1533,29 @@ "data": "Datos", "danger": "Zona de peligro" } + }, + "zeitplan": { + "title": "Horario", + "timesTitle": "Horas de toma", + "timesNote": "Ajusta la hora y la ventana puntual de cada dosis. Para la frecuencia (diaria, semanal…) usa «Edición completa».", + "scheduleLabel": "Horario {index}", + "fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».", + "graceEcho": "Ventana de recordatorio: {minutes} minutos", + "graceLink": "Cambiar en «Recordatorios»", + "save": "Guardar horario", + "saved": "Horario guardado", + "failed": "No se pudo guardar el horario", + "window": { + "intro": "Para cada dosis, define cuándo una toma sigue siendo puntual: una hora exacta (±1h) o tu propia ventana como 07:00–09:00.", + "explicitToggle": "Ventana propia", + "startLabel": "Inicio de la ventana", + "endLabel": "Fin de la ventana", + "onTimeBand": "Puntual: {start}–{end}", + "lateUntil": "con retraso hasta las {time}", + "lateDays": "con retraso hasta {days} días después", + "orderError": "El inicio debe ser anterior al fin.", + "noTimes": "Aún no hay horas de toma definidas." + } } }, "logInjectionSiteTitle": "Registrar sitio de inyección", diff --git a/messages/fr.json b/messages/fr.json index 5058e0816..75f0bb202 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1533,6 +1533,29 @@ "data": "Données", "danger": "Zone de danger" } + }, + "zeitplan": { + "title": "Horaire", + "timesTitle": "Heures de prise", + "timesNote": "Ajustez l'heure et la fenêtre à l'heure de chaque dose. Pour la fréquence (quotidienne, hebdomadaire…), utilisez « Édition complète ».", + "scheduleLabel": "Horaire {index}", + "fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».", + "graceEcho": "Fenêtre de rappel : {minutes} minutes", + "graceLink": "Modifier dans « Rappels »", + "save": "Enregistrer l'horaire", + "saved": "Horaire enregistré", + "failed": "Impossible d'enregistrer l'horaire", + "window": { + "intro": "Pour chaque dose, définissez quand une prise reste à l'heure : un horaire précis (±1 h) ou votre propre fenêtre comme 07:00–09:00.", + "explicitToggle": "Fenêtre personnalisée", + "startLabel": "Début de la fenêtre", + "endLabel": "Fin de la fenêtre", + "onTimeBand": "À l'heure : {start}–{end}", + "lateUntil": "en retard jusqu'à {time}", + "lateDays": "en retard jusqu'à {days} jours après", + "orderError": "Le début doit précéder la fin.", + "noTimes": "Aucune heure de prise définie pour l'instant." + } } }, "logInjectionSiteTitle": "Enregistrer le site d'injection", diff --git a/messages/it.json b/messages/it.json index 766548cc4..8d6623f95 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1533,6 +1533,29 @@ "data": "Dati", "danger": "Zona pericolosa" } + }, + "zeitplan": { + "title": "Programma", + "timesTitle": "Orari di assunzione", + "timesNote": "Regola l'orario e la finestra puntuale di ogni dose. Per la frequenza (giornaliera, settimanale…) usa «Modifica completa».", + "scheduleLabel": "Programma {index}", + "fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».", + "graceEcho": "Finestra di promemoria: {minutes} minuti", + "graceLink": "Modifica in «Promemoria»", + "save": "Salva programma", + "saved": "Programma salvato", + "failed": "Impossibile salvare il programma", + "window": { + "intro": "Per ogni dose, stabilisci quando un'assunzione è ancora puntuale: un orario preciso (±1 h) o una finestra a tua scelta come 07:00–09:00.", + "explicitToggle": "Finestra personalizzata", + "startLabel": "Inizio finestra", + "endLabel": "Fine finestra", + "onTimeBand": "Puntuale: {start}–{end}", + "lateUntil": "in ritardo fino alle {time}", + "lateDays": "in ritardo fino a {days} giorni dopo", + "orderError": "L'inizio deve precedere la fine.", + "noTimes": "Nessun orario di assunzione impostato." + } } }, "logInjectionSiteTitle": "Registra il sito di iniezione", diff --git a/messages/pl.json b/messages/pl.json index b51a72471..c3266cdd8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1533,6 +1533,29 @@ "data": "Dane", "danger": "Strefa zagrożenia" } + }, + "zeitplan": { + "title": "Harmonogram", + "timesTitle": "Godziny przyjęcia", + "timesNote": "Dostosuj godzinę i okno punktualności każdej dawki. Częstotliwość (codziennie, co tydzień…) zmieniasz w „Pełna edycja”.", + "scheduleLabel": "Harmonogram {index}", + "fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.", + "graceEcho": "Okno przypomnienia: {minutes} minut", + "graceLink": "Zmień w „Przypomnienia”", + "save": "Zapisz harmonogram", + "saved": "Zapisano harmonogram", + "failed": "Nie udało się zapisać harmonogramu", + "window": { + "intro": "Dla każdej dawki ustal, kiedy przyjęcie jest jeszcze punktualne — dokładna godzina (±1 h) lub własne okno, np. 07:00–09:00.", + "explicitToggle": "Własne okno", + "startLabel": "Początek okna", + "endLabel": "Koniec okna", + "onTimeBand": "Punktualnie: {start}–{end}", + "lateUntil": "z opóźnieniem do {time}", + "lateDays": "z opóźnieniem do {days} dni później", + "orderError": "Początek musi być przed końcem.", + "noTimes": "Nie ustawiono jeszcze godzin przyjęcia." + } } }, "logInjectionSiteTitle": "Zarejestruj miejsce iniekcji", diff --git a/src/components/medications/scheduling/DoseWindowEditor.tsx b/src/components/medications/scheduling/DoseWindowEditor.tsx new file mode 100644 index 000000000..0bc13f4bb --- /dev/null +++ b/src/components/medications/scheduling/DoseWindowEditor.tsx @@ -0,0 +1,240 @@ +"use client"; + +/** + * v1.15.18 — per-dose configurable on-time WINDOW editor (Marc's headline + * lever). Shared verbatim by the Zeitplan tab (everyday inline edit) and + * the wizard's Step 7 (structural create / edit) so the window semantics + * are identical wherever a dose time is set. + * + * Per dose time the user expresses EITHER a point time (e.g. 19:00 → + * implies the ±1h default band) OR an explicit RANGE (e.g. 07:00–09:00). + * The editor shows the resulting on-time band plus a cadence-aware + * "verspätet bis …" hint derived from the engine's late tail. It is a + * controlled component over the persisted `DoseWindowEntry[]` contract + * (`{ timeOfDay, start, end }`), validated client-side to match the W7 + * zod: HH:mm, `start <= end`, `timeOfDay ∈ timesOfDay`. + * + * Calm, inset-grouped, AA. No card tint — the surface follows the muted + * section chrome the other Zeitplan / Erinnerung rows use. + */ + +import { useCallback, useId, useMemo } from "react"; + +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "@/lib/i18n/context"; +import { + buildRows, + defaultBandForTime, + isOrderedRange, + isValidHhmm, + lateTailDays, + lateTailEndHhmm, + rowsToEntries, + type DoseWindowEntry, + type DoseWindowRow, + type DoseWindowScale, +} from "./dose-window"; + +export interface DoseWindowEditorProps { + /** The schedule's dose times (HH:mm) — one editor row per time. */ + timesOfDay: string[]; + /** The persisted explicit windows for this schedule. */ + value: DoseWindowEntry[]; + /** Emits the next explicit-window set (point-equivalent rows dropped). */ + onChange: (next: DoseWindowEntry[]) => void; + /** + * Cadence scale — drives the late-tail hint. `intraday` (daily / + * multi-daily) shows a wall-clock late end; `dayScale` (weekly / + * rolling) shows the day-count tail. Defaults to `intraday`. + */ + scale?: DoseWindowScale; + /** Disables every control (e.g. while a save is in flight). */ + disabled?: boolean; + /** Stable id-prefix for the rendered inputs (defaults to a generated id). */ + idPrefix?: string; +} + +export function DoseWindowEditor({ + timesOfDay, + value, + onChange, + scale = "intraday", + disabled = false, + idPrefix, +}: DoseWindowEditorProps) { + const { t } = useTranslations(); + const generatedId = useId(); + const prefix = idPrefix ?? generatedId; + + const rows = useMemo( + () => buildRows(timesOfDay, value), + [timesOfDay, value], + ); + + // Rebuild the persisted set from the next row state and bubble it up. + const emit = useCallback( + (nextRows: DoseWindowRow[]) => { + onChange(rowsToEntries(nextRows)); + }, + [onChange], + ); + + const setRow = useCallback( + (timeOfDay: string, patch: Partial) => { + emit( + rows.map((r) => + r.timeOfDay === timeOfDay ? { ...r, ...patch } : r, + ), + ); + }, + [emit, rows], + ); + + 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 { + setRow(row.timeOfDay, { custom: false }); + } + }, + [setRow], + ); + + if (rows.length === 0) { + return ( +

+ {t("medications.detail.zeitplan.window.noTimes")} +

+ ); + } + + return ( +
+

+ {t("medications.detail.zeitplan.window.intro")} +

+ +
+ ); +} diff --git a/src/components/medications/scheduling/__tests__/dose-window.test.ts b/src/components/medications/scheduling/__tests__/dose-window.test.ts new file mode 100644 index 000000000..0e0737a7a --- /dev/null +++ b/src/components/medications/scheduling/__tests__/dose-window.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRows, + defaultBandForTime, + entriesByTime, + hhmmToMinutes, + isExplicitRange, + isOrderedRange, + isValidHhmm, + lateTailDays, + lateTailEndHhmm, + minutesToHhmm, + rowsToEntries, + type DoseWindowEntry, + type DoseWindowRow, +} from "../dose-window"; + +describe("dose-window helpers", () => { + describe("hh:mm conversion", () => { + it("round-trips minutes", () => { + expect(hhmmToMinutes("07:00")).toBe(420); + expect(hhmmToMinutes("00:00")).toBe(0); + expect(hhmmToMinutes("23:59")).toBe(1439); + expect(minutesToHhmm(420)).toBe("07:00"); + expect(minutesToHhmm(0)).toBe("00:00"); + }); + + it("wraps minutes into the day", () => { + expect(minutesToHhmm(-60)).toBe("23:00"); + expect(minutesToHhmm(1440 + 30)).toBe("00:30"); + }); + + it("validates HH:mm", () => { + expect(isValidHhmm("07:00")).toBe(true); + expect(isValidHhmm("24:00")).toBe(false); + expect(isValidHhmm("7:00")).toBe(false); + expect(isValidHhmm("07:60")).toBe(false); + }); + }); + + describe("defaultBandForTime", () => { + it("centres a ±1h band on the dose time", () => { + expect(defaultBandForTime("08:00")).toEqual({ + start: "07:00", + end: "09:00", + }); + expect(defaultBandForTime("19:00")).toEqual({ + start: "18:00", + end: "20:00", + }); + }); + + it("wraps a band near midnight", () => { + expect(defaultBandForTime("00:30")).toEqual({ + start: "23:30", + end: "01:30", + }); + }); + }); + + describe("late tail", () => { + it("intraday returns end + 3h", () => { + // dailyOverdueMinutes = 240 - 60 = 180. + expect(lateTailEndHhmm("09:00", "intraday")).toBe("12:00"); + }); + + it("day-scale returns null HH:mm (counted in days)", () => { + expect(lateTailEndHhmm("09:00", "dayScale")).toBeNull(); + }); + + it("exposes the 4-day day-scale tail", () => { + expect(lateTailDays()).toBe(4); + }); + }); + + describe("isExplicitRange", () => { + it("is false for a point-equivalent band", () => { + expect( + isExplicitRange({ timeOfDay: "08:00", start: "07:00", end: "09:00" }), + ).toBe(false); + }); + + it("is true for a widened band", () => { + expect( + isExplicitRange({ timeOfDay: "08:00", start: "07:00", end: "11:00" }), + ).toBe(true); + expect( + isExplicitRange({ timeOfDay: "08:00", start: "08:00", end: "09:00" }), + ).toBe(true); + }); + }); + + describe("entriesByTime", () => { + it("indexes by timeOfDay and tolerates undefined", () => { + expect(entriesByTime(undefined).size).toBe(0); + const map = entriesByTime([ + { timeOfDay: "08:00", start: "07:00", end: "11:00" }, + ]); + expect(map.get("08:00")?.end).toBe("11:00"); + }); + }); + + describe("buildRows", () => { + it("sorts times and applies stored explicit ranges", () => { + const rows = buildRows( + ["20:00", "08:00"], + [{ timeOfDay: "08:00", start: "07:00", end: "12:00" }], + ); + expect(rows.map((r) => r.timeOfDay)).toEqual(["08:00", "20:00"]); + expect(rows[0]).toEqual({ + timeOfDay: "08:00", + start: "07:00", + end: "12:00", + custom: true, + }); + // 20:00 has no stored window → default ±1h, not custom. + expect(rows[1]).toEqual({ + timeOfDay: "20:00", + start: "19:00", + end: "21:00", + custom: false, + }); + }); + + it("treats a point-equivalent stored entry as the default (not custom)", () => { + const rows = buildRows( + ["08:00"], + [{ timeOfDay: "08:00", start: "07:00", end: "09:00" }], + ); + expect(rows[0].custom).toBe(false); + }); + + it("drops malformed times", () => { + expect(buildRows(["bad", "08:00"], []).map((r) => r.timeOfDay)).toEqual([ + "08:00", + ]); + }); + }); + + describe("rowsToEntries", () => { + it("keeps only custom rows that differ from default", () => { + const rows: DoseWindowRow[] = [ + { timeOfDay: "08:00", start: "07:00", end: "12:00", custom: true }, + { timeOfDay: "20:00", start: "19:00", end: "21:00", custom: false }, + // custom flag on, but band equals default → dropped. + { timeOfDay: "12:00", start: "11:00", end: "13:00", custom: true }, + ]; + const out = rowsToEntries(rows); + expect(out).toEqual([ + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + ]); + }); + + it("drops rows with malformed bounds", () => { + const rows: DoseWindowRow[] = [ + { timeOfDay: "08:00", start: "07:00", end: "", custom: true }, + ]; + expect(rowsToEntries(rows)).toEqual([]); + }); + }); + + describe("isOrderedRange", () => { + it("accepts start <= end", () => { + expect(isOrderedRange("07:00", "09:00")).toBe(true); + expect(isOrderedRange("09:00", "09:00")).toBe(true); + }); + + it("rejects start > end and malformed input", () => { + expect(isOrderedRange("10:00", "09:00")).toBe(false); + expect(isOrderedRange("bad", "09:00")).toBe(false); + }); + }); +}); diff --git a/src/components/medications/scheduling/dose-window.ts b/src/components/medications/scheduling/dose-window.ts new file mode 100644 index 000000000..0489a0ceb --- /dev/null +++ b/src/components/medications/scheduling/dose-window.ts @@ -0,0 +1,184 @@ +/** + * v1.15.18 — per-dose configurable on-time WINDOW helpers (Marc's + * headline lever). + * + * A dose time can be expressed two ways: + * - a POINT (e.g. 19:00) → the engine's symmetric ±1h default band; + * - an explicit RANGE (e.g. 07:00–09:00) → persisted as a + * `{ timeOfDay, start, end }` entry on the schedule's `doseWindows`. + * + * These pure helpers translate between the persisted `DoseWindowEntry[]` + * contract (validated by `doseWindowEntrySchema`) and the per-time view + * model the `` renders, and derive the displayed + * on-time band + the cadence-aware late tail ("verspätet bis …"). They + * are pure + framework-free so the window math is unit-tested without a + * render. + * + * Defaults mirror `DOSE_WINDOW_DEFAULTS` so a POINT time reads identically + * to the engine's no-override derivation: + * - intraday (daily / multi-daily): ±60 min on-time, +180 min late tail; + * - day-scale (weekly / rolling): ±1 day on-time, +4 days late tail. + */ + +import { DOSE_WINDOW_DEFAULTS } from "@/lib/analytics/compliance"; + +/** One persisted explicit window. Matches `doseWindowEntrySchema`. */ +export interface DoseWindowEntry { + timeOfDay: string; + start: string; + end: string; +} + +/** + * Window scale. `intraday` = a daily / multi-daily med (minute-scale + * bands, the HH:mm range is the on-time window). `dayScale` = a weekly / + * rolling injectable (the on-time window is whole-day; the explicit HH:mm + * range still anchors the same wall-clock target but the late tail is + * counted in days). The caller derives this from the schedule cadence. + */ +export type DoseWindowScale = "intraday" | "dayScale"; + +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** Minutes-since-midnight for a regex-valid HH:mm. */ +export function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + +/** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */ +export function minutesToHhmm(total: number): string { + const wrapped = ((total % 1440) + 1440) % 1440; + const h = Math.floor(wrapped / 60); + const m = wrapped % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +/** True when the literal is a well-formed HH:mm. */ +export function isValidHhmm(value: string): boolean { + return TIME_RE.test(value); +} + +/** + * The symmetric default on-time band around a POINT time (±1h for + * intraday). Day-scale targets still surface the ±1h clock band as the + * default range — the whole-day on-time tolerance is the engine's + * concern; the editor only ever stores a wall-clock HH:mm range. + */ +export function defaultBandForTime(time: string): { start: string; end: string } { + if (!isValidHhmm(time)) return { start: time, end: time }; + const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes; + const centre = hhmmToMinutes(time); + return { + start: minutesToHhmm(centre - half), + end: minutesToHhmm(centre + half), + }; +} + +/** + * The late-tail end after the on-time window closes, scale-aware. For an + * intraday med this is `end + dailyOverdueMinutes` (a wall-clock HH:mm); a + * day-scale med returns `null` for the HH:mm (the tail is counted in days, + * surfaced via {@link lateTailDays}). + */ +export function lateTailEndHhmm( + end: string, + scale: DoseWindowScale, +): string | null { + if (scale === "dayScale") return null; + if (!isValidHhmm(end)) return null; + return minutesToHhmm( + hhmmToMinutes(end) + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes, + ); +} + +/** The late-tail length in days for a day-scale med (the 4-day rule). */ +export function lateTailDays(): number { + return DOSE_WINDOW_DEFAULTS.weeklyOverdueDays; +} + +/** + * An entry is an EXPLICIT range (not the default point band) when its + * `[start, end]` differs from the symmetric ±1h band around `timeOfDay`. + * A point-equivalent entry is dropped on serialise so the column stays + * NULL-equivalent when the user touched nothing meaningful. + */ +export function isExplicitRange(entry: DoseWindowEntry): boolean { + const def = defaultBandForTime(entry.timeOfDay); + return entry.start !== def.start || entry.end !== def.end; +} + +/** Index the persisted entries by `timeOfDay` for O(1) per-time lookup. */ +export function entriesByTime( + entries: DoseWindowEntry[] | undefined, +): Map { + const map = new Map(); + for (const e of entries ?? []) map.set(e.timeOfDay, e); + return map; +} + +/** + * Build the per-time view model the editor renders: for every dose time, + * the explicit range when one is stored, else the default ±1h band, plus a + * `custom` flag (a stored explicit range that differs from the default). + */ +export interface DoseWindowRow { + timeOfDay: string; + start: string; + end: string; + /** True when a stored explicit range differs from the default band. */ + custom: boolean; +} + +export function buildRows( + timesOfDay: string[], + entries: DoseWindowEntry[] | undefined, +): DoseWindowRow[] { + const byTime = entriesByTime(entries); + return [...timesOfDay] + .filter(isValidHhmm) + .sort((a, b) => a.localeCompare(b)) + .map((timeOfDay) => { + const stored = byTime.get(timeOfDay); + if (stored && isExplicitRange(stored)) { + return { + timeOfDay, + start: stored.start, + end: stored.end, + custom: true, + }; + } + const def = defaultBandForTime(timeOfDay); + return { timeOfDay, start: def.start, end: def.end, custom: false }; + }); +} + +/** + * Serialise the editor rows back to the persisted `DoseWindowEntry[]`. + * Only rows the user marked `custom` and whose `[start, end]` actually + * differs from the default band survive — a point-equivalent row is + * dropped so the column stays minimal (and a med with no custom windows + * serialises to `[]`, i.e. every slot keeps the default derivation). + * Returns `null`-safe normalised entries; the caller decides whether `[]` + * means "send empty array" or "omit". + */ +export function rowsToEntries(rows: DoseWindowRow[]): DoseWindowEntry[] { + const out: DoseWindowEntry[] = []; + for (const r of rows) { + if (!r.custom) continue; + if (!isValidHhmm(r.start) || !isValidHhmm(r.end)) continue; + const entry: DoseWindowEntry = { + timeOfDay: r.timeOfDay, + start: r.start, + end: r.end, + }; + if (isExplicitRange(entry)) out.push(entry); + } + return out; +} + +/** True when `start <= end` within the day (mirrors the server refine). */ +export function isOrderedRange(start: string, end: string): boolean { + if (!isValidHhmm(start) || !isValidHhmm(end)) return false; + return hhmmToMinutes(start) <= hhmmToMinutes(end); +}