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

+13
View File
@@ -738,6 +738,19 @@
"successToast": "Forschungsmodus aktiviert. Die Wirkstoff-Verlaufskurve ist jetzt auf deiner GLP-1-Medikamentenseite sichtbar.",
"errorToast": "Forschungsmodus konnte nicht aktiviert werden. Bitte erneut versuchen.",
"staleVersionToast": "Der Disclaimer wurde aktualisiert, seit diese Seite geladen wurde. Lade die Seite neu, um die aktuelle Fassung zu lesen."
},
"chart": {
"title": "Geschätzter Wirkstoffverlauf",
"axisLabel": "Geschätzter Spiegel (relativ)",
"emptyState": "Noch keine Einnahmen erfasst.",
"emptyStateCta": "Trage eine Dosis ein, damit die geschätzte Verlaufskurve Gestalt annimmt.",
"estimateNote": "Edukative Schätzung aus EMA-publizierter Populations-Pharmakokinetik. Keine Messung.",
"gatedTitle": "Forschungsmodus ist aus",
"gatedBody": "Aktiviere den Forschungsmodus in den Einstellungen, um die geschätzte Wirkstoffkurve zu sehen. Die Kurve ist opt-in, weil sie eine Lernhilfe und keine klinische Messung ist.",
"gatedStaleTitle": "Disclaimer muss neu bestätigt werden",
"gatedStaleBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn in den Einstellungen erneut und bestätige, um die Kurve wieder einzublenden.",
"gatedCta": "Forschungsmodus in den Einstellungen öffnen",
"unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen."
}
}
},
+13
View File
@@ -738,6 +738,19 @@
"successToast": "Research Mode enabled. The drug-level chart is now visible on your GLP-1 medication page.",
"errorToast": "Could not enable Research Mode. Please try again.",
"staleVersionToast": "The disclaimer was updated since this page loaded. Reload to read the current version."
},
"chart": {
"title": "Estimated drug level",
"axisLabel": "Estimated level (relative)",
"emptyState": "No intake events yet.",
"emptyStateCta": "Log a dose to see the estimated level take shape.",
"estimateNote": "Educational estimate from EMA-published population pharmacokinetics. Not a measurement.",
"gatedTitle": "Research Mode is off",
"gatedBody": "Turn on Research Mode in Settings to see the estimated drug-level curve. The curve is opt-in because it is a learning aid, not a clinical measurement.",
"gatedStaleTitle": "Disclaimer needs re-acknowledgment",
"gatedStaleBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and confirm in Settings to bring the chart back.",
"gatedCta": "Open Research Mode in Settings",
"unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it."
}
}
},
+13
View File
@@ -738,6 +738,19 @@
"successToast": "Modo Investigación activado. La curva de nivel de fármaco es ahora visible en tu página de GLP-1.",
"errorToast": "No se pudo activar el modo Investigación. Inténtalo de nuevo.",
"staleVersionToast": "El disclaimer se actualizó desde que cargó esta página. Recarga para leer la versión actual."
},
"chart": {
"title": "Nivel de fármaco estimado",
"axisLabel": "Nivel estimado (relativo)",
"emptyState": "Sin tomas registradas todavía.",
"emptyStateCta": "Registra una dosis para ver la curva estimada cobrar forma.",
"estimateNote": "Estimación educativa a partir de la farmacocinética poblacional publicada por la EMA. No es una medición.",
"gatedTitle": "Modo Investigación desactivado",
"gatedBody": "Activa el modo Investigación en los ajustes para ver la curva estimada del nivel de fármaco. La curva es de inclusión voluntaria porque es una ayuda de aprendizaje, no una medición clínica.",
"gatedStaleTitle": "El disclaimer requiere reconfirmación",
"gatedStaleBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo en los ajustes y confirma de nuevo para volver a ver la curva.",
"gatedCta": "Abrir el modo Investigación en los ajustes",
"unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional."
}
}
},
+13
View File
@@ -738,6 +738,19 @@
"successToast": "Mode Recherche activé. La courbe de niveau de médicament est maintenant visible sur votre page GLP-1.",
"errorToast": "Impossible d'activer le mode Recherche. Veuillez réessayer.",
"staleVersionToast": "Le disclaimer a été mis à jour depuis le chargement de cette page. Rechargez pour lire la version actuelle."
},
"chart": {
"title": "Niveau de médicament estimé",
"axisLabel": "Niveau estimé (relatif)",
"emptyState": "Aucune prise enregistrée.",
"emptyStateCta": "Enregistrez une dose pour voir la courbe estimée prendre forme.",
"estimateNote": "Estimation éducative à partir de la pharmacocinétique de population publiée par l'EMA. Pas une mesure.",
"gatedTitle": "Mode Recherche désactivé",
"gatedBody": "Activez le mode Recherche dans les paramètres pour voir la courbe estimée du niveau de médicament. La courbe est en opt-in parce que c'est un outil d'apprentissage, pas une mesure clinique.",
"gatedStaleTitle": "Le disclaimer doit être reconfirmé",
"gatedStaleBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le dans les paramètres et confirmez à nouveau pour réafficher la courbe.",
"gatedCta": "Ouvrir le mode Recherche dans les paramètres",
"unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population."
}
}
},
+13
View File
@@ -738,6 +738,19 @@
"successToast": "Modalità Ricerca attivata. La curva del livello del farmaco è ora visibile sulla tua pagina GLP-1.",
"errorToast": "Impossibile attivare la modalità Ricerca. Riprova.",
"staleVersionToast": "Il disclaimer è stato aggiornato dal caricamento di questa pagina. Ricarica per leggere la versione attuale."
},
"chart": {
"title": "Livello stimato del farmaco",
"axisLabel": "Livello stimato (relativo)",
"emptyState": "Nessuna assunzione registrata.",
"emptyStateCta": "Registra una dose per vedere prendere forma la curva stimata.",
"estimateNote": "Stima educativa dalla farmacocinetica di popolazione pubblicata dall'EMA. Non è una misura.",
"gatedTitle": "Modalità Ricerca disattivata",
"gatedBody": "Attiva la modalità Ricerca nelle impostazioni per vedere la curva stimata del livello del farmaco. La curva è opt-in perché è uno strumento di apprendimento, non una misura clinica.",
"gatedStaleTitle": "Il disclaimer richiede una nuova conferma",
"gatedStaleBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo nelle impostazioni e conferma di nuovo per rivedere la curva.",
"gatedCta": "Apri la modalità Ricerca nelle impostazioni",
"unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione."
}
}
},
+13
View File
@@ -738,6 +738,19 @@
"successToast": "Tryb Badawczy włączony. Krzywa poziomu leku jest teraz widoczna na twojej stronie GLP-1.",
"errorToast": "Nie udało się włączyć trybu Badawczego. Spróbuj ponownie.",
"staleVersionToast": "Disclaimer został zaktualizowany od czasu wczytania tej strony. Odśwież, aby przeczytać aktualną wersję."
},
"chart": {
"title": "Szacowany poziom leku",
"axisLabel": "Szacowany poziom (względny)",
"emptyState": "Brak zarejestrowanych przyjęć.",
"emptyStateCta": "Zarejestruj dawkę, aby zobaczyć kształtującą się krzywą szacunkową.",
"estimateNote": "Edukacyjne oszacowanie z opublikowanej przez EMA farmakokinetyki populacyjnej. To nie jest pomiar.",
"gatedTitle": "Tryb Badawczy wyłączony",
"gatedBody": "Włącz tryb Badawczy w Ustawieniach, aby zobaczyć szacowaną krzywą poziomu leku. Krzywa jest opt-in, ponieważ to pomoc edukacyjna, a nie pomiar kliniczny.",
"gatedStaleTitle": "Disclaimer wymaga ponownego potwierdzenia",
"gatedStaleBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie w Ustawieniach i potwierdź, aby krzywa znów się pojawiła.",
"gatedCta": "Otwórz tryb Badawczy w Ustawieniach",
"unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej."
}
}
},
+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);
});
});