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

+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");
});
});