mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user