mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user