refactor(meds): extract shared card parts for structural symmetry

The generic medication card and the GLP-1 variant carried ~120 lines of
hand-synced JSX for the status pill, compliance bars, primary intake
actions, and state badges. Lift these into single-purpose presentational
components under card-parts/ consumed by both cards, so symmetry is
structural rather than maintained by hand.

Unify the day-streak flame onto the canonical text-dracula-orange
warning/streak token. The generic card had drifted onto Tailwind-stock
text-orange-400; both cards now render the same token.

No visual or behavioural change beyond the streak-token convergence.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 20:01:29 +02:00
1 parent 44e83d22df
commit 3a5e157d41
7 files changed
+499 -289

No files matched your search

@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { MedicationCard } from "@/components/medications/medication-card";
import {
Glp1MedicationCard,
type Glp1Medication,
} from "@/components/medications/glp1-medication-card";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
/**
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
* row, and state badges are shared presentational components consumed by
* both the generic `<MedicationCard>` and the `<Glp1MedicationCard>`. The
* symmetry between the two variants is now structural (one component, two
* call sites) rather than two hand-synced JSX blocks.
*
* These tests pin the load-bearing seams of the extracted parts and the
* unified streak/warning token so a regression can't reopen the drift.
*/
function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
},
});
}
function render(node: React.ReactNode, client?: QueryClient) {
const tree = client ? (
<QueryClientProvider client={client}>{node}</QueryClientProvider>
) : (
node
);
return renderToStaticMarkup(<I18nProvider initialLocale="en">{tree}</I18nProvider>);
}
describe("medication card-parts — shared presentational components", () => {
it("compliance bars render the 7d / 30d labels and the day-streak flame", () => {
const html = render(
<MedicationComplianceBars rate7={90} rate30={88} streak={5} />,
);
expect(html).toContain("7-day compliance");
expect(html).toContain("30-day compliance");
expect(html).toContain("90%");
expect(html).toContain("88%");
expect(html).toContain("lucide-flame");
// Canonical streak/warning token — NOT the Tailwind-stock drift.
expect(html).toContain("text-dracula-orange");
expect(html).not.toContain("text-orange-400");
});
it("compliance bars hide the streak flame when streak is zero", () => {
const html = render(
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
);
expect(html).not.toContain("lucide-flame");
});
it("status pill stamps the success token + take-now glyph in window", () => {
const html = render(
<MedicationStatusPill
status="in_window"
windowStart="08:00"
windowEnd="20:00"
/>,
);
expect(html).toContain("Take now");
expect(html).toContain("text-success");
expect(html).toContain("lucide-circle-check");
});
it("status pill stamps the warning token + glyph when very late", () => {
const html = render(
<MedicationStatusPill
status="very_late"
windowStart="08:00"
windowEnd="20:00"
/>,
);
expect(html).toContain("text-warning");
expect(html).toContain("lucide-triangle-alert");
});
it("intake actions row carries exactly the take + skip buttons", () => {
const html = render(
<MedicationIntakeActions intakeLoading={null} onRecordIntake={() => {}} />,
);
expect(html).toContain("lucide-check");
expect(html).toContain("lucide-skip-forward");
expect(html).toContain("min-h-11");
});
it("state badges surface the without-notification + paused labels", () => {
const html = render(
<MedicationStateBadges
notificationsEnabled={false}
active={false}
pausedAt="2026-05-01T08:00:00.000Z"
/>,
);
expect(html).toContain("Without notification");
expect(html).toContain("Paused since");
});
});
/**
* Cross-variant streak-token parity: with a positive streak seeded, the
* flame on BOTH the generic and the GLP-1 card resolves to the canonical
* `text-dracula-orange`, and neither carries the legacy `text-orange-400`.
*/
describe("streak-token parity — generic vs GLP-1 card", () => {
const ramipril = {
id: "med-ramipril-streak",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
schedules: [
{
id: "s-ramipril-streak",
windowStart: "00:00",
windowEnd: "23:59",
label: null,
daysOfWeek: null,
dose: "5 mg",
},
],
};
const mounjaro: Glp1Medication = {
id: "med-mounjaro-streak",
name: "Mounjaro",
dose: "7.5 mg",
category: "HORMONE",
treatmentClass: "GLP1",
dosesPerUnit: 4,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
schedules: [
{
id: "s-mounjaro-streak",
windowStart: "00:00",
windowEnd: "23:59",
label: null,
daysOfWeek: null,
dose: "7.5 mg",
},
],
};
function seedStreak(client: QueryClient, medId: string) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
});
}
it("both cards render the flame with the canonical token, never the drift", () => {
const client = makeClient();
seedStreak(client, ramipril.id);
seedStreak(client, mounjaro.id);
client.setQueryData(["medications", mounjaro.id, "glp1-details"], {
doseChanges: [],
recentIntakes: [],
inventory: null,
});
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).toContain("lucide-flame");
expect(html).toContain("text-dracula-orange");
expect(html).not.toContain("text-orange-400");
}
});
});
@@ -0,0 +1,69 @@
import { Flame } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
interface MedicationComplianceBarsProps {
rate7: number;
rate30: number;
streak: number;
}
/**
* Shared 7-day / 30-day compliance bars plus the day-streak flame for the
* medication cards. Extracted from the generic and GLP-1 cards so the bars
* are structurally identical rather than hand-synced.
*
* The streak flame uses the canonical `text-dracula-orange` warning/streak
* token (globals.css). The generic card historically drifted onto Tailwind
* stock `text-orange-400`; unifying here closes that token gap.
*/
export function MedicationComplianceBars({
rate7,
rate30,
streak,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance7d")}
</span>
<span className="font-medium">{rate7}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance30d")}
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
<div className="flex items-center gap-4 text-xs">
{streak > 0 && (
<span className="text-dracula-orange flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
onRecordIntake: (skipped: boolean) => void;
}
/**
* Shared primary take / skip action row for the medication cards. These are
* the most-tapped controls in HealthLog, so they keep the full default
* button height (`min-h-11`) to clear the WCAG 44-px tap-target floor.
*
* Extracted from the generic and GLP-1 cards so the action row is shared
* structurally rather than kept byte-equivalent by hand.
*/
export function MedicationIntakeActions({
intakeLoading,
onRecordIntake,
}: MedicationIntakeActionsProps) {
const { t } = useTranslations();
return (
<div className="flex gap-2">
<Button
className="min-h-11 flex-1"
onClick={() => onRecordIntake(false)}
disabled={!!intakeLoading}
>
{intakeLoading === "take" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="mr-1 h-4 w-4" />
)}
{t("medications.taken")}
</Button>
<Button
variant="outline"
className="min-h-11"
onClick={() => onRecordIntake(true)}
disabled={!!intakeLoading}
>
{intakeLoading === "skip" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<SkipForward className="mr-1 h-4 w-4" />
)}
{t("medications.skipped")}
</Button>
</div>
);
}
@@ -0,0 +1,40 @@
import { Badge } from "@/components/ui/badge";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
interface MedicationStateBadgesProps {
notificationsEnabled: boolean;
active: boolean;
pausedAt: string | null;
}
/**
* Shared "without notification" / "inactive" / "paused since …" badge pair
* rendered in the medication-card header. Extracted from the generic and
* GLP-1 cards so the two variants stay structurally symmetric instead of
* hand-synced.
*/
export function MedicationStateBadges({
notificationsEnabled,
active,
pausedAt,
}: MedicationStateBadgesProps) {
const { t } = useTranslations();
return (
<>
{!notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!active && (
<Badge variant="secondary" className="text-xs">
{pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</>
);
}
@@ -0,0 +1,60 @@
import { AlertCircle, AlertTriangle, CircleCheck } from "lucide-react";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { useTranslations } from "@/lib/i18n/context";
import type { MedicationWindowStatus } from "@/lib/medications/window-status";
interface MedicationStatusPillProps {
/** Non-null window status; the caller guards `status &&` before rendering. */
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
}
/**
* Shared take-now / overdue / very-overdue status pill for the medication
* cards. Pairs the colour token with a Lucide glyph so colour-blind users
* (red-green) can disambiguate the three tiers — WCAG 1.4.1 (Use of Color).
*
* Extracted from the generic and GLP-1 cards so the pill markup is shared
* structurally rather than kept byte-equivalent by hand.
*/
export function MedicationStatusPill({
status,
windowStart,
windowEnd,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
return (
<p className="text-sm">
<span
className={
"inline-flex items-center gap-1 font-medium " +
(status === "in_window"
? "text-success"
: status === "late"
? "text-dracula-yellow"
: "text-warning")
}
>
{status === "in_window" ? (
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
) : status === "late" ? (
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
)}
{status === "in_window"
? t("medications.takeNow")
: status === "late"
? t("medications.overdue")
: t("medications.veryOverdue")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
</p>
);
}
@@ -2,22 +2,14 @@
import { useEffect, useReducer, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
Flame,
Loader2,
SkipForward,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { Progress } from "@/components/ui/progress";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -25,7 +17,6 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
nextInjectionSite,
@@ -180,7 +171,7 @@ export function Glp1MedicationCard({
onLogSideEffect,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
const { t } = useTranslations();
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
@@ -312,28 +303,22 @@ 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>
)}
</>
<MedicationStateBadges
notificationsEnabled={medication.notificationsEnabled}
active={medication.active}
pausedAt={medication.pausedAt}
/>
);
// 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.
// The four former header icon-buttons (open / edit / history /
// advanced) collapse into a SINGLE overflow menu so the GLP-1 card
// stays symmetric with the generic medication card. The card header
// itself links to the detail page (the former chevron target). The
// optional side-effect quick-log would fold in as a last menu item
// when `onLogSideEffect` is wired, but the medications list page does
// not wire it — side-effect logging lives on the detail page's
// SideEffectsSection — so the menu renders the same items as the
// generic card.
const headerActions = (
<MedicationCardMenu
onEdit={() => onEdit(medication)}
@@ -360,54 +345,17 @@ export function Glp1MedicationCard({
/>
<CardContent className="space-y-3.5">
{/* v1.4.37 W4b — take-now / overdue / very-overdue pill,
byte-equivalent with the generic medication card. The
GLP-1 card historically omitted this row, which made
Mounjaro feel different from Ramipril on the medications
grid even though the underlying schedule contract is
the same shape. */}
{/* Take-now / overdue / very-overdue pill, shared with the
generic medication card. The GLP-1 card historically
omitted this row, which made Mounjaro feel different from
Ramipril on the medications grid even though the underlying
schedule contract is the same shape. */}
{currentWindowStatus.status && (
<p className="text-sm">
<span
className={
"inline-flex items-center gap-1 font-medium " +
(currentWindowStatus.status === "in_window"
? "text-success"
: currentWindowStatus.status === "late"
? "text-dracula-yellow"
: "text-warning")
}
>
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
glyph so colour-blind users (red-green) can
disambiguate take-now from late from very-late.
WCAG 1.4.1 (Use of Color). */}
{currentWindowStatus.status === "in_window" ? (
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
) : currentWindowStatus.status === "late" ? (
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle
className="size-3.5 shrink-0"
aria-hidden="true"
/>
)}
{currentWindowStatus.status === "in_window"
? t("medications.takeNow")
: currentWindowStatus.status === "late"
? t("medications.overdue")
: t("medications.veryOverdue")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
—{" "}
{formatTimeWindowRange(
currentWindowStatus.schedule!.windowStart,
currentWindowStatus.schedule!.windowEnd,
locale,
)}
</span>
</p>
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{/* Injection state — last + next */}
@@ -467,81 +415,25 @@ export function Glp1MedicationCard({
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — identical to the generic card so the page
{/* Compliance bars — shared with the generic card so the page
grid stays harmonious. */}
{medication.active && compliance && (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance7d")}
</span>
<span className="font-medium">{rate7}%</span>
</div>
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance30d")}
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
{streak > 0 && (
<div className="flex items-center gap-4 text-xs">
<span className="text-dracula-orange flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
</span>
</div>
)}
</div>
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* Primary actions row — byte-equivalent with the generic
medication card. v1.4.37 W4b moved the GLP-1-specific
side-effect quick-log out of this row and into the
header-actions overflow (kebab) so Mounjaro and Ramipril
share the canonical two-button primary row. */}
{/* Primary actions row — shared with the generic medication
card. The GLP-1-specific side-effect quick-log lives in the
header-actions overflow (kebab), not this row, so Mounjaro
and Ramipril share the canonical two-button primary row. */}
{medication.active && (
<div className="flex gap-2">
<Button
className="min-h-11 flex-1"
onClick={() => recordIntake(false)}
disabled={!!intakeLoading}
>
{intakeLoading === "take" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="mr-1 h-4 w-4" />
)}
{t("medications.taken")}
</Button>
<Button
variant="outline"
className="min-h-11"
onClick={() => recordIntake(true)}
disabled={!!intakeLoading}
>
{intakeLoading === "skip" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<SkipForward className="mr-1 h-4 w-4" />
)}
{t("medications.skipped")}
</Button>
</div>
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
)}
</CardContent>
</Card>
+24 -138
View File
@@ -2,12 +2,13 @@
import { useState, useEffect, useReducer } from "react";
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 } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
@@ -17,15 +18,6 @@ import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
SkipForward,
Flame,
Loader2,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -255,20 +247,11 @@ export function MedicationCard({
}
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>
)}
</>
<MedicationStateBadges
notificationsEnabled={medication.notificationsEnabled}
active={medication.active}
pausedAt={medication.pausedAt}
/>
);
// v1.7.2 W3 — the four former header icon-buttons (open / edit /
@@ -297,47 +280,11 @@ export function MedicationCard({
<CardContent className="space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<p className="text-sm">
<span
className={
"inline-flex items-center gap-1 font-medium " +
(currentWindowStatus.status === "in_window"
? "text-success"
: currentWindowStatus.status === "late"
? "text-dracula-yellow"
: "text-warning")
}
>
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
glyph so colour-blind users (red-green) can
disambiguate take-now from late from very-late.
WCAG 1.4.1 (Use of Color). */}
{currentWindowStatus.status === "in_window" ? (
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
) : currentWindowStatus.status === "late" ? (
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle
className="size-3.5 shrink-0"
aria-hidden="true"
/>
)}
{currentWindowStatus.status === "in_window"
? t("medications.takeNow")
: currentWindowStatus.status === "late"
? t("medications.overdue")
: t("medications.veryOverdue")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
—{" "}
{formatTimeWindowRange(
currentWindowStatus.schedule!.windowStart,
currentWindowStatus.schedule!.windowEnd,
locale,
)}
</span>
</p>
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{nextSchedule &&
@@ -410,80 +357,19 @@ export function MedicationCard({
{/* Compliance bar */}
{medication.active && compliance && (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance7d")}
</span>
<span className="font-medium">{rate7}%</span>
</div>
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
<Progress
value={rate7}
className="h-2"
aria-label={t("medications.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance30d")}
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress
value={rate30}
className="h-2"
aria-label={t("medications.compliance30d")}
/>
</div>
<div className="flex items-center gap-4 text-xs">
{streak > 0 && (
<span className="flex items-center gap-1 font-medium text-orange-400">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
</span>
)}
</div>
</div>
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* Quick actions — primary buttons of the medication card.
Phase A5 mobile audit flagged the previous size="sm" / 32-px
height as below the WCAG 44-px minimum. These are the most-
tapped controls in HealthLog, so they get the full default
button height. */}
{/* Quick actions — primary buttons of the medication card. */}
{medication.active && (
<div className="flex gap-2">
<Button
className="min-h-11 flex-1"
onClick={() => recordIntake(false)}
disabled={!!intakeLoading}
>
{intakeLoading === "take" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="mr-1 h-4 w-4" />
)}
{t("medications.taken")}
</Button>
<Button
variant="outline"
className="min-h-11"
onClick={() => recordIntake(true)}
disabled={!!intakeLoading}
>
{intakeLoading === "skip" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<SkipForward className="mr-1 h-4 w-4" />
)}
{t("medications.skipped")}
</Button>
</div>
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
)}
</CardContent>
</Card>