feat(medications): show the GLP-1 drug-level estimate by default

Render the estimated drug-level-in-blood curve for any recognised GLP-1
medication without requiring the Research Mode opt-in. The chart still
self-gates on a recognised brand and on having intake data, and the
pharmacokinetic-estimate disclaimer is now always attached so the curve
reads as a modelled value, not a measured blood concentration.

Drop the now-dead Research-Mode gated-placeholder branch and its copy.
The researchModeEnabled flag is unchanged; it still gates the advanced
settings surface and its acknowledgment dialog.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 10:00:48 +02:00
1 parent 9e8b536ff6
commit 28af138691
8 files changed
+105 -212

No files matched your search

+19 -93
View File
@@ -1,11 +1,13 @@
"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 round report carry the rationale.
* Estimated drug-level chart for GLP-1 medications. Visible by default
* for any recognised GLP-1 drug — the curve is the modelled
* active-ingredient level the medication concerns. The y-axis paints
* "Estimated level (relative)" without tick labels and the persistent
* estimate disclaimer makes the pharmacokinetic-ESTIMATE framing
* explicit: this is a modelled value, not a measured blood
* concentration.
*
* Inputs (read-only):
* - Drug id resolved from the medication name (catalog lookup via
@@ -18,10 +20,6 @@
* 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
@@ -38,8 +36,7 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { AlertTriangle, Loader2, Syringe } from "lucide-react";
import { Loader2, Syringe } from "lucide-react";
import {
Area,
AreaChart,
@@ -50,7 +47,6 @@ import {
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -62,7 +58,6 @@ import {
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
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";
@@ -128,23 +123,6 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
[medication.name],
);
const { data: researchMode, isLoading: rmLoading } =
useQuery<ResearchModeStatus | null>({
queryKey: queryKeys.researchMode(),
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: queryKeys.medicationGlp1Details(medication.id),
@@ -154,9 +132,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
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,
enabled: !!drugId,
staleTime: 60 * 1000,
});
@@ -170,7 +146,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
const json = await res.json();
return json.data as { events: IntakeEvent[] };
},
enabled: gateOpen && !!drugId,
enabled: !!drugId,
staleTime: 60 * 1000,
});
@@ -194,7 +170,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
// ── 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 isLoading = !!drugId && (detailsLoading || intakeLoading);
const hasDoses = doses.length > 0;
const animationsEnabled = !prefersReducedMotion();
// Single-window contract — three weekly cycles with the sample step
@@ -214,19 +190,12 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
) : 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
// 1. drug not in catalog → unknown-drug placeholder
// 2. loading → skeleton
// 3. no intake events → empty-state with log CTA
// 4. otherwise → AreaChart
const body = !drugId ? (
<GatedUnknownDrug medicationName={medication.name} />
) : !gateOpen ? (
<GatedPlaceholder
versionsAligned={versionsAligned}
knownState={researchMode}
/>
) : isLoading ? (
// v1.4.43 W11-L6 — match the loaded chart height (240 px) so the
// dashboard tile doesn't reflow when the levels render.
@@ -257,7 +226,10 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
dataSlot="drug-level-chart"
>
{body}
{gateOpen && hasDoses && (
{/* The pharmacokinetic-ESTIMATE disclaimer stays attached whenever
a recognised GLP-1 drug renders the chart — the curve is a
modelled value, never a measured blood concentration. */}
{!!drugId && (
<p
className="text-muted-foreground mt-2 text-xs italic"
data-slot="drug-level-chart-disclaimer"
@@ -306,52 +278,6 @@ function GatedUnknownDrug({ medicationName }: { medicationName: string }) {
);
}
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();
@@ -1,15 +1,16 @@
/**
* v1.4.25 W19c-Frontend — Drug-level chart tests.
* 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`.
* The estimated drug-level curve is visible by default for any
* recognised GLP-1 brand — it does NOT require Research Mode. The
* decision tree is now:
* - the medication's brand resolves to a Glp1DrugId → render, ELSE
* the unknown-drug placeholder.
* - no intake events → empty state.
* - otherwise → the AreaChart, always with the pharmacokinetic
* estimate disclaimer attached.
*
* 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
* 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.
@@ -25,8 +26,7 @@ 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"]
// query results. The component reads two queries:
// - ["medications", id, "glp1-details"]
// - ["medications", id, "intake", "drug-level-chart"]
const queryResults: Record<string, unknown> = {};
@@ -109,15 +109,8 @@ beforeEach(() => {
pkCalls.length = 0;
});
describe("<DrugLevelChart> — gating decision tree", () => {
describe("<DrugLevelChart> — default-on 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"');
@@ -128,48 +121,46 @@ describe("<DrugLevelChart> — gating decision tree", () => {
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",
it("renders the chart for a GLP-1 med WITHOUT any Research Mode opt-in", () => {
// No research-mode query is stubbed at all — the chart must still
// render the curve for a recognised GLP-1 brand.
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",
},
],
});
const html = render(<DrugLevelChart medication={mounjaro} />);
const html = render(
<DrugLevelChart
medication={mounjaro}
asOf={new Date("2026-05-14T12:00:00Z")}
/>,
);
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);
expect(html).toContain('data-slot="drug-level-chart-area"');
// The gated placeholder must never appear now.
expect(html).not.toContain('data-slot="drug-level-chart-gated"');
// The estimate disclaimer is always attached.
expect(html).toContain('data-slot="drug-level-chart-disclaimer"');
expect(pkCalls).toHaveLength(1);
});
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",
});
it("renders the empty state when no intake events exist", () => {
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [],
@@ -180,16 +171,41 @@ describe("<DrugLevelChart> — gating decision tree", () => {
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"');
// The estimate disclaimer stays attached even without data.
expect(html).toContain('data-slot="drug-level-chart-disclaimer"');
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",
it("falls back to the headline dose when no dose-change rows exist", () => {
// No doseChanges rows — every intake resolves to the headline
// "7.5 mg" dose so the curve still renders for a typical med.
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")}
/>,
);
expect(html).toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(1);
const [call] = pkCalls;
expect(call.doses).toHaveLength(1);
expect(call.doses.every((d) => d.doseMg === 7.5)).toBe(true);
});
it("renders the chart when intake events exist", () => {
setQueryResult("medications/med-1/glp1-details", {
doseChanges: [
{
@@ -245,18 +261,11 @@ describe("<DrugLevelChart> — gating decision tree", () => {
});
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
// `<MedicationDetailSection>` chrome alongside Titration /
// The standalone mount on `/medications/[id]/history` lifts onto the
// shared `<MedicationDetailSection>` 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(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart"');
expect(html).toContain('aria-labelledby="drug-level-chart-title"');
@@ -268,12 +277,6 @@ describe("<DrugLevelChart> — gating decision tree", () => {
});
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: [