mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user