feat(medications): estimated drug-level chart on GLP-1 detail pages

Recharts AreaChart over a 21-day window, anchored at now. Wires the
W19c-Backend pure PK module: drug id resolved from the medication
brand via the catalog, doses derived from non-skipped intake events
matched against the dose-change history. Y-axis is unit-less per
research §2.3 — no tick labels, the 'Estimated level (relative)'
caption sits above the frame. The chart gates on enabled +
acknowledgedVersion === currentDisclaimerVersion (defence-in-depth
alongside the server 400) and renders distinct placeholders for
each failure mode. Mounted on /medications/[id]/history when
treatmentClass === GLP1.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:26:21 +02:00
1 parent 3502f57694
commit 85ba382009
9 files changed
+994 -1

No files matched your search

+22 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { IntakeHistoryList } from "@/components/medications/intake-history-list";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
@@ -33,7 +34,12 @@ export default function IntakeHistoryPage({
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as { id: string; name: string; dose: string };
return json.data as {
id: string;
name: string;
dose: string;
treatmentClass?: string;
};
},
enabled: isAuthenticated,
});
@@ -77,6 +83,21 @@ export default function IntakeHistoryPage({
</Button>
</div>
{/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart.
Renders only for GLP-1 medications; the component itself
gates further on Research Mode + version-aligned
acknowledgment, so this page can mount it unconditionally
for any GLP-1 row and trust the chart's internal logic. */}
{medication?.treatmentClass === "GLP1" && (
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
)}
<IntakeHistoryList
medicationId={id}
createOpen={createOpen}
@@ -0,0 +1,555 @@
"use client";
/**
* v1.4.25 W19c-Frontend — Estimated drug-level chart for GLP-1
* 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.
*
* Inputs (read-only):
* - Drug id resolved from the medication name (catalog lookup via
* `findDrugByBrand`); falls back to a gated message when the
* medication isn't in the GLP-1 catalog.
* - Intake events from `GET /api/medications/[id]/intake` — only
* non-skipped events with a `takenAt` contribute.
* - Dose history from `GET /api/medications/[id]/glp1` (the
* `doseChanges` slice). Each intake event is matched to its
* applicable dose by walking the dose-change timeline; intakes
* that pre-date every dose-change row fall back to the
* medication's headline dose string.
* - Research-mode state from `GET /api/auth/me/research-mode`. The
* chart gates on `enabled === true && acknowledgedVersion ===
* currentDisclaimerVersion` — defence-in-depth alongside the
* server-side 400 the POST returns for stale versions.
*
* Pure math: `computeOneCompartment(drug, doses, asOf)` from
* `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.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Activity,
AlertTriangle,
Loader2,
Syringe,
} from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import {
computeOneCompartment,
type DoseEvent,
} from "@/lib/medications/glp1-pk";
import {
GLP1_DRUGS,
findDrugByBrand,
type Glp1DrugId,
type Glp1DrugRecord,
} from "@/lib/medications/glp1-knowledge";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
const CHART_COLOR = "var(--dracula-purple)";
const CHART_FILL_OPACITY = 0.18;
const HOURS_PER_DAY = 24;
/** 21 days back, 0 days forward — three weekly cycles of history with
* "now" at the right edge. Matches research §2.4's recommendation
* 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 ResearchModeStatus {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
}
interface IntakeEvent {
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string;
}
interface DoseChange {
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
}
interface Glp1DetailsResponse {
doseChanges: DoseChange[];
}
export interface DrugLevelChartProps {
/**
* Medication row (already known to be GLP-1 by the parent — typically
* because `treatmentClass === "GLP1"`). The component resolves the
* Glp1DrugId via the brand catalog; if the brand isn't recognised
* the chart renders an empty-state hint instead of throwing.
*/
medication: {
id: string;
/** Human-readable brand or generic name (e.g. "Mounjaro"). */
name: string;
/** Headline dose string ("7.5 mg") used as a fallback when no
* per-event dose-change row covers the intake's takenAt. */
dose: string;
};
/**
* Override "now" for deterministic snapshot tests. Defaults to a
* fresh `Date()` at render time. The chart re-anchors on every
* render, but the underlying React Query data keeps the network
* cost low.
*/
asOf?: Date;
}
export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
const { t } = useTranslations();
const drugRecord = useMemo<Glp1DrugRecord | null>(
() => findDrugByBrand(medication.name),
[medication.name],
);
// The catalog keys records by Glp1DrugId; findDrugByBrand returns the
// record value, not the key. Reverse-lookup so we can pass the id to
// the PK helper + the i18n key below.
const drugId: Glp1DrugId | null = useMemo(() => {
if (!drugRecord) return null;
for (const [id, record] of Object.entries(GLP1_DRUGS)) {
if (record === drugRecord) return id as Glp1DrugId;
}
return null;
}, [drugRecord]);
const { data: researchMode, isLoading: rmLoading } =
useQuery<ResearchModeStatus | null>({
queryKey: ["research-mode"],
queryFn: async () => {
const res = await fetch("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const versionsAligned =
!!researchMode &&
researchMode.acknowledgedVersion === researchMode.currentDisclaimerVersion;
const gateOpen = !!researchMode?.enabled && versionsAligned;
const { data: details, isLoading: detailsLoading } =
useQuery<Glp1DetailsResponse | null>({
queryKey: ["medications", medication.id, "glp1-details"],
queryFn: async () => {
const res = await fetch(`/api/medications/${medication.id}/glp1`);
if (!res.ok) return null;
const json = await res.json();
return json.data as Glp1DetailsResponse;
},
// Only fetch when the user has opted in; saves a round-trip for
// anyone who hasn't enabled Research Mode.
enabled: gateOpen && !!drugId,
staleTime: 60 * 1000,
});
const { data: intakeEnvelope, isLoading: intakeLoading } = useQuery({
queryKey: ["medications", medication.id, "intake", "drug-level-chart"],
queryFn: async () => {
const res = await fetch(
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
);
if (!res.ok) return null;
const json = await res.json();
return json.data as { events: IntakeEvent[] };
},
enabled: gateOpen && !!drugId,
staleTime: 60 * 1000,
});
// Resolve "now" once per render — the asOf override is the only
// safe way to pin the curve for snapshot tests.
const now = useMemo(() => asOf ?? new Date(), [asOf]);
const doses = useMemo<DoseEvent[]>(() => {
if (!drugId || !intakeEnvelope?.events) return [];
const dchg = details?.doseChanges ?? [];
const fallbackMg = parseDoseMg(medication.dose);
return intakeEnvelope.events
.filter((ev) => !ev.skipped && ev.takenAt)
.map((ev) => ({
takenAt: new Date(ev.takenAt as string),
doseMg: resolveDoseMg(ev.takenAt as string, dchg, fallbackMg),
}))
.filter((d) => Number.isFinite(d.doseMg) && d.doseMg > 0);
}, [drugId, intakeEnvelope, details, medication.dose]);
// ── Section header always renders so the parent's "Estimated drug
// level" anchor stays stable; the body switches between gated /
// loading / empty / chart states.
const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading));
const hasDoses = doses.length > 0;
const animationsEnabled = !prefersReducedMotion();
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"
>
{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
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 ? (
<GatedUnknownDrug medicationName={medication.name} />
) : !gateOpen ? (
<GatedPlaceholder
versionsAligned={versionsAligned}
knownState={researchMode}
/>
) : isLoading ? (
<div
className="flex h-[220px] items-center justify-center"
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !hasDoses ? (
<EmptyState />
) : (
<ChartBody
drug={drugId}
doses={doses}
asOf={now}
animationsEnabled={animationsEnabled}
axisLabel={t("medications.researchMode.chart.axisLabel")}
/>
)}
{gateOpen && hasDoses && (
<p
className="text-muted-foreground mt-2 text-xs italic"
data-slot="drug-level-chart-disclaimer"
>
{t("medications.researchMode.chart.estimateNote")}
</p>
)}
</section>
);
}
/* ────────────────────────────────────────────────────────────────
* Body sub-components — pulled out so the tests can pin the gated
* branches without rendering the Recharts tree (Recharts in SSR is
* already exercised by the dashboard chart tests).
* ──────────────────────────────────────────────────────────────── */
function GatedUnknownDrug({ medicationName }: { medicationName: string }) {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground bg-muted/40 rounded-md p-4 text-sm"
data-slot="drug-level-chart-unknown-drug"
>
<p>
{t("medications.researchMode.chart.unknownDrug", {
name: medicationName,
})}
</p>
</div>
);
}
function GatedPlaceholder({
versionsAligned,
knownState,
}: {
versionsAligned: boolean;
knownState: ResearchModeStatus | null | undefined;
}) {
const { t } = useTranslations();
// Three distinguishable states:
// - Research Mode is off entirely.
// - Research Mode is on but the acknowledged version is stale.
// - We don't know yet (server returned null) — still render the
// opt-in CTA so the user has a path forward.
const stale = !!knownState?.enabled && !versionsAligned;
return (
<div
className="bg-muted/40 border-border space-y-3 rounded-md border p-4 text-sm"
data-slot="drug-level-chart-gated"
data-stale={stale ? "true" : "false"}
>
<div className="flex items-start gap-2">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-1">
<p className="text-foreground font-medium">
{stale
? t("medications.researchMode.chart.gatedStaleTitle")
: t("medications.researchMode.chart.gatedTitle")}
</p>
<p className="text-muted-foreground">
{stale
? t("medications.researchMode.chart.gatedStaleBody")
: t("medications.researchMode.chart.gatedBody")}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("medications.researchMode.chart.gatedCta")}
</Link>
</Button>
</div>
);
}
function EmptyState() {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
data-slot="drug-level-chart-empty"
>
<div className="flex items-center gap-2">
<Syringe className="h-4 w-4 shrink-0" aria-hidden="true" />
<p>{t("medications.researchMode.chart.emptyState")}</p>
</div>
<p className="text-xs italic">
{t("medications.researchMode.chart.emptyStateCta")}
</p>
</div>
);
}
function ChartBody({
drug,
doses,
asOf,
animationsEnabled,
axisLabel,
}: {
drug: Glp1DrugId;
doses: readonly DoseEvent[];
asOf: Date;
animationsEnabled: boolean;
/**
* Y-axis caption — research §2.3 requires "Estimated level (relative)"
* with NO unit. Rendered both as the Recharts axis label (in the SVG)
* and as a small visible caption above the chart so screen-reader
* users and SSR snapshots both surface the framing.
*/
axisLabel: string;
}) {
const samples = useMemo(
() =>
computeOneCompartment(drug, doses, asOf, {
windowHoursBefore: WINDOW_HOURS_BEFORE,
windowHoursAfter: WINDOW_HOURS_AFTER,
stepHours: SAMPLE_STEP_HOURS,
}),
[drug, doses, asOf],
);
// 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.
const chartData = useMemo(
() =>
samples.map((s) => ({
dayOffset: Math.round((s.tHours / HOURS_PER_DAY) * 10) / 10,
level: s.concentration,
})),
[samples],
);
return (
<>
<p
className="text-muted-foreground mb-1 text-xs"
data-slot="drug-level-chart-axis-caption"
>
{axisLabel}
</p>
<div
className="h-[240px] touch-pan-y"
data-slot="drug-level-chart-area"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 12, bottom: 16, left: 8 }}
>
<defs>
<linearGradient
id="drug-level-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={CHART_COLOR}
stopOpacity={CHART_FILL_OPACITY * 1.4}
/>
<stop
offset="100%"
stopColor={CHART_COLOR}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="dayOffset"
type="number"
domain={[-21, 0]}
ticks={[-21, -14, -7, 0]}
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
>
<text
x="50%"
y="100%"
dy={14}
textAnchor="middle"
fontSize={10}
fill="var(--muted-foreground)"
/>
</XAxis>
{/* Research §2.3 — y-axis is unit-less. We hide tick labels
and the axis line entirely; the human-readable label sits
outside the chart frame, above. The `tick={false}` flag
suppresses the textual ticks per Recharts. */}
<YAxis
domain={[0, "auto"]}
tick={false}
tickLine={false}
axisLine={false}
width={1}
label={{
value: axisLabel,
angle: -90,
position: "insideLeft",
fontSize: 10,
fill: "var(--muted-foreground)",
offset: 0,
}}
/>
<Tooltip
content={() => null}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
dataKey="level"
stroke={CHART_COLOR}
strokeWidth={2}
fill="url(#drug-level-gradient)"
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</>
);
}
/* ────────────────────────────────────────────────────────────────
* Pure helpers — dose-mg resolution from the medication's dose
* string and the dose-change timeline.
* ──────────────────────────────────────────────────────────────── */
/**
* Parse a dose string like "7.5 mg" → 7.5. Returns NaN when the
* string doesn't carry a recognisable number (e.g. "as needed").
*
* Exported for the unit test.
*/
export function parseDoseMg(input: string): number {
if (!input) return Number.NaN;
const match = input.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return Number.NaN;
return Number.parseFloat(match[1].replace(",", "."));
}
/**
* Resolve the dose in mg that applied at `takenAt` by walking the
* dose-change history. The catalog row with the latest
* `effectiveFrom ≤ takenAt` wins; intakes before any row fall back
* to the medication's headline `dose` string.
*
* Exported for the unit test.
*/
export function resolveDoseMg(
takenAtIso: string,
doseChanges: readonly DoseChange[],
fallbackMg: number,
): number {
const takenAtMs = Date.parse(takenAtIso);
if (!Number.isFinite(takenAtMs)) return fallbackMg;
let bestValue = fallbackMg;
let bestStart = -Infinity;
for (const change of doseChanges) {
const startMs = Date.parse(change.effectiveFrom);
if (!Number.isFinite(startMs)) continue;
if (startMs <= takenAtMs && startMs > bestStart) {
bestStart = startMs;
bestValue = change.doseValue;
}
}
return bestValue;
}
@@ -0,0 +1,339 @@
/**
* v1.4.25 W19c-Frontend — Drug-level chart tests.
*
* Pins the gating decision tree (research §2.3 + §12.4 + W19c-Backend
* phase report). The chart MUST hide until:
* - the medication's brand resolves to a Glp1DrugId, AND
* - `enabled === true` AND
* - `acknowledgedVersion === currentDisclaimerVersion`.
*
* Each gate failure renders a distinct placeholder so the SSR markup
* carries an unambiguous data-slot for assertion. Recharts in
* `renderToStaticMarkup` only paints the static frame (the
* `<ResponsiveContainer>` mounts but doesn't measure), so we assert on
* `data-slot="drug-level-chart-area"` presence + the gradient
* definition + the y-axis having no `<text>` tick labels.
*
* Pure helpers (`parseDoseMg`, `resolveDoseMg`) get their own unit
* coverage — they're tested directly so the data-shape transformation
* stays correct without a Recharts mount.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// Capture queryKey routing so different tests can stub different
// query results. The component reads three queries:
// - ["research-mode"]
// - ["medications", id, "glp1-details"]
// - ["medications", id, "intake", "drug-level-chart"]
const queryResults: Record<string, unknown> = {};
function setQueryResult(keyJoined: string, data: unknown) {
queryResults[keyJoined] = data;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = (queryKey as Array<string | number>).join("/");
return {
data: queryResults[key],
isLoading: false,
};
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
// Pin the PK compute helper so we can assert "the chart asked for the
// right doses" without re-running the math.
const pkCalls: Array<{
drug: string;
doses: Array<{ takenAt: Date; doseMg: number }>;
asOf: Date;
}> = [];
vi.mock("@/lib/medications/glp1-pk", async () => {
const actual =
await vi.importActual<typeof import("@/lib/medications/glp1-pk")>(
"@/lib/medications/glp1-pk",
);
return {
...actual,
computeOneCompartment: (
drug: string,
doses: Array<{ takenAt: Date; doseMg: number }>,
asOf: Date,
) => {
pkCalls.push({ drug, doses, asOf });
// Return a small fixed series so the chart renders a non-empty
// SVG path. Two samples is enough to verify the Area renders.
return [
{ tHours: -24 * 21, concentration: 0 },
{ tHours: -24 * 14, concentration: 0.6 },
{ tHours: -24 * 7, concentration: 1.0 },
{ tHours: 0, concentration: 0.7 },
];
},
};
});
import {
DrugLevelChart,
parseDoseMg,
resolveDoseMg,
} from "../DrugLevelChart";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const mounjaro = {
id: "med-1",
name: "Mounjaro",
dose: "7.5 mg",
};
const ramipril = {
id: "med-2",
name: "Ramipril",
dose: "5 mg",
};
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
pkCalls.length = 0;
});
describe("<DrugLevelChart> — gating decision tree", () => {
it("renders the unknown-drug placeholder for a non-GLP-1 brand", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={ramipril} />);
expect(html).toContain('data-slot="drug-level-chart-unknown-drug"');
expect(html).toContain("Ramipril");
// Chart body must not paint when the drug is unknown.
expect(html).not.toContain('data-slot="drug-level-chart-area"');
// The PK helper is never asked to compute for an unknown drug.
expect(pkCalls).toHaveLength(0);
});
it("renders the gated placeholder when Research Mode is OFF", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-gated"');
expect(html).toContain('data-stale="false"');
expect(html).toContain("Research Mode is off");
expect(html).toContain("Open Research Mode");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the stale-disclaimer placeholder when versions differ", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00Z",
acknowledgedVersion: "2026-04-01.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-gated"');
expect(html).toContain('data-stale="true"');
expect(html).toContain("re-acknowledgment");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the empty state when gate is open but no intake events exist", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [],
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-empty"');
expect(html).toContain("No intake events yet");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the chart when gate is open AND intake events exist", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", {
doseChanges: [
{
id: "dc-1",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 7.5,
doseUnit: "mg",
},
],
});
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [
{
id: "ev-1",
takenAt: "2026-05-13T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-13T08:00:00Z",
},
{
id: "ev-2",
takenAt: "2026-05-06T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-06T08:00:00Z",
},
// A skipped event must NOT contribute to the curve.
{
id: "ev-3",
takenAt: null,
skipped: true,
scheduledFor: "2026-04-29T08:00:00Z",
},
],
});
const fixedNow = new Date("2026-05-14T12:00:00Z");
const html = render(
<DrugLevelChart medication={mounjaro} asOf={fixedNow} />,
);
expect(html).toContain('data-slot="drug-level-chart-area"');
expect(html).toContain("Estimated drug level");
// The estimate disclaimer must accompany the chart body.
expect(html).toContain('data-slot="drug-level-chart-disclaimer"');
// The PK helper was asked for the right drug, with only the two
// non-skipped doses, and with the supplied `asOf`.
expect(pkCalls).toHaveLength(1);
const [call] = pkCalls;
expect(call.drug).toBe("tirzepatide");
expect(call.doses).toHaveLength(2);
expect(call.doses.every((d) => d.doseMg === 7.5)).toBe(true);
expect(call.asOf).toEqual(fixedNow);
});
it("hides the y-axis tick labels (research §2.3, unit-less)", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [
{
id: "ev-1",
takenAt: "2026-05-13T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-13T08:00:00Z",
},
],
});
const html = render(
<DrugLevelChart
medication={mounjaro}
asOf={new Date("2026-05-14T12:00:00Z")}
/>,
);
// Recharts paints `<text class="recharts-cartesian-axis-tick-value">`
// for each numeric tick. The drug-level chart suppresses those for
// the Y axis by passing `tick={false}` — none should appear inside
// the y-axis container. Same content scan that the dashboard chart
// tests already use.
const yAxisTickValues = html.match(
/class="recharts-yAxis.*?recharts-cartesian-axis-tick-value/g,
);
expect(yAxisTickValues).toBeNull();
// The unit-less axis label still renders (it carries the
// educational framing).
expect(html).toContain("Estimated level (relative)");
});
});
describe("parseDoseMg / resolveDoseMg helpers", () => {
it("parses standard '7.5 mg' / '12,5 mg' / '0.25 mg' shapes", () => {
expect(parseDoseMg("7.5 mg")).toBe(7.5);
expect(parseDoseMg("12,5 mg")).toBe(12.5);
expect(parseDoseMg("0.25 mg")).toBe(0.25);
expect(parseDoseMg("15 mg")).toBe(15);
});
it("returns NaN for non-numeric strings", () => {
expect(Number.isNaN(parseDoseMg(""))).toBe(true);
expect(Number.isNaN(parseDoseMg("as needed"))).toBe(true);
});
it("resolves the dose to the latest effectiveFrom <= takenAt", () => {
const history = [
{
id: "a",
effectiveFrom: "2026-01-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
{
id: "b",
effectiveFrom: "2026-03-01T00:00:00Z",
doseValue: 5,
doseUnit: "mg",
},
{
id: "c",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 7.5,
doseUnit: "mg",
},
];
// Before any history → fallback.
expect(resolveDoseMg("2025-12-15T00:00:00Z", history, 1)).toBe(1);
// Inside the 2.5-mg window.
expect(resolveDoseMg("2026-02-15T00:00:00Z", history, 1)).toBe(2.5);
// On the 5-mg boundary day.
expect(resolveDoseMg("2026-03-01T00:00:00Z", history, 1)).toBe(5);
// Inside the 5-mg window.
expect(resolveDoseMg("2026-04-15T00:00:00Z", history, 1)).toBe(5);
// Inside the latest window.
expect(resolveDoseMg("2026-05-10T00:00:00Z", history, 1)).toBe(7.5);
});
it("returns the fallback when takenAt is unparseable", () => {
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
});
});