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}