diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index fdfeb589e..4ce75cc2a 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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 - * ``, 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({ - 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( () => (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 (
@@ -262,114 +205,55 @@ export default function MedicationDetailPage({ - {/* 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. */} - 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 ? ( - -

- {medication.startsOn - ? t("medications.detail.cadence.oneShotOn", { - date: formatters.dateTime(medication.startsOn), - }) - : t("medications.detail.cadence.oneShotPending")} -

-
- ) : ( - 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" && ( - <> - - {/* v1.6.0 — side-effect logbook + cadence/compliance section - restored onto the detail page (they previously lived only - on the legacy `/history` route). */} - - - - - )} - - {/* G-1 §3.5 — Intake history table (primary surface). */} + {/* Intake history table — the primary surface. */} - {/* G-1 §5 — Advanced settings sheet. Hosts Notifications → - Settings → destructive zone. The Phasen button sibling-swaps - to the phase sheet below. */} - setImportOpen(true)} - /> + {/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications + (genuine history context). */} + {isGlp1 && } - {/* 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) && ( - - )} - - {/* Wizard mount — edit-only, lands on Step 1 (no landing - intent). Cancel + success both close via the cascade. */} - 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 && ( +
+ + {t("medications.detail.history.drugLevelDisclosure")} + +
+ + +
+
+ )}
); } diff --git a/src/app/medications/__tests__/detail-page-history-surface.test.ts b/src/app/medications/__tests__/detail-page-history-surface.test.ts index e3556ff9f..e2a127e34 100644 --- a/src/app/medications/__tests__/detail-page-history-surface.test.ts +++ b/src/app/medications/__tests__/detail-page-history-surface.test.ts @@ -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"); + }); }); diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx new file mode 100644 index 000000000..641a235de --- /dev/null +++ b/src/components/medications/medication-detail-summary.tsx @@ -0,0 +1,108 @@ +"use client"; + +/** + * v1.7.2 W3 — compact, non-editable medication-detail header. + * + * Supersedes the v1.7.0 `` (Edit / History / + * Advanced action row) and the `` "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 ( +
+

{name}

+

{dose}

+
+ + +
+

+ {cadenceLine} +

+
+ ); +}