From 3a5e157d419e003c5f2b9865c906597bf6ba0793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 20:00:39 +0200 Subject: [PATCH] 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. --- .../medications/__tests__/card-parts.test.tsx | 208 ++++++++++++++++++ .../card-parts/medication-compliance-bars.tsx | 69 ++++++ .../card-parts/medication-intake-actions.tsx | 55 +++++ .../card-parts/medication-state-badges.tsx | 40 ++++ .../card-parts/medication-status-pill.tsx | 60 +++++ .../medications/glp1-medication-card.tsx | 194 ++++------------ .../medications/medication-card.tsx | 162 ++------------ 7 files changed, 499 insertions(+), 289 deletions(-) create mode 100644 src/components/medications/__tests__/card-parts.test.tsx create mode 100644 src/components/medications/card-parts/medication-compliance-bars.tsx create mode 100644 src/components/medications/card-parts/medication-intake-actions.tsx create mode 100644 src/components/medications/card-parts/medication-state-badges.tsx create mode 100644 src/components/medications/card-parts/medication-status-pill.tsx diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx new file mode 100644 index 000000000..77f64fc65 --- /dev/null +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -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 `` and the ``. 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 ? ( + {node} + ) : ( + node + ); + return renderToStaticMarkup({tree}); +} + +describe("medication card-parts — shared presentational components", () => { + it("compliance bars render the 7d / 30d labels and the day-streak flame", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).not.toContain("lucide-flame"); + }); + + it("status pill stamps the success token + take-now glyph in window", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain("text-warning"); + expect(html).toContain("lucide-triangle-alert"); + }); + + it("intake actions row carries exactly the take + skip buttons", () => { + const html = render( + {}} />, + ); + 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( + , + ); + 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( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ); + const mounjaroHtml = render( + {}} + 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"); + } + }); +}); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx new file mode 100644 index 000000000..7fac27474 --- /dev/null +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -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 ( +
+
+
+ + {t("medications.compliance7d")} + + {rate7}% +
+ {/* aria-label so the bar has an accessible name. */} + +
+ +
+
+ + {t("medications.compliance30d")} + + {rate30}% +
+ +
+ +
+ {streak > 0 && ( + + + {streak} {t("medications.dayStreak")} + + )} +
+
+ ); +} diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx new file mode 100644 index 000000000..a2f3b5327 --- /dev/null +++ b/src/components/medications/card-parts/medication-intake-actions.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/medications/card-parts/medication-state-badges.tsx b/src/components/medications/card-parts/medication-state-badges.tsx new file mode 100644 index 000000000..9de3b824e --- /dev/null +++ b/src/components/medications/card-parts/medication-state-badges.tsx @@ -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 && ( + + {t("medications.withoutNotification")} + + )} + {!active && ( + + {pausedAt + ? `${t("medications.pausedSince")} ${formatDateTime(pausedAt)}` + : t("medications.inactive")} + + )} + + ); +} diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx new file mode 100644 index 000000000..521a05196 --- /dev/null +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -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; + 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 ( +

+ + {status === "in_window" ? ( + + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + +

+ ); +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index ea42b4bae..ed38a03bb 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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(null); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); @@ -312,28 +303,22 @@ export function Glp1MedicationCard({ } const stateBadges = ( - <> - {!medication.notificationsEnabled && ( - - {t("medications.withoutNotification")} - - )} - {!medication.active && ( - - {medication.pausedAt - ? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}` - : t("medications.inactive")} - - )} - + ); - // 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 = ( onEdit(medication)} @@ -360,54 +345,17 @@ export function Glp1MedicationCard({ /> - {/* 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 && ( -

- - {/* 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" ? ( - - - {" "} - —{" "} - {formatTimeWindowRange( - currentWindowStatus.schedule!.windowStart, - currentWindowStatus.schedule!.windowEnd, - locale, - )} - -

+ )} {/* 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 && ( -
-
-
- - {t("medications.compliance7d")} - - {rate7}% -
- {/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */} - -
-
-
- - {t("medications.compliance30d")} - - {rate30}% -
- -
- {streak > 0 && ( -
- - - {streak} {t("medications.dayStreak")} - -
- )} -
+ )} - {/* 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 && ( -
- - -
+ )}
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 8d4dcf271..615189f83 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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 && ( - - {t("medications.withoutNotification")} - - )} - {!medication.active && ( - - {medication.pausedAt - ? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}` - : t("medications.inactive")} - - )} - + ); // v1.7.2 W3 — the four former header icon-buttons (open / edit / @@ -297,47 +280,11 @@ export function MedicationCard({ {/* Status, last & next intake info */} {currentWindowStatus.status && ( -

- - {/* 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" ? ( - - - {" "} - —{" "} - {formatTimeWindowRange( - currentWindowStatus.schedule!.windowStart, - currentWindowStatus.schedule!.windowEnd, - locale, - )} - -

+ )} {nextSchedule && @@ -410,80 +357,19 @@ export function MedicationCard({ {/* Compliance bar */} {medication.active && compliance && ( -
-
-
- - {t("medications.compliance7d")} - - {rate7}% -
- {/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */} - -
- -
-
- - {t("medications.compliance30d")} - - {rate30}% -
- -
- -
- {streak > 0 && ( - - - {streak} {t("medications.dayStreak")} - - )} -
-
+ )} - {/* 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 && ( -
- - -
+ )}