From 3f9fd9095fcbba4b5f0bff4c58a2029ca91bc4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 22:56:46 +0200 Subject: [PATCH] feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/pl.json | 24 +- .../[id]/titration/__tests__/route.test.ts | 227 -------------- .../api/medications/[id]/titration/route.ts | 136 --------- src/app/medications/[id]/history/page.tsx | 14 +- src/app/medications/[id]/page.tsx | 13 +- .../medications/TitrationSection.tsx | 245 --------------- .../__tests__/TitrationSection.test.tsx | 221 -------------- .../__tests__/dose-strength-curve.test.tsx | 187 ++++++++++++ .../medications/dose-strength-curve.tsx | 284 ++++++++++++++++++ src/lib/__tests__/query-keys.test.ts | 5 - .../titration/__tests__/ladder.test.ts | 198 ------------ src/lib/medications/titration/ladder.ts | 213 ------------- src/lib/query-keys.ts | 2 - 18 files changed, 528 insertions(+), 1361 deletions(-) delete mode 100644 src/app/api/medications/[id]/titration/__tests__/route.test.ts delete mode 100644 src/app/api/medications/[id]/titration/route.ts delete mode 100644 src/components/medications/TitrationSection.tsx delete mode 100644 src/components/medications/__tests__/TitrationSection.test.tsx create mode 100644 src/components/medications/__tests__/dose-strength-curve.test.tsx create mode 100644 src/components/medications/dose-strength-curve.tsx delete mode 100644 src/lib/medications/titration/__tests__/ladder.test.ts delete mode 100644 src/lib/medications/titration/ladder.ts diff --git a/messages/de.json b/messages/de.json index aca919e6b..59e872e6d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 98da77bdc..0c8c6d762 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index dfffd48e9..4ab9c2848 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8053b2ac4..f8f5687da 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index c3cac5c7f..6f7d8517c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index eda914fea..94ef0699e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/medications/[id]/titration/__tests__/route.test.ts b/src/app/api/medications/[id]/titration/__tests__/route.test.ts deleted file mode 100644 index f505fb912..000000000 --- a/src/app/api/medications/[id]/titration/__tests__/route.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/app/api/medications/[id]/titration/route.ts b/src/app/api/medications/[id]/titration/route.ts deleted file mode 100644 index 55ee026cb..000000000 --- a/src/app/api/medications/[id]/titration/route.ts +++ /dev/null @@ -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, - }); - }, -); - diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 5de206a80..e33edcab7 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -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 && (
+ -
)} diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 4f5a5aa93..32de81d9e 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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 && } - {/* 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 && (
+ -
)} diff --git a/src/components/medications/TitrationSection.tsx b/src/components/medications/TitrationSection.tsx deleted file mode 100644 index 41b8c8f2a..000000000 --- a/src/components/medications/TitrationSection.tsx +++ /dev/null @@ -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 - * `` 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 => { - 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 ? ( - - {data.drugInn} - - ) : null; - - return ( - - {isLoading && ( -
- - {t("medications.titration.loading")} -
- )} - - {!!error && !isLoading && ( -

- {t("medications.titration.loadFailed")} -

- )} - - {data && !isLoading && ( -
-

- {t("medications.titration.drugLadderHeader")} -

- - {hasLadder ? ( -
    - {data.ladder.map((step) => { - const isCurrent = currentIdx === step.stepIndex; - const isPast = - currentIdx !== null && step.stepIndex < currentIdx; - return ( -
  1. - - {t("medications.titration.stepLabel", { - n: step.stepIndex + 1, - })} - - - {step.doseMg} {t("medications.titration.doseUnitMg")} - - - {t("medications.titration.typicalWeeksOnStep", { - weeks: step.typicalWeeks, - })} - - {isCurrent && ( - - {t("medications.titration.youAreHere")} - - )} -
  2. - ); - })} -
- ) : ( -

- {t("medications.titration.emptyState")} -

- )} - - {data.currentStep === null && hasLadder && ( -

- {t("medications.titration.nonStandardDose")} -

- )} - - {dwellHint && ( -

- {dwellHint} -

- )} - - {nextStepCaption && ( -

{nextStepCaption}

- )} - - {data.nextStep === null && data.currentStep !== null && ( -

- {t("medications.titration.ceilingMessage")} -

- )} - -
-

- {t("medications.titration.disclaimer")} -

- - {t("medications.titration.emaSourceCta")} - -
-
- )} -
- ); -} diff --git a/src/components/medications/__tests__/TitrationSection.test.tsx b/src/components/medications/__tests__/TitrationSection.test.tsx deleted file mode 100644 index 916aa743e..000000000 --- a/src/components/medications/__tests__/TitrationSection.test.tsx +++ /dev/null @@ -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( - - {node} - , - ); -} - -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(" — surface render", () => { - it("renders the section heading and drug INN", () => { - const client = makeClient(); - seed(client, "med-1", SEED_ON_STEP_5MG); - const html = render(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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( - , - client, - "de", - ); - expect(html).toContain("Titrationsstufen"); - expect(html).toContain("Standard-Schema (EMA-Referenz)"); - expect(html).toContain("Hier stehst du"); - }); -}); diff --git a/src/components/medications/__tests__/dose-strength-curve.test.tsx b/src/components/medications/__tests__/dose-strength-curve.test.tsx new file mode 100644 index 000000000..601a446bb --- /dev/null +++ b/src/components/medications/__tests__/dose-strength-curve.test.tsx @@ -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 = {}; + +function setQueryResult(keyJoined: string, data: unknown) { + queryResults[keyJoined] = data; +} + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + const key = (queryKey as Array).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( + {node}, + ); +} + +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("", () => { + it("renders the empty state with no current dose for zero rows", () => { + setQueryResult(KEY, { doseChanges: [] }); + + const html = render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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"'); + }); +}); diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx new file mode 100644 index 000000000..ca25ea1d4 --- /dev/null +++ b/src/components/medications/dose-strength-curve.tsx @@ -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 `` 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({ + 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 ? ( +
+ +
+ ) : !hasCurve ? ( +
+
+
+

{t("medications.doseStrength.empty")}

+
+ ) : ( +
+ + + + fmt.dateShort(new Date(v as number))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + /> + fmt.number(v as number)} + /> + 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", + }} + /> + + + +
+ ); + + return ( + + {hasCurve && ( +

+ {t("medications.doseStrength.axisLabel", { unit })} +

+ )} + {body} +
+ ); +} diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 63ff50f52..82bacdb27 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -131,11 +131,6 @@ describe("queryKeys factory", () => { "med1", "compliance", ]); - expect(queryKeys.medicationTitration("med1")).toEqual([ - "medications", - "med1", - "titration", - ]); expect(queryKeys.medicationCadence("med1")).toEqual([ "medications", "med1", diff --git a/src/lib/medications/titration/__tests__/ladder.test.ts b/src/lib/medications/titration/__tests__/ladder.test.ts deleted file mode 100644 index caea3820a..000000000 --- a/src/lib/medications/titration/__tests__/ladder.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/src/lib/medications/titration/ladder.ts b/src/lib/medications/titration/ladder.ts deleted file mode 100644 index c007541ce..000000000 --- a/src/lib/medications/titration/ladder.ts +++ /dev/null @@ -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; -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 4c223cffa..dc967e3aa 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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) =>