feat(medications): pure titration-ladder helpers from W19a knowledge

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:21:57 +02:00
1 parent 631b841c5f
commit bdaf128931
2 files changed
+399

No files matched your search

@@ -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);
}
});
});
+201
View File
@@ -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;
}