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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:13 +02:00
1 parent 6f45d72a84
commit fb6e494f64
19 files changed
+797 -297

No files matched your search

+7 -15
View File
@@ -171,14 +171,15 @@ export default function MedicationsPage() {
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
}
// v1.15.18 — the card kebab actions now navigate to the full-page
// tabbed detail surface on the right tab rather than opening a modal
// editor / sheet. Edit lands on Zeitplan (the everyday schedule view +
// the hero's "Vollständig bearbeiten" jump into the wizard), History on
// Verlauf, Advanced on Erweitert (the dissolved advanced-settings).
// v1.15.20 — the kebab's Edit opens the medication wizard directly:
// it navigates to the detail page with `?edit=1`, which the tabbed
// shell reads synchronously to mount the wizard in edit mode (the same
// `editOpen` path the hero's "Vollständig bearbeiten" uses). History
// still lands on the Verlauf tab; the former Advanced item is retired —
// the Erweitert tab stays reachable through the card body.
function openEdit(med: Medication) {
seedDetail(med);
router.push(`/medications/${med.id}?tab=zeitplan`);
router.push(`/medications/${med.id}?edit=1`);
}
function openHistory(med: Medication) {
@@ -186,11 +187,6 @@ export default function MedicationsPage() {
router.push(`/medications/${med.id}?tab=verlauf`);
}
function openAdvanced(med: Medication) {
seedDetail(med);
router.push(`/medications/${med.id}?tab=erweitert`);
}
function closeDialog() {
setDialogOpen(false);
}
@@ -327,7 +323,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
) : (
<MedicationCard
@@ -335,7 +330,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
),
)}
@@ -357,7 +351,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
) : (
<MedicationCard
@@ -365,7 +358,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
),
)}
@@ -262,7 +262,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
titleId="side-effects-heading"
title={t("medications.sideEffects.section")}
headerExtras={headerExtras}
bodyPaddingY="py-2.5"
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
@@ -349,7 +349,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -358,7 +357,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -194,7 +194,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -208,26 +207,24 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toMatch(/lucide-syringe/i);
});
it("collapses edit / history / advanced into a single overflow kebab + navigable header", () => {
// v1.7.2 W3 — the four former header icon-buttons collapse into one
it("collapses edit / history into a single overflow kebab + navigable header", () => {
// v1.7.2 W3 — the former header icon-buttons collapse into one
// kebab; the card header links to the detail page. The menu items
// (edit / history / advanced) render inside the portalled dropdown
// content, so SSR markup carries only the kebab trigger + the header
// link, not the individual action buttons.
// (edit / history) render inside the portalled dropdown content, so
// SSR markup carries only the kebab trigger + the header link, not
// the individual action buttons. v1.15.20 retires the Advanced item.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const onEdit = vi.fn();
const onOpenHistory = vi.fn();
const onOpenAdvanced = vi.fn();
const html = render(
<Glp1MedicationCard
medication={med7p5}
onEdit={onEdit}
onOpenHistory={onOpenHistory}
onOpenAdvanced={onOpenAdvanced}
/>,
client,
);
@@ -240,13 +237,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
// Smoke-check the handler contract — SSR can't fire DOM events, so
// invoke the handlers the way each menu item's onClick would and pin
// the medication-object payload the parent routes / sheets against.
// the medication-object payload the parent routes against.
onOpenHistory(med7p5);
onOpenAdvanced(med7p5);
onEdit(med7p5);
expect(onOpenHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
expect(onOpenAdvanced).toHaveBeenCalledWith(
expect(onEdit).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
});
@@ -264,7 +261,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={defaultMed}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -288,7 +284,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -315,7 +310,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -364,7 +358,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -397,7 +390,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -428,7 +420,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -454,7 +445,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -479,7 +469,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={handler}
/>,
client,
@@ -516,7 +505,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -540,7 +528,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -565,7 +552,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={paused}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -598,7 +584,6 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
"de",
@@ -101,7 +101,6 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
medication={rollingMed}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -151,7 +150,6 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -206,7 +204,6 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -153,7 +153,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -162,7 +161,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -197,7 +195,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -219,7 +216,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -228,7 +224,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -252,7 +247,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -261,7 +255,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -285,7 +278,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -294,7 +286,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -364,7 +355,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramiprilFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -373,7 +363,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaroFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -426,7 +415,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramiprilBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -435,7 +423,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaroBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -520,7 +507,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={oral}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
@@ -529,7 +515,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={prn}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
@@ -538,7 +523,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={oneShot}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
@@ -547,7 +531,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={glp1}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
@@ -0,0 +1,108 @@
/**
* v1.15.20 — supply-runway estimate behind the detail Übersicht's
* "lasts about N more days" line. Pure helpers, no render needed.
*/
import { describe, it, expect } from "vitest";
import {
estimateDailyDoseCount,
estimateRunwayDays,
type RunwaySchedule,
} from "@/components/medications/detail/supply-runway";
function schedule(partial: Partial<RunwaySchedule>): RunwaySchedule {
return {
windowStart: "08:00",
daysOfWeek: null,
...partial,
};
}
describe("estimateDailyDoseCount", () => {
it("counts a plain daily schedule as its times-of-day per day", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00", "20:00"] }),
]),
).toBe(2);
});
it("falls back to one dose per day when timesOfDay is absent", () => {
expect(estimateDailyDoseCount([schedule({})])).toBe(1);
});
it("scales a rolling interval down (one dose every N days)", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
]),
).toBeCloseTo(1 / 7);
});
it("scales weekday picks against the 7-day week", () => {
// Mon/Wed/Fri → 3 doses per 7 days.
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], daysOfWeek: "1,3,5" }),
]),
).toBeCloseTo(3 / 7);
});
it("honours the encoded interval-weeks cadence", () => {
// Every 2nd week on one weekday → 1 dose per 14 days.
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], daysOfWeek: "i2;1" }),
]),
).toBeCloseTo(1 / 14);
});
it("approximates a monthly RRULE at one dose per 30 days", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }),
]),
).toBeCloseTo(1 / 30);
});
it("sums across multiple schedules", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"] }),
schedule({ timesOfDay: ["20:00"] }),
]),
).toBe(2);
});
});
describe("estimateRunwayDays", () => {
it("divides the remaining doses by the daily consumption", () => {
expect(
estimateRunwayDays(14, [schedule({ timesOfDay: ["08:00", "20:00"] })]),
).toBe(7);
});
it("floors to whole days", () => {
expect(estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })])).toBe(
2,
);
});
it("returns null when no supply remains", () => {
expect(estimateRunwayDays(0, [schedule({})])).toBeNull();
});
it("returns null when no schedule consumes doses", () => {
expect(estimateRunwayDays(10, [])).toBeNull();
});
it("covers the weekly injection case (rolling 7-day pen)", () => {
// 4 doses left on a once-a-week injection → ~28 days.
expect(
estimateRunwayDays(4, [
schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
]),
).toBe(28);
});
});
@@ -11,24 +11,35 @@
*
* Tab state lives in the URL (`?tab=<slug>`), so a tab is deep-linkable,
* survives reload, and is reachable from the medications-list card kebab
* (Edit→zeitplan, History→verlauf, Advanced→erweitert). Slugs are ASCII /
* (History→verlauf). The kebab's Edit arrives as `?edit=1`, which opens
* the wizard in edit mode on top of the landing tab. Slugs are ASCII /
* locale-independent; the visible labels are i18n.
*
* v1.15.20 — six tabs: the former Erinnerung tab dissolved into Zeitplan
* (a "Erinnerung" card under the times editor owns the notifications
* switch + the reminder grace), and Übersicht grew from a lone
* compliance bar into the status landing surface (next due dose,
* reminder state, supply runway — each with a jump link into its owner
* tab). Stale `?tab=erinnerung` deep-links land on Zeitplan.
*
* Only the active tab body mounts (Radix `Tabs` default) so inactive tabs
* do not fetch. The hero stays above the tabs as the always-visible
* read-only summary plus the "Vollständig bearbeiten" jump into the
* creation/edit wizard (the structural-edit tool — name / class /
* cadence kind / schedules). Everyday levers (reminders, supply, history)
* live inline in the tabs.
*
* Verlauf renders the existing editable intake history and Zeitplan the
* read-only cadence summary for now — the dose-history ledger and the
* per-dose window editor land in the next wave. Injektion is gated to
* injectable routes of administration.
* live inline in the tabs. Injektion is gated to injectable routes of
* administration.
*/
import { useCallback, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Pencil } from "lucide-react";
@@ -56,19 +67,37 @@ import {
} from "@/components/medications/sections/destructive-zone-section";
import { SettingsGroup } from "@/components/medications/settings-group";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import {
parseScheduleRecurrence,
} from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
// shared chart skeleton standing in while the chunk streams.
const DrugLevelChart = dynamic(
() =>
import("@/components/medications/DrugLevelChart").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const DoseStrengthCurve = dynamic(
() =>
import("@/components/medications/dose-strength-curve").then((mod) => ({
default: mod.DoseStrengthCurve,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
interface ScheduleSnapshot {
id: string;
windowStart: string;
@@ -106,6 +135,12 @@ export interface MedicationDetailSnapshot {
oneShot?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
/**
* v1.15.20 — server-computed next due instant (the detail GET carries
* it alongside the row). The Übersicht status surface renders it
* directly; no client-side recurrence walking.
*/
nextDueAt?: string | null;
schedules: ScheduleSnapshot[];
}
@@ -115,7 +150,6 @@ export interface MedicationDetailSnapshot {
const TAB_SLUGS = [
"uebersicht",
"zeitplan",
"erinnerung",
"bestand",
"verlauf",
"injektion",
@@ -123,6 +157,16 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
/**
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
* that now owns the reminder controls instead of falling back to the
* landing tab.
*/
const LEGACY_TAB_SLUGS: Record<string, TabSlug> = {
erinnerung: "zeitplan",
};
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
@@ -161,10 +205,17 @@ export function MedicationDetailTabs({
medication: MedicationDetailSnapshot;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const router = useRouter();
const searchParams = useSearchParams();
const [editOpen, setEditOpen] = useState(false);
// v1.15.20 — the medications-list kebab's "Bearbeiten" arrives as
// `?edit=1` and opens the wizard straight away (the same editOpen the
// hero button drives). Read synchronously so the wizard mounts open on
// the first render; the effect below strips the param so a close +
// reload stays closed.
const shouldOpenEditFromUrl = searchParams?.get("edit") === "1";
const [editOpen, setEditOpen] = useState(shouldOpenEditFromUrl);
const [importOpen, setImportOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
@@ -188,10 +239,30 @@ export function MedicationDetailTabs({
[isInjectable],
);
const requested = searchParams?.get("tab") as TabSlug | null;
const requestedRaw = searchParams?.get("tab");
const requested = (
requestedRaw && requestedRaw in LEGACY_TAB_SLUGS
? LEGACY_TAB_SLUGS[requestedRaw]
: requestedRaw
) as TabSlug | null;
const activeTab: TabSlug =
requested && availableTabs.includes(requested) ? requested : "uebersicht";
useEffect(() => {
if (shouldOpenEditFromUrl) {
// Drop `?edit=1` (keep any `tab` param) so a refresh after closing
// the wizard does not keep reopening it.
const params = new URLSearchParams(
Array.from(searchParams?.entries() ?? []),
);
params.delete("edit");
const qs = params.toString();
router.replace(`/medications/${id}${qs ? `?${qs}` : ""}`, {
scroll: false,
});
}
}, [shouldOpenEditFromUrl, id, router, searchParams]);
const onTabChange = useCallback(
(next: string) => {
const params = new URLSearchParams(
@@ -230,6 +301,38 @@ export function MedicationDetailTabs({
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
// v1.15.20 — supply readout for the Übersicht status surface. Same
// query key the Bestand tab uses, so the two tabs share one cache slot
// and the row only renders when the user has actually registered
// supply items.
const { data: inventory } = useQuery({
queryKey: queryKeys.medicationInventory(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}/inventory`);
if (!res.ok) return null;
return (await res.json()).data as {
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
dosesTotal: number;
dosesRemaining: number;
}>;
} | null;
},
enabled: activeTab === "uebersicht",
staleTime: 30_000,
});
const inventoryItems = Array.isArray(inventory?.items)
? inventory.items
: [];
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
const dosesRemaining = liveItems.reduce(
(sum, i) => sum + i.dosesRemaining,
0,
);
const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0);
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
@@ -285,8 +388,68 @@ export function MedicationDetailTabs({
))}
</TabsList>
{/* ÜBERSICHT — landing read view: summary echo + compliance. */}
{/* ÜBERSICHT — the status landing surface: next due dose,
reminder state and supply runway (each with a jump link into
its owner tab), then the compliance readout. All values come
from data the page already holds — no new endpoints. */}
<TabsContent value="uebersicht" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-uebersicht-status-heading"
title={t("medications.detail.uebersicht.statusTitle")}
dataSlot="medication-uebersicht-status"
>
<ul className="divide-border/60 divide-y">
<StatusRow
label={t("medications.detail.uebersicht.nextDoseLabel")}
value={
!medication.active
? t("medications.detail.uebersicht.pausedHint")
: medication.nextDueAt
? fmt.dateTime(medication.nextDueAt)
: t("medications.detail.uebersicht.nextDoseNone")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-next-dose"
/>
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
medication.notificationsEnabled
? t("medications.detail.uebersicht.reminderOn")
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-reminder"
/>
{inventoryItems.length > 0 && (
<StatusRow
label={t("medications.detail.uebersicht.supplyLabel")}
value={
<>
{t("medications.detail.bestand.summary", {
remaining: dosesRemaining,
total: dosesTotal,
})}
{runwayDays !== null && (
<span className="text-muted-foreground">
{" · "}
{t("medications.detail.uebersicht.supplyRunway", {
days: runwayDays,
})}
</span>
)}
</>
}
jumpLabel={t("medications.detail.uebersicht.jumpToBestand")}
onJump={() => onTabChange("bestand")}
dataSlot="uebersicht-supply"
/>
)}
</ul>
</MedicationDetailSection>
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
@@ -309,19 +472,11 @@ export function MedicationDetailTabs({
</TabsContent>
{/* 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. */}
each dose's on-time window, then the Erinnerung card (the
notifications switch + the reminder grace, folded in from the
dissolved Erinnerung tab). Cadence-kind stays structural (the
hero's "Vollständig bearbeiten"). */}
<TabsContent value="zeitplan" className="space-y-6 pt-2">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{medication.schedules.length > 0 ? (
<MedicationDetailSection
titleId="medication-zeitplan-times-heading"
@@ -346,34 +501,40 @@ export function MedicationDetailTabs({
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}))}
onRequestReminderTab={() => onTabChange("erinnerung")}
/>
</MedicationDetailSection>
) : null}
</TabsContent>
{/* ERINNERUNG — notifications switch + reminder grace (the single
owner of `reminderGraceMinutes`). */}
<TabsContent value="erinnerung" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="erinnerung-group"
<MedicationDetailSection
titleId="medication-zeitplan-reminder-heading"
title={t("medications.detail.zeitplan.reminderTitle")}
dataSlot="medication-zeitplan-reminder"
>
<div className="py-3">
<div className="space-y-4">
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
</div>
<div className="py-3">
<GraceRow
medicationId={id}
reminderGraceMinutes={
medication.schedules[0]?.reminderGraceMinutes ?? null
}
schedules={medication.schedules.map((s) => ({
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,
}))}
/>
</div>
</SettingsGroup>
</MedicationDetailSection>
</TabsContent>
{/* BESTAND — inventory readout for all meds. */}
@@ -382,25 +543,15 @@ export function MedicationDetailTabs({
</TabsContent>
{/* VERLAUF — the dose-history ledger: every expected slot with its
status + ad-hoc takes tagged, inline Genommen/Übersprungen with
instant optimistic recompute, edit/add (incl. the late-take "diesem
Slot zuordnen?" nudge). CSV import rides the header ghost. */}
status + ad-hoc takes tagged, inline Genommen with instant
optimistic recompute, edit/add (incl. the late-take "diesem
Slot zuordnen?" nudge). CSV import lives under Erweitert →
Daten (the DataPortabilityRow), not in this header. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-verlauf-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-verlauf-section"
headerExtras={
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="verlauf-import"
>
{t("medications.detail.intake.importButton")}
</Button>
}
>
<DoseHistoryLedger
medicationId={id}
@@ -413,13 +564,6 @@ export function MedicationDetailTabs({
}))}
/>
</MedicationDetailSection>
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</TabsContent>
{/* INJEKTION — injectable routes only. Current GLP-1 charts +
@@ -545,6 +689,45 @@ export function MedicationDetailTabs({
);
}
/**
* One Übersicht status row: label + value on the left, a quiet jump link
* into the owning tab on the right. The link is a button (the tab switch
* is shallow URL state, not a navigation) styled as an inline text link.
*/
function StatusRow({
label,
value,
jumpLabel,
onJump,
dataSlot,
}: {
label: string;
value: ReactNode;
jumpLabel: string;
onJump: () => void;
dataSlot: string;
}) {
return (
<li
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
data-slot={dataSlot}
>
<div className="min-w-0 space-y-0.5">
<p className="text-muted-foreground text-xs font-medium">{label}</p>
<p className="text-foreground text-sm">{value}</p>
</div>
<button
type="button"
onClick={onJump}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot={`${dataSlot}-jump`}
>
{jumpLabel}
</button>
</li>
);
}
/**
* The danger zone's purge gate needs `intakeCount`. Fetch it lazily here
* (the Erweitert tab pays for it; the other tabs never do) rather than
@@ -0,0 +1,69 @@
/**
* v1.15.20 — supply-runway estimate for the medication detail Übersicht.
*
* Pure helpers that turn the schedule snapshot the detail GET already
* carries into an approximate doses-per-day figure, so the Übersicht tab
* can render "lasts about N more days" from the inventory remaining-doses
* count without a new endpoint. The estimate is deliberately coarse
* (calendar months ≈ 30 days) — the copy says "about" and the Bestand tab
* stays the authoritative per-item readout.
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
/** The schedule fields the runway estimate reads. */
export interface RunwaySchedule {
windowStart: string;
daysOfWeek: string | null;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
}
/**
* Approximate doses per day across every schedule: times-of-day count,
* scaled down by the cadence (rolling interval, weekly day picks +
* interval weeks, monthly/yearly RRULEs).
*/
export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number {
let perDay = 0;
for (const s of schedules) {
const times =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
if (
typeof s.rollingIntervalDays === "number" &&
s.rollingIntervalDays >= 1
) {
perDay += times / s.rollingIntervalDays;
continue;
}
const rrule = s.rrule ?? "";
if (/FREQ=MONTHLY/.test(rrule)) {
perDay += times / 30;
continue;
}
if (/FREQ=YEARLY/.test(rrule)) {
perDay += times / 365;
continue;
}
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7;
const weeks = intervalWeeks >= 1 ? intervalWeeks : 1;
perDay += (times * daysPerWeek) / (7 * weeks);
}
return perDay;
}
/**
* Whole days the remaining supply covers, or `null` when no estimate is
* possible (no supply, no consuming schedule).
*/
export function estimateRunwayDays(
dosesRemaining: number,
schedules: RunwaySchedule[],
): number | null {
if (dosesRemaining <= 0) return null;
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
return Math.floor(dosesRemaining / perDay);
}
@@ -10,13 +10,16 @@
* day first, chronological inside the day.
*
* Three interactions land here:
* - a pending / missed slot exposes Genommen / Übersprungen ON the row
* (~2 taps, not buried), which mark the dose with INSTANT feedback — the
* - an actionable slot (upcoming, or missed today) exposes ONE visible
* [Genommen] button that marks the dose with INSTANT feedback — the
* cached ledger + the headline % are mutated optimistically (the engines
* are pure, so the client mirrors their accounting) before the server
* write fires and the authoritative refetch reconciles;
* - any row with an intake can be edited (time / skipped) or deleted via the
* shared `<IntakeEditDialog>` + delete confirm;
* write fires and the authoritative refetch reconciles. The success
* toast carries an Undo. Übersprungen and the backfill of older missed
* slots live in the row's overflow kebab;
* - settled rows (taken / skipped / a past missed day) carry ONLY the
* kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen — so the
* ledger reads as a calm record, not a button wall;
* - a header "+ Eintrag" opens an add dialog scoped to this medication
* (incl. backdated / off-schedule); an off-window take surfaces the
* "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via
@@ -35,13 +38,23 @@ import {
Check,
CircleCheck,
Loader2,
MoreVertical,
Pencil,
Plus,
SkipForward,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
@@ -60,6 +73,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
import {
@@ -177,11 +191,26 @@ export function DoseHistoryLedger({
[data, timeZone],
);
// Today's local day key — a missed slot from TODAY is still in active
// play (visible Genommen button); older missed days settle into the
// row kebab so the history reads calm.
const todayKey = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[timeZone],
);
/**
* Genommen / Übersprungen on a pending / missed slot. The cached ledger is
* mutated optimistically (row status + the headline % flip in this paint),
* then the server write fires and the dependent-key invalidation reconciles.
* A failed write rolls the optimistic snapshot back + toasts (mirrors the
* A failed write rolls the optimistic snapshot back + toasts; a successful
* one carries the same Undo action the medication cards offer (mirrors the
* C1/C2 pattern in `use-medication-intake.ts`).
*/
const markSlot = useCallback(
@@ -220,6 +249,15 @@ export function DoseHistoryLedger({
);
return;
}
// The POST returns the created event; its id drives the Undo
// affordance (the same soft-delete route the cards use).
let eventId: string | undefined;
try {
const json = await res.json();
eventId = json?.data?.id as string | undefined;
} catch {
/* dose recorded; the body is best-effort for the id */
}
toast.success(
t(
action === "skipped"
@@ -227,6 +265,20 @@ export function DoseHistoryLedger({
: "medications.intakeToastTaken",
{ name: medicationName },
),
eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () =>
void runUndoIntake({
medication: { id: medicationId, name: medicationName },
eventId,
t,
queryClient,
}),
},
}
: undefined,
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
@@ -244,6 +296,49 @@ export function DoseHistoryLedger({
[marking, medicationId, medicationName, queryClient, queryKey, t],
);
/**
* Flip an existing intake between Übersprungen and Genommen from the row
* kebab. Same `PUT intake/[eventId]` contract the edit dialog uses; a
* skipped→taken flip anchors `takenAt` on the slot instant (a past slot)
* rather than "now".
*/
const toggleIntakeSkipped = useCallback(
async (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => {
try {
const body = intake.skipped
? {
skipped: false,
takenAt: intake.scheduledFor ?? new Date().toISOString(),
}
: { skipped: true, takenAt: null };
const res = await fetch(
`/api/medications/${medicationId}/intake/${intake.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!res.ok) {
toast.error(t("medications.detail.intake.edit.failed"));
return;
}
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
toast.success(t("medications.detail.intake.edit.savedToast"));
} catch {
toast.error(t("medications.detail.intake.edit.failed"));
}
},
[medicationId, queryClient, queryKey, t],
);
async function confirmRowDelete() {
if (!pendingDeleteId) return;
const id = pendingDeleteId;
@@ -330,7 +425,9 @@ export function DoseHistoryLedger({
key={`${row.kind}:${row.at}:${row.intake?.id ?? i}`}
row={row}
marking={marking}
isToday={group.dayKey === todayKey}
onMark={markSlot}
onToggleSkipped={toggleIntakeSkipped}
onEdit={(ev) => setEditingEvent(ev)}
onDelete={(id) => setPendingDeleteId(id)}
/>
@@ -367,20 +464,34 @@ export function DoseHistoryLedger({
}
/**
* One ledger row: time + status pill + (when an intake is attributed) its real
* take time, plus the inline actions. A pending/missed slot offers Genommen /
* Übersprungen; a row with an intake offers Bearbeiten / Löschen.
* One ledger row: time + status (colour + glyph + text) + (when an intake is
* attributed) its real take time, plus the row actions.
*
* Action shape (v1.15.20): an actionable slot that is still in play
* (upcoming, or missed TODAY) carries exactly ONE visible [Genommen] button —
* label visible on every viewport — plus the overflow kebab (Übersprungen).
* Every settled row (taken / skipped / a past missed day / ad-hoc) carries
* ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen —
* and, for a past missed slot, the Genommen / Übersprungen backfill marks.
*/
function LedgerRowItem({
row,
marking,
isToday,
onMark,
onToggleSkipped,
onEdit,
onDelete,
}: {
row: LedgerRow;
marking: string | null;
isToday: boolean;
onMark: (row: LedgerRow, action: "taken" | "skipped") => void;
onToggleSkipped: (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onEdit: (event: {
id: string;
takenAt: string | null;
@@ -393,6 +504,10 @@ function LedgerRowItem({
const fmt = useFormatters();
const actionable = isSlotActionable(row);
// A missed slot from an earlier day stays markable, but only through the
// kebab — the visible button is reserved for doses still in play.
const showTakenButton =
actionable && (row.status === "upcoming" || isToday);
const intake = row.intake;
const takeBusy = marking === `${row.at}:taken`;
const skipBusy = marking === `${row.at}:skipped`;
@@ -445,81 +560,125 @@ function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{actionable && (
<>
{showTakenButton && (
<Button
variant="outline"
size="sm"
onClick={() => onMark(row, "taken")}
disabled={takeBusy || skipBusy}
aria-busy={takeBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-taken"
>
{takeBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="size-4" />
)}
{t("medications.detail.verlauf.markTaken")}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => onMark(row, "taken")}
disabled={takeBusy || skipBusy}
aria-busy={takeBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-taken"
variant="ghost"
size="icon-sm"
className="min-h-9 min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
data-slot="ledger-row-menu"
>
{takeBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-slot="ledger-row-menu-content"
>
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onMark(row, "skipped")}
disabled={takeBusy || skipBusy}
aria-busy={skipBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
</DropdownMenuItem>
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</span>
</Button>
</>
)}
{intake?.id && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
className="min-h-9"
data-slot="ledger-edit"
>
{t("medications.detail.intake.rowActions.edit")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(intake.id as string)}
className="text-destructive min-h-9"
data-slot="ledger-delete"
>
{t("medications.detail.intake.rowActions.delete")}
</Button>
</>
)}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<DropdownMenuItem
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-edit"
>
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
id: intake.id as string,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward
aria-hidden="true"
className="mr-2 size-4"
/>
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
className="text-destructive focus:text-destructive"
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
@@ -30,7 +30,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
*
* Marc directive 2026-05-14: NO chart on the medication card. The card
* Maintainer directive 2026-05-14: NO chart on the medication card. The card
* stays text-rich (drug name + current dose, last/next injection,
* injection-site rotation hint, side-effect quick-log). v1.4.28
* retired the inline dose-history disclosure and the inventory
@@ -129,12 +129,6 @@ interface Glp1MedicationCardProps {
* card. The parent owns the navigation.
*/
onOpenHistory: (med: Glp1Medication) => void;
/**
* v1.15.18 — navigates to the medication detail page's Erweitert tab
* (`/medications/{id}?tab=erweitert`), the home of the dissolved
* advanced settings.
*/
onOpenAdvanced: (med: Glp1Medication) => void;
onLogSideEffect?: (med: Glp1Medication) => void;
}
@@ -167,7 +161,6 @@ export function Glp1MedicationCard({
medication,
onEdit,
onOpenHistory,
onOpenAdvanced,
onLogSideEffect,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
@@ -364,7 +357,6 @@ export function Glp1MedicationCard({
<MedicationCardMenu
onEdit={() => onEdit(medication)}
onOpenHistory={() => onOpenHistory(medication)}
onOpenAdvanced={() => onOpenAdvanced(medication)}
onLogSideEffect={
onLogSideEffect ? () => onLogSideEffect(medication) : undefined
}
@@ -7,8 +7,11 @@
* collapsed their former four header icon-buttons (open / edit / history
* / advanced) into a single overflow kebab so the card header carries one
* affordance instead of a cluster. The card body itself navigates to the
* detail page (the former chevron target); this menu owns the edit /
* history / advanced actions.
* detail page (the former chevron target); this menu owns the edit and
* history actions. v1.15.20 retires the "Advanced settings" item — the
* detail page's Erweitert tab is reachable through the card body, and
* Edit now opens the medication wizard directly instead of landing on
* the Zeitplan tab.
*
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
* "Log side effect" item into the SAME menu, but it is NOT wired on the
@@ -28,7 +31,7 @@
* navigation.
*/
import { History, MoreVertical, Pencil, SlidersHorizontal, Stethoscope } from "lucide-react";
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -43,7 +46,6 @@ import { useTranslations } from "@/lib/i18n/context";
export interface MedicationCardMenuProps {
onEdit: () => void;
onOpenHistory: () => void;
onOpenAdvanced: () => void;
/** GLP-1-only: folds the side-effect quick-log into the same menu. */
onLogSideEffect?: () => void;
}
@@ -51,7 +53,6 @@ export interface MedicationCardMenuProps {
export function MedicationCardMenu({
onEdit,
onOpenHistory,
onOpenAdvanced,
onLogSideEffect,
}: MedicationCardMenuProps) {
const { t } = useTranslations();
@@ -85,10 +86,6 @@ export function MedicationCardMenu({
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenAdvanced}>
<SlidersHorizontal className="mr-2 h-4 w-4" />
{t("medications.detail.header.advancedLabel")}
</DropdownMenuItem>
{onLogSideEffect && (
<>
<DropdownMenuSeparator />
@@ -98,19 +98,12 @@ interface MedicationCardProps {
* (`/medications/{id}?tab=verlauf`). The parent owns the navigation.
*/
onOpenHistory: (med: Medication) => void;
/**
* v1.15.18 — navigates to the medication detail page's Erweitert tab
* (`/medications/{id}?tab=erweitert`), the home of the dissolved
* advanced settings.
*/
onOpenAdvanced: (med: Medication) => void;
}
export function MedicationCard({
medication,
onEdit,
onOpenHistory,
onOpenAdvanced,
}: MedicationCardProps) {
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
@@ -288,7 +281,6 @@ export function MedicationCard({
<MedicationCardMenu
onEdit={() => onEdit(medication)}
onOpenHistory={() => onOpenHistory(medication)}
onOpenAdvanced={() => onOpenAdvanced(medication)}
/>
);
@@ -9,19 +9,18 @@ import { type ReactNode } from "react";
* divider + body padding — and drifted on padding, border opacity, and
* aria wiring.
*
* v1.4.28 FB-F3 / F4 — heading scale collapsed to one shape across the
* `/medications/[id]` surface so the page reads as one consistent
* document. Title classes now match the settings symmetry sweep:
* `text-base font-semibold leading-6 tracking-tight`. Body band keeps
* its `text-xs` density; one heading scale (`text-base font-semibold`),
* one body scale (`text-sm`), one micro scale (`text-xs`) on the page.
* v1.15.20 — lifted to the card language the rest of the detail page
* speaks (`SettingsGroup`, the dashboard cards): `bg-card` surface,
* `rounded-xl`, `p-4 md:p-6` padding. The body band reads at `text-sm`
* (the standard body scale) instead of the former `text-xs` container,
* so every tab renders the same surface vocabulary.
*
* Contract:
* - `border-border/60 rounded-md border` chrome
* - `px-3 py-2.5` header row with
* - `bg-card rounded-xl border p-4 md:p-6` chrome
* - header row with
* `text-foreground text-base font-semibold leading-6 tracking-tight`
* title
* - `border-border/60 border-t px-3 py-3 text-xs` body band
* title, separated from the body by a `border-border/60` rule
* - `pt-3 text-sm` body band
* - `aria-labelledby` wired to a stable per-section id derived from `titleId`
*
* Consumers pass:
@@ -35,9 +34,6 @@ import { type ReactNode } from "react";
* - `dataSlot` — optional `data-slot` attribute on the outer section
* so cross-section selectors (Playwright, snapshot tests) stay
* stable.
* - `bodyPaddingY` — escape hatch for sections that need a different
* vertical padding (the side-effects body runs at py-2.5 because
* it owns an inline timeline with its own gap rhythm).
*/
export interface MedicationDetailSectionProps {
titleId: string;
@@ -45,7 +41,6 @@ export interface MedicationDetailSectionProps {
headerExtras?: ReactNode;
children: ReactNode;
dataSlot?: string;
bodyPaddingY?: "py-2.5" | "py-3";
}
export function MedicationDetailSection({
@@ -54,15 +49,14 @@ export function MedicationDetailSection({
headerExtras,
children,
dataSlot,
bodyPaddingY = "py-3",
}: MedicationDetailSectionProps) {
return (
<section
className="border-border/60 rounded-md border"
className="bg-card rounded-xl border p-4 md:p-6"
aria-labelledby={titleId}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<header className="flex items-center justify-between gap-2 px-3 py-2.5">
<header className="border-border/60 flex items-center justify-between gap-2 border-b pb-3">
<h2
id={titleId}
className="text-foreground text-base font-semibold leading-6 tracking-tight"
@@ -71,11 +65,7 @@ export function MedicationDetailSection({
</h2>
{headerExtras}
</header>
<div
className={`border-border/60 border-t px-3 text-xs ${bodyPaddingY}`}
>
{children}
</div>
<div className="pt-3 text-sm">{children}</div>
</section>
);
}
@@ -87,7 +87,7 @@ export function MedicationDetailSummary({
return (
<div className="space-y-1.5" data-slot="medication-detail-summary">
<h1 className="text-2xl font-bold tracking-tight">{name}</h1>
<h1 className="text-2xl font-semibold tracking-tight">{name}</h1>
<p className="text-muted-foreground text-sm">{dose}</p>
<div
className="text-muted-foreground flex items-center gap-2 text-xs"
@@ -11,8 +11,8 @@
* 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.
* 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.
*/
@@ -81,12 +81,9 @@ function effectiveTimes(s: EditableSchedule): string[] {
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();
@@ -238,19 +235,6 @@ export function ScheduleTimesEditor({
{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>
@@ -159,7 +159,7 @@ export function TimesOfDayChips({
if (!TIME_RE.test(v)) return;
onChange([v]);
}}
className="h-11 w-32"
className="h-11 w-32 sm:h-9"
aria-label={t(`${i18nPrefix}.label`)}
/>
</div>
@@ -195,7 +195,10 @@ export function TimesOfDayChips({
data-active={isOn ? "true" : "false"}
onClick={() => onPresetToggle(preset)}
className={[
"focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50",
// 44 px tap floor on mobile, 36 px on sm+ — the same
// height system the dose-window inputs + save button use
// so the Zeitplan tab renders one control height.
"focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 sm:h-9",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
@@ -227,7 +230,7 @@ export function TimesOfDayChips({
key={time}
data-slot="times-of-day-chip"
data-time={time}
className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium"
className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium sm:h-9"
>
<span>{time}</span>
<button
@@ -256,7 +259,7 @@ export function TimesOfDayChips({
value={draft}
disabled={disabled || atCap}
onChange={(e) => setDraft(e.target.value)}
className="h-11 w-32"
className="h-11 w-32 sm:h-9"
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-draft-input"
/>
@@ -266,7 +269,7 @@ export function TimesOfDayChips({
size="sm"
disabled={disabled || !draft || atCap}
onClick={onAddDraft}
className="h-11"
className="h-11 sm:h-9"
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-add"
>
@@ -11,9 +11,11 @@
* the modal advanced-settings sheet; the detail page's tabs consume the
* individual rows (`GraceRow` / `DrugCodingRow` / `PhasesRow`) directly.
*
* Per H-4-UX: the Grace row's label flags it as primary-schedule
* scoped so the user knows a multi-schedule medication does not get a
* fan-out from this control.
* v1.15.20 — the Grace row writes the SAME value onto every schedule of
* a multi-schedule medication (full `schedules` round-trip, the same
* wholesale-replace contract the Zeitplan times editor uses) instead of
* silently touching only the primary schedule. A helper line states the
* all-schedules scope when more than one schedule exists.
*/
import { useState } from "react";
@@ -25,38 +27,105 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
/** The schedule fields the grace save round-trips on a wholesale PUT. */
export interface GraceScheduleSnapshot {
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;
}
/**
* v1.7.0 — primary-schedule reminder-window (grace) row. Self-saves via
* `PUT /api/medications/[id]` with a top-level `reminderGraceMinutes`.
* v1.7.0 — reminder-window (grace) row. Self-saves via
* `PUT /api/medications/[id]`.
*
* v1.15.20 — a single-schedule medication keeps the cheap top-level
* `reminderGraceMinutes` write (the server applies it to the primary
* schedule). A multi-schedule medication round-trips the FULL schedules
* array with the same grace on every schedule — every field is preserved
* verbatim from the snapshot, only `reminderGraceMinutes` changes — so
* the value can no longer drift apart per schedule.
*/
export function GraceRow({
medicationId,
reminderGraceMinutes,
schedules,
}: {
medicationId: string;
reminderGraceMinutes?: number | null;
schedules: GraceScheduleSnapshot[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const firstGrace = schedules[0]?.reminderGraceMinutes;
const [graceValue, setGraceValue] = useState(
typeof reminderGraceMinutes === "number" ? reminderGraceMinutes : 30,
typeof firstGrace === "number" ? firstGrace : 30,
);
const [graceBusy, setGraceBusy] = useState(false);
const multiSchedule = schedules.length > 1;
async function saveGrace() {
if (graceBusy) return;
setGraceBusy(true);
try {
// Primary-schedule scope: the PUT body's schedules array is
// absent so the server preserves existing schedules; the route
// accepts top-level `reminderGraceMinutes` as a primary-schedule
// override on the v1.5.4 flat-form bridge.
const body = multiSchedule
? {
// Wholesale schedules replace (the PUT contract): preserve
// every cadence field from the snapshot, set the SAME grace
// on every schedule.
schedules: schedules.map((s) => {
const recurrence = parseScheduleRecurrence(s.daysOfWeek);
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? undefined,
dose: s.dose ?? undefined,
timesOfDay:
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
daysOfWeek: recurrence.daysOfWeek,
intervalWeeks: recurrence.intervalWeeks,
...(s.rrule ? { rrule: s.rrule } : {}),
...(typeof s.rollingIntervalDays === "number"
? { rollingIntervalDays: s.rollingIntervalDays }
: {}),
reminderGraceMinutes: graceValue,
...(s.scheduleType
? {
scheduleType: s.scheduleType as
| "SCHEDULED"
| "PRN"
| "CYCLIC",
}
: {}),
...(typeof s.cyclicOnWeeks === "number"
? { cyclicOnWeeks: s.cyclicOnWeeks }
: {}),
...(typeof s.cyclicOffWeeks === "number"
? { cyclicOffWeeks: s.cyclicOffWeeks }
: {}),
...(s.doseWindows ? { doseWindows: s.doseWindows } : {}),
};
}),
}
: { reminderGraceMinutes: graceValue };
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reminderGraceMinutes: graceValue }),
body: JSON.stringify(body),
});
if (!res.ok) {
toast.error(t("medications.detail.settings.grace.failed"));
@@ -79,9 +148,11 @@ export function GraceRow({
>
{t("medications.detail.settings.grace.label")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.detail.settings.grace.primaryScheduleNote")}
</p>
{multiSchedule && (
<p className="text-muted-foreground text-xs">
{t("medications.detail.settings.grace.appliesToAllNote")}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Input
id="medication-detail-grace-input"
@@ -28,7 +28,7 @@ import type { StepProps } from "./Step1Name";
/**
* Dose unit choices, ordered so mg / ml / IE / µg lead and the
* device-form units (Tablette, Hub, Sprühstoß, …) follow. Mirrors the
* v1.5.3 list with the Marc-requested order tightening.
* v1.5.3 list with the maintainer-requested order tightening.
*/
const DOSE_UNIT_KEYS = [
"mg",
@@ -88,7 +88,6 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
maxLength={20}
autoComplete="off"
enterKeyHint="next"
className="h-11"
/>
</div>
<div className="space-y-1.5">
@@ -99,7 +98,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
value={payload.doseUnit}
onValueChange={(v) => applyPartial({ doseUnit: v })}
>
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
<SelectTrigger id="wizard-dose-unit" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -127,7 +126,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
applyPartial({ deliveryForm: v as MedicationDeliveryForm })
}
>
<SelectTrigger id="wizard-delivery-form" className="h-11 w-full">
<SelectTrigger id="wizard-delivery-form" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -158,7 +157,6 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
)}
maxLength={3}
autoComplete="off"
className="h-11"
/>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.dosesPerUnitHint")}