From 3ff7b48f953d5b5c885a51a59b5c29a7d4f77db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 19:39:05 +0200 Subject: [PATCH] feat(medications): make the Zeitplan tab edit times and windows inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Zeitplan tab's read-only summary placeholder with an inline editor for the everyday schedule levers: the dose times-of-day and each dose's on-time window via the shared . Frequency and cadence-kind stay structural — the tab links out to the wizard for those. self-saves via PUT /api/medications/[id] with the full schedules array, preserving every cadence field from the snapshot verbatim and changing only the times and windows; a window whose dose time was removed is dropped before the write. Reminder grace is a read-only echo with a jump to the Reminders tab, its single owner. --- .../detail/MedicationDetailTabs.tsx | 43 ++- .../scheduling/ScheduleTimesEditor.tsx | 276 ++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 src/components/medications/scheduling/ScheduleTimesEditor.tsx diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index ac1f23485..aed58d3e7 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -38,6 +38,8 @@ import { MedicationDetailSummary } from "@/components/medications/medication-det import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; +import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor"; +import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { InventorySection } from "@/components/medications/sections/inventory-section"; import { NotificationsBody } from "@/components/medications/sections/notifications-section"; @@ -78,6 +80,12 @@ interface ScheduleSnapshot { rrule?: string | null; rollingIntervalDays?: number | null; reminderGraceMinutes?: number | null; + scheduleType?: string | null; + cyclicOnWeeks?: number | null; + cyclicOffWeeks?: number | null; + // v1.15.18 — per-dose on-time windows; the GET returns the raw Json + // column, the Zeitplan inline editor round-trips it on save. + doseWindows?: DoseWindowEntry[] | null; } export interface MedicationDetailSnapshot { @@ -142,6 +150,7 @@ function snapshotToWizardPayload( timesOfDay: s.timesOfDay, rrule: s.rrule ?? null, rollingIntervalDays: s.rollingIntervalDays ?? null, + doseWindows: s.doseWindows ?? null, })), }; } @@ -299,8 +308,10 @@ export function MedicationDetailTabs({ - {/* ZEITPLAN — read-only cadence view for now; the inline times + - per-dose window editor lands in the next wave. */} + {/* ZEITPLAN — inline edit of the everyday levers: dose times + + each dose's on-time window. Cadence-kind stays structural + (the hero's "Vollständig bearbeiten"); grace is a read-only + echo here, owned by the Erinnerung tab. */} + {medication.schedules.length > 0 ? ( + + ({ + id: s.id, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + dose: s.dose, + daysOfWeek: s.daysOfWeek, + timesOfDay: s.timesOfDay, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + reminderGraceMinutes: s.reminderGraceMinutes, + scheduleType: s.scheduleType, + cyclicOnWeeks: s.cyclicOnWeeks, + cyclicOffWeeks: s.cyclicOffWeeks, + doseWindows: s.doseWindows, + }))} + onRequestReminderTab={() => onTabChange("erinnerung")} + /> + + ) : null} {/* ERINNERUNG — notifications switch + reminder grace (the single diff --git a/src/components/medications/scheduling/ScheduleTimesEditor.tsx b/src/components/medications/scheduling/ScheduleTimesEditor.tsx new file mode 100644 index 000000000..c0e6dd1bb --- /dev/null +++ b/src/components/medications/scheduling/ScheduleTimesEditor.tsx @@ -0,0 +1,276 @@ +"use client"; + +/** + * v1.15.18 — Zeitplan tab inline schedule editor. + * + * The everyday levers on an EXISTING schedule — the dose times-of-day and + * each dose's on-time window (via ``) — without + * re-walking the wizard. Frequency / cadence-kind stays a structural + * concern: the tab links out to "Vollständig bearbeiten" for that. + * + * Self-saves via `PUT /api/medications/[id]` with the FULL `schedules` + * array (the route replaces schedules wholesale, so every cadence field + * is preserved verbatim from the snapshot and only the times + windows + * change). Reminder grace is a READ-ONLY echo here — its single owner is + * the Erinnerung tab. + * + * Calm, inset-grouped, AA. No card tint. + */ + +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips"; +import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor"; +import { + type DoseWindowEntry, + type DoseWindowScale, +} from "@/components/medications/scheduling/dose-window"; +import { useTranslations } from "@/lib/i18n/context"; +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; + +/** The schedule fields the inline editor reads + round-trips on save. */ +export interface EditableSchedule { + id: string; + windowStart: string; + windowEnd: string; + label: string | null; + dose: string | null; + daysOfWeek: string | null; + timesOfDay?: string[]; + rrule?: string | null; + rollingIntervalDays?: number | null; + reminderGraceMinutes?: number | null; + scheduleType?: string | null; + cyclicOnWeeks?: number | null; + cyclicOffWeeks?: number | null; + doseWindows?: DoseWindowEntry[] | null; +} + +/** Per-schedule working state (times + windows are the only mutable parts). */ +interface ScheduleEdit { + timesOfDay: string[]; + doseWindows: DoseWindowEntry[]; +} + +/** + * Classify the cadence scale for the late-tail hint. A weekly / monthly + * RRULE or any rolling interval is day-scale (the 4-day late rule); a + * daily / multi-daily med is intraday (the ±1h / +3h minute bands). + */ +function scaleForSchedule(s: EditableSchedule): DoseWindowScale { + if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 2) { + return "dayScale"; + } + const rrule = s.rrule ?? ""; + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/.test(rrule)) return "dayScale"; + return "intraday"; +} + +/** Effective dose times for a schedule (timesOfDay, else legacy windowStart). */ +function effectiveTimes(s: EditableSchedule): string[] { + return s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart]; +} + +export function ScheduleTimesEditor({ + medicationId, + schedules, + onRequestReminderTab, +}: { + medicationId: string; + schedules: EditableSchedule[]; + /** Jump the user to the Erinnerung tab (the grace owner). */ + onRequestReminderTab?: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const initial = useMemo( + () => + schedules.map((s) => ({ + timesOfDay: effectiveTimes(s), + doseWindows: s.doseWindows ?? [], + })), + [schedules], + ); + + const [edits, setEdits] = useState(initial); + const [busy, setBusy] = useState(false); + + function patchSchedule(index: number, patch: Partial) { + setEdits((prev) => + prev.map((e, i) => (i === index ? { ...e, ...patch } : e)), + ); + } + + async function save() { + if (busy) return; + setBusy(true); + try { + // Replace schedules wholesale — preserve every cadence field from + // the snapshot field-by-field, change only the times + windows. + const body = { + schedules: schedules.map((s, i) => { + const edit = edits[i]; + const times = + edit.timesOfDay.length > 0 ? edit.timesOfDay : [s.windowStart]; + const recurrence = parseScheduleRecurrence(s.daysOfWeek); + // Drop windows whose timeOfDay no longer names a live dose time + // (so a removed time can't leave an orphaned window the server + // would 422 on). + const liveTimes = new Set(times); + const windows = edit.doseWindows.filter((w) => + liveTimes.has(w.timeOfDay), + ); + return { + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? undefined, + dose: s.dose ?? undefined, + timesOfDay: times, + daysOfWeek: recurrence.daysOfWeek, + intervalWeeks: recurrence.intervalWeeks, + ...(s.rrule ? { rrule: s.rrule } : {}), + ...(typeof s.rollingIntervalDays === "number" + ? { rollingIntervalDays: s.rollingIntervalDays } + : {}), + ...(typeof s.reminderGraceMinutes === "number" + ? { reminderGraceMinutes: s.reminderGraceMinutes } + : {}), + ...(s.scheduleType + ? { + scheduleType: s.scheduleType as + | "SCHEDULED" + | "PRN" + | "CYCLIC", + } + : {}), + ...(typeof s.cyclicOnWeeks === "number" + ? { cyclicOnWeeks: s.cyclicOnWeeks } + : {}), + ...(typeof s.cyclicOffWeeks === "number" + ? { cyclicOffWeeks: s.cyclicOffWeeks } + : {}), + doseWindows: windows, + }; + }), + }; + + const res = await fetch(`/api/medications/${medicationId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + toast.error(t("medications.detail.zeitplan.failed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.detail.zeitplan.saved")); + } catch { + toast.error(t("medications.detail.zeitplan.failed")); + } finally { + setBusy(false); + } + } + + return ( +
+ {schedules.map((s, i) => { + const edit = edits[i]; + const scale = scaleForSchedule(s); + const grace = + typeof s.reminderGraceMinutes === "number" + ? s.reminderGraceMinutes + : null; + return ( +
+ {schedules.length > 1 ? ( +

+ {s.label ?? + t("medications.detail.zeitplan.scheduleLabel", { + index: i + 1, + })} +

+ ) : null} + +
+ +

+ {t("medications.detail.zeitplan.timesNote")} +

+ patchSchedule(i, { timesOfDay })} + maxChips={ + typeof s.rollingIntervalDays === "number" ? 1 : 8 + } + /> +
+ + patchSchedule(i, { doseWindows })} + scale={scale} + disabled={busy} + idPrefix={`dose-window-${s.id}`} + /> + + {grace !== null ? ( +

+ {t("medications.detail.zeitplan.graceEcho", { + minutes: grace, + })} + {onRequestReminderTab ? ( + <> + {" · "} + + + ) : null} +

+ ) : null} +
+ ); + })} + +
+

+ {t("medications.detail.zeitplan.fullEditNote")} +

+ +
+
+ ); +}