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) <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 c48541e74f
commit 61d91df251
12 files changed
+285 -1162

No files matched your search

@@ -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 `<Syringe>` 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 = (
<>
<CardTitle className="text-lg">
{name} {dose}
</CardTitle>
{/* 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. */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
</div>
{stateBadges ? (
<div
data-slot="medication-card-header-state-badges"
className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
{stateBadges}
</div>
) : null}
</>
);
return (
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<CardTitle className="text-lg">
{name} {dose}
</CardTitle>
{/* 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. */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
</div>
{stateBadges ? (
<div
data-slot="medication-card-header-state-badges"
className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
{stateBadges}
</div>
) : null}
</div>
{href ? (
<Link
href={href}
aria-label={linkLabel}
data-slot="medication-card-header-link"
className="-m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{body}
</Link>
) : (
<div className="min-w-0 space-y-1">{body}</div>
)}
{actions ? (
<div className="flex items-center gap-0.5">{actions}</div>
<div className="flex shrink-0 items-center gap-0.5">{actions}</div>
) : null}
</div>
</CardHeader>
@@ -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 `<SideEffectsSection>` (W19d) and `<TitrationSection>`
* on the medication detail page (v1.4.28 retired the IntakeHistoryList
* block below). Same chrome as the W19d / W19f sections, composed via
* the shared `<MedicationDetailSection>` 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<CadenceTimelineEntry["status"], string> = {
// 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<CadenceResponse> => {
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 = (
<div className="flex items-center gap-2">
<Badge
variant={reminderEnabled ? "secondary" : "outline"}
className="gap-1 text-[10px]"
>
{reminderEnabled ? (
<BellRing className="h-3 w-3" />
) : (
<BellOff className="h-3 w-3" />
)}
{t(
reminderEnabled
? "medications.scheduling.reminders.on"
: "medications.scheduling.reminders.off",
)}
</Badge>
<Button
size="sm"
variant="outline"
asChild
aria-label={t("medications.scheduling.editCta")}
>
<Link href="/medications">
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t("medications.scheduling.editCta")}
</Link>
</Button>
</div>
);
return (
<MedicationDetailSection
titleId="scheduling-heading"
title={t("medications.scheduling.section")}
headerExtras={headerExtras}
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.scheduling.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.scheduling.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-4">
{/* Next-dose chip */}
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground">
{data.next ? (
<>
{t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "}
<span className="text-foreground font-medium">
{fmt.dateShort(new Date(data.next.windowStart))}
</span>
</>
) : (
t("medications.scheduling.cadenceViz.noNextDose")
)}
</p>
</div>
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
<p className="text-muted-foreground py-1">
{t("medications.scheduling.cadenceViz.emptyState")}
</p>
) : (
<>
<div
// v1.4.27 MB7 / CF-49 — tighten the gap from
// `gap-1` (4 px) to `gap-0.5` (2 px) on `<sm` so
// the 30-day cadence grid fits in fewer rows on
// Pixel 5 (375 px) without sacrificing the 44 px
// tap target per cell. At `sm:` and above the
// original 4 px gap returns; the wider container
// there already absorbs the timeline in 1-2 rows.
className="flex flex-wrap gap-0.5 sm:gap-1"
role="img"
aria-label={t(
"medications.scheduling.cadenceViz.ariaTimeline",
)}
data-slot="cadence-timeline"
>
{data.timeline.map((slot, i) => {
const slotLabel = `${fmt.dateShort(new Date(slot.windowStart))} — ${t(
`medications.scheduling.cadenceViz.status.${slot.status}`,
)}`;
return (
<button
type="button"
key={`${slot.windowStart}-${i}`}
title={slotLabel}
aria-label={slotLabel}
data-slot="cadence-timeline-cell"
data-status={slot.status}
// 44×44 tap target per WCAG 2.5.5; the visible
// cell stays the 12 px square the design system
// pins inside, centered with flex.
className="inline-flex h-11 w-11 items-center justify-center rounded-sm focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2"
>
<span
className={`h-3 w-3 rounded-sm ${STATUS_STYLES[slot.status]}`}
aria-hidden="true"
/>
</button>
);
})}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-xs">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
<span
className={`h-2.5 w-2.5 rounded-sm ${STATUS_STYLES[s]}`}
/>
<span>
{t(
`medications.scheduling.cadenceViz.status.${s}`,
)}
</span>
</span>
),
)}
</div>
</>
)}
</div>
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<ComplianceChip
label={t("medications.scheduling.compliance.adherenceRate")}
value={adherenceLabel ?? "—"}
tooltip={t(
"medications.scheduling.compliance.chips.adherenceTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.currentStreak")}
value={`${data.chips.currentStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.streakTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.longestStreak")}
value={`${data.chips.longestStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.longestTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.missedLast30")}
value={`${data.chips.missedLast30} ${t(
"medications.scheduling.compliance.unit.doses",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.missedTooltip",
)}
/>
</div>
</div>
</div>
)}
</MedicationDetailSection>
);
}
interface ComplianceChipProps {
label: string;
value: string;
tooltip: string;
}
function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) {
return (
<div
className="bg-muted/30 rounded-md px-2.5 py-1.5"
title={tooltip}
aria-label={`${label}: ${value}. ${tooltip}`}
>
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{label}
</p>
<p className="text-foreground text-sm font-medium">{value}</p>
</div>
);
}
@@ -88,4 +88,32 @@ describe("<MedicationCardHeader>", () => {
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(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
href="/medications/med-1"
linkLabel="Open medication detail page"
/>,
);
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(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
/>,
);
expect(html).not.toContain('data-slot="medication-card-header-link"');
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
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("<SchedulingSection> — surface render", () => {
it("renders the section heading in English", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={false} />,
client,
);
expect(html).toContain("Reminders off");
});
});
describe("<SchedulingSection> — chips", () => {
it("renders the four compliance chip values", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("No data");
});
});
describe("<SchedulingSection> — timeline", () => {
it("renders the timeline empty-state when no slots are seeded", () => {
const client = makeClient();
seed(client, "med-1", { ...SEEDED, timeline: [] });
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("Taken");
expect(html).toContain("Skipped");
expect(html).toContain("Missed");
expect(html).toContain("Upcoming");
});
});
@@ -194,12 +194,12 @@ describe("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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", () => {
@@ -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(
<MedicationCard
medication={ramipril}
onEdit={() => {}}
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(
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
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", () => {
@@ -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(
<MedicationDetailHeader
name={props.name ?? "Test Drug"}
dose={props.dose ?? "5 mg"}
active={props.active ?? true}
endsOn={props.endsOn}
onEditPlan={() => {}}
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("<h1");
const buttonIndex = html.indexOf(
'data-slot="medication-detail-edit-button"',
);
expect(headingIndex).toBeGreaterThan(-1);
expect(buttonIndex).toBeGreaterThan(-1);
expect(headingIndex).toBeLessThan(buttonIndex);
});
it("renders the edit, history and advanced buttons (v1.7.0)", () => {
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/,
);
});
});
@@ -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 `<h2> → <p> → 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 (
<MedicationDetailSection
titleId="medication-detail-cadence-heading"
title={t("medications.detail.cadence.title")}
dataSlot="medication-detail-cadence-section"
headerExtras={
hideEdit ? null : (
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="min-h-11 sm:min-h-9"
data-slot="cadence-summary-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
)
}
>
<p className="text-sm" data-slot="cadence-summary-line">
{line}
</p>
</MedicationDetailSection>
);
}
@@ -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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.openDetailPage")}
>
<Link href={`/medications/${medication.id}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{/* 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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenHistory(medication)}
aria-label={t("medications.detail.header.historyLabel")}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenAdvanced(medication)}
aria-label={t("medications.detail.header.advancedLabel")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
{/* 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 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={() => onLogSideEffect(medication)}
className="whitespace-nowrap"
>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
<MedicationCardMenu
onEdit={() => 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")}
/>
<CardContent className="space-y-3.5">
@@ -0,0 +1,98 @@
"use client";
/**
* v1.7.2 W3 — shared medication-card overflow menu.
*
* Both the generic `<MedicationCard>` and the `<Glp1MedicationCard>`
* 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
aria-label={t("common.moreOptions")}
// Keep the kebab a non-navigating control inside the
// tappable card — opening the menu must never also fire the
// card's detail-page navigation.
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56"
data-slot="medication-card-menu"
>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenAdvanced}>
<SlidersHorizontal className="mr-2 h-4 w-4" />
{t("medications.detail.header.advancedLabel")}
</DropdownMenuItem>
{onLogSideEffect && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogSideEffect}>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+11 -59
View File
@@ -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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.openDetailPage")}
>
<Link href={`/medications/${medication.id}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{/* 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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenHistory(medication)}
aria-label={t("medications.detail.header.historyLabel")}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenAdvanced(medication)}
aria-label={t("medications.detail.header.advancedLabel")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
</>
<MedicationCardMenu
onEdit={() => 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")}
/>
<CardContent className="space-y-3.5">
@@ -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 `<AdvancedSettingsSheet>`
* (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 (
<div
className="flex items-start justify-between gap-3"
data-slot="medication-detail-header"
>
<div className="space-y-1">
<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>
</div>
{/* 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. */}
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onEditPlan}
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={onOpenHistory}
aria-label={t("medications.detail.header.historyLabel")}
data-slot="medication-detail-history-button"
>
<History aria-hidden="true" className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={onOpenAdvanced}
aria-label={t("medications.detail.header.advancedLabel")}
data-slot="medication-detail-advanced-button"
>
<SlidersHorizontal aria-hidden="true" className="h-4 w-4" />
</Button>
</div>
</div>
);
}