From 61d91df251d2e7999ec7c8b78178731828de08e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 19:43:13 +0200 Subject: [PATCH] feat(meds): collapse card actions into an overflow menu The overview cards (generic and GLP-1) carried four header icon-buttons (open / edit / history / advanced). Collapse them into a single overflow kebab and make the card header link to the detail page. The GLP-1 card folds its side-effect quick-log into the same menu so both variants keep one symmetric affordance. Menu items inherit the 44px tap-target floor from the dropdown primitive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../medications/MedicationCardHeader.tsx | 93 +++-- .../medications/SchedulingSection.tsx | 323 ------------------ .../__tests__/MedicationCardHeader.test.tsx | 28 ++ .../__tests__/SchedulingSection.test.tsx | 195 ----------- .../__tests__/medication-card-glp1.test.tsx | 65 ++-- .../medication-card-symmetry.test.tsx | 115 ++----- .../medication-detail-header.test.tsx | 125 ------- .../medications/cadence-summary-row.tsx | 72 ---- .../medications/glp1-medication-card.tsx | 107 +----- .../medications/medication-card-menu.tsx | 98 ++++++ .../medications/medication-card.tsx | 70 +--- .../medications/medication-detail-header.tsx | 156 --------- 12 files changed, 285 insertions(+), 1162 deletions(-) delete mode 100644 src/components/medications/SchedulingSection.tsx delete mode 100644 src/components/medications/__tests__/SchedulingSection.test.tsx delete mode 100644 src/components/medications/__tests__/medication-detail-header.test.tsx delete mode 100644 src/components/medications/cadence-summary-row.tsx create mode 100644 src/components/medications/medication-card-menu.tsx delete mode 100644 src/components/medications/medication-detail-header.tsx diff --git a/src/components/medications/MedicationCardHeader.tsx b/src/components/medications/MedicationCardHeader.tsx index dc13fd51a..fe8f3dbb6 100644 --- a/src/components/medications/MedicationCardHeader.tsx +++ b/src/components/medications/MedicationCardHeader.tsx @@ -1,6 +1,7 @@ "use client"; import { type ReactNode } from "react"; +import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { CardHeader, CardTitle } from "@/components/ui/card"; @@ -16,10 +17,15 @@ import { CardHeader, CardTitle } from "@/components/ui/card"; * Line 1: `{name} {dose}` — bold, `text-lg` * Line 2: `{categoryLabel}` outline badge + optional state badges * - * The trailing `actions` slot carries the icon-button cluster (history + - * edit) on the right of the row. State badges (without-notification, - * paused-since, inactive) ride on line 2 alongside the class label so a - * narrow viewport keeps a single inline run. + * The trailing `actions` slot carries the overflow kebab on the right of + * the row. State badges (without-notification, paused-since, inactive) + * ride on line 2 alongside the class label so a narrow viewport keeps a + * single inline run. + * + * v1.7.2 W3 — when `href` is set the name + dose + category region + * becomes a Link to the medication detail page (the former chevron + * target). The kebab in `actions` is a sibling outside the Link so + * opening the menu never also navigates. * * The GLP-1 row previously surfaced a `` glyph + middle-dot * separator on line 1 and demoted the dose to a muted inline span. This @@ -32,6 +38,14 @@ export interface MedicationCardHeaderProps { categoryLabel: string; stateBadges?: ReactNode; actions?: ReactNode; + /** + * v1.7.2 W3 — when set, the name + dose + category region links to the + * medication detail page so the card body is tappable/navigable. The + * `actions` kebab stays a sibling outside the Link. + */ + href?: string; + /** Accessible name for the navigating Link region. */ + linkLabel?: string; } export function MedicationCardHeader({ @@ -40,38 +54,55 @@ export function MedicationCardHeader({ categoryLabel, stateBadges, actions, + href, + linkLabel, }: MedicationCardHeaderProps) { + const body = ( + <> + + {name} {dose} + + {/* D-H6 — state badges (without-notification, paused-since, + inactive) used to share line 2 with the category badge + via `flex flex-wrap`. At 320 px any state badge pushed + the row to three lines and broke the FB-G1 "two-line, + no exceptions" contract for ~20 % of configured drugs. + State badges now ride their own row below the category + badge so the canonical row stays two lines on narrow + viewports. */} +
+ + {categoryLabel} + +
+ {stateBadges ? ( +
+ {stateBadges} +
+ ) : null} + + ); + return (
-
- - {name} {dose} - - {/* D-H6 — state badges (without-notification, paused-since, - inactive) used to share line 2 with the category badge - via `flex flex-wrap`. At 320 px any state badge pushed - the row to three lines and broke the FB-G1 "two-line, - no exceptions" contract for ~20 % of configured drugs. - State badges now ride their own row below the category - badge so the canonical row stays two lines on narrow - viewports. */} -
- - {categoryLabel} - -
- {stateBadges ? ( -
- {stateBadges} -
- ) : null} -
+ {href ? ( + + {body} + + ) : ( +
{body}
+ )} {actions ? ( -
{actions}
+
{actions}
) : null}
diff --git a/src/components/medications/SchedulingSection.tsx b/src/components/medications/SchedulingSection.tsx deleted file mode 100644 index eb7fabda4..000000000 --- a/src/components/medications/SchedulingSection.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { Loader2, BellRing, BellOff, Pencil } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; - -/** - * v1.4.25 W19e — GLP-1 cadence + compliance section. - * - * Sits between `` (W19d) and `` - * on the medication detail page (v1.4.28 retired the IntakeHistoryList - * block below). Same chrome as the W19d / W19f sections, composed via - * the shared `` wrapper. - * - * Three sub-sections, top-to-bottom: - * - * 1. Header strip — reminder on/off badge + next-due chip + "Edit - * schedule" link back to the medication list (where the existing - * `medication-form` is the canonical schedule editor; we - * deliberately do not re-implement the schedule editor here so - * there's no second source of truth). - * - * 2. Cadence visualisation — a 30-day track showing one cell per - * expected dose, status-coloured (taken / skipped / missed / - * upcoming). The legend below the track names each status. - * v1.4.25 W21 Fix-N — each cell is wrapped in a 44×44 px button - * to meet the WCAG 2.5.5 / Apple HIG tap-target rule; the - * visible cell remains the 12 px square the design system pins. - * - * 3. Compliance chips — four monochrome chips: adherence rate %, - * current streak, longest streak, missed last 30. Marc-memory: - * no gamified badges, no WebP art. - * - * Data comes from /api/medications/[id]/cadence; the route delegates - * to the pure scheduling helpers so the chart, the chips, and the - * route agree dose-for-dose. - */ - -interface CadenceTimelineEntry { - day: string; - windowStart: string; - windowEnd: string; - scheduleIndex: number; - status: "taken" | "skipped" | "missed" | "upcoming"; -} - -interface CadenceChips { - adherenceRate: number | null; - currentStreak: number; - longestStreak: number; - missedLast30: number; - windowDays: number; -} - -interface CadenceResponse { - windowDays: number; - anchorIso: string; - next: { - windowStart: string; - windowEnd: string; - scheduleIndex: number; - } | null; - chips: CadenceChips; - timeline: CadenceTimelineEntry[]; -} - -interface SchedulingSectionProps { - medicationId: string; - reminderEnabled: boolean; -} - -const STATUS_STYLES: Record = { - // Monochrome scale; chart visually identical contract — no new colour - // tokens, all classes already exist in the design system. - taken: "bg-primary/85", - skipped: "bg-muted", - missed: "bg-destructive/65", - upcoming: "border-border/70 border bg-background", -}; - -export function SchedulingSection({ - medicationId, - reminderEnabled, -}: SchedulingSectionProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.medicationCadence(medicationId), - queryFn: async (): Promise => { - const res = await fetch( - `/api/medications/${medicationId}/cadence?days=30`, - ); - if (!res.ok) { - throw new Error(`Failed to load cadence: ${res.status}`); - } - const json = await res.json(); - return json.data as CadenceResponse; - }, - }); - - const adherenceLabel = useMemo(() => { - if (!data) return null; - if (data.chips.adherenceRate === null) { - return t("medications.scheduling.compliance.noData"); - } - return `${data.chips.adherenceRate}%`; - }, [data, t]); - - const headerExtras = ( -
- - {reminderEnabled ? ( - - ) : ( - - )} - {t( - reminderEnabled - ? "medications.scheduling.reminders.on" - : "medications.scheduling.reminders.off", - )} - - -
- ); - - return ( - - {isLoading && ( -
- - {t("medications.scheduling.loading")} -
- )} - - {!!error && !isLoading && ( -

- {t("medications.scheduling.loadFailed")} -

- )} - - {data && !isLoading && ( -
- {/* Next-dose chip */} -
-

- {data.next ? ( - <> - {t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "} - - {fmt.dateShort(new Date(data.next.windowStart))} - - - ) : ( - t("medications.scheduling.cadenceViz.noNextDose") - )} -

-
- - {/* 30-day timeline */} -
-

- {t("medications.scheduling.cadenceViz.title")} -

- {data.timeline.length === 0 ? ( -

- {t("medications.scheduling.cadenceViz.emptyState")} -

- ) : ( - <> -
- {data.timeline.map((slot, i) => { - const slotLabel = `${fmt.dateShort(new Date(slot.windowStart))} — ${t( - `medications.scheduling.cadenceViz.status.${slot.status}`, - )}`; - return ( - - ); - })} -
-
- {(["taken", "skipped", "missed", "upcoming"] as const).map( - (s) => ( - - - - {t( - `medications.scheduling.cadenceViz.status.${s}`, - )} - - - ), - )} -
- - )} -
- - {/* Compliance chips */} -
-

- {t("medications.scheduling.compliance.title")} -

-
- - - - -
-
-
- )} -
- ); -} - -interface ComplianceChipProps { - label: string; - value: string; - tooltip: string; -} - -function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) { - return ( -
-

- {label} -

-

{value}

-
- ); -} diff --git a/src/components/medications/__tests__/MedicationCardHeader.test.tsx b/src/components/medications/__tests__/MedicationCardHeader.test.tsx index 48a5617e8..baa0d60ba 100644 --- a/src/components/medications/__tests__/MedicationCardHeader.test.tsx +++ b/src/components/medications/__tests__/MedicationCardHeader.test.tsx @@ -88,4 +88,32 @@ describe("", () => { expect(categoryIdx).toBeGreaterThan(-1); expect(stateRowIdx).toBeGreaterThan(categoryIdx); }); + + it("wraps the name/dose/category region in a detail-page link when href is set (v1.7.2 W3)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="medication-card-header-link"'); + expect(html).toContain('href="/medications/med-1"'); + expect(html).toContain('aria-label="Open medication detail page"'); + // The title still rides inside the link region. + expect(html).toContain("Ramipril 5 mg"); + }); + + it("falls back to a non-navigating div when href is omitted", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="medication-card-header-link"'); + }); }); diff --git a/src/components/medications/__tests__/SchedulingSection.test.tsx b/src/components/medications/__tests__/SchedulingSection.test.tsx deleted file mode 100644 index c627dba39..000000000 --- a/src/components/medications/__tests__/SchedulingSection.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { SchedulingSection } from "@/components/medications/SchedulingSection"; - -/** - * v1.4.25 W19e — SchedulingSection SSR smoke tests. - * - * Same testing convention as `SideEffectsSection.test.tsx`: - * `renderToStaticMarkup` + seeded react-query cache. The - * fetch-driven interactive branches (loading spinner, error state) - * are covered by the API-route tests + the pure cadence + compliance - * helper tests; the surface tests here pin the static-render contract: - * - * 1. Section heading + reminder badge render in EN and DE. - * 2. Compliance chips display the four values (adherence, current, - * longest, missed) when data is seeded. - * 3. Empty timeline yields the empty-state copy. - * 4. Reminder badge switches text between on / off. - */ - -function makeClient() { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - }, - }, - }); -} - -interface SeedShape { - windowDays: number; - next: { windowStart: string; windowEnd: string; scheduleIndex: number } | null; - chips: { - adherenceRate: number | null; - currentStreak: number; - longestStreak: number; - missedLast30: number; - windowDays: number; - }; - timeline: Array<{ - day: string; - windowStart: string; - windowEnd: string; - scheduleIndex: number; - status: "taken" | "skipped" | "missed" | "upcoming"; - }>; -} - -function seed(client: QueryClient, medId: string, payload: SeedShape) { - client.setQueryData(["medications", medId, "cadence"], payload); -} - -function render( - node: React.ReactNode, - client: QueryClient, - locale: "en" | "de" = "en", -) { - return renderToStaticMarkup( - - {node} - , - ); -} - -const SEEDED: SeedShape = { - windowDays: 30, - next: { - windowStart: "2026-05-15T08:00:00.000Z", - windowEnd: "2026-05-15T09:00:00.000Z", - scheduleIndex: 0, - }, - chips: { - adherenceRate: 92, - currentStreak: 12, - longestStreak: 21, - missedLast30: 2, - windowDays: 30, - }, - timeline: [ - { - day: "2026-05-13T00:00:00.000Z", - windowStart: "2026-05-13T08:00:00.000Z", - windowEnd: "2026-05-13T09:00:00.000Z", - scheduleIndex: 0, - status: "taken", - }, - { - day: "2026-05-14T00:00:00.000Z", - windowStart: "2026-05-14T08:00:00.000Z", - windowEnd: "2026-05-14T09:00:00.000Z", - scheduleIndex: 0, - status: "missed", - }, - ], -}; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe(" — surface render", () => { - it("renders the section heading in English", () => { - const client = makeClient(); - seed(client, "med-1", SEEDED); - const html = render( - , - client, - ); - expect(html).toContain("Schedule"); - expect(html).toContain("Reminders on"); - }); - - it("renders the section heading in German", () => { - const client = makeClient(); - seed(client, "med-1", SEEDED); - const html = render( - , - client, - "de", - ); - expect(html).toContain("Zeitplan"); - expect(html).toContain("Erinnerungen an"); - }); - - it("switches the reminder badge when reminderEnabled is false", () => { - const client = makeClient(); - seed(client, "med-1", SEEDED); - const html = render( - , - client, - ); - expect(html).toContain("Reminders off"); - }); -}); - -describe(" — chips", () => { - it("renders the four compliance chip values", () => { - const client = makeClient(); - seed(client, "med-1", SEEDED); - const html = render( - , - client, - ); - expect(html).toContain("92%"); - expect(html).toContain("12"); - expect(html).toContain("21"); - expect(html).toContain("Current streak"); - expect(html).toContain("Longest streak"); - expect(html).toContain("Adherence"); - }); - - it("renders `No data` when adherence is null", () => { - const client = makeClient(); - seed(client, "med-1", { - ...SEEDED, - chips: { ...SEEDED.chips, adherenceRate: null }, - }); - const html = render( - , - client, - ); - expect(html).toContain("No data"); - }); -}); - -describe(" — timeline", () => { - it("renders the timeline empty-state when no slots are seeded", () => { - const client = makeClient(); - seed(client, "med-1", { ...SEEDED, timeline: [] }); - const html = render( - , - client, - ); - expect(html).toContain("No doses scheduled in this window"); - }); - - it("renders the legend with all four statuses", () => { - const client = makeClient(); - seed(client, "med-1", SEEDED); - const html = render( - , - client, - ); - expect(html).toContain("Taken"); - expect(html).toContain("Skipped"); - expect(html).toContain("Missed"); - expect(html).toContain("Upcoming"); - }); -}); diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 8dc387918..fa3086ad9 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -194,12 +194,12 @@ describe(" — GLP-1 variant rendering", () => { expect(html).not.toMatch(/lucide-syringe/i); }); - it("renders edit + history + advanced header actions with distinct aria-labels", () => { - // v1.7.1 — the GLP-1 card carries the same three header actions as - // the generic medication card: edit (pencil), history, and advanced - // (sliders). All three surface with their localised aria-labels and - // their click handlers deliver the GLP-1 medication object the parent - // routes / sheets against. + it("collapses edit / history / advanced into a single overflow kebab + navigable header", () => { + // v1.7.2 W3 — the four former header icon-buttons collapse into one + // kebab; the card header links to the detail page. The menu items + // (edit / history / advanced) render inside the portalled dropdown + // content, so SSR markup carries only the kebab trigger + the header + // link, not the individual action buttons. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, {}); @@ -218,13 +218,15 @@ describe(" — GLP-1 variant rendering", () => { client, ); - expect(html).toContain('aria-label="Edit"'); - expect(html).toContain('aria-label="View intake history"'); - expect(html).toContain('aria-label="Advanced settings"'); + expect(html).toContain('aria-label="More options"'); + expect(html).toContain('data-slot="medication-card-header-link"'); + expect(html).toContain('href="/medications/med-glp1-1"'); + // No standalone chevron / sliders icon-buttons survive in the header. + expect(html).not.toContain("lucide-chevron-right"); // Smoke-check the handler contract — SSR can't fire DOM events, so - // invoke the handlers the way each button's onClick would and pin - // the medication-object payload. + // invoke the handlers the way each menu item's onClick would and pin + // the medication-object payload the parent routes / sheets against. onOpenHistory(med7p5); onOpenAdvanced(med7p5); expect(onOpenHistory).toHaveBeenCalledWith( @@ -443,15 +445,12 @@ describe(" — GLP-1 variant rendering", () => { expect(html).not.toContain("Low stock"); }); - it("side-effect quick-log moves into the header actions overflow", () => { - // v1.4.37 W4b — the side-effect quick-log no longer sits in the - // primary actions row (which used to read as a three-button row - // and broke symmetry with the generic Ramipril card). It now - // lives behind a kebab overflow in the header `actions` slot. - // SSR doesn't open Radix Portal content, so we assert the trigger - // surfaces with the localised `common.moreOptions` aria-label and - // smoke-check that invoking the handler delivers the GLP-1 - // medication object the parent prefills MoodEntry with. + it("folds the side-effect quick-log into the same overflow kebab", () => { + // v1.7.2 W3 — the side-effect quick-log folds into the SAME overflow + // menu as edit / history / advanced, so the GLP-1 header carries one + // kebab whether or not the hand-off is wired (parity with the generic + // card). SSR doesn't open Radix Portal content, so we assert the + // single trigger surfaces and smoke-check the handler payload. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, {}); @@ -468,8 +467,9 @@ describe(" — GLP-1 variant rendering", () => { client, ); - // Kebab trigger renders with the localised aria-label. - expect(html).toContain('aria-label="More options"'); + // Exactly one kebab trigger with the localised aria-label. + const triggers = html.match(/aria-label="More options"/g) ?? []; + expect(triggers).toHaveLength(1); // The side-effect button must no longer ride alongside // Eingenommen / Übersprungen in the primary actions row. expect(html).not.toMatch( @@ -484,11 +484,11 @@ describe(" — GLP-1 variant rendering", () => { ); }); - it("omits the overflow kebab when onLogSideEffect is not supplied", () => { - // Back-compat: the overflow is opt-in via the prop. Pages that - // haven't wired the MoodEntry hand-off yet render Mounjaro with - // exactly the same header-actions shape as Ramipril (history + - // edit, no kebab) so the medications list stays symmetric. + it("still renders the single kebab when onLogSideEffect is not supplied", () => { + // v1.7.2 W3 — the kebab is unconditional now (it always carries edit + // / history / advanced); the side-effect item is the only opt-in + // part. Mounjaro and Ramipril therefore share the same one-kebab + // header shape regardless of the hand-off prop. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, {}); @@ -503,7 +503,7 @@ describe(" — GLP-1 variant rendering", () => { client, ); - expect(html).not.toContain('aria-label="More options"'); + expect(html).toContain('aria-label="More options"'); }); it("AI-disabled state: no Coach hand-off button is rendered today", () => { @@ -557,11 +557,10 @@ describe(" — GLP-1 variant rendering", () => { expect(html).toContain("opacity-60"); expect(html).toContain("Paused since"); // Primary actions ("Taken" / "Skipped") are suppressed for - // inactive medications. The header-actions overflow trigger is - // unconditionally absent when `onLogSideEffect` is not supplied - // (this test fixture doesn't wire it), so Mounjaro and Ramipril - // headers stay shape-equivalent in the paused state. - expect(html).not.toContain('aria-label="More options"'); + // inactive medications, but the header overflow kebab (edit / + // history / advanced) still renders so the user can reach those + // actions on a paused medication. + expect(html).toContain('aria-label="More options"'); }); it("renders German copy under the 'de' locale", () => { diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index 128ffd2f2..8085c0cb7 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -11,17 +11,22 @@ import { /** * v1.4.37 W4b — symmetry contract between the generic medication card - * (Ramipril/non-GLP-1) and the GLP-1 variant (Mounjaro). Marc reported - * during the v1.4.37 UX audit that the two cards "felt different" - * even though both should read as the same row shape. These tests pin - * the load-bearing structural seams so a future refactor can't quietly - * regress the parity. + * (Ramipril/non-GLP-1) and the GLP-1 variant (Mounjaro). The two cards + * must read as the same row shape. These tests pin the load-bearing + * structural seams so a future refactor can't quietly regress parity. + * + * v1.7.2 W3 — the four former header icon-buttons (open / edit / history + * / advanced) collapsed into a SINGLE overflow kebab on both cards; the + * card header itself links to the detail page (the former chevron + * target). The GLP-1 card folds its "Log side effect" item into the same + * kebab. Radix dropdown content is portalled, so SSR markup only carries + * the kebab trigger (the menu items render on open at runtime). * * What we assert: - * 1. Header `actions` cluster — both cards always render `History` - * and `Pencil` icon buttons. The Mounjaro card optionally renders - * an additional kebab when `onLogSideEffect` is wired; absent the - * prop the headers are byte-shape equivalent. + * 1. Header `actions` — both cards render exactly one kebab trigger + * (`common.moreOptions` aria-label) and a navigable header link to + * the detail page. No standalone chevron / pencil / history / + * sliders icon-buttons survive in the header. * 2. Category-label badge — both cards consult the same * `getMedicationCategoryLabel` lookup. No card hard-codes the * `medications.treatmentClassGlp1` string into the slot. @@ -29,8 +34,7 @@ import { * configured schedule window and `lastTakenAt` is null/older, both * cards render the `medications.takeNow` localised text. * 4. Primary actions row — exactly two buttons (Eingenommen / - * Übersprungen). The side-effect quick-log lives in the header - * overflow when wired, not in this row. + * Übersprungen). * 5. Purple dose accent — both cards render * `font-medium text-purple-400` on the upcoming schedule dose. */ @@ -124,7 +128,7 @@ const mounjaro: Glp1Medication = { }; describe("medication card symmetry — Ramipril vs Mounjaro", () => { - it("both cards render the detail-nav + Pencil header buttons", () => { + it("both cards collapse the header actions into a single kebab + navigable header link", () => { const client = makeClient(); seedCompliance(client, ramipril.id); seedCompliance(client, mounjaro.id); @@ -149,74 +153,27 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { client, ); - for (const html of [ramiprilHtml, mounjaroHtml]) { - // v1.5.6 F-1 M-1 — the detail-nav icon swapped from the - // history glyph to a neutral chevron now that it routes to the - // detail page rather than the history sub-route. - expect(html).toContain("lucide-chevron-right"); - expect(html).toContain("lucide-pencil"); + for (const [html, href] of [ + [ramiprilHtml, `/medications/${ramipril.id}`], + [mounjaroHtml, `/medications/${mounjaro.id}`], + ] as const) { + // v1.7.2 W3 — exactly one overflow kebab; the four former icon + // buttons (chevron / pencil / history / sliders) are gone from the + // header. The menu items render on open (portalled), not in SSR. + expect(html).toContain('aria-label="More options"'); + expect(html).not.toContain("lucide-chevron-right"); + // The card body itself navigates to the detail page. + expect(html).toContain( + 'data-slot="medication-card-header-link"', + ); + expect(html).toContain(`href="${href}"`); } }); - it("the overview card renders the three header actions (edit / history / advanced)", () => { - // v1.7.1 — the overview card mirrors the detail-page header: an - // edit pencil, a History (clock) button routing to the intake - // history, and a SlidersHorizontal button opening the shared - // advanced-settings sheet. Each carries a distinct aria-label. - const client = makeClient(); - seedCompliance(client, ramipril.id); - - const html = render( - {}} - onOpenHistory={() => {}} - onOpenAdvanced={() => {}} - />, - client, - ); - - expect(html).toContain("lucide-pencil"); - expect(html).toContain("lucide-history"); - expect(html).toContain("lucide-sliders-horizontal"); - expect(html).toContain('aria-label="Edit"'); - expect(html).toContain('aria-label="View intake history"'); - expect(html).toContain('aria-label="Advanced settings"'); - }); - - it("neither card paints a kebab when no overflow prop is wired", () => { - // Symmetry default: without onLogSideEffect, the Mounjaro header - // shape matches the Ramipril header exactly (history + edit only). - const client = makeClient(); - seedCompliance(client, ramipril.id); - seedCompliance(client, mounjaro.id); - seedGlp1Details(client, mounjaro.id); - - const ramiprilHtml = render( - {}} - onOpenHistory={() => {}} - onOpenAdvanced={() => {}} - />, - client, - ); - const mounjaroHtml = render( - {}} - onOpenHistory={() => {}} - onOpenAdvanced={() => {}} - />, - client, - ); - - for (const html of [ramiprilHtml, mounjaroHtml]) { - expect(html).not.toContain('aria-label="More options"'); - } - }); - - it("Mounjaro gains a kebab when onLogSideEffect is supplied (GLP-1-only)", () => { + it("Mounjaro keeps the single kebab even when onLogSideEffect is supplied (GLP-1-only)", () => { + // The side-effect quick-log folds into the SAME overflow menu, so the + // GLP-1 header shape stays byte-symmetric with the generic card: one + // kebab trigger, no extra control. const client = makeClient(); seedCompliance(client, mounjaro.id); seedGlp1Details(client, mounjaro.id); @@ -232,7 +189,9 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { client, ); - expect(html).toContain('aria-label="More options"'); + // Exactly one kebab trigger. + const triggers = html.match(/aria-label="More options"/g) ?? []; + expect(triggers).toHaveLength(1); }); it("category label uses the shared category-map lookup on both cards", () => { diff --git a/src/components/medications/__tests__/medication-detail-header.test.tsx b/src/components/medications/__tests__/medication-detail-header.test.tsx deleted file mode 100644 index 1a02513b2..000000000 --- a/src/components/medications/__tests__/medication-detail-header.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * v1.5.5 D-3 §9.1 + §10 invariant 5 — status pill renders the label - * always (never colour-only) and the dot rides Dracula tokens. - * - * Critical because an earlier draft proposed bare Tailwind palette - * (`bg-emerald-500` / `bg-amber-500`) which: - * - bypasses the theme so it would not flip with dark/light; - * - relied on colour alone to convey state (WCAG 1.4.1 violation); - * - drifted out of sync with `--success` / `--warning` Dracula - * tokens declared in `src/app/globals.css`. - * - * These tests pin the contract: - * - active medication → label "Aktiv" + Dracula `--success` token - * - paused medication → label "Pausiert" + `--warning` token - * - ended medication → label "Beendet" + muted-foreground - * - dot always carries `aria-hidden="true"` - * - DOM order: H1 (name) before the edit button (C-E4-3) - */ - -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -vi.mock("@/lib/i18n/context", () => ({ - useTranslations: () => ({ - t: (key: string) => key, - }), -})); - -import { MedicationDetailHeader } from "@/components/medications/medication-detail-header"; - -function render(props: { - name?: string; - dose?: string; - active?: boolean; - endsOn?: string | null; -}) { - return renderToStaticMarkup( - {}} - onOpenHistory={() => {}} - onOpenAdvanced={() => {}} - />, - ); -} - -describe("MedicationDetailHeader (D-3 §9.1)", () => { - it("renders the status label and the --success dot for an active medication", () => { - const html = render({ active: true }); - expect(html).toContain("medications.detail.status.active"); - expect(html).toContain("[hsl(var(--success))]"); - expect(html).toContain('aria-hidden="true"'); - }); - - it("renders the paused label + --warning dot for an inactive medication", () => { - const html = render({ active: false }); - expect(html).toContain("medications.detail.status.paused"); - expect(html).toContain("[hsl(var(--warning))]"); - }); - - it("renders the ended label + muted dot when endsOn is past", () => { - const html = render({ - active: false, - endsOn: new Date(Date.now() - 86_400_000).toISOString(), - }); - expect(html).toContain("medications.detail.status.ended"); - expect(html).toContain("bg-muted-foreground"); - }); - - it("DOM order is heading before edit button (C-E4-3)", () => { - const html = render({ active: true }); - const headingIndex = html.indexOf(" { - const html = render({ active: true }); - expect(html).toContain("common.edit"); - expect(html).toContain('data-slot="medication-detail-edit-button"'); - expect(html).toContain('data-slot="medication-detail-history-button"'); - expect(html).toContain('data-slot="medication-detail-advanced-button"'); - expect(html).toContain( - 'aria-label="medications.detail.header.historyLabel"', - ); - expect(html).toContain( - 'aria-label="medications.detail.header.advancedLabel"', - ); - }); - - it("orders the buttons edit → history → advanced", () => { - const html = render({ active: true }); - const edit = html.indexOf('data-slot="medication-detail-edit-button"'); - const history = html.indexOf( - 'data-slot="medication-detail-history-button"', - ); - const advanced = html.indexOf( - 'data-slot="medication-detail-advanced-button"', - ); - expect(edit).toBeGreaterThan(-1); - expect(history).toBeGreaterThan(edit); - expect(advanced).toBeGreaterThan(history); - }); - - it("status pill text always renders (no colour-only state)", () => { - // Regardless of theme support the label MUST be in the DOM so a - // screen reader announces "Aktiv" even when the dot's hue is - // imperceptible to the user. - const activeHtml = render({ active: true }); - const pausedHtml = render({ active: false }); - expect(activeHtml).toMatch( - /data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.active/, - ); - expect(pausedHtml).toMatch( - /data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.paused/, - ); - }); -}); diff --git a/src/components/medications/cadence-summary-row.tsx b/src/components/medications/cadence-summary-row.tsx deleted file mode 100644 index 5cc40cd80..000000000 --- a/src/components/medications/cadence-summary-row.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -/** - * v1.5.5 D-3 §9.3 — Cadence summary row. - * - * Renders the medication's cadence as a plain-language line (e.g. - * "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs", - * "Monatlich am 15.") with an optional course-window sub-line, and an - * optional edit pencil whose `onEdit` the caller wires (suppressed via - * `hideEdit` on the read-only detail surface). - * For one-shot medications the line collapses to - * `Einmalig am DD.MM.` and the edit affordance is suppressed (the - * header pencil owns the wizard route per D-3 §6 one-shot variant). - * - * The DOM order is `

→

→ edit`. The button comes last so a - * screen-reader tab walk announces the title and the line before the - * affordance (C-E4-3). - */ - -import { Pencil } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; -import { - hydrateWizardPayload, - summariseCadence, - type MedicationPayload, -} from "@/components/medications/wizard/wizard-payload"; -import { useTranslations } from "@/lib/i18n/context"; - -export interface CadenceSummaryRowProps { - medication: MedicationPayload; - /** Hides the edit pencil — the one-shot variant relies on the header. */ - hideEdit?: boolean; - onEdit: () => void; -} - -export function CadenceSummaryRow({ - medication, - hideEdit = false, - onEdit, -}: CadenceSummaryRowProps) { - const { t } = useTranslations(); - const payload = hydrateWizardPayload(medication); - const line = summariseCadence(payload, t); - - return ( - -

- {line} -

- - ); -} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 4963cd6b2..ea42b4bae 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -2,33 +2,21 @@ import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; import { AlertCircle, AlertTriangle, Check, - ChevronRight, CircleCheck, Flame, - History, Loader2, - MoreVertical, - Pencil, SkipForward, - SlidersHorizontal, - Stethoscope, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; +import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { Progress } from "@/components/ui/progress"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { @@ -340,84 +328,21 @@ export function Glp1MedicationCard({ ); + // v1.7.2 W3 — the four former header icon-buttons (open / edit / + // history / advanced) plus the GLP-1 side-effect kebab collapse into a + // SINGLE overflow menu so the GLP-1 card stays byte-symmetric with the + // generic medication card. The card header itself links to the detail + // page (the former chevron target). The side-effect quick-log folds in + // as a last menu item when `onLogSideEffect` is wired. const headerActions = ( - <> - {/* v1.5.5 — routes to the new medication detail page; the - detail-page intake-history preview links onward to - `/medications/{id}/history` for the bulk-delete sub-route. */} - - - {/* v1.7.1 — History + Advanced icon buttons mirror the generic - medication card and the detail-page header so the GLP-1 card - carries the same three actions (edit / history / advanced). - History routes to the full intake-history view; Advanced opens - the shared settings sheet mounted by the list page. */} - - - {/* v1.4.37 W4b — GLP-1 specifics (side-effect quick-log etc.) - live in the header actions overflow so the primary action - row stays the canonical two-button shape (Eingenommen / - Übersprungen) shared with the generic medication card. The - kebab only renders when at least one overflow item is wired - for this medication kind. */} - {onLogSideEffect && ( - - - - - - onLogSideEffect(medication)} - className="whitespace-nowrap" - > - - {t("medications.glp1LogSideEffect")} - - - - )} - + onEdit(medication)} + onOpenHistory={() => onOpenHistory(medication)} + onOpenAdvanced={() => onOpenAdvanced(medication)} + onLogSideEffect={ + onLogSideEffect ? () => onLogSideEffect(medication) : undefined + } + /> ); const categoryLabel = getMedicationCategoryLabel(medication.category, t); @@ -430,6 +355,8 @@ export function Glp1MedicationCard({ categoryLabel={categoryLabel} stateBadges={stateBadges} actions={headerActions} + href={`/medications/${medication.id}`} + linkLabel={t("medications.openDetailPage")} /> diff --git a/src/components/medications/medication-card-menu.tsx b/src/components/medications/medication-card-menu.tsx new file mode 100644 index 000000000..a5690692f --- /dev/null +++ b/src/components/medications/medication-card-menu.tsx @@ -0,0 +1,98 @@ +"use client"; + +/** + * v1.7.2 W3 — shared medication-card overflow menu. + * + * Both the generic `` and the `` + * collapsed their former four header icon-buttons (open / edit / history + * / advanced) into a single overflow kebab so the card header carries one + * affordance instead of a cluster. The card body itself navigates to the + * detail page (the former chevron target); this menu owns the edit / + * history / advanced actions. The GLP-1 card folds its "Log side effect" + * item into the SAME menu via the optional `onLogSideEffect` slot so the + * two card variants stay byte-symmetric in the header. + * + * The trigger is a 44px ghost icon-button (WCAG 2.5.5); every + * `DropdownMenuItem` already carries `min-h-11` from the shadcn + * primitive, so the menu rows meet the tap-target floor too. + * + * The kebab click is a non-navigating interactive element inside a + * tappable card; the caller stops propagation on the wrapping + * trigger so opening the menu never also fires the card's detail-page + * navigation. + */ + +import { History, MoreVertical, Pencil, SlidersHorizontal, Stethoscope } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface MedicationCardMenuProps { + onEdit: () => void; + onOpenHistory: () => void; + onOpenAdvanced: () => void; + /** GLP-1-only: folds the side-effect quick-log into the same menu. */ + onLogSideEffect?: () => void; +} + +export function MedicationCardMenu({ + onEdit, + onOpenHistory, + onOpenAdvanced, + onLogSideEffect, +}: MedicationCardMenuProps) { + const { t } = useTranslations(); + + return ( + + + + + + + + {t("common.edit")} + + + + {t("medications.detail.header.historyLabel")} + + + + {t("medications.detail.header.advancedLabel")} + + {onLogSideEffect && ( + <> + + + + {t("medications.glp1LogSideEffect")} + + + )} + + + ); +} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 73b29cd50..8d4dcf271 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { Card, CardContent } from "@/components/ui/card"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; +import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; @@ -23,13 +24,8 @@ import { CircleCheck, SkipForward, Flame, - History, - Pencil, - SlidersHorizontal, Loader2, - ChevronRight, } from "lucide-react"; -import Link from "next/link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { invalidateKeys, @@ -275,61 +271,15 @@ export function MedicationCard({ ); + // v1.7.2 W3 — the four former header icon-buttons (open / edit / + // history / advanced) collapse into a single overflow kebab. The card + // header itself links to the detail page (the former chevron target). const headerActions = ( - <> - {/* Phase A5: bumped from `h-8 w-8` (32px) to `min-h-11 - min-w-11` (44px) so these meet the WCAG 2.5.5 minimum - tap-target on mobile without the icon glyph itself - changing size. */} - {/* v1.5.5 — the kebab-style "open" icon routes to the new - medication detail page. The detail-page intake-history - preview links onward to `/medications/{id}/history` for the - bulk-delete sub-route, so the user still reaches the full - history in one extra tap. */} - - - {/* v1.7.1 — History + Advanced icon buttons mirror the - detail-page header so the overview card carries the same three - actions (edit / history / advanced). History routes to the - full intake-history view; Advanced opens the shared settings - sheet mounted by the list page. */} - - - + onEdit(medication)} + onOpenHistory={() => onOpenHistory(medication)} + onOpenAdvanced={() => onOpenAdvanced(medication)} + /> ); return ( @@ -340,6 +290,8 @@ export function MedicationCard({ categoryLabel={categoryLabel} stateBadges={stateBadges} actions={headerActions} + href={`/medications/${medication.id}`} + linkLabel={t("medications.openDetailPage")} /> diff --git a/src/components/medications/medication-detail-header.tsx b/src/components/medications/medication-detail-header.tsx deleted file mode 100644 index 0528eb419..000000000 --- a/src/components/medications/medication-detail-header.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -/** - * v1.5.5 D-3 §9.1 — medication-detail page header band. - * - * Renders the drug name as the H1, dose as a muted sub-line, and a - * status row carrying a Dracula-token dot + always-visible label - * (Aktiv / Pausiert / Beendet) and a three-button action row: Edit - * (pencil, opens the wizard at Step 1), History (clock, routes to the - * full intake-history view) and Advanced (sliders, opens the advanced - * settings sheet). The DOM order is `name → dose → status → edit → - * history → advanced` so the screen reader announces the drug first - * and the affordances last (C-E4-3). - * - * The status pill text is always rendered; the dot carries - * `aria-hidden="true"` and reads from Dracula tokens via - * `bg-[hsl(var(--success))]` / `bg-[hsl(var(--warning))]` / - * `bg-muted-foreground`. The "since DD.MM." drift the earlier draft - * mixed into the header is dropped — lifecycle dates live on the - * destructive zone, not on the header (E-2 M-1). - */ - -import { History, Pencil, SlidersHorizontal } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; - -export interface MedicationDetailHeaderProps { - name: string; - dose: string; - active: boolean; - endsOn?: string | null; - /** - * v1.6.0 — the single "Bearbeiten" button opens the unified editor - * (mode="edit"). The two-option dropdown the v1.5.6 header carried - * collapsed to one direct button so editing is a single tap. - */ - onEditPlan: () => void; - /** - * v1.7.0 — the History (clock) button routes directly to the full - * intake-history view. A `() => void` so the page owns the - * `router.push`; an icon-only button (not an anchor) since it carries - * an `aria-label`. - */ - onOpenHistory: () => void; - /** - * v1.7.0 — the sliders button opens the `` - * (Data / Reminders / Lifecycle / Danger zone). Kept as a discreet - * secondary affordance beside the primary edit button. - */ - onOpenAdvanced: () => void; -} - -type Status = "active" | "paused" | "ended"; - -function resolveStatus(active: boolean, endsOn?: string | null): Status { - // `endsOn` set + in the past = the medication is archived. The - // server flips `active=false` in that case but a paused medication - // also reads false; distinguish here so the pill carries the right - // label. - if (endsOn) { - const end = new Date(endsOn); - if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) { - return "ended"; - } - } - return active ? "active" : "paused"; -} - -export function MedicationDetailHeader({ - name, - dose, - active, - endsOn, - onEditPlan, - onOpenHistory, - onOpenAdvanced, -}: MedicationDetailHeaderProps) { - const { t } = useTranslations(); - 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"; - - return ( -
-
-

{name}

-

{dose}

-
- - -
-
- {/* v1.7.0 — three-button row: Edit (primary, labelled) → - History (ghost icon, direct to /history) → Advanced (ghost - icon, sliders). DOM order primary → read → config. All carry a - 44px touch target on mobile. */} -
- - - -
-
- ); -}