feat(medications): promote the detail page to a tabbed shell

Make /medications/[id] the canonical full-page home for one medication:
a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf ·
Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a
"Vollständig bearbeiten" jump into the wizard for structural edits.
Everyday levers move inline into the tabs.

Übersicht surfaces the adherence bars, Erinnerung owns notifications +
reminder grace, Bestand reads the pen/vial inventory (all medications,
calm empty state), Verlauf renders the editable intake history, and
Injektion (injectable routes only) carries the existing GLP-1 charts +
side-effect log. The ledger, the per-dose window editor and the
injection body-map land in the following waves.

The legacy /medications/[id]/history route now redirects to ?tab=verlauf
for bookmarks and deep links. Tab labels are i18n across six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 19:10:18 +02:00
1 parent 6398cdece5
commit ac39b9ca34
12 files changed
+986 -324

No files matched your search

+40
View File
@@ -1457,6 +1457,46 @@
"back": "Zurück zum Medikament",
"subtitle": "Vollständiger Einnahmeverlauf",
"plannedSuffix": "geplant"
},
"tabs": {
"uebersicht": "Übersicht",
"zeitplan": "Zeitplan",
"erinnerung": "Erinnerung",
"bestand": "Bestand",
"verlauf": "Verlauf",
"injektion": "Injektion",
"erweitert": "Erweitert"
},
"shell": {
"fullEdit": "Vollständig bearbeiten",
"loadFailed": "Dieses Medikament konnte nicht geladen werden.",
"tablistLabel": "Medikamenten-Detailbereiche"
},
"uebersicht": {
"complianceTitle": "Therapietreue",
"complianceEmpty": "Noch keine Daten zur Therapietreue."
},
"bestand": {
"title": "Bestand",
"summary": "{remaining} von {total} Dosen übrig",
"itemsTitle": "Pens / Ampullen",
"doses": "{remaining} / {total} Dosen",
"state": {
"ACTIVE": "Ungeöffnet",
"IN_USE": "In Gebrauch",
"EXPIRED": "Abgelaufen",
"USED_UP": "Aufgebraucht"
},
"empty": "Kein Bestand erfasst.",
"emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen."
},
"erweitert": {
"group": {
"lifecycle": "Lebenszyklus",
"externalApi": "Externe API",
"data": "Daten",
"danger": "Gefahrenzone"
}
}
},
"logInjectionSiteTitle": "Injektionsstelle erfassen",
+40
View File
@@ -1457,6 +1457,46 @@
"back": "Back to medication",
"subtitle": "Full intake history",
"plannedSuffix": "planned"
},
"tabs": {
"uebersicht": "Overview",
"zeitplan": "Schedule",
"erinnerung": "Reminders",
"bestand": "Supply",
"verlauf": "History",
"injektion": "Injection",
"erweitert": "Advanced"
},
"shell": {
"fullEdit": "Edit fully",
"loadFailed": "This medication could not be loaded.",
"tablistLabel": "Medication detail sections"
},
"uebersicht": {
"complianceTitle": "Adherence",
"complianceEmpty": "No adherence data yet."
},
"bestand": {
"title": "Supply",
"summary": "{remaining} of {total} doses remaining",
"itemsTitle": "Pens / vials",
"doses": "{remaining} / {total} doses",
"state": {
"ACTIVE": "Unopened",
"IN_USE": "In use",
"EXPIRED": "Expired",
"USED_UP": "Used up"
},
"empty": "No supply recorded.",
"emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply."
},
"erweitert": {
"group": {
"lifecycle": "Lifecycle",
"externalApi": "External API",
"data": "Data",
"danger": "Danger zone"
}
}
},
"logInjectionSiteTitle": "Log injection site",
+40
View File
@@ -1457,6 +1457,46 @@
"back": "Volver al medicamento",
"subtitle": "Historial completo de tomas",
"plannedSuffix": "planificado"
},
"tabs": {
"uebersicht": "Resumen",
"zeitplan": "Horario",
"erinnerung": "Recordatorios",
"bestand": "Existencias",
"verlauf": "Historial",
"injektion": "Inyección",
"erweitert": "Avanzado"
},
"shell": {
"fullEdit": "Editar por completo",
"loadFailed": "No se pudo cargar este medicamento.",
"tablistLabel": "Secciones de detalle del medicamento"
},
"uebersicht": {
"complianceTitle": "Adherencia",
"complianceEmpty": "Aún no hay datos de adherencia."
},
"bestand": {
"title": "Existencias",
"summary": "{remaining} de {total} dosis restantes",
"itemsTitle": "Plumas / viales",
"doses": "{remaining} / {total} dosis",
"state": {
"ACTIVE": "Sin abrir",
"IN_USE": "En uso",
"EXPIRED": "Caducado",
"USED_UP": "Agotado"
},
"empty": "No hay existencias registradas.",
"emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro."
},
"erweitert": {
"group": {
"lifecycle": "Ciclo de vida",
"externalApi": "API externa",
"data": "Datos",
"danger": "Zona de peligro"
}
}
},
"logInjectionSiteTitle": "Registrar sitio de inyección",
+40
View File
@@ -1457,6 +1457,46 @@
"back": "Retour au médicament",
"subtitle": "Historique complet des prises",
"plannedSuffix": "prévu"
},
"tabs": {
"uebersicht": "Aperçu",
"zeitplan": "Horaire",
"erinnerung": "Rappels",
"bestand": "Stock",
"verlauf": "Historique",
"injektion": "Injection",
"erweitert": "Avancé"
},
"shell": {
"fullEdit": "Modifier entièrement",
"loadFailed": "Ce médicament n'a pas pu être chargé.",
"tablistLabel": "Sections de détail du médicament"
},
"uebersicht": {
"complianceTitle": "Observance",
"complianceEmpty": "Aucune donnée d'observance pour l'instant."
},
"bestand": {
"title": "Stock",
"summary": "{remaining} doses restantes sur {total}",
"itemsTitle": "Stylos / flacons",
"doses": "{remaining} / {total} doses",
"state": {
"ACTIVE": "Non ouvert",
"IN_USE": "En cours",
"EXPIRED": "Expiré",
"USED_UP": "Épuisé"
},
"empty": "Aucun stock enregistré.",
"emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie."
},
"erweitert": {
"group": {
"lifecycle": "Cycle de vie",
"externalApi": "API externe",
"data": "Données",
"danger": "Zone de danger"
}
}
},
"logInjectionSiteTitle": "Enregistrer le site d'injection",
+40
View File
@@ -1457,6 +1457,46 @@
"back": "Torna al farmaco",
"subtitle": "Cronologia completa delle assunzioni",
"plannedSuffix": "pianificato"
},
"tabs": {
"uebersicht": "Panoramica",
"zeitplan": "Programma",
"erinnerung": "Promemoria",
"bestand": "Scorta",
"verlauf": "Cronologia",
"injektion": "Iniezione",
"erweitert": "Avanzate"
},
"shell": {
"fullEdit": "Modifica completa",
"loadFailed": "Impossibile caricare questo farmaco.",
"tablistLabel": "Sezioni di dettaglio del farmaco"
},
"uebersicht": {
"complianceTitle": "Aderenza",
"complianceEmpty": "Ancora nessun dato sull'aderenza."
},
"bestand": {
"title": "Scorta",
"summary": "{remaining} dosi rimanenti su {total}",
"itemsTitle": "Penne / flaconi",
"doses": "{remaining} / {total} dosi",
"state": {
"ACTIVE": "Non aperto",
"IN_USE": "In uso",
"EXPIRED": "Scaduto",
"USED_UP": "Esaurito"
},
"empty": "Nessuna scorta registrata.",
"emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia."
},
"erweitert": {
"group": {
"lifecycle": "Ciclo di vita",
"externalApi": "API esterna",
"data": "Dati",
"danger": "Zona pericolosa"
}
}
},
"logInjectionSiteTitle": "Registra il sito di iniezione",
+40
View File
@@ -1457,6 +1457,46 @@
"back": "Powrót do leku",
"subtitle": "Pełna historia przyjęć",
"plannedSuffix": "zaplanowane"
},
"tabs": {
"uebersicht": "Przegląd",
"zeitplan": "Harmonogram",
"erinnerung": "Przypomnienia",
"bestand": "Zapas",
"verlauf": "Historia",
"injektion": "Iniekcja",
"erweitert": "Zaawansowane"
},
"shell": {
"fullEdit": "Edytuj w pełni",
"loadFailed": "Nie udało się załadować tego leku.",
"tablistLabel": "Sekcje szczegółów leku"
},
"uebersicht": {
"complianceTitle": "Przestrzeganie",
"complianceEmpty": "Brak danych o przestrzeganiu."
},
"bestand": {
"title": "Zapas",
"summary": "Pozostało {remaining} z {total} dawek",
"itemsTitle": "Wstrzykiwacze / fiolki",
"doses": "{remaining} / {total} dawek",
"state": {
"ACTIVE": "Nieotwarte",
"IN_USE": "W użyciu",
"EXPIRED": "Przeterminowane",
"USED_UP": "Zużyte"
},
"empty": "Nie zarejestrowano zapasu.",
"emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie."
},
"erweitert": {
"group": {
"lifecycle": "Cykl życia",
"externalApi": "Zewnętrzne API",
"data": "Dane",
"danger": "Strefa zagrożenia"
}
}
},
"logInjectionSiteTitle": "Zarejestruj miejsce iniekcji",
+19 -132
View File
@@ -1,153 +1,40 @@
"use client";
/**
* v1.7.0 — full intake-history view.
* v1.15.18 — legacy intake-history route.
*
* Reached directly from the detail-page header's History button. This
* surface is intake-history ONLY: the estimated drug-level curve, the
* side-effect logbook, the schedule ("Rhythmus") editor and the
* titration ladder no longer dominate here. The only editable units
* are individual intakes (edit / delete / bulk-delete via the shared
* `<IntakeHistoryEditable>`); CSV import is present but de-emphasised
* as a ghost action in the header.
*
* Sort defaults to `scheduledFor desc` so the order reads
* today → yesterday → … and skipped rows (`takenAt: null`) never float
* to the top (O-1).
*
* The estimated active-ingredient curve stays available as a
* default-CLOSED disclosure at the bottom for GLP-1 medications (O-2);
* it is genuine history context but opt-in, not the default read.
* The full intake history folded into the medication detail page's
* Verlauf tab. This route stays only as a deep-link redirect so existing
* bookmarks and any iOS deep-link keep landing on the right surface.
*/
import { useEffect, useState, use } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2, Upload } from "lucide-react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function IntakeHistoryPage({
export default function IntakeHistoryRedirectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const { t } = useTranslations();
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [authLoading, isAuthenticated, router]);
const { data: medication, isLoading: medLoading } = useQuery({
queryKey: queryKeys.medicationDetail(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as {
id: string;
name: string;
dose: string;
treatmentClass?: string;
notificationsEnabled?: boolean;
};
},
enabled: isAuthenticated,
});
if (authLoading || medLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
const isGlp1 = medication?.treatmentClass === "GLP1";
router.replace(`/medications/${id}?tab=verlauf`);
}, [id, router]);
return (
<div className="space-y-6">
{/* Back to the medication detail page — history is a drill-down
OF the medication, not a sibling of the list. */}
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href={`/medications/${id}`}>
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.detail.history.back")}
</Link>
</Button>
{medication && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{medication.name}
</h1>
<p className="text-muted-foreground text-sm">{medication.dose}</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.history.subtitle")}
</p>
</div>
{/* De-emphasised import — ghost / muted, not a prominent CTA. */}
<Button
variant="ghost"
size="sm"
onClick={() => setImportOpen(true)}
className="text-muted-foreground min-h-11 sm:min-h-9"
data-slot="history-import-trigger"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
</div>
)}
{medication && (
<IntakeHistoryEditable
medicationId={id}
pageSize={25}
defaultSortBy="scheduledFor"
/>
)}
{/* Estimated drug-level curve (the modelled active-ingredient
level) + dose-strength (titration) curve for GLP-1 medications.
Visible by default — the drug-level estimate is the one the
medication concerns, so it leads; the dose-strength curve plots
the user's own logged dose-change history below it. */}
{medication && isGlp1 && (
<div className="space-y-6" data-slot="history-drug-level-section">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
<div
className="flex h-64 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
/>
</div>
);
}
+15 -155
View File
@@ -1,29 +1,18 @@
"use client";
/**
* v1.7.2 W3 — medication detail page as the history view.
* v1.15.18 — medication detail page as the full-page tabbed shell.
*
* Supersedes the v1.5.6 composition that carried an Edit / History /
* Advanced action row and a "Rhythmus" cadence block. Editing and
* advanced settings are now reached from the medications-list card kebab
* only; the detail page is purely history-centric:
*
* back link → compact, NON-editable header (name / dose / status +
* plain-language cadence line) → intake-history table → GLP-1
* drug-level / titration as a default-CLOSED disclosure.
*
* The side-effect logbook stays inline for GLP-1 medications (genuine
* history context). CSV import rides the intake-history section header.
*
* Reads:
*
* - `medicationDetail(id)` for the medication snapshot.
* - `medicationIntakeList(id, …)` for the header total.
*
* Mutations all cascade through `medicationDependentKeys`.
* Supersedes the v1.7.2 history-centric composition. The page now owns
* auth + the medication read and hands the snapshot to
* `<MedicationDetailTabs>`, which carries the tab strip (Übersicht ·
* Zeitplan · Erinnerung · Bestand · Verlauf · Injektion* · Erweitert),
* `?tab=` URL state, the read-only hero and the "Vollständig bearbeiten"
* jump into the wizard. The former modal advanced-settings sheet and the
* separate `/history` route both fold into tabs here.
*/
import { use, useEffect, useMemo, useState } from "react";
import { use, useEffect } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
@@ -32,75 +21,13 @@ import { ArrowLeft, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
interface ScheduleSnapshot {
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;
}
interface MedicationDetailSnapshot {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
schedules: ScheduleSnapshot[];
}
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
})),
};
}
import {
MedicationDetailTabs,
type MedicationDetailSnapshot,
} from "@/components/medications/detail/MedicationDetailTabs";
export default function MedicationDetailPage({
params,
@@ -112,8 +39,6 @@ export default function MedicationDetailPage({
const router = useRouter();
const { t } = useTranslations();
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
@@ -139,13 +64,6 @@ export default function MedicationDetailPage({
retry: false,
});
// v1.7.2 W3 — derive the wizard payload purely to feed the read-only
// cadence summary line (the editor itself lives on the card kebab).
const wizardPayload = useMemo<MedicationPayload | null>(
() => (medication ? snapshotToWizardPayload(medication) : null),
[medication],
);
if (authLoading || isLoading) {
return (
<div
@@ -178,70 +96,12 @@ export default function MedicationDetailPage({
data-slot="medication-detail-error-card"
>
<p className="text-destructive text-sm">
{t("medications.loadFailed")}
{t("medications.detail.shell.loadFailed")}
</p>
</Card>
</div>
);
}
const oneShot = medication.oneShot === true;
// `wizardPayload` is non-null here — the early returns above bail
// before this point whenever `medication` is undefined.
const payload = wizardPayload as MedicationPayload;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.back")}
</Link>
</Button>
{/* v1.7.2 W3 — compact, NON-editable header. Editing and advanced
settings are reached from the medications-list card kebab only;
the detail page is history-centric. The header is a read-only
summary: name / dose / status + plain-language cadence line. */}
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{/* Intake history table — the primary surface. */}
<IntakeHistoryPreview
medicationId={id}
importOpen={importOpen}
onImportOpenChange={setImportOpen}
/>
{/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications
(genuine history context). */}
{isGlp1 && <SideEffectsSection medicationId={id} />}
{/* GLP-1 estimated drug-level curve (the modelled active-ingredient
level) + dose-strength (titration) curve. Visible by default for
GLP-1 medications — the drug-level estimate is the one the
medication concerns, so it leads; the dose-strength curve plots
the user's own logged dose-change history below it. */}
{isGlp1 && (
<div className="space-y-6" data-slot="detail-drug-level-section">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
</div>
);
return <MedicationDetailTabs medication={medication} />;
}
@@ -1,19 +1,15 @@
/**
* v1.7.2 W3 — the `/medications/[id]` detail page IS the history view.
* v1.15.18 — the `/medications/[id]` detail page is the tabbed shell.
*
* Editing and advanced settings are reached from the medications-list
* card kebab only; the detail page is purely history-centric. Source-
* level guards (the page is a client component with `use()` + hooks, so
* a full render is heavier than the contract warrants):
* - no `TodaysDoseCard` import or render (lives only on the list page);
* - no `isToday` / `todayEvent` derivations (dead once the card is
* gone);
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
* plumbing;
* - the page no longer hosts the wizard, the `AdvancedSettingsSheet`,
* the `MedicationDetailHeader` action row, or an editable cadence
* ("Rhythmus") block — those moved to the card kebab. It renders the
* read-only `MedicationDetailSummary` + the intake-history table.
* The page owns auth + the medication read and delegates the rest to
* `<MedicationDetailTabs>` (the tab strip, `?tab=` URL state, the hero
* and the wizard / advanced affordances). Source-level guards (the page
* is a client component with `use()` + hooks, so a full render is
* heavier than the contract warrants):
* - it renders `MedicationDetailTabs` and nothing tab-specific itself;
* - it carries no `TodaysDoseCard` (lives only on the list page);
* - it hosts neither the wizard nor the modal advanced sheet directly
* (those live inside the tab component).
*/
import { readFileSync } from "node:fs";
@@ -25,35 +21,18 @@ const source = readFileSync(
"utf8",
);
describe("medication detail page is the history view (W3)", () => {
describe("medication detail page is the tabbed shell (v1.15.18)", () => {
it("delegates to MedicationDetailTabs", () => {
expect(source).toContain("MedicationDetailTabs");
});
it("does not import or render TodaysDoseCard", () => {
expect(source).not.toContain("TodaysDoseCard");
expect(source).not.toContain("todays-dose-card");
});
it("drops the dead isToday / todayEvent derivations", () => {
expect(source).not.toContain("isToday");
expect(source).not.toContain("todayEvent");
});
it("drops the landingIntent plumbing", () => {
expect(source).not.toContain("landingIntent");
expect(source).not.toContain("wizardIntent");
expect(source).not.toContain("openWizardWithIntent");
});
it("no longer hosts the wizard, advanced sheet, detail-header, or cadence row", () => {
// v1.7.2 W3 — editing + advanced moved to the medications-list card
// kebab; the detail page carries none of those affordances.
it("does not host the wizard or the modal advanced sheet directly", () => {
expect(source).not.toContain("AdvancedSettingsSheet");
expect(source).not.toContain("MedicationWizardDialog");
expect(source).not.toContain("MedicationDetailHeader");
expect(source).not.toContain("CadenceSummaryRow");
expect(source).not.toContain("hideEdit");
});
it("renders the read-only summary header + the intake-history table", () => {
expect(source).toContain("MedicationDetailSummary");
expect(source).toContain("IntakeHistoryPreview");
});
});
@@ -0,0 +1,559 @@
"use client";
/**
* v1.15.18 — medication detail full-page tabbed shell.
*
* Promotes the detail page from a read-only history view to the
* canonical home for ONE medication. The modal `<AdvancedSettingsSheet>`
* is dissolved into the Erweitert tab (no more five-card stack in a
* sheet) and the separate `/history` route folds into the Verlauf tab
* (the route now redirects to `?tab=verlauf`).
*
* 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 /
* locale-independent; the visible labels are i18n.
*
* 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.
*/
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { InventorySection } from "@/components/medications/sections/inventory-section";
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
import {
DrugCodingRow,
GraceRow,
PhasesRow,
} from "@/components/medications/sections/settings-section";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
import {
DangerZoneBody,
LifecycleManageBody,
} 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 { 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 type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
interface ScheduleSnapshot {
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;
}
export interface MedicationDetailSnapshot {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
trackInjectionSites?: boolean;
allowedInjectionSites?: string[];
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
schedules: ScheduleSnapshot[];
}
// Slugs are ASCII / locale-independent so the URL stays stable across
// locales; the visible labels resolve through i18n. The list drives both
// the tab strip render and the `?tab=` round-trip validation.
const TAB_SLUGS = [
"uebersicht",
"zeitplan",
"erinnerung",
"bestand",
"verlauf",
"injektion",
"erweitert",
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
trackInjectionSites: med.trackInjectionSites ?? false,
allowedInjectionSites: med.allowedInjectionSites ?? [],
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
})),
};
}
export function MedicationDetailTabs({
medication,
}: {
medication: MedicationDetailSnapshot;
}) {
const { t } = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const [editOpen, setEditOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
const id = medication.id;
const oneShot = medication.oneShot === true;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
const payload = useMemo(
() => snapshotToWizardPayload(medication),
[medication],
);
// Only the active tab body mounts, but the tab REGISTRY must agree with
// whatever `?tab=` carries — Injektion is absent for non-injectable
// medications, so a stale `?tab=injektion` deep-link falls back to the
// landing tab instead of rendering an empty surface.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
[isInjectable],
);
const requested = searchParams?.get("tab") as TabSlug | null;
const activeTab: TabSlug =
requested && availableTabs.includes(requested) ? requested : "uebersicht";
const onTabChange = useCallback(
(next: string) => {
const params = new URLSearchParams(
Array.from(searchParams?.entries() ?? []),
);
params.set("tab", next);
// Shallow URL swap so the tab is deep-linkable + survives reload
// without a full navigation. `scroll: false` keeps the viewport
// pinned to the tab strip on switch.
router.replace(`/medications/${id}?${params.toString()}`, {
scroll: false,
});
},
[id, router, searchParams],
);
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}/compliance`);
if (!res.ok) return null;
return (await res.json()).data as {
compliance7?: { rate: number; streak: number };
compliance30?: { rate: number };
complianceDisplay?: ComplianceDisplay;
};
},
enabled: activeTab === "uebersicht",
staleTime: 30_000,
});
const display = compliance?.complianceDisplay;
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.back")}
</Link>
</Button>
{/* HERO — always-visible read-only summary + the structural-edit
jump into the wizard. Everyday levers live in the tabs below. */}
<div className="flex items-start justify-between gap-3">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">
{t("medications.detail.shell.fullEdit")}
</span>
</Button>
</div>
<Tabs
value={activeTab}
onValueChange={onTabChange}
data-slot="medication-detail-tabs"
>
<TabsList
className="w-full justify-start"
aria-label={t("medications.detail.shell.tablistLabel")}
>
{availableTabs.map((slug) => (
<TabsTrigger
key={slug}
value={slug}
data-slot={`medication-tab-${slug}`}
>
{t(`medications.detail.tabs.${slug}`)}
</TabsTrigger>
))}
</TabsList>
{/* ÜBERSICHT — landing read view: summary echo + compliance. */}
<TabsContent value="uebersicht" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
dataSlot="medication-uebersicht-compliance"
>
{compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.uebersicht.complianceEmpty")}
</p>
)}
</MedicationDetailSection>
</TabsContent>
{/* ZEITPLAN — read-only cadence view for now; the inline times +
per-dose window editor lands in the next wave. */}
<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}
/>
</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"
>
<div className="py-3">
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
</div>
<div className="py-3">
<GraceRow
medicationId={id}
reminderGraceMinutes={
medication.schedules[0]?.reminderGraceMinutes ?? null
}
/>
</div>
</SettingsGroup>
</TabsContent>
{/* BESTAND — inventory readout for all meds. */}
<TabsContent value="bestand" className="space-y-4 pt-2">
<InventorySection medicationId={id} />
</TabsContent>
{/* VERLAUF — the existing editable intake history (the dose-history
ledger replaces it in the next wave). CSV import rides the
header ghost. */}
<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>
}
>
<IntakeHistoryEditable
medicationId={id}
pageSize={25}
defaultSortBy="scheduledFor"
/>
</MedicationDetailSection>
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</TabsContent>
{/* INJEKTION — injectable routes only. Current GLP-1 charts +
side-effect log; the body-map + adherence detail land next
wave. */}
{isInjectable && (
<TabsContent value="injektion" className="space-y-6 pt-2">
{isGlp1 && <SideEffectsSection medicationId={id} />}
{isGlp1 && (
<div
className="space-y-6"
data-slot="injektion-drug-level-section"
>
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
{!isGlp1 && (
<DoseStrengthCurve medicationId={id} />
)}
</TabsContent>
)}
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="erweitert-group-external-api"
>
<div className="py-3">
<ApiTokensRow
medicationId={id}
medicationName={medication.name}
/>
</div>
<div className="py-3">
<DrugCodingRow
medicationId={id}
atcCode={medication.atcCode}
rxNormCode={medication.rxNormCode}
/>
</div>
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
dataSlot="erweitert-group-data"
>
<div className="py-3">
<DataPortabilityRow
medicationId={id}
onOpenImport={() => setImportOpen(true)}
/>
</div>
</SettingsGroup>
<ComplianceGatedDangerZone
medicationId={id}
medicationName={medication.name}
/>
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</TabsContent>
</Tabs>
{/* Structural edit — the wizard stays the name / class / cadence-kind
/ schedule tool. Hydrates from the medication payload in edit
mode; closing it leaves the user on the same tab. */}
<MedicationWizardDialog
open={editOpen}
onOpenChange={setEditOpen}
mode="edit"
initial={payload}
onSuccess={() => setEditOpen(false)}
/>
{/* GLP-1 phase/titration editor — sibling-swapped from the Lifecycle
group so the modal stack never exceeds the tab + one sheet. */}
{isGlp1 && (
<PhaseConfigSheet
medicationId={id}
open={phaseSheetOpen}
onOpenChange={setPhaseSheetOpen}
/>
)}
</div>
);
}
/**
* 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
* threading a count the page doesn't otherwise need.
*/
function ComplianceGatedDangerZone({
medicationId,
medicationName,
}: {
medicationId: string;
medicationName: string;
}) {
const params = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 1,
offset: 0,
status: "completed",
};
const { data: intakeCount } = useQuery({
queryKey: queryKeys.medicationIntakeList(medicationId, params),
queryFn: async () => {
const search = new URLSearchParams({
sortBy: params.sortBy,
sortDir: params.sortDir,
limit: String(params.limit),
offset: String(params.offset),
status: params.status,
});
const res = await fetch(
`/api/medications/${medicationId}/intake?${search.toString()}`,
);
if (!res.ok) return 0;
const json = await res.json();
return (json.data?.meta?.total ?? 0) as number;
},
staleTime: 30_000,
});
const { t } = useTranslations();
return (
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
className="border-destructive/40 bg-destructive/5"
>
<div className="py-3">
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</div>
</SettingsGroup>
);
}
@@ -0,0 +1,130 @@
"use client";
/**
* v1.15.18 — Bestand (supply) tab body.
*
* Read-only inventory readout for the medication detail page's Supply
* tab. Reads `GET /api/medications/[id]/inventory` (the same per-item
* list the pen/vial CRUD route serves) and renders a calm summary — the
* doses remaining across every non-terminal item plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count too);
* the richer pen math is implicit in the per-item doses figures.
*
* No write affordance here yet — registering a pen still rides the
* dedicated inventory route; the tab is the read surface the spec asks
* for. An empty inventory shows a quiet empty state, not a spinner.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, PackageOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
interface InventoryItem {
id: string;
state: InventoryState;
dosesTotal: number;
dosesRemaining: number;
}
interface InventoryResponse {
items: InventoryItem[];
meta?: { total: number };
}
const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive"> = {
ACTIVE: "secondary",
IN_USE: "secondary",
EXPIRED: "destructive",
USED_UP: "outline",
};
export function InventorySection({ medicationId }: { medicationId: string }) {
const { t } = useTranslations();
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/inventory`);
if (!res.ok) throw new Error("inventory_failed");
return (await res.json()).data as InventoryResponse;
},
staleTime: 30_000,
});
if (isLoading) {
return (
<div
className="flex h-32 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
/>
</div>
);
}
const items = Array.isArray(data?.items) ? data.items : [];
// Active supply: doses left across every non-terminal (not used-up) item.
const live = items.filter((i) => i.state !== "USED_UP");
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
if (items.length === 0) {
return (
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
/>
);
}
return (
<div className="space-y-4" data-slot="medication-inventory-section">
<SettingsGroup
label={t("medications.detail.bestand.title")}
dataSlot="inventory-summary-group"
>
<div className="py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
</p>
</div>
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.bestand.itemsTitle")}
dataSlot="inventory-items-group"
>
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="inventory-item-row"
>
<span className="text-foreground text-sm">
{t("medications.detail.bestand.doses", {
remaining: item.dosesRemaining,
total: item.dosesTotal,
})}
</span>
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
</div>
))}
</SettingsGroup>
</div>
);
}
+7
View File
@@ -265,6 +265,13 @@ export const queryKeys = {
["medications", medicationId, "cadence"] as const,
medicationGlp1Details: (medicationId: string) =>
["medications", medicationId, "glp1-details"] as const,
/**
* v1.15.18 — per-medication pen/vial inventory list, consumed by the
* detail page's Bestand (supply) tab. Rides under the `["medications"]`
* prefix so an intake mutation that decrements a pen invalidates it.
*/
medicationInventory: (medicationId: string) =>
["medications", medicationId, "inventory"] as const,
medicationIntakeDrugLevelChart: (medicationId: string) =>
["medications", medicationId, "intake", "drug-level-chart"] as const,
/**