From d6456a30ebc44883684b036cba78c2cc899ef157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 23:02:28 +0200 Subject: [PATCH] refactor(medications): extract MedicationDetailSection wrapper across 4 components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the shared chrome (border + header row + dotted divider + body band) into one wrapper so the three Wave-4b panels (Titration, Scheduling, SideEffects) stop drifting on padding, border opacity, and aria-wiring rules. - src/components/medications/medication-detail-section.tsx (new) — composes the section shell with a stable titleId / headerExtras contract. - TitrationSection — drops the hand-rolled templateFill helper (the i18n t() function already accepts the params bag); adopts the wrapper. - SchedulingSection — adopts the wrapper; grows cadence-timeline cells to a 44 px tap-target per WCAG 2.5.5 while keeping the 12 px visible cell. - SideEffectsSection — adopts the wrapper; swaps the native ` for the shadcn-radix ` { - const next = e.target.value as MedicationSideEffectCategory; - setCategory(next); + onValueChange={(next) => { + setCategory(next as MedicationSideEffectCategory); // Reset entry — the previously-selected entry probably // doesn't belong to the new category. setEntry(null); }} - className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm" > - {SIDE_EFFECT_CATEGORY_ORDER.map((c) => ( - - ))} - + + + + + {SIDE_EFFECT_CATEGORY_ORDER.map((c) => ( + + {t( + `medications.sideEffects.categories.${categoryI18nKey(c)}`, + )} + + ))} + +
@@ -460,6 +481,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { - + ); } diff --git a/src/components/medications/TitrationSection.tsx b/src/components/medications/TitrationSection.tsx index b424cc46e..1a2207d8c 100644 --- a/src/components/medications/TitrationSection.tsx +++ b/src/components/medications/TitrationSection.tsx @@ -5,13 +5,15 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, ExternalLink } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** * v1.4.25 W19f — GLP-1 titration-ladder section. * * Sits between `` (W19e) and `` * on the medication detail page. Same chrome as the W19d / W19e - * sections so the Wave-4b panels read as one visual group. + * sections so the Wave-4b panels read as one visual group — chrome is + * now composed via the shared `` wrapper. * * Layout: * - Header: drug INN + "Standard ladder (EMA reference)" @@ -24,6 +26,11 @@ import { useTranslations } from "@/lib/i18n/context"; * ladder is a *reference*, never a prescription (MDR boundary). * - Disclaimer + EMA-source link. * + * v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper; + * the i18n `t()` function already accepts the `params` bag with + * `{key}` placeholder substitution. One source of truth for the + * template-fill semantics. + * * Data comes from `/api/medications/[id]/titration`; that route owns * the prisma queries and delegates math to the pure ladder helpers. */ @@ -50,16 +57,6 @@ interface TitrationSectionProps { medicationId: string; } -function templateFill( - template: string, - values: Record, -): string { - return template.replace(/\{(\w+)\}/g, (match, key) => { - if (key in values) return String(values[key]); - return match; - }); -} - export function TitrationSection({ medicationId }: TitrationSectionProps) { const { t } = useTranslations(); @@ -85,12 +82,12 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) { if (!data || data.currentStep === null) return null; if (data.weeksOnCurrentStep <= 0) return null; if (data.escalationDue) { - return templateFill(t("medications.titration.escalationDueHint"), { + return t("medications.titration.escalationDueHint", { weeks: data.weeksOnCurrentStep, typical: data.currentStep.typicalWeeks, }); } - return templateFill(t("medications.titration.currentStepDwell"), { + return t("medications.titration.currentStepDwell", { weeks: data.weeksOnCurrentStep, }); }, [data, t]); @@ -98,151 +95,141 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) { const nextStepCaption = useMemo(() => { if (!data) return null; if (!data.nextStep) return null; - return templateFill(t("medications.titration.nextStepCaption"), { + return t("medications.titration.nextStepCaption", { dose: data.nextStep.doseMg, weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks, }); }, [data, t]); - return ( -
-
-

- {t("medications.titration.section")} -

- {data && ( - - {data.drugInn} - - )} -
+ {data.drugInn} + + ) : null; -
- {isLoading && ( -
- - {t("medications.titration.loading")} -
- )} + return ( + + {isLoading && ( +
+ + {t("medications.titration.loading")} +
+ )} - {!!error && !isLoading && ( -

- {t("medications.titration.loadFailed")} + {!!error && !isLoading && ( +

+ {t("medications.titration.loadFailed")} +

+ )} + + {data && !isLoading && ( +
+

+ {t("medications.titration.drugLadderHeader")}

- )} - {data && !isLoading && ( -
-

- {t("medications.titration.drugLadderHeader")} + {hasLadder ? ( +

    + {data.ladder.map((step) => { + const isCurrent = currentIdx === step.stepIndex; + const isPast = + currentIdx !== null && step.stepIndex < currentIdx; + return ( +
  1. + + {t("medications.titration.stepLabel", { + n: step.stepIndex + 1, + })} + + + {step.doseMg} {t("medications.titration.doseUnitMg")} + + + {t("medications.titration.typicalWeeksOnStep", { + weeks: step.typicalWeeks, + })} + + {isCurrent && ( + + {t("medications.titration.youAreHere")} + + )} +
  2. + ); + })} +
+ ) : ( +

+ {t("medications.titration.emptyState")}

+ )} - {hasLadder ? ( -
    - {data.ladder.map((step) => { - const isCurrent = currentIdx === step.stepIndex; - const isPast = - currentIdx !== null && step.stepIndex < currentIdx; - return ( -
  1. - - {templateFill(t("medications.titration.stepLabel"), { - n: step.stepIndex + 1, - })} - - - {step.doseMg} {t("medications.titration.doseUnitMg")} - - - {templateFill( - t("medications.titration.typicalWeeksOnStep"), - { weeks: step.typicalWeeks }, - )} - - {isCurrent && ( - - {t("medications.titration.youAreHere")} - - )} -
  2. - ); - })} -
- ) : ( -

- {t("medications.titration.emptyState")} -

- )} + {data.currentStep === null && hasLadder && ( +

+ {t("medications.titration.nonStandardDose")} +

+ )} - {data.currentStep === null && hasLadder && ( -

- {t("medications.titration.nonStandardDose")} -

- )} + {dwellHint && ( +

+ {dwellHint} +

+ )} - {dwellHint && ( -

- {dwellHint} -

- )} + {nextStepCaption && ( +

{nextStepCaption}

+ )} - {nextStepCaption && ( -

{nextStepCaption}

- )} + {data.nextStep === null && data.currentStep !== null && ( +

+ {t("medications.titration.ceilingMessage")} +

+ )} - {data.nextStep === null && data.currentStep !== null && ( -

- {t("medications.titration.ceilingMessage")} -

- )} - -
-

- {t("medications.titration.disclaimer")} -

- - {t("medications.titration.emaSourceCta")} - -
+
+

+ {t("medications.titration.disclaimer")} +

+ + {t("medications.titration.emaSourceCta")} +
- )} -
-
+
+ )} + ); } diff --git a/src/components/medications/inventory-section.tsx b/src/components/medications/inventory-section.tsx index e3c907625..c0910ae1c 100644 --- a/src/components/medications/inventory-section.tsx +++ b/src/components/medications/inventory-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react"; @@ -16,6 +16,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { MedicationInventoryState } from "@/generated/prisma/client"; +import { + DEFAULT_IN_USE_WINDOW_DAYS, + daysRemainingInUse, +} from "@/lib/medications/inventory/state-machine"; /** * v1.4.25 W19b — Inventory disclosure for the GLP-1 medication card. @@ -30,13 +35,19 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; * * The UI deliberately reuses existing primitives — Card, Dialog, * Input, Badge, Button — no new shadcn block. + * + * v1.4.25 W21 Fix-N + * - The local `InventoryState` string-union has been dropped in + * favour of the canonical `MedicationInventoryState` enum from + * the Prisma client. + * - The reimplemented client-side `daysRemainingInUse` has been + * replaced by the widened pure helper from the inventory + * state-machine module; one rule for the deadline math. */ -type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - interface InventoryItem { id: string; - state: InventoryState; + state: MedicationInventoryState; dosesTotal: number; dosesRemaining: number; firstUseAt: string | null; @@ -54,21 +65,6 @@ interface InventorySectionProps { defaultDosesPerUnit: number | null; } -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -function daysRemainingInUse( - firstUseAt: string | null, - windowDays: number, -): number | null { - if (!firstUseAt) return null; - const deadline = new Date(firstUseAt).getTime() + windowDays * MS_PER_DAY; - const ms = deadline - Date.now(); - if (ms <= 0) return 0; - return Math.floor(ms / MS_PER_DAY); -} - -const IN_USE_WINDOW_DAYS = 30; - export function InventorySection({ medicationId, defaultDosesPerUnit, @@ -211,12 +207,22 @@ export function InventorySection({ (i) => i.state === "EXPIRED" || i.state === "USED_UP", ); + // Snapshot "now" once per render. The disclosure is opt-in (the + // user clicks to expand), so re-rendering on focus events should + // not cause the "days remaining" labels to flicker between values. + // eslint-disable-next-line react-hooks/purity -- intentional one-shot snapshot + const nowMs = useMemo(() => Date.now(), []); + function stateLabel(item: InventoryItem): string { if (item.state === "ACTIVE") { return t("medications.inventory.state.active"); } if (item.state === "IN_USE") { - const d = daysRemainingInUse(item.firstUseAt, IN_USE_WINDOW_DAYS); + const d = daysRemainingInUse( + { firstUseAt: item.firstUseAt }, + nowMs, + DEFAULT_IN_USE_WINDOW_DAYS, + ); return t("medications.inventory.state.inUse", { days: d ?? 0 }); } if (item.state === "EXPIRED") { @@ -226,7 +232,7 @@ export function InventorySection({ } function badgeVariant( - state: InventoryState, + state: MedicationInventoryState, ): "default" | "outline" | "secondary" | "destructive" { if (state === "EXPIRED") return "destructive"; if (state === "USED_UP") return "secondary"; diff --git a/src/components/medications/medication-detail-section.tsx b/src/components/medications/medication-detail-section.tsx new file mode 100644 index 000000000..b4ac1cc14 --- /dev/null +++ b/src/components/medications/medication-detail-section.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { type ReactNode } from "react"; + +/** + * v1.4.25 W21 Fix-N — section wrapper for the three medication-detail + * panels (Titration, Scheduling, SideEffects). The four wave-4b + * sections previously hand-rolled the same chrome — border + header + * row + dotted divider + body padding — and drifted on padding (px-3 + * vs px-3 py-2.5 vs px-3 py-2), border opacity (border-border/60 vs + * /70), and aria wiring (aria-labelledby was inconsistent). + * + * This wrapper locks the contract: + * - `border-border/60 rounded-md border` chrome + * - `px-3 py-2.5` header row with `text-foreground/85 text-sm font-medium` title + * - `border-border/60 border-t px-3 py-3 text-xs` body band + * - `aria-labelledby` wired to a stable per-section id derived from `titleId` + * + * Consumers pass: + * - `titleId` — the DOM id on the heading element. Components owning + * a heading id already (e.g. "titration-heading") pass it through; + * new surfaces should follow the `-heading` convention. + * - `title` — the rendered heading text (already i18n-translated by + * the caller). + * - `headerExtras` — optional right-side header content (e.g. drug + * INN badge, edit CTA, "Add" button). + * - `dataSlot` — optional `data-slot` attribute on the outer section + * so cross-section selectors (Playwright, snapshot tests) stay + * stable. + * - `bodyPaddingY` — escape hatch for sections that need a different + * vertical padding (the side-effects body runs at py-2.5 because + * it owns an inline timeline with its own gap rhythm). + */ +export interface MedicationDetailSectionProps { + titleId: string; + title: ReactNode; + headerExtras?: ReactNode; + children: ReactNode; + dataSlot?: string; + bodyPaddingY?: "py-2.5" | "py-3"; +} + +export function MedicationDetailSection({ + titleId, + title, + headerExtras, + children, + dataSlot, + bodyPaddingY = "py-3", +}: MedicationDetailSectionProps) { + return ( +
+
+

+ {title} +

+ {headerExtras} +
+
+ {children} +
+
+ ); +} diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts index 24fcc8bb9..120347070 100644 --- a/src/lib/medications/inventory/__tests__/state-machine.test.ts +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -224,4 +224,32 @@ describe("daysRemainingInUse", () => { }); expect(daysRemainingInUse(item, NOW_MS)).toBe(0); }); + + describe("thin-shape overload (v1.4.25 W21 Fix-N)", () => { + // The medication-detail card calls this with just `{ firstUseAt }` + // because it has already filtered the list to state === "IN_USE" + // before reaching the helper. The thin overload skips the + // state-machine gate. + it("accepts a thin `{ firstUseAt: Date }` shape", () => { + const firstUseAt = new Date(NOW_MS - 5 * MS_PER_DAY); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(25); + }); + + it("accepts a thin `{ firstUseAt: string }` ISO shape", () => { + const firstUseAt = new Date(NOW_MS - 10 * MS_PER_DAY).toISOString(); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(20); + }); + + it("returns null when the thin firstUseAt is null", () => { + expect(daysRemainingInUse({ firstUseAt: null }, NOW_MS)).toBeNull(); + }); + + it("skips the state-machine gate for the thin form", () => { + // 31 days past the window — the full-view path would return + // null here (state ≠ IN_USE). The thin form returns 0 because + // the caller has already gated on IN_USE. + const firstUseAt = new Date(NOW_MS - 31 * MS_PER_DAY); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(0); + }); + }); }); diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index d49a4ca5c..4b87f5c19 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -207,17 +207,36 @@ export function decrementDose( * in-use deadline (the EXPIRED transition is still correct via * `computeInventoryState`, but the day-count chip shows the * 30-day-clock number). + * + * v1.4.25 W21 Fix-N — widened to accept either the full + * `InventoryItemView` (server-side path: state-machine-gated, returns + * null for non-IN_USE) OR a thin `{ firstUseAt }` shape (client-side + * path: caller already knows state is IN_USE so the gate is a no-op). + * The widening collapses the reimplementation the inventory-section + * client previously carried. */ export function daysRemainingInUse( - item: InventoryItemView, + item: InventoryItemView | { firstUseAt: Date | string | null }, nowMs: number, inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, ): number | null { - if (!item.firstUseAt) return null; - const state = computeInventoryState(item, nowMs, inUseWindowDays); - if (state !== "IN_USE") return null; - const deadlineMs = - item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; + const firstUseRaw = item.firstUseAt; + if (!firstUseRaw) return null; + const firstUseAt = + firstUseRaw instanceof Date ? firstUseRaw : new Date(firstUseRaw); + // Only the full view triggers the state-machine gate; thin callers + // (the inventory disclosure UI on the medication card) supply just + // `firstUseAt` and have already filtered on state === "IN_USE" before + // reaching here. The gate is silently skipped for the thin form. + if ("state" in item && "dosesRemaining" in item) { + const state = computeInventoryState( + { ...item, firstUseAt }, + nowMs, + inUseWindowDays, + ); + if (state !== "IN_USE") return null; + } + const deadlineMs = firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; const remainingMs = deadlineMs - nowMs; if (remainingMs <= 0) return 0; return Math.floor(remainingMs / MS_PER_DAY);