feat(medications): make the Zeitplan tab edit times and windows inline

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 <DoseWindowEditor>. Frequency and
cadence-kind stay structural — the tab links out to the wizard for those.

<ScheduleTimesEditor> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 19:39:05 +02:00
1 parent e849b9cfb9
commit 3ff7b48f95
2 files changed
+317 -2

No files matched your search

@@ -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({
</MedicationDetailSection>
</TabsContent>
{/* 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. */}
<TabsContent value="zeitplan" className="space-y-6 pt-2">
<MedicationDetailSummary
name={medication.name}
@@ -311,6 +322,34 @@ export function MedicationDetailTabs({
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{medication.schedules.length > 0 ? (
<MedicationDetailSection
titleId="medication-zeitplan-times-heading"
title={t("medications.detail.zeitplan.title")}
dataSlot="medication-zeitplan-section"
>
<ScheduleTimesEditor
medicationId={id}
schedules={medication.schedules.map((s) => ({
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")}
/>
</MedicationDetailSection>
) : null}
</TabsContent>
{/* ERINNERUNG — notifications switch + reminder grace (the single
@@ -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 `<DoseWindowEditor>`) — 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<ScheduleEdit[]>(
() =>
schedules.map((s) => ({
timesOfDay: effectiveTimes(s),
doseWindows: s.doseWindows ?? [],
})),
[schedules],
);
const [edits, setEdits] = useState<ScheduleEdit[]>(initial);
const [busy, setBusy] = useState(false);
function patchSchedule(index: number, patch: Partial<ScheduleEdit>) {
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 (
<div className="space-y-6" data-slot="schedule-times-editor">
{schedules.map((s, i) => {
const edit = edits[i];
const scale = scaleForSchedule(s);
const grace =
typeof s.reminderGraceMinutes === "number"
? s.reminderGraceMinutes
: null;
return (
<div
key={s.id}
className="space-y-4"
data-slot="schedule-times-editor-schedule"
data-schedule-id={s.id}
>
{schedules.length > 1 ? (
<h4 className="text-foreground text-sm font-medium">
{s.label ??
t("medications.detail.zeitplan.scheduleLabel", {
index: i + 1,
})}
</h4>
) : null}
<div className="space-y-2">
<Label className="text-foreground text-sm font-medium">
{t("medications.detail.zeitplan.timesTitle")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.timesNote")}
</p>
<TimesOfDayChips
value={edit.timesOfDay}
onChange={(timesOfDay) => patchSchedule(i, { timesOfDay })}
maxChips={
typeof s.rollingIntervalDays === "number" ? 1 : 8
}
/>
</div>
<DoseWindowEditor
timesOfDay={edit.timesOfDay}
value={edit.doseWindows}
onChange={(doseWindows) => patchSchedule(i, { doseWindows })}
scale={scale}
disabled={busy}
idPrefix={`dose-window-${s.id}`}
/>
{grace !== null ? (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-grace-echo"
>
{t("medications.detail.zeitplan.graceEcho", {
minutes: grace,
})}
{onRequestReminderTab ? (
<>
{" · "}
<button
type="button"
onClick={onRequestReminderTab}
className="text-primary underline-offset-2 hover:underline focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-grace-link"
>
{t("medications.detail.zeitplan.graceLink")}
</button>
</>
) : null}
</p>
) : null}
</div>
);
})}
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.fullEditNote")}
</p>
<Button
onClick={() => void save()}
disabled={busy}
aria-busy={busy || undefined}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="zeitplan-save"
>
{t("medications.detail.zeitplan.save")}
</Button>
</div>
</div>
);
}