mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
2ee488d541
commit
63baf47c96
9 files changed
+736
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user