mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
3502f57694
commit
85ba382009
9 files changed
+994
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user