From e5cb74b45947ab9c1787e6be8ae7969bf03ad7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 02:40:30 +0200 Subject: [PATCH] refactor(medications): lift DrugLevelChart onto MedicationDetailSection The drug-level chart shipped a hand-rolled section wrapper (rounded-xl border p-4) that diverged from the three sibling sections on /medications/[id]/history. The chart now mounts inside the shared MedicationDetailSection primitive so the page reads on one chrome recipe. The compact prop + windowHoursBefore override retired with the dashboard tile that consumed them; the chart now ships one window contract and one body height. ~95 LOC removed. --- src/components/medications/DrugLevelChart.tsx | 225 ++++++------------ .../__tests__/DrugLevelChart.test.tsx | 23 ++ 2 files changed, 100 insertions(+), 148 deletions(-) diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index 22cb388ea..5895270bf 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -5,7 +5,7 @@ * medications. Opt-in behind Research Mode (the acknowledgment dialog * in this directory gates entry); even when enabled the chart paints * "Estimated level (relative)" on the y-axis without tick labels — - * research §2.3 + W19c-Backend phase report carry the rationale. + * research §2.3 + W19c-Backend round report carry the rationale. * * Inputs (read-only): * - Drug id resolved from the medication name (catalog lookup via @@ -27,17 +27,19 @@ * `glp1-pk.ts`. The chart renders a 21-day window so three weekly * cycles are visible, anchored at "now". Mobile-first ResponsiveContainer, * Dracula tokens that match the rest of the chart suite. + * + * v1.4.28 — the dashboard tile that consumed the compact rendering + * branch retired with FB-A2. The standalone `/medications/[id]/history` + * page is the sole mount; the chart now lifts onto the canonical + * `` chrome alongside Titration / Scheduling / + * SideEffects (UI-H1). The `compact` prop + `windowHoursBefore` override + * dropped with the tile. */ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; -import { - Activity, - AlertTriangle, - Loader2, - Syringe, -} from "lucide-react"; +import { AlertTriangle, Loader2, Syringe } from "lucide-react"; import { Area, AreaChart, @@ -61,6 +63,7 @@ import { import { parseDoseMg } from "@/lib/medications/dose-string"; import type { ResearchModeStatus } from "@/lib/medications/research-mode-types"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; const CHART_COLOR = "var(--dracula-purple)"; const CHART_FILL_OPACITY = 0.18; @@ -111,34 +114,9 @@ export interface DrugLevelChartProps { * cost low. */ asOf?: Date; - /** - * v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1 - * dashboard tile carves out a mini pane for this chart). When set: - * - the outer `
` wrapper drops so the - * parent tile owns the card surface (no card-inside-a-card). - * - the chart body shrinks (160 px instead of 240 px) to match - * mini-mode density of the other tile-internal charts. - * - the header + disclaimer collapse to a single inline caption. - */ - compact?: boolean; - /** - * v1.4.27 B1 — window length override (in hours of history before - * `asOf`). Defaults to 21 × 24 = 504 h (three weekly cycles), the - * shape the standalone surface ships. The dashboard mini-chart - * exposes 7d / 30d / 90d / All via a range strip and threads the - * mapped window length through this prop. The sample step scales - * with the window so an "All"-mode chart doesn't paint thousands of - * points: 6 h / 12 h / 24 h / 48 h for ≤ 21 d / ≤ 60 d / ≤ 180 d / >. - */ - windowHoursBefore?: number; } -export function DrugLevelChart({ - medication, - asOf, - compact = false, - windowHoursBefore, -}: DrugLevelChartProps) { +export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const { t } = useTranslations(); // Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N @@ -218,125 +196,82 @@ export function DrugLevelChart({ const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading)); const hasDoses = doses.length > 0; const animationsEnabled = !prefersReducedMotion(); - // Resolve the window length once, then derive a sample step that - // keeps the AreaChart inside a sensible point count even on the - // "All" branch of the dashboard's range strip. - const resolvedWindowHours = windowHoursBefore ?? WINDOW_HOURS_BEFORE; - const resolvedStepHours = pickSampleStepHours(resolvedWindowHours); - const chartBodyHeightPx = compact ? 160 : 240; + // Single-window contract — three weekly cycles with the sample step + // tuned for the AreaChart's point count. The dashboard tile that + // previously threaded a window-length override retired with FB-A2. + const stepHours = pickSampleStepHours(WINDOW_HOURS_BEFORE); - // Compact mode mounts the chart inside a host card (the GLP-1 tile) - // and skips the outer `
` wrapper so the user doesn't see a - // card painted inside a card. The disclaimer also collapses to a - // single muted line to keep the tile's vertical footprint tight. - // v1.4.27 MB7 / CF-66 — drop the `md:p-6` lift so the wrapper - // stays at the `p-4` density across viewports. The 6 unit padding - // on `md+` made the GLP-1 tile feel oversized relative to the - // neighbouring trend cards, which all use `p-4`. Compact mode - // retains the `space-y-2` layout without an outer card wrapper. - const wrapperClass = compact - ? "space-y-2" - : "bg-card border-border rounded-xl border p-4"; - const wrapperProps = { - "aria-labelledby": "drug-level-chart-title", - className: wrapperClass, - "data-slot": "drug-level-chart", - "data-compact": compact ? "true" : undefined, - } as const; - const body = ( - <> - {/* v1.4.28 FB-F3 / F4 — align the standalone chart header to the - canonical `` heading scale so the - full `/medications/[id]` surface reads on one heading shape. - The Activity glyph + drug INN line stay as header chrome on - the right; the heading element is now `

` + canonical - classes to match the surrounding wave-4b sections. */} - {!compact && ( -
- -

- {t("medications.researchMode.chart.title")} -

- {drugId && ( - - · {t(`medications.glp1.drug.${drugId}.name`)} - - )} -
- )} - {compact && ( -

- {t("medications.researchMode.chart.title")} -

- )} + // UI-H1 — the chart mounts inside the shared + // `` chrome alongside Titration / Scheduling + // / SideEffects so the `/medications/[id]/history` page reads on one + // section recipe. The drug-INN qualifier rides the headerExtras slot + // on the right edge of the header band. + const headerExtras = drugId ? ( + + {t(`medications.glp1.drug.${drugId}.name`)} + + ) : null; - {/* Decision tree: - 1. drug not in catalog → gated-unknown-drug placeholder - 2. !researchMode.enabled OR → gated placeholder + Settings CTA - versions !== aligned (the defence-in-depth rule) - 3. loading → skeleton - 4. no intake events → empty-state with log CTA - 5. otherwise → AreaChart */} - {!drugId ? ( - - ) : !gateOpen ? ( - - ) : isLoading ? ( -
- -
- ) : !hasDoses ? ( - - ) : ( - - )} + // Decision tree: + // 1. drug not in catalog → gated-unknown-drug placeholder + // 2. !researchMode.enabled OR → gated placeholder + Settings CTA + // versions !== aligned (the defence-in-depth rule) + // 3. loading → skeleton + // 4. no intake events → empty-state with log CTA + // 5. otherwise → AreaChart + const body = !drugId ? ( + + ) : !gateOpen ? ( + + ) : isLoading ? ( +
+ +
+ ) : !hasDoses ? ( + + ) : ( + + ); + return ( + + {body} {gateOpen && hasDoses && (

{t("medications.researchMode.chart.estimateNote")}

)} - +
); - - return compact ?
{body}
:
{body}
; } /** - * v1.4.27 B1 — pick a sample step in hours appropriate for the chosen - * window length. The default 6 h is fine at ≤ 21 days (≤ 84 samples); - * "All"-mode windows need a coarser grid so the AreaChart stays under - * a few hundred points. The chart is qualitative (research §2.3) so a - * coarse step does not change the rising/peak/fading shape. + * Pick a sample step in hours appropriate for the chosen window + * length. The default 6 h is fine at ≤ 21 days (≤ 84 samples); longer + * windows need a coarser grid so the AreaChart stays under a few + * hundred points. The chart is qualitative (research §2.3) so a coarse + * step does not change the rising/peak/fading shape. */ function pickSampleStepHours(windowHoursBefore: number): number { const days = windowHoursBefore / 24; @@ -439,9 +374,7 @@ function ChartBody({ asOf, animationsEnabled, axisLabel, - windowHoursBefore, stepHours, - heightPx, }: { drug: Glp1DrugId; doses: readonly DoseEvent[]; @@ -454,22 +387,18 @@ function ChartBody({ * users and SSR snapshots both surface the framing. */ axisLabel: string; - /** Window length in hours; the chart's x-axis domain is `[-days, 0]`. */ - windowHoursBefore: number; /** Sample step in hours; scales with the window length. */ stepHours: number; - /** Chart-area height in CSS pixels; compact mode passes 160. */ - heightPx: number; }) { - const windowDays = windowHoursBefore / HOURS_PER_DAY; + const windowDays = WINDOW_HOURS_BEFORE / HOURS_PER_DAY; const samples = useMemo( () => computeOneCompartment(drug, doses, asOf, { - windowHoursBefore, + windowHoursBefore: WINDOW_HOURS_BEFORE, windowHoursAfter: WINDOW_HOURS_AFTER, stepHours, }), - [drug, doses, asOf, windowHoursBefore, stepHours], + [drug, doses, asOf, stepHours], ); // Map samples to chart points. x-axis is "days since now" — negative @@ -504,7 +433,7 @@ function ChartBody({

diff --git a/src/components/medications/__tests__/DrugLevelChart.test.tsx b/src/components/medications/__tests__/DrugLevelChart.test.tsx index 97f6346df..372973b33 100644 --- a/src/components/medications/__tests__/DrugLevelChart.test.tsx +++ b/src/components/medications/__tests__/DrugLevelChart.test.tsx @@ -244,6 +244,29 @@ describe(" — gating decision tree", () => { expect(call.asOf).toEqual(fixedNow); }); + it("wraps the chart inside the canonical MedicationDetailSection chrome (UI-H1)", () => { + // v1.4.28 — the dashboard tile retired and the standalone + // mount on `/medications/[id]/history` lifts onto the shared + // `` chrome alongside Titration / + // Scheduling / SideEffects. The aria-labelledby thread + the + // `border-border/60 rounded-md border` shell are the + // load-bearing seams. + setQueryResult("research-mode", { + enabled: true, + acknowledgedAt: "2026-05-14T00:00:00Z", + acknowledgedVersion: "2026-05-14.1", + currentDisclaimerVersion: "2026-05-14.1", + }); + const html = render(); + expect(html).toContain('data-slot="drug-level-chart"'); + expect(html).toContain('aria-labelledby="drug-level-chart-title"'); + expect(html).toContain("border-border/60"); + expect(html).toContain("rounded-md"); + // No more standalone `
` block on the chart — the section + // header band owns the heading scale now. + expect(html).not.toContain("text-dracula-purple h-4 w-4"); + }); + it("hides the y-axis tick labels (research §2.3, unit-less)", () => { setQueryResult("research-mode", { enabled: true,