From bdaf128931df4c9690ca4d0be4248129fc068fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 21:21:57 +0200 Subject: [PATCH] feat(medications): pure titration-ladder helpers from W19a knowledge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W19f reads the EMA titration schedules already pinned in the glp1-knowledge layer (titrationStepsMg + titrationIntervalWeeks) and normalises them into a TitrationStep contract the upcoming API route and detail-page section will share: - getLadder(drugId) / ladderFromRecord(record) - findCurrentStep(drugId, latestDoseMg) — snaps within ±10 %, null outside the tolerance window so non-standard doses don't get falsely highlighted - nextStep(drugId, currentStep) — null at the ceiling - weeksOnCurrentStep(drugId, currentStep, doseChanges, asOf) — counts elapsed whole-weeks since the latest matching dose-change row - escalationDue(drugId, currentStep, weeksOnStep) — observational flag (titrationIntervalWeeks reached AND next step exists) The escalationDue helper is a *display* flag, not advice — the UI copy stays observational (MDR boundary, parallel to W19c ground rules). No DB access; the route owns prisma, this module owns math. Same shape as the W19d / W19e pure modules. 22 cases pass covering every EMA-approved drug's ladder, tolerance boundaries, the ceiling edge, weeks-on-step for empty / matching / future histories, and the escalationDue toggle. --- .../titration/__tests__/ladder.test.ts | 198 +++++++++++++++++ src/lib/medications/titration/ladder.ts | 201 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 src/lib/medications/titration/__tests__/ladder.test.ts create mode 100644 src/lib/medications/titration/ladder.ts diff --git a/src/lib/medications/titration/__tests__/ladder.test.ts b/src/lib/medications/titration/__tests__/ladder.test.ts new file mode 100644 index 000000000..caea3820a --- /dev/null +++ b/src/lib/medications/titration/__tests__/ladder.test.ts @@ -0,0 +1,198 @@ +/** + * 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 new file mode 100644 index 000000000..8114ec246 --- /dev/null +++ b/src/lib/medications/titration/ladder.ts @@ -0,0 +1,201 @@ +/** + * 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 step exists. + * + * 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( + drugId: Glp1DrugId, + currentStep: TitrationStep | null, + weeksOnStep: number, +): boolean { + if (!currentStep) return false; + if (!nextStep(drugId, currentStep)) return false; + return weeksOnStep >= currentStep.typicalWeeks; +}