From c0ec1fecfbe1cf01e1253fd54c86e54502e94d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:43:24 +0200 Subject: [PATCH] feat(dashboard): wire the GLP-1 drug-level pane behind a tab strip and range picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GLP-1 tile previously rendered a single weight chart — a downstream proxy for the dose-response. The maintainer-flagged headline is the estimated drug-level curve because it surfaces the sawtooth shape of the weekly cycle directly. Add a two-tab segmented control (Drug-Level default / Weight) inside the chart pane and a 7d / 30d / 90d / All range strip above. The Drug-Level tab mounts , which now drops its outer card wrapper and renders at 160 px instead of 240 px when 'compact' is set, so the dashboard tile owns the card surface. Range selections thread through a new windowHoursBefore prop on DrugLevelChart; the sample step scales 6/12/24/48 h with window length so 'All' mode does not paint thousands of points. The standalone surface at /medications/[id]/history keeps its full 21-day default and full-card chrome — both consumers share one implementation. New translation keys referenced (B6 sweep picks up): - dashboard.glp1.tabLevel - dashboard.glp1.tabWeight - dashboard.glp1.tabsAria - dashboard.glp1.rangeStripLabel - dashboard.glp1.levelUnavailable - dashboard.glp1.weightUnavailable Test coverage extended: glp1-tile test asserts tab strip + range strip DOM, drug-level default selection, level-unavailable hint when the medication has no id; the existing DrugLevelChart test suite continues to pin the standalone-surface contract. --- .../dashboard/__tests__/glp1-tile.test.tsx | 98 ++++++++++- src/components/dashboard/glp1-tile.tsx | 166 +++++++++++++++++- src/components/medications/DrugLevelChart.tsx | 154 ++++++++++++---- 3 files changed, 376 insertions(+), 42 deletions(-) diff --git a/src/components/dashboard/__tests__/glp1-tile.test.tsx b/src/components/dashboard/__tests__/glp1-tile.test.tsx index 0123f6da2..ff9fc6548 100644 --- a/src/components/dashboard/__tests__/glp1-tile.test.tsx +++ b/src/components/dashboard/__tests__/glp1-tile.test.tsx @@ -261,8 +261,102 @@ describe("", () => { expect(html).not.toContain("Ozempic 0mg"); // No weight delta caption when both sides are null. expect(html).not.toContain('data-slot="glp1-tile-delta"'); - // No chart when the series is empty. - expect(html).not.toContain('data-slot="glp1-tile-chart"'); + // v1.4.27 B1 — the tab strip + range strip always render; the + // weight pane shows the muted "no data yet" hint when the series + // is empty rather than suppressing the whole chart row. + expect(html).toContain('data-slot="glp1-tile-chart"'); + expect(html).toContain('data-slot="glp1-tile-tab-level"'); + expect(html).toContain('data-slot="glp1-tile-tab-weight"'); + }); + + it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => { + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Mounjaro", + genericName: "tirzepatide", + medicationId: "med-1", + currentDose: { + value: 7.5, + unit: "mg", + since: "2026-04-01", + weeksOnDose: 6, + }, + doseHistory: [], + lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 }, + nextInjection: { date: "2026-05-17", daysAway: 3 }, + startWeight: 92.0, + currentWeight: 87.8, + weightDeltaKg: -4.2, + weightSeries: [{ date: "2026-05-13", weight: 87.8 }], + injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"], + }, + ], + }, + isPending: false, + }; + const html = render(); + // Tab strip + range strip are present on a populated tile. + expect(html).toContain('data-slot="glp1-tile-tabs"'); + expect(html).toContain('data-slot="glp1-tile-tab-level"'); + expect(html).toContain('data-slot="glp1-tile-tab-weight"'); + expect(html).toContain('data-slot="glp1-tile-range-strip"'); + // Drug-Level tab is the default selection. + expect(html).toMatch( + /data-slot="glp1-tile-tab-level"[^>]*data-active="true"/, + ); + expect(html).toMatch( + /data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/, + ); + // Range strip exposes 7d / 30d / 90d / All buttons. + expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/); + expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/); + expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/); + expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/); + // 30d preset is the default. + expect(html).toMatch( + /data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/, + ); + }); + + it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => { + // Some legacy meds don't have a Medication.id surfaced through the + // dashboard route (the route returns medicationId: null). The + // drug-level pane needs a stable medication id to mount the + // chart, so we render a muted hint instead of the chart. + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Ozempic", + genericName: "semaglutide", + medicationId: null, + currentDose: { + value: 0.5, + unit: "mg", + since: "2026-04-01", + weeksOnDose: 6, + }, + doseHistory: [], + lastInjection: null, + nextInjection: null, + startWeight: 92.0, + currentWeight: 87.8, + weightDeltaKg: -4.2, + weightSeries: [{ date: "2026-05-13", weight: 87.8 }], + injectionDates: [], + }, + ], + }, + isPending: false, + }; + const html = render(); + // Drug-level tab stays selected by default; pane renders the + // unavailable hint because medicationId is null. + expect(html).toContain('data-slot="glp1-tile-level-unavailable"'); }); it("renders German copy under the de locale", () => { diff --git a/src/components/dashboard/glp1-tile.tsx b/src/components/dashboard/glp1-tile.tsx index 5c2597fdc..7b11ac9f1 100644 --- a/src/components/dashboard/glp1-tile.tsx +++ b/src/components/dashboard/glp1-tile.tsx @@ -26,13 +26,14 @@ */ import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; +import { CHART_RANGE_PRESETS } from "@/lib/charts/constants"; const HealthChart = dynamic( () => @@ -42,6 +43,28 @@ const HealthChart = dynamic( { ssr: false }, ); +const DrugLevelChart = dynamic( + () => + import("@/components/medications/DrugLevelChart").then((mod) => ({ + default: mod.DrugLevelChart, + })), + { ssr: false }, +); + +/** + * Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's + * window length in hours. `points: 0` (the "All" preset) maps to 365 + * days — past that point the unit-less curve flattens to invisibility, + * so a year of history is the practical ceiling. + */ +const HOURS_PER_DAY = 24; +function rangePointsToHours(points: number): number { + if (points === 0) return 365 * HOURS_PER_DAY; + return points * HOURS_PER_DAY; +} + +type Glp1ChartTab = "level" | "weight"; + interface DoseHistoryEntry { value: number; unit: string; @@ -166,6 +189,14 @@ export function Glp1Tile() { const dateWithWeekday = useDateWithWeekday(); const deltaDisplay = useDeltaDisplay(); + // v1.4.27 B1 — the chart pane carries two views (drug-level / weight) + // and a 7d / 30d / 90d / All range strip above. Drug-level is the + // default because it's the more informative pane the maintainer + // asked for; the weight pane stays one tap away. Both states live + // on the tile so a parent re-render doesn't reset the user's pick. + const [activeTab, setActiveTab] = useState("level"); + const [rangePoints, setRangePoints] = useState(30); + const { data, isPending } = useQuery({ queryKey: queryKeys.dashboardGlp1(), enabled: isAuthenticated, @@ -287,12 +318,84 @@ export function Glp1Tile() { )} - {/* Compact weight chart with the vertical injection-day markers - the v1.4.25 W6 chart-extension adds. Skipped when we have no - weight readings since the GLP-1 was started — the empty - chart would draw nothing useful. */} - {med.weightSeries.length > 0 && ( -
+ {/* v1.4.27 B1 — chart pane carrying a two-tab segmented control + (Drug-Level default / Weight) plus a 7d / 30d / 90d / All + range strip above. Drug-Level reads the unit-less curve from + `` (Research Mode gating is owned + by the chart itself); Weight retains the v1.4.25 W6 mini + chart with vertical injection markers. */} +
+
+
+ setActiveTab("level")} + label={t("dashboard.glp1.tabLevel")} + slot="glp1-tile-tab-level" + /> + setActiveTab("weight")} + label={t("dashboard.glp1.tabWeight")} + slot="glp1-tile-tab-weight" + /> +
+
+ {CHART_RANGE_PRESETS.map((preset) => ( + + ))} +
+
+ + {activeTab === "level" && med.medicationId ? ( + + ) : activeTab === "level" ? ( +

+ {t("dashboard.glp1.levelUnavailable")} +

+ ) : med.weightSeries.length > 0 ? ( ({ date }))} userTimezone={user?.timezone} /> -
- )} + ) : ( +

+ {t("dashboard.glp1.weightUnavailable")} +

+ )} +
); } +/** + * v1.4.27 B1 — small segmented-control button used by the tile's + * tab strip. Carved into its own component so the active/inactive + * styling lives in one place and the test can pin the data-slot + * markers per tab. + */ +function TabButton({ + active, + onClick, + label, + slot, +}: { + active: boolean; + onClick: () => void; + label: string; + slot: string; +}) { + return ( + + ); +} + /** * Carve the delta caption pill into its own component so the * loss/gain/flat tone selection is colocated with the rendering. The diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index 0b8474349..4d8964c7d 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -70,7 +70,6 @@ const HOURS_PER_DAY = 24; * for a research-view chart that emphasises the sawtooth shape. */ const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY; const WINDOW_HOURS_AFTER = 0; -const SAMPLE_STEP_HOURS = 6; interface IntakeEvent { id: string; @@ -112,9 +111,34 @@ 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 }: DrugLevelChartProps) { +export function DrugLevelChart({ + medication, + asOf, + compact = false, + windowHoursBefore, +}: DrugLevelChartProps) { const { t } = useTranslations(); // Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N @@ -194,27 +218,46 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { 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; - return ( -
-
- -

+ // 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. + const wrapperClass = compact + ? "space-y-2" + : "bg-card border-border rounded-xl border p-4 md:p-6"; + const wrapperProps = { + "aria-labelledby": "drug-level-chart-title", + className: wrapperClass, + "data-slot": "drug-level-chart", + "data-compact": compact ? "true" : undefined, + } as const; + const body = ( + <> + {!compact && ( +
+ +

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

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

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

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

+ )} {/* Decision tree: 1. drug not in catalog → gated-unknown-drug placeholder @@ -232,7 +275,11 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { /> ) : isLoading ? (
@@ -246,19 +293,43 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { asOf={now} animationsEnabled={animationsEnabled} axisLabel={t("medications.researchMode.chart.axisLabel")} + windowHoursBefore={resolvedWindowHours} + stepHours={resolvedStepHours} + heightPx={chartBodyHeightPx} /> )} {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. + */ +function pickSampleStepHours(windowHoursBefore: number): number { + const days = windowHoursBefore / 24; + if (days <= 21) return 6; + if (days <= 60) return 12; + if (days <= 180) return 24; + return 48; } /* ──────────────────────────────────────────────────────────────── @@ -354,6 +425,9 @@ function ChartBody({ asOf, animationsEnabled, axisLabel, + windowHoursBefore, + stepHours, + heightPx, }: { drug: Glp1DrugId; doses: readonly DoseEvent[]; @@ -366,20 +440,27 @@ 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 samples = useMemo( () => computeOneCompartment(drug, doses, asOf, { - windowHoursBefore: WINDOW_HOURS_BEFORE, + windowHoursBefore, windowHoursAfter: WINDOW_HOURS_AFTER, - stepHours: SAMPLE_STEP_HOURS, + stepHours, }), - [drug, doses, asOf], + [drug, doses, asOf, windowHoursBefore, stepHours], ); // Map samples to chart points. x-axis is "days since now" — negative - // values for the past 21 days, with 0 = now. Recharts uses the raw - // domain so categorical day labels don't crowd a 21-day window. + // values for the past window, with 0 = now. Recharts uses the raw + // domain so categorical day labels don't crowd the visible range. const chartData = useMemo( () => samples.map((s) => ({ @@ -389,6 +470,16 @@ function ChartBody({ [samples], ); + // Pick four x-axis ticks evenly across the window so the strip + // reads as quarter / midpoint / three-quarter / now regardless of + // whether the user picked 7d, 30d, 90d, or All. + const ticks = useMemo(() => { + const quarter = -Math.round(windowDays * 0.75); + const midpoint = -Math.round(windowDays * 0.5); + const lastQuarter = -Math.round(windowDays * 0.25); + return [-Math.round(windowDays), quarter, midpoint, lastQuarter, 0]; + }, [windowDays]); + return ( <>

@@ -434,8 +526,8 @@ function ChartBody({ (v === 0 ? "0" : `${v}d`)} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false}