feat(dashboard): wire the GLP-1 drug-level pane behind a tab strip and range picker

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 <DrugLevelChart compact …>, 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:43:24 +02:00
1 parent e8fb0a7521
commit c0ec1fecfb
3 files changed
+376 -42

No files matched your search

@@ -261,8 +261,102 @@ describe("<Glp1Tile>", () => {
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", () => {
+157 -9
View File
@@ -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<Glp1ChartTab>("level");
const [rangePoints, setRangePoints] = useState<number>(30);
const { data, isPending } = useQuery({
queryKey: queryKeys.dashboardGlp1(),
enabled: isAuthenticated,
@@ -287,12 +318,84 @@ export function Glp1Tile() {
)}
</dl>
{/* 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 && (
<div data-slot="glp1-tile-chart">
{/* 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
`<DrugLevelChart compact …>` (Research Mode gating is owned
by the chart itself); Weight retains the v1.4.25 W6 mini
chart with vertical injection markers. */}
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="glp1-tile-tabs"
role="tablist"
aria-label={t("dashboard.glp1.tabsAria")}
className="bg-muted/40 inline-flex rounded-md p-0.5"
>
<TabButton
active={activeTab === "level"}
onClick={() => setActiveTab("level")}
label={t("dashboard.glp1.tabLevel")}
slot="glp1-tile-tab-level"
/>
<TabButton
active={activeTab === "weight"}
onClick={() => setActiveTab("weight")}
label={t("dashboard.glp1.tabWeight")}
slot="glp1-tile-tab-weight"
/>
</div>
<div
data-slot="glp1-tile-range-strip"
role="radiogroup"
aria-label={t("dashboard.glp1.rangeStripLabel")}
className="inline-flex items-center gap-1 text-[11px]"
>
{CHART_RANGE_PRESETS.map((preset) => (
<button
key={preset.points}
type="button"
role="radio"
aria-checked={rangePoints === preset.points}
data-slot="glp1-tile-range-button"
data-points={preset.points}
data-active={rangePoints === preset.points ? "true" : "false"}
title={t(preset.titleKey)}
onClick={() => setRangePoints(preset.points)}
className={cn(
"rounded px-1.5 py-0.5 font-medium tabular-nums",
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
rangePoints === preset.points
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground",
)}
>
{t(preset.labelKey)}
</button>
))}
</div>
</div>
{activeTab === "level" && med.medicationId ? (
<DrugLevelChart
compact
medication={{
id: med.medicationId,
name: med.name,
dose: med.currentDose
? `${med.currentDose.value}${med.currentDose.unit}`
: "",
}}
windowHoursBefore={rangePointsToHours(rangePoints)}
/>
) : activeTab === "level" ? (
<p
data-slot="glp1-tile-level-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.levelUnavailable")}
</p>
) : med.weightSeries.length > 0 ? (
<HealthChart
mini
types={["WEIGHT"]}
@@ -302,12 +405,57 @@ export function Glp1Tile() {
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
</div>
)}
) : (
<p
data-slot="glp1-tile-weight-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.weightUnavailable")}
</p>
)}
</div>
</div>
);
}
/**
* 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 (
<button
type="button"
role="tab"
aria-selected={active}
data-slot={slot}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"rounded px-2 py-1 text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
/**
* Carve the delta caption pill into its own component so the
* loss/gain/flat tone selection is colocated with the rendering. The
+123 -31
View File
@@ -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 `<section class="bg-card …">` 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 (
<section
aria-labelledby="drug-level-chart-title"
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="drug-level-chart"
>
<header className="mb-3 flex flex-wrap items-center gap-2">
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
<h3
id="drug-level-chart-title"
className="text-sm font-semibold"
>
// Compact mode mounts the chart inside a host card (the GLP-1 tile)
// and skips the outer `<section>` 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 && (
<header className="mb-3 flex flex-wrap items-center gap-2">
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
<h3 id="drug-level-chart-title" className="text-sm font-semibold">
{t("medications.researchMode.chart.title")}
</h3>
{drugId && (
<span className="text-muted-foreground text-xs">
· {t(`medications.glp1.drug.${drugId}.name`)}
</span>
)}
</header>
)}
{compact && (
<h3 id="drug-level-chart-title" className="sr-only">
{t("medications.researchMode.chart.title")}
</h3>
{drugId && (
<span className="text-muted-foreground text-xs">
· {t(`medications.glp1.drug.${drugId}.name`)}
</span>
)}
</header>
)}
{/* Decision tree:
1. drug not in catalog → gated-unknown-drug placeholder
@@ -232,7 +275,11 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
/>
) : isLoading ? (
<div
className="flex h-[220px] items-center justify-center"
className={
compact
? "flex h-[160px] items-center justify-center"
: "flex h-[220px] items-center justify-center"
}
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
@@ -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 && (
<p
className="text-muted-foreground mt-2 text-xs italic"
className={
compact
? "text-muted-foreground text-[10px] leading-snug italic"
: "text-muted-foreground mt-2 text-xs italic"
}
data-slot="drug-level-chart-disclaimer"
>
{t("medications.researchMode.chart.estimateNote")}
</p>
)}
</section>
</>
);
return compact ? <div {...wrapperProps}>{body}</div> : <section {...wrapperProps}>{body}</section>;
}
/**
* 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 (
<>
<p
@@ -398,7 +489,8 @@ function ChartBody({
{axisLabel}
</p>
<div
className="h-[240px] touch-pan-y"
className="touch-pan-y"
style={{ height: `${heightPx}px` }}
data-slot="drug-level-chart-area"
>
<ResponsiveContainer width="100%" height="100%">
@@ -434,8 +526,8 @@ function ChartBody({
<XAxis
dataKey="dayOffset"
type="number"
domain={[-21, 0]}
ticks={[-21, -14, -7, 0]}
domain={[-windowDays, 0]}
ticks={ticks}
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}