mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user