feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course

The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.

The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.

Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:59:14 +02:00
1 parent c1f30466e7
commit 3f9fd9095f
18 files changed
+528 -1361

No files matched your search

+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen."
}
},
"titration": {
"section": "Titrationsstufen",
"loading": "Lade Titrationsdaten",
"loadFailed": "Titrationsdaten konnten nicht geladen werden.",
"drugLadderHeader": "Standard-Schema (EMA-Referenz)",
"typicalWeeksOnStep": "≥ {weeks} Wo. typisch",
"youAreHere": "Hier stehst du",
"nextStepCaption": "Nächste Stufe (typisch): {dose} mg nach ~{weeks} Wochen auf der aktuellen Stufe.",
"ceilingMessage": "Du bist auf der höchsten Standardstufe.",
"escalationDueHint": "Du bist seit {weeks} Wochen auf dieser Stufe; das Schema sieht eine Steigerung typischerweise nach ~{typical} Wochen vor.",
"currentStepDwell": "Seit {weeks} Wochen auf dieser Stufe",
"doseUnitMg": "mg",
"emptyState": "Noch keine Dosis-Historie. Erfasse eine Dosis, um deine Position im Schema zu sehen.",
"nonStandardDose": "Aktuelle Dosis liegt außerhalb des Standard-Schemas.",
"disclaimer": "EMA-Referenzwerte. Sprich mit deiner Ärztin oder deinem Arzt, bevor du Dosen änderst.",
"emaSourceCta": "EMA-Quelle",
"stepLabel": "Stufe {n}"
"doseStrength": {
"title": "Dosisstärke im Zeitverlauf",
"axisLabel": "Erfasste Dosis ({unit})",
"tooltipLabel": "Dosis",
"currentDose": "Aktuelle Dosis: {dose} {unit}",
"noCurrentDose": "Noch keine Dosis erfasst.",
"empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen."
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Titration ladder",
"loading": "Loading titration data",
"loadFailed": "Titration data failed to load.",
"drugLadderHeader": "Standard ladder (EMA reference)",
"typicalWeeksOnStep": "≥ {weeks} wks typical",
"youAreHere": "You are here",
"nextStepCaption": "Next step (typical): {dose} mg after ~{weeks} weeks on the current step.",
"ceilingMessage": "You are at the top of the standard ladder.",
"escalationDueHint": "You've been on this step for {weeks} weeks; the ladder typically steps up around {typical} weeks.",
"currentStepDwell": "On this step for {weeks} weeks",
"doseUnitMg": "mg",
"emptyState": "No dose history yet. Log a dose to see where you are on the ladder.",
"nonStandardDose": "Current dose is outside the standard ladder buckets.",
"disclaimer": "EMA reference values. Talk to your doctor before changing doses.",
"emaSourceCta": "EMA source",
"stepLabel": "Step {n}"
"doseStrength": {
"title": "Dose strength over time",
"axisLabel": "Recorded dose ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Current dose: {dose} {unit}",
"noCurrentDose": "No dose recorded yet.",
"empty": "No titration history yet. Log a dose change to see the strength curve."
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Escalado de titulación",
"loading": "Cargando datos de titulación",
"loadFailed": "No se pudieron cargar los datos de titulación.",
"drugLadderHeader": "Escalado estándar (referencia EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. típico",
"youAreHere": "Estás aquí",
"nextStepCaption": "Siguiente escalón (típico): {dose} mg tras ~{weeks} semanas en el escalón actual.",
"ceilingMessage": "Estás en el último escalón estándar.",
"escalationDueHint": "Llevas {weeks} semanas en este escalón; el esquema suele avanzar tras ~{typical} semanas.",
"currentStepDwell": "En este escalón desde hace {weeks} semanas",
"doseUnitMg": "mg",
"emptyState": "Aún no hay historial de dosis. Registra una dosis para ver tu posición en el escalado.",
"nonStandardDose": "La dosis actual está fuera de los escalones estándar.",
"disclaimer": "Valores de referencia EMA. Consulta a tu médico antes de cambiar la dosis.",
"emaSourceCta": "Fuente EMA",
"stepLabel": "Escalón {n}"
"doseStrength": {
"title": "Dosis a lo largo del tiempo",
"axisLabel": "Dosis registrada ({unit})",
"tooltipLabel": "Dosis",
"currentDose": "Dosis actual: {dose} {unit}",
"noCurrentDose": "Aún no hay dosis registrada.",
"empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis."
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Paliers de titration",
"loading": "Chargement des données de titration",
"loadFailed": "Échec du chargement des données de titration.",
"drugLadderHeader": "Paliers standard (référence EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. typique",
"youAreHere": "Vous êtes ici",
"nextStepCaption": "Palier suivant (typique) : {dose} mg après ~{weeks} semaines au palier actuel.",
"ceilingMessage": "Vous êtes au sommet du palier standard.",
"escalationDueHint": "Vous êtes à ce palier depuis {weeks} semaines ; le schéma prévoit une progression typique après ~{typical} semaines.",
"currentStepDwell": "À ce palier depuis {weeks} semaines",
"doseUnitMg": "mg",
"emptyState": "Aucun historique de dose. Enregistrez une dose pour voir votre position dans le schéma.",
"nonStandardDose": "La dose actuelle est en dehors des paliers standard.",
"disclaimer": "Valeurs de référence EMA. Consultez votre médecin avant toute modification de dose.",
"emaSourceCta": "Source EMA",
"stepLabel": "Palier {n}"
"doseStrength": {
"title": "Dose au fil du temps",
"axisLabel": "Dose enregistrée ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Dose actuelle : {dose} {unit}",
"noCurrentDose": "Aucune dose enregistrée pour l'instant.",
"empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose."
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Scala di titolazione",
"loading": "Caricamento dati di titolazione",
"loadFailed": "Impossibile caricare i dati di titolazione.",
"drugLadderHeader": "Scala standard (riferimento EMA)",
"typicalWeeksOnStep": "≥ {weeks} sett. tipico",
"youAreHere": "Sei qui",
"nextStepCaption": "Prossimo gradino (tipico): {dose} mg dopo ~{weeks} settimane al gradino attuale.",
"ceilingMessage": "Sei al gradino più alto della scala standard.",
"escalationDueHint": "Sei su questo gradino da {weeks} settimane; lo schema prevede un avanzamento tipico dopo ~{typical} settimane.",
"currentStepDwell": "Su questo gradino da {weeks} settimane",
"doseUnitMg": "mg",
"emptyState": "Nessuna cronologia delle dosi. Registra una dose per vedere la tua posizione sulla scala.",
"nonStandardDose": "La dose attuale è fuori dai gradini standard.",
"disclaimer": "Valori di riferimento EMA. Consulta il tuo medico prima di cambiare la dose.",
"emaSourceCta": "Fonte EMA",
"stepLabel": "Gradino {n}"
"doseStrength": {
"title": "Dose nel tempo",
"axisLabel": "Dose registrata ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Dose attuale: {dose} {unit}",
"noCurrentDose": "Nessuna dose ancora registrata.",
"empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose."
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
+7 -17
View File
@@ -980,23 +980,13 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Schemat titracji",
"loading": "Wczytywanie danych titracji",
"loadFailed": "Nie udało się wczytać danych titracji.",
"drugLadderHeader": "Standardowy schemat (referencja EMA)",
"typicalWeeksOnStep": "≥ {weeks} tyg. typowo",
"youAreHere": "Jesteś tutaj",
"nextStepCaption": "Następny stopień (typowo): {dose} mg po ~{weeks} tygodniach na obecnym stopniu.",
"ceilingMessage": "Jesteś na najwyższym stopniu schematu standardowego.",
"escalationDueHint": "Jesteś na tym stopniu od {weeks} tygodni; schemat zwykle przewiduje przejście wyżej po ~{typical} tygodniach.",
"currentStepDwell": "Na tym stopniu od {weeks} tygodni",
"doseUnitMg": "mg",
"emptyState": "Brak historii dawek. Zarejestruj dawkę, aby zobaczyć swoją pozycję w schemacie.",
"nonStandardDose": "Aktualna dawka jest poza standardowymi stopniami schematu.",
"disclaimer": "Wartości referencyjne EMA. Skonsultuj się z lekarzem przed zmianą dawki.",
"emaSourceCta": "Źródło EMA",
"stepLabel": "Stopień {n}"
"doseStrength": {
"title": "Dawka w czasie",
"axisLabel": "Zapisana dawka ({unit})",
"tooltipLabel": "Dawka",
"currentDose": "Aktualna dawka: {dose} {unit}",
"noCurrentDose": "Nie zapisano jeszcze żadnej dawki.",
"empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki."
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
@@ -1,227 +0,0 @@
/**
* v1.4.25 W19f — titration-ladder route tests.
*
* Mirrors the W19e cadence-route test fixture: external dependencies
* are mocked at module boundaries (prisma, session, logging
* transport) so each test pins one contract case. The pure ladder
* helpers are covered separately in
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeMed(
overrides: Partial<{
name: string;
dose: string;
treatmentClass: "GLP1" | "GENERIC";
userId: string;
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
}> = {},
) {
return {
id: "med-1",
name: overrides.name ?? "Mounjaro",
dose: overrides.dose ?? "5 mg",
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
userId: overrides.userId ?? "user-1",
createdAt: new Date(),
doseChanges: overrides.doseChanges ?? [],
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/titration", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ userId: "OTHER" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication is not GLP-1", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ treatmentClass: "GENERIC" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ name: "MysteryDrug" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "5 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
{
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
doseValue: 5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.drugId).toBe("tirzepatide");
expect(json.data.ladder.length).toBe(6);
expect(json.data.currentStep.doseMg).toBe(5);
expect(json.data.currentStepIndex).toBe(1);
expect(json.data.weeksOnCurrentStep).toBe(2);
expect(json.data.nextStep.doseMg).toBe(7.5);
expect(json.data.escalationDue).toBe(false);
});
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
doseChanges: [],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep).toBeNull();
expect(json.data.currentStepIndex).toBeNull();
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("returns a null next-step at the ladder ceiling", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "15 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
doseValue: 15,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(15);
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "2.5 mg",
doseChanges: [
{
// Six weeks ago, past tirzepatide's 4-week interval.
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(2.5);
expect(json.data.weeksOnCurrentStep).toBe(6);
expect(json.data.escalationDue).toBe(true);
});
});
@@ -1,136 +0,0 @@
/**
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
*
* GET /api/medications/[id]/titration
* - returns the EMA-reference ladder for the medication's drug,
* the user's current step (matched within ±10 % tolerance to
* their latest dose), how long they've been on that step, the
* immediate-next step (if any), and the observational
* escalation-due flag.
*
* Pure computation — no writes. Delegates math to the
* `src/lib/medications/titration/ladder` module so the route, the
* Coach snapshot (future wave), and the detail-page section all read
* the same numbers.
*
* Auth: cookie-session via requireAuth(); medication is verified to
* belong to the caller before any read. Defence-in-depth: returns
* 404 for non-GLP-1 medications (the UI already gates on
* `treatmentClass === "GLP1"`).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
findDrugByBrand,
findDrugIdByBrand,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
escalationDue,
findCurrentStep,
ladderFromRecord,
nextStep,
weeksOnCurrentStep,
} from "@/lib/medications/titration/ladder";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
// the 404 leak shape stays consistent across every medication
// sub-route.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) {
return apiError("Medication not found", 404);
}
if (med.treatmentClass !== "GLP1") {
// Defence-in-depth: the UI already gates the section on this
// class, but the route refuses to surface the ladder for a
// non-GLP-1 row so we never leak a misleading "you are here"
// on a generic medication.
return apiError("Medication not found", 404);
}
// Resolve the GLP-1 drug record from the medication name. Same
// path the DrugLevelChart uses; null when the medication name
// doesn't map to a catalog brand (e.g. user typed a generic
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
// through the shared helpers in glp1-knowledge.
const record = findDrugByBrand(med.name);
const drugId = findDrugIdByBrand(med.name);
if (!record || !drugId) {
return apiError("Medication not found", 404);
}
const ladder = ladderFromRecord(record);
// Resolve the user's most-recent dose. Prefer the
// MedicationDoseChange stream (the history of record); fall back
// to the legacy free-text `medication.dose` if the stream is empty.
const latestChange =
med.doseChanges.length > 0
? med.doseChanges[med.doseChanges.length - 1]
: null;
const latestDoseMg = latestChange
? latestChange.doseValue
: parseDoseMgOrNull(med.dose);
const currentStep = findCurrentStep(drugId, latestDoseMg);
const next = nextStep(drugId, currentStep);
const asOf = new Date();
const weeks = weeksOnCurrentStep(
drugId,
currentStep,
med.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
})),
asOf,
);
const due = escalationDue(drugId, currentStep, weeks);
annotate({
action: {
name: "medication.titration",
entity_type: "medication",
entity_id: id,
},
meta: {
drug_id: drugId,
step_index: currentStep?.stepIndex ?? null,
weeks_on_step: weeks,
},
});
return apiSuccess({
drugId,
drugInn: record.inn,
ladder,
currentStep,
currentStepIndex: currentStep?.stepIndex ?? null,
weeksOnCurrentStep: weeks,
nextStep: next,
escalationDue: due,
sourceEMA: record.sourceEMA,
});
},
);
+8 -6
View File
@@ -29,7 +29,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { useTranslations } from "@/lib/i18n/context";
@@ -129,10 +129,12 @@ export default function IntakeHistoryPage({
/>
)}
{/* O-2 — estimated active-ingredient curve + titration ladder as a
default-CLOSED disclosure for GLP-1 medications. Genuine
context, but opt-in so the intake table stays the dominant
surface. The chart itself gates further on Research Mode. */}
{/* O-2 — dose-strength (titration) curve + estimated
active-ingredient curve as a default-CLOSED disclosure for
GLP-1 medications. Genuine context, but opt-in so the intake
table stays the dominant surface. The dose-strength curve plots
the user's own logged dose-change history; the drug-level chart
gates further on Research Mode. */}
{medication && isGlp1 && (
<details
className="border-border/60 rounded-md border"
@@ -142,6 +144,7 @@ export default function IntakeHistoryPage({
{t("medications.detail.history.drugLevelDisclosure")}
</summary>
<div className="border-border/60 space-y-6 border-t px-3 py-3">
<DoseStrengthCurve medicationId={id} />
<DrugLevelChart
medication={{
id: medication.id,
@@ -149,7 +152,6 @@ export default function IntakeHistoryPage({
dose: medication.dose,
}}
/>
<TitrationSection medicationId={id} />
</div>
</details>
)}
+7 -6
View File
@@ -37,7 +37,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
@@ -235,10 +235,11 @@ export default function MedicationDetailPage({
(genuine history context). */}
{isGlp1 && <SideEffectsSection medicationId={id} />}
{/* GLP-1 drug-level curve + titration ladder as a default-CLOSED
disclosure — genuine history context, opt-in so the intake
table stays the dominant surface. The chart gates further on
Research Mode. */}
{/* GLP-1 dose-strength (titration) curve + estimated drug-level
chart as a default-CLOSED disclosure — genuine history context,
opt-in so the intake table stays the dominant surface. The
dose-strength curve plots the user's own logged dose-change
history; the drug-level chart gates further on Research Mode. */}
{isGlp1 && (
<details
className="border-border/60 rounded-md border"
@@ -248,6 +249,7 @@ export default function MedicationDetailPage({
{t("medications.detail.history.drugLevelDisclosure")}
</summary>
<div className="border-border/60 space-y-6 border-t px-3 py-3">
<DoseStrengthCurve medicationId={id} />
<DrugLevelChart
medication={{
id: medication.id,
@@ -255,7 +257,6 @@ export default function MedicationDetailPage({
dose: medication.dose,
}}
/>
<TitrationSection medicationId={id} />
</div>
</details>
)}
@@ -1,245 +0,0 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
/**
* v1.4.25 W19f — GLP-1 titration-ladder section.
*
* Sits at the bottom of the medication detail page below the W19e
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
* Same chrome as the W19d / W19e sections so the panels read as one
* visual group — chrome is now composed via the shared
* `<MedicationDetailSection>` wrapper.
*
* Layout:
* - Header: drug INN + "Standard ladder (EMA reference)"
* - Vertical step list (mobile) — same nodes laid horizontally
* from `sm` upwards. Each step shows dose, typical-weeks badge,
* and a status dot (current / past / upcoming).
* - Current-step ring with "You are here" caption.
* - Next-step caption + observational dwell hint when the EMA
* dwell-time has elapsed. Copy is strictly observational; the
* ladder is a *reference*, never a prescription (MDR boundary).
* - Disclaimer + EMA-source link.
*
* v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper;
* the i18n `t()` function already accepts the `params` bag with
* `{key}` placeholder substitution. One source of truth for the
* template-fill semantics.
*
* Data comes from `/api/medications/[id]/titration`; that route owns
* the prisma queries and delegates math to the pure ladder helpers.
*/
interface TitrationStep {
stepIndex: number;
doseMg: number;
typicalWeeks: number;
}
interface TitrationResponse {
drugId: string;
drugInn: string;
ladder: TitrationStep[];
currentStep: TitrationStep | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: TitrationStep | null;
escalationDue: boolean;
sourceEMA: string;
}
interface TitrationSectionProps {
medicationId: string;
}
export function TitrationSection({ medicationId }: TitrationSectionProps) {
const { t } = useTranslations();
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.medicationTitration(medicationId),
queryFn: async (): Promise<TitrationResponse> => {
const res = await fetch(`/api/medications/${medicationId}/titration`);
if (!res.ok) {
throw new Error(`Failed to load titration: ${res.status}`);
}
const json = await res.json();
return json.data as TitrationResponse;
},
});
const hasLadder = !!data && data.ladder.length > 0;
const currentIdx = data?.currentStepIndex ?? null;
// Compose the optional dwell hint AND the next-step caption. The
// dwell hint is strictly observational — no "you should step up";
// the boolean only gates *display*, never *action*.
const dwellHint = useMemo(() => {
if (!data || data.currentStep === null) return null;
if (data.weeksOnCurrentStep <= 0) return null;
if (data.escalationDue) {
return t("medications.titration.escalationDueHint", {
weeks: data.weeksOnCurrentStep,
typical: data.currentStep.typicalWeeks,
});
}
return t("medications.titration.currentStepDwell", {
weeks: data.weeksOnCurrentStep,
});
}, [data, t]);
const nextStepCaption = useMemo(() => {
if (!data) return null;
if (!data.nextStep) return null;
return t("medications.titration.nextStepCaption", {
dose: data.nextStep.doseMg,
weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks,
});
}, [data, t]);
const headerExtras = data ? (
<span
className="text-muted-foreground text-xs uppercase tracking-wide"
data-slot="titration-drug-inn"
>
{data.drugInn}
</span>
) : null;
return (
<MedicationDetailSection
titleId="titration-heading"
title={t("medications.titration.section")}
headerExtras={headerExtras}
dataSlot="titration-section"
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.titration.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.titration.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
</p>
{hasLadder ? (
<ol
// v1.4.27 MB7 / CF-48 — keep the vertical (`flex-col`)
// layout until `md:` instead of breaking to horizontal
// at `sm:`. On Pixel 5 (375 px) the horizontal ladder
// had 4-5 visible steps but each step's "Standard 0.5
// mg" + "typical 4 weeks" label combo wrapped to 3
// lines per step, which read as a denser block than
// the vertical layout below. From `md:` upwards the
// wider container can absorb the horizontal stride.
className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-stretch"
aria-label={t("medications.titration.drugLadderHeader")}
>
{data.ladder.map((step) => {
const isCurrent = currentIdx === step.stepIndex;
const isPast =
currentIdx !== null && step.stepIndex < currentIdx;
return (
<li
key={step.stepIndex}
className={[
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 md:min-w-[7rem]",
isCurrent
? "border-primary ring-primary/40 ring-2"
: "",
].join(" ")}
aria-current={isCurrent ? "step" : undefined}
data-slot="titration-step"
data-step-index={step.stepIndex}
data-step-state={
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.titration.stepLabel", {
n: step.stepIndex + 1,
})}
</span>
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-xs">
{t("medications.titration.typicalWeeksOnStep", {
weeks: step.typicalWeeks,
})}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-xs font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
</li>
);
})}
</ol>
) : (
<p className="text-muted-foreground">
{t("medications.titration.emptyState")}
</p>
)}
{data.currentStep === null && hasLadder && (
<p className="text-muted-foreground">
{t("medications.titration.nonStandardDose")}
</p>
)}
{dwellHint && (
<p
className="text-muted-foreground"
data-slot="titration-dwell-hint"
>
{dwellHint}
</p>
)}
{nextStepCaption && (
<p className="text-muted-foreground">{nextStepCaption}</p>
)}
{data.nextStep === null && data.currentStep !== null && (
<p className="text-muted-foreground">
{t("medications.titration.ceilingMessage")}
</p>
)}
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-xs">
{t("medications.titration.disclaimer")}
</p>
<a
href={data.sourceEMA}
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
</a>
</div>
</div>
)}
</MedicationDetailSection>
);
}
@@ -1,221 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TitrationSection } from "@/components/medications/TitrationSection";
/**
* v1.4.25 W19f — TitrationSection SSR smoke tests.
*
* Same testing convention as `SchedulingSection.test.tsx`:
* `renderToStaticMarkup` + seeded react-query cache. Interactive
* branches (loading spinner, fetch-error) are covered by the API
* route tests + the pure ladder tests; surface tests here pin the
* static-render contract:
*
* 1. Header + drug INN render.
* 2. Each ladder step renders with dose + typical-weeks label.
* 3. The current step has a "You are here" caption.
* 4. The next-step caption renders when a next step exists.
* 5. The ceiling message renders when the user is on the top
* step (no next step).
* 6. The non-standard dose caption renders when current step is
* null but the ladder is non-empty.
* 7. The disclaimer + EMA source link always render with data.
* 8. Empty ladder → empty-state copy renders.
* 9. The escalationDueHint copy is observational, not prescriptive.
* 10. German locale renders.
*/
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
interface SeedShape {
drugId: string;
drugInn: string;
ladder: Array<{ stepIndex: number; doseMg: number; typicalWeeks: number }>;
currentStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
escalationDue: boolean;
sourceEMA: string;
}
function seed(client: QueryClient, medId: string, payload: SeedShape) {
client.setQueryData(["medications", medId, "titration"], payload);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const TIRZEPATIDE_LADDER = [
{ stepIndex: 0, doseMg: 2.5, typicalWeeks: 4 },
{ stepIndex: 1, doseMg: 5, typicalWeeks: 4 },
{ stepIndex: 2, doseMg: 7.5, typicalWeeks: 4 },
{ stepIndex: 3, doseMg: 10, typicalWeeks: 4 },
{ stepIndex: 4, doseMg: 12.5, typicalWeeks: 4 },
{ stepIndex: 5, doseMg: 15, typicalWeeks: 4 },
];
const SEED_ON_STEP_5MG: SeedShape = {
drugId: "tirzepatide",
drugInn: "Tirzepatide",
ladder: TIRZEPATIDE_LADDER,
currentStep: TIRZEPATIDE_LADDER[1],
currentStepIndex: 1,
weeksOnCurrentStep: 2,
nextStep: TIRZEPATIDE_LADDER[2],
escalationDue: false,
sourceEMA:
"https://www.ema.europa.eu/en/documents/product-information/mounjaro-epar-product-information_en.pdf",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("<TitrationSection> — surface render", () => {
it("renders the section heading and drug INN", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Titration ladder");
expect(html).toContain("Tirzepatide");
expect(html).toContain("Standard ladder (EMA reference)");
});
it("renders every step on the ladder", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
for (const step of TIRZEPATIDE_LADDER) {
// Each step's dose label appears (e.g. "2.5 mg", "15 mg").
expect(html).toContain(`${step.doseMg} mg`);
}
// Step ladder count = 6 steps; each step renders one card.
const stepCards = html.match(/data-slot="titration-step"/g) ?? [];
expect(stepCards.length).toBe(6);
});
it('renders "You are here" on the current step only', () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are here");
// Only one "you are here" caption rendered, on the matched step.
expect(html.match(/You are here/g)?.length).toBe(1);
});
it("renders the next-step caption when a next step exists", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Next step (typical): 7.5 mg");
});
it("renders the ceiling message at the top of the ladder", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: TIRZEPATIDE_LADDER[5],
currentStepIndex: 5,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are at the top of the standard ladder.");
});
it("renders the non-standard dose caption when current step is null", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: null,
currentStepIndex: null,
nextStep: null,
weeksOnCurrentStep: 0,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"Current dose is outside the standard ladder buckets.",
);
});
it("always renders the disclaimer and the EMA-source link", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"EMA reference values. Talk to your doctor before changing doses.",
);
expect(html).toContain("EMA source");
expect(html).toContain("mounjaro-epar-product-information");
});
it("renders the empty-state copy when the ladder is empty", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
ladder: [],
currentStep: null,
currentStepIndex: null,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"No dose history yet. Log a dose to see where you are on the ladder.",
);
});
it("renders the escalation-due hint as an observation, not a prescription", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
weeksOnCurrentStep: 6,
escalationDue: true,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
// The copy must NOT prescribe an action ("you should step up").
expect(html).not.toMatch(/should step up/i);
expect(html).not.toMatch(/must step up/i);
expect(html).not.toMatch(/recommend.*step up/i);
// The observational template must be rendered.
expect(html).toContain("the ladder typically steps up around");
// The escaped apostrophe is the SSR render of the curly-apostrophe-free
// template; we assert on the unencoded plain segment instead of the
// contraction itself to stay robust to the renderer's HTML entity choice.
expect(html).toContain("been on this step for 6 weeks");
});
it("renders the section heading in German", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(
<TitrationSection medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Titrationsstufen");
expect(html).toContain("Standard-Schema (EMA-Referenz)");
expect(html).toContain("Hier stehst du");
});
});
@@ -0,0 +1,187 @@
/**
* v1.8.6 W4c — dose-strength (titration) curve tests.
*
* Pins the three honest data states and the pure point-builder:
* - 0 dose-change rows → empty state, current-dose absent.
* - exactly 1 row → empty state, current dose surfaced (no flat
* single-point chart).
* - 2+ rows → the step-curve SVG body renders.
*
* Recharts in `renderToStaticMarkup` paints the static frame only, so we
* assert on the `data-slot` seams rather than measured geometry — the
* same approach the sibling DrugLevelChart test uses.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
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 };
},
}));
import {
DoseStrengthCurve,
buildCurvePoints,
} from "../dose-strength-curve";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const KEY = "medications/med-1/glp1-details";
const ASOF = new Date("2026-06-01T12:00:00Z");
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
});
describe("buildCurvePoints", () => {
it("returns an empty list for no dose changes", () => {
expect(buildCurvePoints([], ASOF)).toEqual([]);
});
it("returns the single row unchanged (no carry-forward) for one change", () => {
const pts = buildCurvePoints(
[
{
id: "a",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
],
ASOF,
);
expect(pts).toHaveLength(1);
expect(pts[0].dose).toBe(2.5);
});
it("sorts by effectiveFrom and carries the latest dose forward to asOf", () => {
const pts = buildCurvePoints(
[
{
id: "b",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 5,
doseUnit: "mg",
},
{
id: "a",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
],
ASOF,
);
// two source rows + the carried-forward "now" anchor
expect(pts).toHaveLength(3);
expect(pts.map((p) => p.dose)).toEqual([2.5, 5, 5]);
expect(pts[2].t).toBe(ASOF.getTime());
});
it("drops rows with unparseable dates or non-finite doses", () => {
const pts = buildCurvePoints(
[
{
id: "bad-date",
effectiveFrom: "not-a-date",
doseValue: 5,
doseUnit: "mg",
},
{
id: "ok",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
{
id: "ok-2",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 7.5,
doseUnit: "mg",
},
],
ASOF,
);
expect(pts.map((p) => p.dose)).toEqual([2.5, 7.5, 7.5]);
});
});
describe("<DoseStrengthCurve>", () => {
it("renders the empty state with no current dose for zero rows", () => {
setQueryResult(KEY, { doseChanges: [] });
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("No dose recorded yet.");
expect(html).toContain("No titration history yet.");
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
});
it("surfaces the current dose but no curve for a single dose change", () => {
setQueryResult(KEY, {
doseChanges: [
{
id: "a",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 5,
doseUnit: "mg",
},
],
});
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("Current dose: 5 mg");
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
});
it("renders the step curve for two or more dose changes", () => {
setQueryResult(KEY, {
doseChanges: [
{
id: "a",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
{
id: "b",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 5,
doseUnit: "mg",
},
],
});
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
expect(html).toContain('data-slot="dose-strength-curve-area"');
expect(html).toContain('data-slot="dose-strength-curve-caption"');
// Mounted inside the shared section chrome.
expect(html).toContain('aria-labelledby="dose-strength-curve-title"');
expect(html).not.toContain('data-slot="dose-strength-curve-empty"');
});
});
@@ -0,0 +1,284 @@
"use client";
/**
* v1.8.6 W4c — GLP-1 dose-strength (titration) curve.
*
* Replaces the EMA *reference*-ladder block that showed no values when
* the medication's brand fell outside the catalog. This surface plots
* the user's OWN dose-strength history — the `MedicationDoseChange`
* stream (`effectiveFrom` + `doseValue` + `doseUnit`) returned by
* `GET /api/medications/[id]/glp1` — as a step curve over time, so a
* 2.5 → 5 → 7.5 → 10 mg escalation reads at a glance.
*
* Honest data contract:
* - 2+ dose-change rows → step curve, the latest dose carried forward
* to "now" so the current strength stays visible at the right edge.
* - exactly 1 row → no escalation to draw; surface the single current
* dose plainly with a "no titration history yet" caption rather than
* a flat single-point chart.
* - 0 rows → empty state with the same caption. The
* `MedicationDoseChange` table is the history of record; when it is
* empty there is genuinely no time series to plot.
*
* No Research-Mode gate — unlike the estimated-PK `DrugLevelChart`, this
* curve plots only logged facts (the user's recorded dose strengths), so
* it carries no pharmacokinetic estimate and needs no disclaimer gate.
*
* Dracula purple tokens + the shared `<MedicationDetailSection>` chrome
* so the GLP-1 disclosure reads as one visual group with the drug-level
* chart that sits beside it.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, TrendingUp } from "lucide-react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
const CURVE_COLOR = "var(--dracula-purple)";
interface DoseChange {
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
note?: string | null;
}
interface Glp1DetailsResponse {
doseChanges: DoseChange[];
}
export interface DoseStrengthCurveProps {
medicationId: string;
/** Override "now" for deterministic snapshot tests. */
asOf?: Date;
}
interface CurvePoint {
t: number;
dose: number;
unit: string;
}
/**
* Build the step-curve points from the dose-change stream. The series is
* sorted by `effectiveFrom`, finite-guarded, and the latest dose is
* carried forward to `asOf` so the current strength anchors the right
* edge. Exported for the unit test.
*/
export function buildCurvePoints(
doseChanges: readonly DoseChange[],
asOf: Date,
): CurvePoint[] {
const sorted = doseChanges
.map((dc) => ({
t: Date.parse(dc.effectiveFrom),
dose: dc.doseValue,
unit: dc.doseUnit,
}))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.dose))
.sort((a, b) => a.t - b.t);
if (sorted.length < 2) return sorted;
// Carry the latest dose forward to "now" so the most-recent strength
// reads as a flat segment up to the right edge rather than ending at
// the last change date.
const last = sorted[sorted.length - 1];
const nowMs = asOf.getTime();
if (nowMs > last.t) {
sorted.push({ t: nowMs, dose: last.dose, unit: last.unit });
}
return sorted;
}
export function DoseStrengthCurve({
medicationId,
asOf,
}: DoseStrengthCurveProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: details, isLoading } = useQuery<Glp1DetailsResponse | null>({
queryKey: queryKeys.medicationGlp1Details(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/glp1`);
if (!res.ok) return null;
const json = await res.json();
return json.data as Glp1DetailsResponse;
},
staleTime: 60 * 1000,
});
const now = useMemo(() => asOf ?? new Date(), [asOf]);
const doseChanges = useMemo(
() => details?.doseChanges ?? [],
[details],
);
const points = useMemo(
() => buildCurvePoints(doseChanges, now),
[doseChanges, now],
);
const animationsEnabled = !prefersReducedMotion();
// The latest logged dose — surfaced in the single-row / empty states so
// the user always sees their current strength even without a curve.
const latest = useMemo(() => {
if (doseChanges.length === 0) return null;
const sorted = [...doseChanges].sort(
(a, b) => Date.parse(a.effectiveFrom) - Date.parse(b.effectiveFrom),
);
return sorted[sorted.length - 1];
}, [doseChanges]);
const hasCurve = points.length >= 2;
const chartData = useMemo(
() =>
points.map((p) => ({
t: p.t,
dose: p.dose,
})),
[points],
);
// Four evenly-spaced date ticks across the plotted span so the strip
// reads start → … → now without crowding.
const ticks = useMemo(() => {
if (chartData.length < 2) return [];
const first = chartData[0].t;
const last = chartData[chartData.length - 1].t;
const span = last - first;
if (span <= 0) return [first];
return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f));
}, [chartData]);
const unit = latest?.doseUnit ?? "mg";
const body = isLoading ? (
<div
className="flex h-[200px] min-h-[200px] items-center justify-center"
data-slot="dose-strength-curve-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !hasCurve ? (
<div
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
data-slot="dose-strength-curve-empty"
>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 shrink-0" aria-hidden="true" />
{latest ? (
<p>
{t("medications.doseStrength.currentDose", {
dose: fmt.number(latest.doseValue),
unit: latest.doseUnit,
})}
</p>
) : (
<p>{t("medications.doseStrength.noCurrentDose")}</p>
)}
</div>
<p className="text-xs italic">{t("medications.doseStrength.empty")}</p>
</div>
) : (
<div
className="touch-pan-y"
style={{ height: "200px" }}
data-slot="dose-strength-curve-area"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShort(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={[0, "auto"]}
width={34}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => fmt.number(v as number)}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
labelFormatter={(v) => fmt.dateShort(new Date(v as number))}
formatter={(value) => [
`${fmt.number(value as number)} ${unit}`,
t("medications.doseStrength.tooltipLabel"),
]}
contentStyle={{
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
fontSize: "0.75rem",
}}
/>
<Line
type="stepAfter"
dataKey="dose"
stroke={CURVE_COLOR}
strokeWidth={2}
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
return (
<MedicationDetailSection
titleId="dose-strength-curve-title"
title={t("medications.doseStrength.title")}
dataSlot="dose-strength-curve"
>
{hasCurve && (
<p
className="text-muted-foreground mb-2 text-xs"
data-slot="dose-strength-curve-caption"
>
{t("medications.doseStrength.axisLabel", { unit })}
</p>
)}
{body}
</MedicationDetailSection>
);
}
-5
View File
@@ -131,11 +131,6 @@ describe("queryKeys factory", () => {
"med1",
"compliance",
]);
expect(queryKeys.medicationTitration("med1")).toEqual([
"medications",
"med1",
"titration",
]);
expect(queryKeys.medicationCadence("med1")).toEqual([
"medications",
"med1",
@@ -1,198 +0,0 @@
/**
* v1.4.25 W19f — pure titration-ladder helper tests.
*
* Pins the contract the API route and the detail-page section read:
* - every EMA-approved drug exposes a non-empty, strictly ascending
* ladder;
* - `findCurrentStep` snaps within a ±10 % tolerance and returns
* null outside that window;
* - `nextStep` returns null at the ladder ceiling;
* - `weeksOnCurrentStep` is 0 with no matching dose-change rows;
* - `escalationDue` toggles at the EMA-reference dwell-time
* boundary (and never fires past the ceiling).
*/
import { describe, expect, it } from "vitest";
import {
escalationDue,
findCurrentStep,
getLadder,
ladderFromRecord,
nextStep,
weeksOnCurrentStep,
type DoseChangeLike,
} from "../ladder";
import {
GLP1_DRUG_IDS,
GLP1_DRUGS,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
function d(iso: string): Date {
return new Date(iso);
}
describe("getLadder / ladderFromRecord", () => {
it("returns a non-empty ladder for every EMA-approved drug", () => {
for (const id of GLP1_DRUG_IDS) {
const ladder = getLadder(id);
expect(ladder.length).toBeGreaterThan(0);
}
});
it("produces strictly ascending doses with sequential indices", () => {
for (const id of GLP1_DRUG_IDS) {
const ladder = getLadder(id);
for (let i = 0; i < ladder.length; i++) {
expect(ladder[i].stepIndex).toBe(i);
if (i > 0) {
expect(ladder[i].doseMg).toBeGreaterThan(ladder[i - 1].doseMg);
}
}
}
});
it("carries the EMA-reference dwell time on every step", () => {
for (const id of GLP1_DRUG_IDS) {
const expected = GLP1_DRUGS[id].titrationIntervalWeeks;
const ladder = getLadder(id);
for (const step of ladder) {
expect(step.typicalWeeks).toBe(expected);
}
}
});
it("accepts the record-direct overload", () => {
const ladderById = getLadder("tirzepatide");
const ladderByRecord = ladderFromRecord(GLP1_DRUGS.tirzepatide);
expect(ladderByRecord).toEqual(ladderById);
});
});
describe("findCurrentStep", () => {
it("returns null when the latest dose is null or non-finite", () => {
expect(findCurrentStep("tirzepatide", null)).toBeNull();
expect(findCurrentStep("tirzepatide", Number.NaN)).toBeNull();
expect(findCurrentStep("tirzepatide", 0)).toBeNull();
});
it("matches an exact dose on the ladder", () => {
// Tirzepatide ladder: 2.5, 5, 7.5, 10, 12.5, 15
expect(findCurrentStep("tirzepatide", 5)?.doseMg).toBe(5);
expect(findCurrentStep("tirzepatide", 15)?.stepIndex).toBe(5);
});
it("snaps within the ±10 % tolerance window", () => {
// 5 mg ± 10 % = [4.5, 5.5]; 4.7 should round to step 5 mg.
expect(findCurrentStep("tirzepatide", 4.7)?.doseMg).toBe(5);
expect(findCurrentStep("tirzepatide", 5.4)?.doseMg).toBe(5);
});
it("returns null outside the tolerance window", () => {
// 6 mg is > 5 mg + 10 % AND < 7.5 mg − 10 % → unmatched.
expect(findCurrentStep("tirzepatide", 6.0)).toBeNull();
// 3 mg also outside any step (2.5 + 10 % = 2.75; 5 − 10 % = 4.5).
expect(findCurrentStep("tirzepatide", 3.5)).toBeNull();
});
it("works for semaglutide (smallest dose step on the catalog)", () => {
expect(findCurrentStep("semaglutide", 0.25)?.doseMg).toBe(0.25);
expect(findCurrentStep("semaglutide", 1.0)?.stepIndex).toBe(2);
});
});
describe("nextStep", () => {
it("returns the immediate-next step from the bottom of the ladder", () => {
const current = findCurrentStep("tirzepatide", 2.5);
expect(nextStep("tirzepatide", current)?.doseMg).toBe(5);
});
it("returns null at the ladder ceiling", () => {
const top = findCurrentStep("tirzepatide", 15);
expect(top?.doseMg).toBe(15);
expect(nextStep("tirzepatide", top)).toBeNull();
});
it("returns null when given a null current step", () => {
expect(nextStep("tirzepatide", null)).toBeNull();
});
});
describe("weeksOnCurrentStep", () => {
const ASOF = d("2026-05-14T00:00:00.000Z");
it("returns 0 with no dose-change history", () => {
const current = findCurrentStep("tirzepatide", 5);
expect(weeksOnCurrentStep("tirzepatide", current, [], ASOF)).toBe(0);
});
it("returns 0 when current step is null", () => {
expect(weeksOnCurrentStep("tirzepatide", null, [], ASOF)).toBe(0);
});
it("returns the elapsed whole-weeks since the latest matching change", () => {
const current = findCurrentStep("tirzepatide", 5);
const events: DoseChangeLike[] = [
// Switched onto 5 mg six weeks ago.
{ effectiveFrom: d("2026-04-02T00:00:00.000Z"), doseValue: 5 },
];
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(6);
});
it("ignores dose-change rows for other steps", () => {
const current = findCurrentStep("tirzepatide", 5);
const events: DoseChangeLike[] = [
// A 2.5 mg row from 12 weeks ago should not count.
{ effectiveFrom: d("2026-02-19T00:00:00.000Z"), doseValue: 2.5 },
// A 5 mg row from 2 weeks ago — this is the latest matching row.
{ effectiveFrom: d("2026-04-30T00:00:00.000Z"), doseValue: 5 },
];
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(2);
});
it("returns 0 when the only matching change is in the future", () => {
const current = findCurrentStep("tirzepatide", 5);
const events: DoseChangeLike[] = [
{ effectiveFrom: d("2027-01-01T00:00:00.000Z"), doseValue: 5 },
];
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(0);
});
});
describe("escalationDue", () => {
const TIRZEPATIDE_DWELL = GLP1_DRUGS.tirzepatide.titrationIntervalWeeks;
it("returns false when current step is null", () => {
expect(escalationDue("tirzepatide", null, 99)).toBe(false);
});
it("returns false below the dwell-time boundary", () => {
const current = findCurrentStep("tirzepatide", 5);
expect(
escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL - 1),
).toBe(false);
});
it("toggles true at the dwell-time boundary", () => {
const current = findCurrentStep("tirzepatide", 5);
expect(escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL)).toBe(
true,
);
});
it("never fires past the ladder ceiling", () => {
const top = findCurrentStep("tirzepatide", 15);
expect(escalationDue("tirzepatide", top, 999)).toBe(false);
});
});
describe("catalog coverage", () => {
it("exposes a ladder for every drug id in the catalog", () => {
const ids: Glp1DrugId[] = [...GLP1_DRUG_IDS];
expect(ids.length).toBe(5);
for (const id of ids) {
const ladder = getLadder(id);
expect(ladder.length).toBeGreaterThan(0);
}
});
});
-213
View File
@@ -1,213 +0,0 @@
/**
* v1.4.25 W19f — pure titration-ladder helpers for the GLP-1 detail page.
*
* The W19a knowledge layer (`glp1-knowledge.ts`) already carries the
* EMA-approved escalation schedules under `titrationStepsMg` +
* `titrationIntervalWeeks`. This module turns that static reference
* into a small contract the API route and the detail-page section
* can both read:
*
* - `getLadder(drugId)` — normalises the catalog row into an
* ordered `TitrationStep[]` with
* `stepIndex` and `typicalWeeks`.
* - `findCurrentStep(...)` — matches the user's latest dose to
* the closest ladder step within a
* ±10 % tolerance window; returns
* null when the dose is outside any
* ladder bucket (e.g. user is on a
* non-standard dose).
* - `nextStep(...)` — immediate-next step, null at the
* ladder ceiling.
* - `weeksOnCurrentStep(...)` — how long the user has actually been
* on the matched step, derived from
* the `MedicationDoseChange` stream.
* - `escalationDue(...)` — true when (a) there's a next step
* and (b) the user has been on the
* current step at least
* `titrationIntervalWeeks`. Display
* copy is strictly observational
* ("ladder typically steps up around
* N weeks") — never prescriptive
* (parallel to the W19c-Safety
* GROUND RULE: MDR boundary).
*
* No DB access; every function takes pre-fetched rows. Same shape as
* the W19d / W19e pure modules so the route owns prisma and this
* module owns the math.
*/
import {
GLP1_DRUGS,
type Glp1DrugId,
type Glp1DrugRecord,
} from "@/lib/medications/glp1-knowledge";
/**
* Normalised ladder step — easier to consume in the UI than the bare
* `number[]` on the catalog record because each step carries its own
* position and the typical-weeks figure attached to the drug.
*/
export interface TitrationStep {
/** 0-based position in the ladder. */
stepIndex: number;
/** Step dose in mg (always > 0, strictly ascending across the ladder). */
doseMg: number;
/** EMA-reference minimum weeks before stepping up from this dose. */
typicalWeeks: number;
}
/** Dose-change row this module needs — a tiny subset of the model. */
export interface DoseChangeLike {
/** When the user transitioned onto a new dose. */
effectiveFrom: Date;
/** Numeric dose, paired with `doseUnit` for display elsewhere. */
doseValue: number;
}
/** ±10 % match radius for snapping a logged dose to a ladder step. */
const STEP_MATCH_TOLERANCE = 0.1;
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
/**
* Build the ordered ladder for a given drug-id. Returns an empty
* array if the id is not in the catalog (defensive — the catalog is
* static so this branch is unreachable in practice).
*/
export function getLadder(drugId: Glp1DrugId): TitrationStep[] {
const record = GLP1_DRUGS[drugId];
if (!record) return [];
return ladderFromRecord(record);
}
/**
* Convenience overload that takes the record directly (the chart and
* the API route already resolve the record once via `findDrugByBrand`
* — passing it through avoids a second lookup).
*/
export function ladderFromRecord(record: Glp1DrugRecord): TitrationStep[] {
return record.titrationStepsMg.map((doseMg, stepIndex) => ({
stepIndex,
doseMg,
typicalWeeks: record.titrationIntervalWeeks,
}));
}
/**
* Match the user's most recent dose to the closest step on the
* ladder, within ±10 % tolerance.
*
* Returns null when the latest dose is outside any step's tolerance
* window — this is the "user is on a non-standard dose" path the UI
* surfaces with an explanatory caption instead of a misleading
* highlight.
*/
export function findCurrentStep(
drugId: Glp1DrugId,
latestDoseMg: number | null,
): TitrationStep | null {
if (latestDoseMg === null || !Number.isFinite(latestDoseMg)) return null;
if (latestDoseMg <= 0) return null;
const ladder = getLadder(drugId);
if (ladder.length === 0) return null;
let best: { step: TitrationStep; diff: number } | null = null;
for (const step of ladder) {
const diff = Math.abs(step.doseMg - latestDoseMg);
const tolerance = step.doseMg * STEP_MATCH_TOLERANCE;
if (diff > tolerance) continue;
if (!best || diff < best.diff) {
best = { step, diff };
}
}
return best?.step ?? null;
}
/**
* Return the immediate-next step above `currentStep`, or null when
* the user is already on the ceiling. Identity-by-stepIndex so callers
* that round-tripped the step through JSON still match.
*/
export function nextStep(
drugId: Glp1DrugId,
currentStep: TitrationStep | null,
): TitrationStep | null {
if (!currentStep) return null;
const ladder = getLadder(drugId);
const idx = ladder.findIndex((s) => s.stepIndex === currentStep.stepIndex);
if (idx < 0) return null;
if (idx + 1 >= ladder.length) return null;
return ladder[idx + 1];
}
/**
* Count how long the user has been on the matched step, in whole
* weeks rounded down. Reads the `MedicationDoseChange` stream and
* scans backwards from `asOf` for the most recent transition onto
* (or past) the matched dose; the elapsed time since that transition
* is the answer.
*
* Returns 0 when:
* - no dose-change rows match the step at all (e.g. brand-new med
* where the user hasn't logged a titration row yet);
* - the latest matching change is in the future;
* - `currentStep` is null.
*
* Rationale: matching by step (not by exact mg) is what makes this
* resilient to a user logging "0.5 mg" and the ladder declaring
* "0.5 mg" — we already snap to a step in `findCurrentStep`, so we
* snap dose-change rows the same way here.
*/
export function weeksOnCurrentStep(
drugId: Glp1DrugId,
currentStep: TitrationStep | null,
doseChanges: DoseChangeLike[],
asOf: Date,
): number {
if (!currentStep) return 0;
const tolerance = currentStep.doseMg * STEP_MATCH_TOLERANCE;
// Sort defensively — the API route orders by effectiveFrom asc but
// we don't trust caller ordering.
const matches = doseChanges
.filter((dc) => {
const diff = Math.abs(dc.doseValue - currentStep.doseMg);
return diff <= tolerance;
})
.sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime());
if (matches.length === 0) return 0;
const latest = matches[0];
if (latest.effectiveFrom.getTime() > asOf.getTime()) return 0;
const elapsedMs = asOf.getTime() - latest.effectiveFrom.getTime();
return Math.max(0, Math.floor(elapsedMs / MS_PER_WEEK));
}
/**
* Display flag — true when the EMA-reference minimum dwell time has
* elapsed AND a next ladder step exists.
*
* Parameters:
* - `drugIdForCeiling`: identifies which ladder to walk so the
* "next step exists" guard knows when the user has hit the
* ceiling. The dwell-time check itself reads `typicalWeeks`
* straight off `currentStep`; the drug id is only needed to
* answer "is there a step above this one?".
* - `currentStep`: the step the user is sitting on today. Null
* when titration is paused or the user is at the bottom of the
* ladder with no recorded dose history.
* - `weeksOnStep`: how many complete weeks have elapsed since the
* user landed on `currentStep`. Computed by `weeksOnCurrentStep`.
*
* IMPORTANT: this is a *reference* signal, not advice. The UI copy
* tied to this boolean is strictly observational ("ladder typically
* steps up around N weeks") — never prescriptive. MDR boundary, see
* the W19c safety ground-rules.
*/
export function escalationDue(
drugIdForCeiling: Glp1DrugId,
currentStep: TitrationStep | null,
weeksOnStep: number,
): boolean {
if (!currentStep) return false;
if (!nextStep(drugIdForCeiling, currentStep)) return false;
return weeksOnStep >= currentStep.typicalWeeks;
}
-2
View File
@@ -116,8 +116,6 @@ export const queryKeys = {
*/
medicationCompliance: (medicationId: string) =>
["medications", medicationId, "compliance"] as const,
medicationTitration: (medicationId: string) =>
["medications", medicationId, "titration"] as const,
medicationCadence: (medicationId: string) =>
["medications", medicationId, "cadence"] as const,
medicationGlp1Details: (medicationId: string) =>