feat(meds): simplify the detail page into the history view

The detail page carried an Edit / History / Advanced action row and a
'Rhythmus' cadence block that duplicated the card actions. Drop them:
editing and advanced settings are now reached from the card overflow
menu only. The detail page leads with the intake-history table, keeps
the GLP-1 drug-level / titration as a default-closed disclosure, and
renders a compact, read-only summary header (name, dose, status, plain-
language cadence line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-05-31 19:44:14 +02:00
1 parent 61d91df251
commit d6ce53a318
3 files changed
+186 -186

No files matched your search

+54 -170
View File
@@ -1,28 +1,26 @@
"use client";
/**
* v1.5.6 G-1 — medication detail page as a pure history surface.
* v1.7.2 W3 — medication detail page as the history view.
*
* Supersedes the v1.5.5 composition that stacked every setting inline.
* The page now reads as a calm "Vergangenheit" view: back link → slim
* header (name / dose / status + a two-option Bearbeiten dropdown) →
* STATIC cadence line → GLP-1 dose-ladder read-only visualisation →
* intake-history table. Every setting — Notifications, API tokens,
* Phasen, CSV import, grace and the destructive zone — moves into the
* `<AdvancedSettingsSheet>`, reached from the dropdown's "Erweiterte
* Einstellungen" item. "Plan bearbeiten" opens the wizard at Step 1.
* 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:
*
* One-shot variant drops the dose-ladder section and renders the
* static `Einmalig am …` line in place of the cadence summary.
* 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 `intakeCount`, which the
* sheet's destructive zone needs.
* - `medicationIntakeList(id, …)` for the header total.
*
* Mutations all cascade through `medicationDependentKeys` (which
* includes the `compliance-chart-inline` prefix per D-3 §10).
* Mutations all cascade through `medicationDependentKeys`.
*/
import { use, useEffect, useMemo, useState } from "react";
@@ -32,7 +30,7 @@ import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
@@ -40,14 +38,9 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
interface ScheduleSnapshot {
@@ -80,24 +73,6 @@ interface MedicationDetailSnapshot {
schedules: ScheduleSnapshot[];
}
interface IntakeListResponse {
events: Array<{
id: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
}>;
meta: { total: number; limit: number; offset: number };
}
const INTAKE_PREVIEW_PARAMS = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 14,
offset: 0,
status: "completed",
};
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
@@ -136,11 +111,7 @@ export default function MedicationDetailPage({
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const { t } = useTranslations();
const formatters = useFormatters();
const [wizardOpen, setWizardOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
@@ -163,28 +134,8 @@ export default function MedicationDetailPage({
enabled: isAuthenticated,
});
const { data: intakeList } = useQuery<IntakeListResponse>({
queryKey: queryKeys.medicationIntakeList(id, INTAKE_PREVIEW_PARAMS),
queryFn: async () => {
const params = new URLSearchParams({
sortBy: INTAKE_PREVIEW_PARAMS.sortBy,
sortDir: INTAKE_PREVIEW_PARAMS.sortDir,
limit: String(INTAKE_PREVIEW_PARAMS.limit),
offset: String(INTAKE_PREVIEW_PARAMS.offset),
status: INTAKE_PREVIEW_PARAMS.status,
});
const res = await fetch(
`/api/medications/${id}/intake?${params.toString()}`,
);
if (!res.ok) throw new Error("intake_list_failed");
return (await res.json()).data as IntakeListResponse;
},
enabled: isAuthenticated && !!medication,
});
// v1.5.6 F-1 M-2 — memoise the wizard payload so the header dropdown
// + wizard mount share one object identity per medication snapshot
// instead of recomputing it on every render.
// 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],
@@ -235,18 +186,10 @@ export default function MedicationDetailPage({
}
const oneShot = medication.oneShot === true;
const intakeCount = intakeList?.meta.total ?? 0;
const primaryGrace = medication.schedules[0]?.reminderGraceMinutes ?? null;
// `wizardPayload` is non-null here — the early returns above bail
// before this point whenever `medication` is undefined.
const payload = wizardPayload as MedicationPayload;
// v1.5.6 G-1 §5 — sibling-swap: close the advanced sheet first, then
// open the phase sheet so the two never stack (modal depth ≤ 2).
function openPhaseSheet() {
setAdvancedOpen(false);
setPhaseSheetOpen(true);
}
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
return (
<div className="space-y-6" data-slot="medication-detail-page">
@@ -262,114 +205,55 @@ export default function MedicationDetailPage({
</Link>
</Button>
{/* v1.7.0 — Header band. Three buttons: Edit opens the wizard,
History routes directly to the full intake-history view, and
Advanced opens the settings sheet. The page owns the wizard +
advanced open-states; History is a plain navigation. */}
<MedicationDetailHeader
{/* 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}
onEditPlan={() => setWizardOpen(true)}
onOpenHistory={() => router.push(`/medications/${id}/history`)}
onOpenAdvanced={() => setAdvancedOpen(true)}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{/* G-1 §3.3 — Cadence line. One-shot collapses to the
`Einmalig am …` card; recurring renders the summary row whose
edit affordance opens the editor directly. */}
{oneShot ? (
<Card
className="p-5 sm:p-6"
data-slot="medication-detail-one-shot-cadence"
>
<p className="text-foreground text-sm">
{medication.startsOn
? t("medications.detail.cadence.oneShotOn", {
date: formatters.dateTime(medication.startsOn),
})
: t("medications.detail.cadence.oneShotPending")}
</p>
</Card>
) : (
<CadenceSummaryRow
medication={payload}
onEdit={() => setWizardOpen(true)}
/>
)}
{/* G-1 §3.4 — Dose ladder / Phasen visualisation (read-only,
GLP-1 recurring only). The editor lives in the sheet. */}
{!oneShot && medication.treatmentClass === "GLP1" && (
<>
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
{/* v1.6.0 — side-effect logbook + cadence/compliance section
restored onto the detail page (they previously lived only
on the legacy `/history` route). */}
<SideEffectsSection medicationId={id} />
<SchedulingSection
medicationId={id}
reminderEnabled={medication.notificationsEnabled}
/>
<TitrationSection medicationId={id} />
</>
)}
{/* G-1 §3.5 — Intake history table (primary surface). */}
{/* Intake history table — the primary surface. */}
<IntakeHistoryPreview
medicationId={id}
importOpen={importOpen}
onImportOpenChange={setImportOpen}
/>
{/* G-1 §5 — Advanced settings sheet. Hosts Notifications →
Settings → destructive zone. The Phasen button sibling-swaps
to the phase sheet below. */}
<AdvancedSettingsSheet
open={advancedOpen}
onOpenChange={setAdvancedOpen}
medicationId={id}
medicationName={medication.name}
treatmentClass={medication.treatmentClass}
active={medication.active}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
notificationsEnabled={medication.notificationsEnabled}
reminderGraceMinutes={primaryGrace}
intakeCount={intakeCount}
onRequestPhaseSheet={openPhaseSheet}
onOpenImport={() => setImportOpen(true)}
/>
{/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications
(genuine history context). */}
{isGlp1 && <SideEffectsSection medicationId={id} />}
{/* G-1 §5 — phase sheet mounted as a sibling of the advanced
sheet so they never stack (GLP-1 + course window only). */}
{!oneShot &&
medication.treatmentClass === "GLP1" &&
Boolean(medication.startsOn) &&
Boolean(medication.endsOn) && (
<PhaseConfigSheet
medicationId={id}
open={phaseSheetOpen}
onOpenChange={setPhaseSheetOpen}
/>
)}
{/* Wizard mount — edit-only, lands on Step 1 (no landing
intent). Cancel + success both close via the cascade. */}
<MedicationWizardDialog
open={wizardOpen}
onOpenChange={setWizardOpen}
mode="edit"
initial={payload}
onSuccess={() => setWizardOpen(false)}
/>
{/* GLP-1 drug-level curve + titration ladder as a default-CLOSED
disclosure — genuine history context, opt-in so the intake
table stays the dominant surface. The chart gates further on
Research Mode. */}
{isGlp1 && (
<details
className="border-border/60 rounded-md border"
data-slot="detail-drug-level-disclosure"
>
<summary className="text-foreground flex min-h-11 cursor-pointer items-center px-3 text-sm font-medium select-none sm:min-h-9">
{t("medications.detail.history.drugLevelDisclosure")}
</summary>
<div className="border-border/60 space-y-6 border-t px-3 py-3">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<TitrationSection medicationId={id} />
</div>
</details>
)}
</div>
);
}
@@ -1,19 +1,19 @@
/**
* v1.5.6 G-1 §3 — the `/medications/[id]` detail page is a pure
* history surface.
* v1.7.2 W3 — the `/medications/[id]` detail page IS the history view.
*
* 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 (removed from the detail
* page; it lives only on the list page);
* 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 header dropdown is the only wizard entry, landing
* on Step 1);
* - the page hosts the `AdvancedSettingsSheet` and renders an
* editable cadence row (the v1.6.0 editor opens directly from the
* row, so `hideEdit` is gone).
* 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.
*/
import { readFileSync } from "node:fs";
@@ -25,7 +25,7 @@ const source = readFileSync(
"utf8",
);
describe("medication detail page is a pure history surface (G-1 §3)", () => {
describe("medication detail page is the history view (W3)", () => {
it("does not import or render TodaysDoseCard", () => {
expect(source).not.toContain("TodaysDoseCard");
expect(source).not.toContain("todays-dose-card");
@@ -42,10 +42,18 @@ describe("medication detail page is a pure history surface (G-1 §3)", () => {
expect(source).not.toContain("openWizardWithIntent");
});
it("hosts the AdvancedSettingsSheet and an editable cadence row", () => {
expect(source).toContain("AdvancedSettingsSheet");
// v1.6.0 — the cadence row opens the editor directly; the dead
// `hideEdit` prop is gone.
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.
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,108 @@
"use client";
/**
* v1.7.2 W3 — compact, non-editable medication-detail header.
*
* Supersedes the v1.7.0 `<MedicationDetailHeader>` (Edit / History /
* Advanced action row) and the `<CadenceSummaryRow>` "Rhythmus" block.
* The detail page is now history-centric: editing and advanced settings
* are reached from the medications-list card kebab only, so this header
* carries NO action buttons. It is a read-only summary line:
*
* name (H1) → dose (muted) → status pill → plain-language cadence line
*
* One-shot medications render `Einmalig am …` in place of the cadence
* summary; a pending one-shot falls back to the pending copy.
*/
import { Badge } from "@/components/ui/badge";
import {
hydrateWizardPayload,
summariseCadence,
type MedicationPayload,
} from "@/components/medications/wizard/wizard-payload";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
export interface MedicationDetailSummaryProps {
name: string;
dose: string;
active: boolean;
endsOn?: string | null;
/** Wizard payload used to derive the plain-language cadence line. */
payload: MedicationPayload;
oneShot: boolean;
startsOn?: string | null;
}
type Status = "active" | "paused" | "ended";
function resolveStatus(active: boolean, endsOn?: string | null): Status {
if (endsOn) {
const end = new Date(endsOn);
if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) {
return "ended";
}
}
return active ? "active" : "paused";
}
export function MedicationDetailSummary({
name,
dose,
active,
endsOn,
payload,
oneShot,
startsOn,
}: MedicationDetailSummaryProps) {
const { t } = useTranslations();
const formatters = useFormatters();
const status = resolveStatus(active, endsOn);
const statusLabel =
status === "active"
? t("medications.detail.status.active")
: status === "paused"
? t("medications.detail.status.paused")
: t("medications.detail.status.ended");
const dotClass =
status === "active"
? "bg-[hsl(var(--success))]"
: status === "paused"
? "bg-[hsl(var(--warning))]"
: "bg-muted-foreground";
const cadenceLine = oneShot
? startsOn
? t("medications.detail.cadence.oneShotOn", {
date: formatters.dateTime(startsOn),
})
: t("medications.detail.cadence.oneShotPending")
: summariseCadence(hydrateWizardPayload(payload), t);
return (
<div className="space-y-1.5" data-slot="medication-detail-summary">
<h1 className="text-2xl font-bold 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"
data-slot="medication-detail-status-row"
>
<Badge variant="secondary" className="gap-1.5">
<span
aria-hidden="true"
className={`inline-block h-2 w-2 rounded-full ${dotClass}`}
/>
{statusLabel}
</Badge>
</div>
<p
className="text-muted-foreground text-sm"
data-slot="medication-detail-cadence-line"
>
{cadenceLine}
</p>
</div>
);
}