refactor(medications): unify medication-list row shape

Carve a shared <MedicationCardHeader> primitive consumed by both the
generic <MedicationCard> and the <Glp1MedicationCard>. Both rows now
render the canonical two-line shape: line 1 = {name} {dose} (bold), line
2 = class label + optional state badges. The GLP-1 row drops its syringe
glyph and middle-dot separator and reads as the same row shape every
other medication does. Tests updated to pin the new contract.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:41:17 +02:00
1 parent 8c8d6dc27f
commit 6f6992c683
4 files changed
+179 -112

No files matched your search

@@ -0,0 +1,64 @@
"use client";
import { type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { CardHeader, CardTitle } from "@/components/ui/card";
/**
* v1.4.28 FB-G1 — shared medication-list row header.
*
* The generic `<MedicationCard>` and the `<Glp1MedicationCard>` carry
* the same two-line row shape on the medications list page. Both rows
* route their title + dose + drug class through this primitive so the
* surface stays one consistent shape:
*
* 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 GLP-1 row previously surfaced a `<Syringe>` glyph + middle-dot
* separator on line 1 and demoted the dose to a muted inline span. This
* primitive folds it into the canonical two-line shape; the syringe icon
* and the dot separator are gone from the list row.
*/
export interface MedicationCardHeaderProps {
name: string;
dose: string;
categoryLabel: string;
stateBadges?: ReactNode;
actions?: ReactNode;
}
export function MedicationCardHeader({
name,
dose,
categoryLabel,
stateBadges,
actions,
}: MedicationCardHeaderProps) {
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>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
{stateBadges}
</div>
</div>
{actions ? (
<div className="flex items-center gap-0.5">{actions}</div>
) : null}
</div>
</CardHeader>
);
}
@@ -178,8 +178,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
);
expect(html).toContain("GLP-1 injection");
// The Syringe lucide icon renders an SVG with this class.
expect(html).toMatch(/lucide-syringe/i);
// v1.4.28 FB-G1 — the Syringe glyph + middle-dot separator on the
// list row are gone. The list row reads as the canonical two-line
// shape: `{name} {dose}` on line 1, class label on line 2.
expect(html).not.toMatch(/lucide-syringe/i);
});
it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => {
@@ -204,7 +206,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dose history");
});
it("shows drug name + current dose ('Mounjaro · 7.5 mg')", () => {
it("shows drug name + current dose on line 1 ('Mounjaro 7.5 mg')", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
@@ -216,9 +218,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Mounjaro");
expect(html).toContain("7.5 mg");
// Card title joins the two with the middle-dot separator the
// glp1Headline copy uses.
expect(html).toContain("·");
// v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and
// surfaces `{name} {dose}` together on line 1 via the shared
// `<MedicationCardHeader>` primitive. The dot separator is gone.
expect(html).toContain("Mounjaro 7.5 mg");
});
it("shows last + next injection labels with localised weekday", () => {
@@ -11,12 +11,12 @@ import {
Pencil,
SkipForward,
Stethoscope,
Syringe,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
@@ -241,60 +241,57 @@ export function Glp1MedicationCard({
});
}
const stateBadges = (
<>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</>
);
const headerActions = (
<>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History 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>
</>
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-lg">
<Syringe className="text-dracula-purple h-4 w-4 shrink-0" />
<span>{medication.name}</span>
<span className="text-muted-foreground text-sm font-normal">
· {medication.dose}
</span>
</CardTitle>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-xs">
{t("medications.treatmentClassGlp1")}
</Badge>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History 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>
</div>
</div>
</CardHeader>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={t("medications.treatmentClassGlp1")}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="space-y-3.5">
{/* Injection state — last + next */}
+55 -52
View File
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
@@ -367,59 +368,61 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
return formatDateTime(value);
}
const stateBadges = (
<>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</>
);
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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History 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>
</>
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-lg">{medication.name}</CardTitle>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span>{medication.dose}</span>
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-0.5">
{/* 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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History 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>
</div>
</div>
</CardHeader>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="space-y-3.5">
{/* Status, last & next intake info */}