feat(medications): add the shared per-dose on-time window editor

Introduce <DoseWindowEditor>, the configurable on-time window control
shared by the schedule tab and the wizard. Per dose time the user keeps
the precise time (the symmetric +/-1h default band) or sets an explicit
range such as 07:00-09:00; the row shows the resulting on-time band plus
the cadence-aware late tail. The component is controlled over the
persisted doseWindows contract ([{timeOfDay,start,end}]) and validates
HH:mm, start<=end, and timeOfDay membership to mirror the server schema.

Pure window math lives in dose-window.ts (band derivation, late tail,
default-vs-explicit detection, serialise) so it is unit-tested without a
render. Adds the medications.detail.zeitplan namespace across six
locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 19:38:51 +02:00
1 parent 2ee488d541
commit 63baf47c96
9 files changed
+736

No files matched your search

+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
@@ -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<DoseWindowRow>) => {
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 (
<p
className="text-muted-foreground text-sm"
data-slot="dose-window-editor-empty"
>
{t("medications.detail.zeitplan.window.noTimes")}
</p>
);
}
return (
<div className="space-y-3" data-slot="dose-window-editor">
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.window.intro")}
</p>
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => {
const ordered = isOrderedRange(row.start, row.end);
const lateEnd = lateTailEndHhmm(row.end, scale);
const switchId = `${prefix}-custom-${row.timeOfDay}`;
const startId = `${prefix}-start-${row.timeOfDay}`;
const endId = `${prefix}-end-${row.timeOfDay}`;
return (
<li
key={row.timeOfDay}
className="space-y-2 p-3"
data-slot="dose-window-row"
data-time={row.timeOfDay}
data-custom={row.custom ? "true" : "false"}
>
<div className="flex items-center justify-between gap-3">
<span className="text-foreground text-sm font-medium tabular-nums">
{row.timeOfDay}
</span>
<div className="flex items-center gap-2">
<Label
htmlFor={switchId}
className="text-muted-foreground text-xs"
>
{t("medications.detail.zeitplan.window.explicitToggle")}
</Label>
<Switch
id={switchId}
checked={row.custom}
disabled={disabled}
onCheckedChange={(checked) => toggleCustom(row, checked)}
data-slot="dose-window-explicit-switch"
/>
</div>
</div>
{row.custom ? (
<div className="space-y-2" data-slot="dose-window-range">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor={startId} className="sr-only">
{t("medications.detail.zeitplan.window.startLabel")}
</Label>
<Input
id={startId}
type="time"
value={row.start}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { start: e.target.value })
}
className="h-11 w-32 sm:h-9"
aria-label={t(
"medications.detail.zeitplan.window.startLabel",
)}
data-slot="dose-window-start"
/>
<span className="text-muted-foreground text-sm">–</span>
<Label htmlFor={endId} className="sr-only">
{t("medications.detail.zeitplan.window.endLabel")}
</Label>
<Input
id={endId}
type="time"
value={row.end}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { end: e.target.value })
}
className="h-11 w-32 sm:h-9"
aria-label={t(
"medications.detail.zeitplan.window.endLabel",
)}
data-slot="dose-window-end"
/>
</div>
{isValidHhmm(row.start) &&
isValidHhmm(row.end) &&
!ordered ? (
<p
className="text-destructive text-xs"
role="alert"
data-slot="dose-window-error"
>
{t("medications.detail.zeitplan.window.orderError")}
</p>
) : null}
</div>
) : null}
{/* The resulting on-time band + the late tail. Always shown so
the point case still communicates its implied ±1h band. */}
<p
className="text-muted-foreground text-xs"
data-slot="dose-window-band"
>
{t("medications.detail.zeitplan.window.onTimeBand", {
start: row.start,
end: row.end,
})}
{scale === "dayScale"
? ` · ${t("medications.detail.zeitplan.window.lateDays", {
days: lateTailDays(),
})}`
: lateEnd
? ` · ${t("medications.detail.zeitplan.window.lateUntil", {
time: lateEnd,
})}`
: ""}
</p>
</li>
);
})}
</ul>
</div>
);
}
@@ -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<DoseWindowEntry[]>([
{ 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);
});
});
});
@@ -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 `<DoseWindowEditor>` 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<string, DoseWindowEntry> {
const map = new Map<string, DoseWindowEntry>();
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);
}