mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
9e8b536ff6
commit
28af138691
8 files changed
+105
-212
No files matched your search
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user