Files
HealthLog/src/components/medications/scheduling/ScheduleTimesEditor.tsx
T
Marc-André Bombeck fb6e494f64 feat(medications): calmer detail page — one card language, six tabs, a status overview
Every detail section now sits on the card surface with the standard
padding instead of a bare border on the page background, the schedule
tab loses its duplicated summary header, and the reminder tab folds
into the schedule tab next to the times it gates (old deep links
redirect). The overview tab becomes a real landing surface — next due
dose, reminder state and supply runway with jump links — instead of a
lone compliance bar. History rows calm down: settled rows carry a
single row menu, actionable rows exactly one visible take button plus
the menu, and the quick-mark toast gains undo. The card menu drops the
advanced-settings entry and its edit action opens the editor it
promises. Schedule-tab controls share one height system, and the
injection charts load on demand so the chart library leaves the
initial bundle.
2026-06-10 13:34:13 +02:00

261 lines
8.6 KiB
TypeScript

"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 owner is the
* Erinnerung card directly below on the same 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,
}: {
medicationId: string;
schedules: EditableSchedule[];
}) {
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,
})}
</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>
);
}