diff --git a/src/lib/medications/__tests__/med-target-map.test.ts b/src/lib/medications/__tests__/med-target-map.test.ts index 81e7103ff..3c3bf6ecf 100644 --- a/src/lib/medications/__tests__/med-target-map.test.ts +++ b/src/lib/medications/__tests__/med-target-map.test.ts @@ -3,6 +3,8 @@ import { inferMedTargetClass, primaryTargetForClass, MED_TARGET_MAP, + resolveMedicationTargets, + targetsForEfficacyClass, } from "@/lib/medications/med-target-map"; describe("med-target-map — class inference", () => { @@ -56,3 +58,83 @@ describe("med-target-map — targets", () => { expect(MED_TARGET_MAP.glp1).toEqual(["BLOOD_GLUCOSE", "WEIGHT"]); }); }); + +describe("resolveMedicationTargets — three-tier efficacy resolution", () => { + it("prefers the ATC class prefix over the name", () => { + // C09 (ACE/ARB) → blood pressure, even when the name is unrecognised. + const r = resolveMedicationTargets({ + name: "SomeUnknownBrand", + atcCode: "C09AA05", + }); + expect(r?.tier).toBe("atc"); + expect(r?.cls).toBe("antihypertensive"); + expect(r?.targets[0]).toEqual({ + kind: "metric", + measurementType: "BLOOD_PRESSURE_SYS", + }); + }); + + it("maps the lipid-modifier ATC class to an LDL lab target", () => { + const r = resolveMedicationTargets({ name: "x", atcCode: "C10AA05" }); + expect(r?.cls).toBe("statin"); + expect(r?.targets[0]).toEqual({ + kind: "lab", + analyte: "LDL", + label: "LDL cholesterol", + }); + }); + + it("routes the GLP-1 ATC leaf to glucose+weight, not the A10 fallback", () => { + const r = resolveMedicationTargets({ name: "x", atcCode: "A10BJ06" }); + expect(r?.cls).toBe("glp1"); + const other = resolveMedicationTargets({ name: "x", atcCode: "A10BA02" }); + expect(other?.cls).toBe("antidiabetic"); + }); + + it("maps thyroid ATC to a TSH lab target", () => { + const r = resolveMedicationTargets({ name: "x", atcCode: "H03AA01" }); + expect(r?.cls).toBe("thyroid"); + expect(r?.targets[0].kind).toBe("lab"); + }); + + it("falls back to name inference when no valid ATC code is present", () => { + const r = resolveMedicationTargets({ name: "Atorvastatin 20mg" }); + expect(r?.tier).toBe("name"); + expect(r?.cls).toBe("statin"); + const bp = resolveMedicationTargets({ name: "Ramipril" }); + expect(bp?.tier).toBe("name"); + expect(bp?.cls).toBe("antihypertensive"); + }); + + it("infers supplement lab targets by name", () => { + expect(resolveMedicationTargets({ name: "Cholecalciferol" })?.cls).toBe( + "vitamin_d", + ); + expect( + resolveMedicationTargets({ name: "Ferrous sulfate 200mg" })?.cls, + ).toBe("iron"); + }); + + it("ignores a malformed ATC code and falls through to the name", () => { + const r = resolveMedicationTargets({ + name: "Ramipril", + atcCode: "not-an-atc", + }); + expect(r?.tier).toBe("name"); + expect(r?.cls).toBe("antihypertensive"); + }); + + it("conservative-fails to null for an unknown med with no ATC", () => { + expect(resolveMedicationTargets({ name: "Aspirin" })).toBeNull(); + expect(resolveMedicationTargets({ name: "" })).toBeNull(); + }); + + it("exposes the ordered target list per efficacy class", () => { + expect(targetsForEfficacyClass("antihypertensive")).toHaveLength(2); + expect(targetsForEfficacyClass("thyroid")[0]).toEqual({ + kind: "lab", + analyte: "TSH", + label: "TSH", + }); + }); +}); diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 7aca54bf4..500ede128 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -158,3 +158,202 @@ export function inferMedTargetClass( } return null; } + +// ─── v1.28 efficacy resolver (extends the closed map above) ─────────── +// +// The efficacy view ("Wirkung" tab + the Insights summary) needs a target +// resolver that (a) can point at LAB analytes (statin→LDL, thyroid→TSH, +// supplements→their marker) — the documented follow-on the v1.22 map left +// out — and (b) consults the WHO ATC class prefix on `Medication.atcCode` +// before falling back to name inference. It is ADDITIVE: the metric-only +// `MED_TARGET_MAP` / `inferMedTargetClass` / `primaryTargetForClass` above +// stay byte-identical so the adherence-storyline safety path is untouched. +// +// Same discipline as the map above: closed tables, whole-word / fixed-prefix +// matching, conservative-fail to an EMPTY target list (never a guess). The +// clinical association ("this class is prescribed to move this outcome") is +// documented, guideline-cited and vendor-blind in the external knowledge base +// (`medications/drug-class-targets.md`); this code is its mechanical mirror. +// No efficacy claim is encoded here — only what a class is prescribed to move. + +/** + * A resolved target: either a first-class measurement series or a lab analyte. + * The lab variant carries a `contains`-match needle (matched against a + * `LabResult.analyte` or the linked `Biomarker.name`, exactly as + * `getLabHistory` matches) plus a human label for the DTO / UI. + */ +export type MedTarget = + | { kind: "metric"; measurementType: MeasurementType } + | { kind: "lab"; analyte: string; label: string }; + +/** + * The wider class set the efficacy resolver reasons about: the three + * metric classes above plus the lab-analyte classes (statin→LDL, + * thyroid→TSH) and the two supplement markers named in the plan. + */ +export type EfficacyMedClass = + | MedTargetClass + | "statin" + | "thyroid" + | "vitamin_d" + | "iron"; + +/** Class → its ordered target list (primary first). Closed + conservative. */ +const EFFICACY_TARGETS: Readonly< + Record +> = { + antihypertensive: [ + { kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" }, + { kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" }, + ], + antidiabetic: [{ kind: "metric", measurementType: "BLOOD_GLUCOSE" }], + glp1: [ + { kind: "metric", measurementType: "WEIGHT" }, + { kind: "metric", measurementType: "BLOOD_GLUCOSE" }, + ], + // Lipid-modifiers (statins et al.) are monitored on LDL — a lab analyte. + statin: [{ kind: "lab", analyte: "LDL", label: "LDL cholesterol" }], + // Thyroid therapy is titrated against TSH — a lab analyte. + thyroid: [{ kind: "lab", analyte: "TSH", label: "TSH" }], + // Supplements are tracked against their own marker. + vitamin_d: [ + { kind: "lab", analyte: "Vitamin D", label: "Vitamin D (25-OH)" }, + ], + iron: [{ kind: "lab", analyte: "Ferritin", label: "Ferritin" }], +} as const; + +/** + * WHO ATC class-prefix → efficacy class. Prefix-level ONLY (the class the + * substance belongs to), never a specific-product claim. Longer prefixes are + * tested first so `A10BJ` (GLP-1/GIP agonists) wins over the `A10` fallback. + * The prefix is upper-cased + validated against the `atcCode` shape before use. + */ +const ATC_PREFIX_CLASS: ReadonlyArray = [ + // Antidiabetics: GLP-1 / GIP agonists move glucose AND weight; the rest of + // A10 (metformin, sulfonylureas, SGLT2, DPP-4, insulin) move glucose. + ["A10BJ", "glp1"], + ["A10", "antidiabetic"], + // Cardiovascular: antihypertensives (C02 other, C03 diuretics, C07 beta + // blockers, C08 CCB, C09 ACE/ARB) → blood pressure. + ["C02", "antihypertensive"], + ["C03", "antihypertensive"], + ["C07", "antihypertensive"], + ["C08", "antihypertensive"], + ["C09", "antihypertensive"], + // Lipid-modifying agents → LDL. + ["C10", "statin"], + // Thyroid therapy → TSH. + ["H03A", "thyroid"], + // Vitamin D / analogues (A11CC) and vitamin-D-only combos (A11CB). + ["A11CC", "vitamin_d"], + ["A11CB", "vitamin_d"], + // Iron preparations (oral B03AA/AB/AD/AE, parenteral B03AC). + ["B03A", "iron"], +]; + +const ATC_CODE_RE = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/; + +/** Whole-word name needles for the lab classes (metric classes stay above). */ +const STATIN_NEEDLES: readonly string[] = [ + "atorvastatin", + "simvastatin", + "rosuvastatin", + "pravastatin", + "fluvastatin", + "lovastatin", + "pitavastatin", + "ezetimibe", +]; +const THYROID_NEEDLES: readonly string[] = [ + "levothyroxine", + "liothyronine", + "thyroxine", + "euthyrox", +]; +const VITAMIN_D_NEEDLES: readonly string[] = [ + "cholecalciferol", + "colecalciferol", + "ergocalciferol", + "calcifediol", + "calcitriol", +]; +const IRON_NEEDLES: readonly string[] = [ + "ferrous", + "ferric", + "iron bisglycinate", +]; + +/** Resolve a med's efficacy class from its ATC prefix, or `null`. */ +function classFromAtc( + atcCode: string | null | undefined, +): EfficacyMedClass | null { + if (!atcCode) return null; + const code = atcCode.trim().toUpperCase(); + if (!ATC_CODE_RE.test(code)) return null; + for (const [prefix, cls] of ATC_PREFIX_CLASS) { + if (code.startsWith(prefix)) return cls; + } + return null; +} + +/** Resolve a med's efficacy class from its name (metric + lab needles). */ +function classFromName( + name: string, + treatmentClass?: string | null, +): EfficacyMedClass | null { + // Metric classes stay authoritative through the existing inferer. + const metricClass = inferMedTargetClass(name, treatmentClass); + if (metricClass) return metricClass; + + const normalised = normaliseName(name); + if (!normalised) return null; + if (STATIN_NEEDLES.some((n) => containsWord(normalised, n))) return "statin"; + if (THYROID_NEEDLES.some((n) => containsWord(normalised, n))) return "thyroid"; + if (VITAMIN_D_NEEDLES.some((n) => containsWord(normalised, n))) { + return "vitamin_d"; + } + if (IRON_NEEDLES.some((n) => containsWord(normalised, n))) return "iron"; + return null; +} + +/** The tier a target list was resolved through (provenance for the DTO/UI). */ +export type MedTargetTier = "atc" | "name"; + +export interface ResolvedMedTargets { + cls: EfficacyMedClass; + tier: MedTargetTier; + targets: readonly MedTarget[]; +} + +/** + * Resolve the derived (non-override) efficacy targets for a medication: + * ATC class-prefix FIRST (the guideline-native class key), then whole-word + * name inference. Returns `null` when no class is confidently known — the + * caller then falls back to the user's own explicit pick (tier 1, persisted) + * or the "track this against…" chooser. Never guesses a target. + * + * Tier 1 (user override) is NOT resolved here — it is persisted and applied by + * the server efficacy builder, which layers it on top of this derived result. + */ +export function resolveMedicationTargets(med: { + name: string; + treatmentClass?: string | null; + atcCode?: string | null; +}): ResolvedMedTargets | null { + const viaAtc = classFromAtc(med.atcCode); + if (viaAtc) { + return { cls: viaAtc, tier: "atc", targets: EFFICACY_TARGETS[viaAtc] }; + } + const viaName = classFromName(med.name, med.treatmentClass); + if (viaName) { + return { cls: viaName, tier: "name", targets: EFFICACY_TARGETS[viaName] }; + } + return null; +} + +/** The ordered target list for an efficacy class (primary first). */ +export function targetsForEfficacyClass( + cls: EfficacyMedClass, +): readonly MedTarget[] { + return EFFICACY_TARGETS[cls]; +}