mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(medications): GLP-1 EMA drug knowledge layer — 5 drugs, EPAR-sourced
Static read-only catalog covering the five EMA-approved GLP-1 / GIP-GLP-1 receptor agonists (tirzepatide, semaglutide, liraglutide, dulaglutide, exenatide). Each record carries pharmacology, titration, storage, pen form-factor, EMA §4.8 adverse-reaction frequencies, contraindications, warnings, and a direct EMA EPAR citation. Tirzepatide PK parameters are pinned to Schneck & Urva 2024 (DOI 10.1002/psp4.13099) — half-life 5.0 d, Ka 0.0373 h⁻¹, CL 0.0329 L/h per 70 kg, F 0.80, two-compartment model. One-to-many brand mapping (Mounjaro+Zepbound → tirzepatide, Ozempic+Wegovy+Rybelsus → semaglutide, etc.) plus per-brand route overrides for the only mixed-route entry (Rybelsus oral, others SC). Retatrutide deliberately omitted — no EMA approval as of 2026-05. 32 unit tests cover shape integrity, parameter pinning, brand-route consistency, ascending titration ladders, storage window sanity, and N7 retatrutide-exclusion.
This commit is contained in:
1 parent
62e4b1d281
commit
cee5bf54b6
2 files changed
+863
No files matched your search
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GLP1_DRUG_IDS,
|
||||
GLP1_DRUGS,
|
||||
findDrugByBrand,
|
||||
routeForBrand,
|
||||
type Glp1DrugId,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
|
||||
describe("glp1-knowledge catalog", () => {
|
||||
describe("shape integrity", () => {
|
||||
it("includes exactly the five EMA-approved drug ids", () => {
|
||||
const keys = Object.keys(GLP1_DRUGS).sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
"dulaglutide",
|
||||
"exenatide",
|
||||
"liraglutide",
|
||||
"semaglutide",
|
||||
"tirzepatide",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never lists retatrutide (no EMA approval — N7 exclusion)", () => {
|
||||
// Belt-and-braces — both at the catalog level AND the brand
|
||||
// level. Retatrutide has no EMA EPAR; including it would
|
||||
// imply endorsement of unauthorised use.
|
||||
expect(GLP1_DRUGS).not.toHaveProperty("retatrutide");
|
||||
for (const record of Object.values(GLP1_DRUGS)) {
|
||||
expect(
|
||||
record.brands.map((b) => b.toLowerCase()),
|
||||
).not.toContain("retatrutide");
|
||||
expect(record.inn.toLowerCase()).not.toBe("retatrutide");
|
||||
}
|
||||
});
|
||||
|
||||
it("populates every required field for every drug", () => {
|
||||
for (const [id, record] of Object.entries(GLP1_DRUGS)) {
|
||||
expect(record.inn, `inn missing on ${id}`).toBeTruthy();
|
||||
expect(record.brands.length, `brands empty on ${id}`).toBeGreaterThan(0);
|
||||
expect(record.route, `route missing on ${id}`).toBeTruthy();
|
||||
expect(record.drugClass, `drugClass missing on ${id}`).toBeTruthy();
|
||||
expect(record.pharmacology, `pharmacology missing on ${id}`).toBeTruthy();
|
||||
expect(
|
||||
record.pharmacology.halfLifeDays,
|
||||
`halfLifeDays missing on ${id}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
record.pharmacology.bioavailability,
|
||||
`bioavailability missing on ${id}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(record.storage.unopened.temperatureCelsius.min).toBeDefined();
|
||||
expect(record.storage.inUse.maxDays).toBeGreaterThan(0);
|
||||
expect(record.titrationStepsMg.length).toBeGreaterThan(0);
|
||||
expect(record.maxDoseMg).toBeGreaterThan(0);
|
||||
expect(record.sideEffects.veryCommon.length).toBeGreaterThan(0);
|
||||
expect(record.sourceEMA, `sourceEMA missing on ${id}`).toMatch(
|
||||
/^https:\/\/www\.ema\.europa\.eu\//,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches drug-ids and inn to the expected case", () => {
|
||||
// Internal ids are lowercase; INN is title-case per WHO
|
||||
// convention.
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
expect(id).toBe(id.toLowerCase());
|
||||
const record = GLP1_DRUGS[id];
|
||||
expect(record.inn[0]).toBe(record.inn[0].toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the catalog readonly at the type level", () => {
|
||||
// `as const` discipline — verify mutating the catalog at
|
||||
// runtime is impossible (frozen Object.freeze isn't applied
|
||||
// by `as const`, but the tests assert the static contract).
|
||||
// This is mostly a developer-readability test: if a future
|
||||
// refactor drops `as const`, the test failure says why.
|
||||
const tirzepatide = GLP1_DRUGS.tirzepatide;
|
||||
expect(tirzepatide.brands).toEqual(["Mounjaro", "Zepbound"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pharmacology values vs psp4.13099 + EMA EPAR", () => {
|
||||
it("pins tirzepatide half-life to 5.0 d (EMA EPAR §5.2 ≈ 5 d)", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.pharmacology.halfLifeDays).toBe(5.0);
|
||||
});
|
||||
|
||||
it("pins tirzepatide Ka to 0.0373 h⁻¹ (psp4.13099 Table 3)", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.pharmacology.absorptionRateHourlyKa).toBe(
|
||||
0.0373,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins tirzepatide clearance to 0.0329 L/h per 70 kg (psp4.13099 Table 3)", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.pharmacology.clearanceLPerHour70kg).toBe(
|
||||
0.0329,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins tirzepatide bioavailability to 0.80 (psp4.13099 fixed)", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.pharmacology.bioavailability).toBe(0.8);
|
||||
});
|
||||
|
||||
it("pins semaglutide half-life to 7 d (EMA EPAR §5.2 ≈ 1 week)", () => {
|
||||
expect(GLP1_DRUGS.semaglutide.pharmacology.halfLifeDays).toBe(7.0);
|
||||
});
|
||||
|
||||
it("pins liraglutide half-life near 13 h (EMA EPAR §5.2)", () => {
|
||||
// Stored as days for shape consistency — 13 h = 0.5416…
|
||||
const days = GLP1_DRUGS.liraglutide.pharmacology.halfLifeDays;
|
||||
expect(days * 24).toBeCloseTo(13, 1);
|
||||
});
|
||||
|
||||
it("pins dulaglutide half-life to ~5 d (EMA EPAR §5.2)", () => {
|
||||
expect(GLP1_DRUGS.dulaglutide.pharmacology.halfLifeDays).toBe(5.0);
|
||||
});
|
||||
|
||||
it("pins exenatide IR half-life near 2.4 h (Byetta EMA EPAR §5.2)", () => {
|
||||
const days = GLP1_DRUGS.exenatide.pharmacology.halfLifeDays;
|
||||
expect(days * 24).toBeCloseTo(2.4, 1);
|
||||
});
|
||||
|
||||
it("flags tirzepatide as two-compartment per the journal-of-record", () => {
|
||||
// psp4.13099 verbatim: "two-compartment model with first
|
||||
// order absorption and elimination."
|
||||
expect(GLP1_DRUGS.tirzepatide.pharmacology.compartmentModel).toBe(
|
||||
"two-compartment",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brand-route mapping consistency", () => {
|
||||
it("keeps Rybelsus mapped to oral, Ozempic + Wegovy to SC", () => {
|
||||
expect(routeForBrand("semaglutide", "Rybelsus")).toBe("oral");
|
||||
expect(routeForBrand("semaglutide", "Ozempic")).toBe("subcutaneous");
|
||||
expect(routeForBrand("semaglutide", "Wegovy")).toBe("subcutaneous");
|
||||
});
|
||||
|
||||
it("returns the default route when no per-brand override exists", () => {
|
||||
expect(routeForBrand("tirzepatide", "Mounjaro")).toBe("subcutaneous");
|
||||
expect(routeForBrand("tirzepatide", "Zepbound")).toBe("subcutaneous");
|
||||
expect(routeForBrand("liraglutide", "Saxenda")).toBe("subcutaneous");
|
||||
expect(routeForBrand("liraglutide", "Victoza")).toBe("subcutaneous");
|
||||
});
|
||||
|
||||
it("returns null for an unknown brand on a known drug", () => {
|
||||
expect(routeForBrand("tirzepatide", "DoesNotExist")).toBeNull();
|
||||
});
|
||||
|
||||
it("never marks an injection-only drug brand as oral", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const record = GLP1_DRUGS[id];
|
||||
for (const brand of record.brands) {
|
||||
const route = routeForBrand(id, brand);
|
||||
// The only oral brand is Rybelsus.
|
||||
if (brand !== "Rybelsus") {
|
||||
expect(route).toBe("subcutaneous");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("titration ladders", () => {
|
||||
it("ascends strictly for every drug", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const steps = GLP1_DRUGS[id].titrationStepsMg;
|
||||
for (let i = 1; i < steps.length; i++) {
|
||||
expect(
|
||||
steps[i],
|
||||
`${id} titration step ${i} not strictly ascending`,
|
||||
).toBeGreaterThan(steps[i - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("tops out at maxDoseMg for every drug", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const record = GLP1_DRUGS[id];
|
||||
const lastStep = record.titrationStepsMg[record.titrationStepsMg.length - 1];
|
||||
expect(lastStep).toBe(record.maxDoseMg);
|
||||
}
|
||||
});
|
||||
|
||||
it("captures the 2.5-→-15 mg Mounjaro ladder per EMA §4.2", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.titrationStepsMg).toEqual([
|
||||
2.5, 5, 7.5, 10, 12.5, 15,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage windows", () => {
|
||||
it("refrigerates every product unopened at 2–8 °C", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const range = GLP1_DRUGS[id].storage.unopened.temperatureCelsius;
|
||||
expect(range.min).toBe(2);
|
||||
expect(range.max).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps in-use temperature at ≤ 30 °C per EMA §6.3", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const range = GLP1_DRUGS[id].storage.inUse.temperatureCelsius;
|
||||
// Byetta has a 25 °C cap; everything else 30 °C.
|
||||
expect(range.max).toBeLessThanOrEqual(30);
|
||||
expect(range.max).toBeGreaterThanOrEqual(25);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every in-use window between 1 and 60 days", () => {
|
||||
// Sanity check — no infinite shelf life, no stale defaults.
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const days = GLP1_DRUGS[id].storage.inUse.maxDays;
|
||||
expect(days).toBeGreaterThan(0);
|
||||
expect(days).toBeLessThanOrEqual(60);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets Mounjaro KwikPen to 30 days post-opening per EMA §6.3", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.storage.inUse.maxDays).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDrugByBrand", () => {
|
||||
it("finds tirzepatide by Mounjaro and Zepbound", () => {
|
||||
expect(findDrugByBrand("Mounjaro")?.inn).toBe("Tirzepatide");
|
||||
expect(findDrugByBrand("Zepbound")?.inn).toBe("Tirzepatide");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(findDrugByBrand("mounjaro")?.inn).toBe("Tirzepatide");
|
||||
expect(findDrugByBrand("MOUNJARO")?.inn).toBe("Tirzepatide");
|
||||
});
|
||||
|
||||
it("returns null for unknown brand", () => {
|
||||
expect(findDrugByBrand("BogusName")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(findDrugByBrand(" Wegovy ")?.inn).toBe("Semaglutide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("citations", () => {
|
||||
it("links every record to an EMA EPAR PDF", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
expect(GLP1_DRUGS[id].sourceEMA).toMatch(
|
||||
/^https:\/\/www\.ema\.europa\.eu\/.*\.pdf$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches the psp4.13099 journal citation to tirzepatide", () => {
|
||||
expect(GLP1_DRUGS.tirzepatide.sourceJournal?.doi).toBe(
|
||||
"10.1002/psp4.13099",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLP1_DRUG_IDS", () => {
|
||||
it("covers every catalog key in a stable order", () => {
|
||||
const fromIds = [...GLP1_DRUG_IDS].sort();
|
||||
const fromCatalog = Object.keys(GLP1_DRUGS).sort() as Glp1DrugId[];
|
||||
expect(fromIds).toEqual(fromCatalog);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* v1.4.25 W19a — GLP-1 EMA drug knowledge layer.
|
||||
*
|
||||
* Read-only static reference data for the five EMA-approved GLP-1
|
||||
* receptor agonists (and one dual GIP/GLP-1 agonist). Values are
|
||||
* extracted verbatim from EMA EPAR Product Information PDFs and
|
||||
* cross-validated against the journal-of-record population PK paper
|
||||
* for tirzepatide (Schneck & Urva 2024, DOI 10.1002/psp4.13099).
|
||||
*
|
||||
* Posture (per `.planning/research/glp1-feature-inspiration.md` §11):
|
||||
* - Display only. No autonomous dose escalation, no individual
|
||||
* weight-loss projection, no drug-drug interaction checking,
|
||||
* no mixing/reconstitution math.
|
||||
* - Coach must NEVER reason from these numbers as if they were
|
||||
* individual predictions; GROUND RULE 9 still applies.
|
||||
* - Retatrutide is deliberately excluded — no EMA approval as of
|
||||
* 2026-05; including it would imply endorsement of unauthorised
|
||||
* use (N7).
|
||||
*
|
||||
* Sources:
|
||||
* - EMA EPAR PDFs linked per drug below.
|
||||
* - Schneck KB, Urva S. "Population pharmacokinetics of the GIP/GLP
|
||||
* receptor agonist tirzepatide." CPT Pharmacometrics Syst
|
||||
* Pharmacol. 2024;13(3):494-503. DOI 10.1002/psp4.13099.
|
||||
*
|
||||
* Numbers are pinned exactly per the research file's §1.1–§1.5 and
|
||||
* the psp4.13099 cross-validation in §2.6. Drift introduces clinical
|
||||
* risk; see `__tests__/glp1-knowledge-drift.test.ts` for the guard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Internal drug ids — stable across schema migrations. Brand-aware
|
||||
* UI looks up by id, not by brand name.
|
||||
*/
|
||||
export type Glp1DrugId =
|
||||
| "tirzepatide"
|
||||
| "semaglutide"
|
||||
| "liraglutide"
|
||||
| "dulaglutide"
|
||||
| "exenatide";
|
||||
|
||||
/**
|
||||
* Route enum — most agonists are subcutaneous; Rybelsus is oral
|
||||
* semaglutide. The catalog records the default route on the drug
|
||||
* record AND a per-brand override map so a single drug-id can carry
|
||||
* mixed routes (semaglutide → Ozempic/Wegovy sc, Rybelsus oral).
|
||||
*/
|
||||
export type Glp1Route = "subcutaneous" | "oral";
|
||||
|
||||
/**
|
||||
* Drug class descriptor — Tirzepatide is the only dual GIP/GLP-1
|
||||
* agonist among EMA-approved agents (2026-05); the rest are pure
|
||||
* GLP-1 receptor agonists.
|
||||
*/
|
||||
export type Glp1DrugClass = "GIP-GLP1 dual agonist" | "GLP-1 receptor agonist";
|
||||
|
||||
export type Glp1Pharmacology = {
|
||||
/** Terminal elimination half-life. Days for the long-acting agents,
|
||||
* hours for liraglutide / short-acting exenatide. */
|
||||
halfLifeDays: number;
|
||||
/** Time to maximum concentration after a SC dose, hours. Liraglutide
|
||||
* uses the EMA EPAR §5.2 mean (~11 h); the weekly agents use the
|
||||
* pharmacology-text midpoint of their EMA-published 8–72 h range. */
|
||||
tmaxHours: number;
|
||||
/** First-order absorption rate constant. Populated from the
|
||||
* journal-of-record where one exists (tirzepatide ←
|
||||
* Schneck/Urva 2024); marked null where EMA does not publish a
|
||||
* pop-PK Ka estimate (the EPARs cite only terminal half-life). */
|
||||
absorptionRateHourlyKa: number | null;
|
||||
/** SC bioavailability, fraction in [0,1]. EMA EPAR §5.2 values. */
|
||||
bioavailability: number;
|
||||
/** Volume of distribution per kg body weight. Derived from the
|
||||
* EPAR's reported Vd at a 70 kg reference where the source gives
|
||||
* an absolute litre value. */
|
||||
vdLitersPerKg: number;
|
||||
/** Apparent clearance at the 70 kg reference weight, L/h. From
|
||||
* EPAR §5.2 or the journal-of-record where available. */
|
||||
clearanceLPerHour70kg: number;
|
||||
/** Weeks until steady-state plateau on the standard cadence. */
|
||||
steadyStateWeeks: number;
|
||||
/** Compartment model that best describes published PK. The
|
||||
* research-view curve (R8, deferred to v1.5) will honour this. */
|
||||
compartmentModel: "one-compartment" | "two-compartment";
|
||||
};
|
||||
|
||||
export type Glp1Storage = {
|
||||
/** Refrigerated stability — every GLP-1 product is shipped 2–8 °C
|
||||
* per EMA. */
|
||||
unopened: {
|
||||
temperatureCelsius: { min: number; max: number };
|
||||
};
|
||||
/** Post-opening / in-use window. EMA §6.3 caps every product at
|
||||
* ≤ 30 °C; the day-count varies by product. */
|
||||
inUse: {
|
||||
temperatureCelsius: { min: number; max: number };
|
||||
maxDays: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type Glp1Pen = {
|
||||
/** Pen device name as printed on the carton. */
|
||||
type: string;
|
||||
/** Doses per pen / per cartridge — drives W19b inventory
|
||||
* depletion math. `null` means single-use (one pen per dose). */
|
||||
dosesPerPen: number | null;
|
||||
};
|
||||
|
||||
export type Glp1AdverseReactions = {
|
||||
/** ≥ 1/10 frequency per EMA §4.8. */
|
||||
veryCommon: readonly string[];
|
||||
/** 1/100 to <1/10 per EMA §4.8. */
|
||||
common: readonly string[];
|
||||
/** 1/1 000 to <1/100 per EMA §4.8. */
|
||||
uncommon: readonly string[];
|
||||
/** < 1/1 000 per EMA §4.8 — typically post-marketing signals. */
|
||||
rare: readonly string[];
|
||||
};
|
||||
|
||||
export type Glp1Indications = {
|
||||
t2dm: { firstLine: boolean };
|
||||
weightManagement: { bmiThreshold: number | null };
|
||||
};
|
||||
|
||||
export type Glp1SourceCitations = {
|
||||
/** Direct link to the EMA EPAR product-information PDF. */
|
||||
sourceEMA: string;
|
||||
/** Peer-reviewed pharmacometric citation where one exists. */
|
||||
sourceJournal?: {
|
||||
citation: string;
|
||||
doi: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Glp1DrugRecord = {
|
||||
inn: string;
|
||||
brands: readonly string[];
|
||||
/** Default route for the drug; brand-specific overrides go in
|
||||
* `brandRoutes`. */
|
||||
route: Glp1Route;
|
||||
/** Per-brand route override map. Only populated when at least one
|
||||
* brand departs from the default route (e.g. Rybelsus is oral
|
||||
* semaglutide while Ozempic / Wegovy are SC). */
|
||||
brandRoutes?: Readonly<Record<string, Glp1Route>>;
|
||||
drugClass: Glp1DrugClass;
|
||||
pharmacology: Glp1Pharmacology;
|
||||
/** Standard titration ladder in mg. Strictly ascending. The
|
||||
* schedule is informational per EMA EPAR §4.2 — the UI surfaces
|
||||
* it for context, never as autonomous escalation guidance (N1). */
|
||||
titrationStepsMg: readonly number[];
|
||||
/** Minimum interval between titration steps per EMA §4.2. */
|
||||
titrationIntervalWeeks: number;
|
||||
maxDoseMg: number;
|
||||
standardInjectionFrequency: "daily" | "twice-daily" | "weekly";
|
||||
indications: Glp1Indications;
|
||||
storage: Glp1Storage;
|
||||
pen: Glp1Pen;
|
||||
sideEffects: Glp1AdverseReactions;
|
||||
contraindications: readonly string[];
|
||||
warnings: readonly string[];
|
||||
} & Glp1SourceCitations;
|
||||
|
||||
/**
|
||||
* The catalog. Five drug records keyed by stable id. Brand-name
|
||||
* lookup is one-to-many; route-mapping is exposed both as the
|
||||
* record default and the per-brand override.
|
||||
*/
|
||||
export const GLP1_DRUGS: Readonly<Record<Glp1DrugId, Glp1DrugRecord>> = {
|
||||
// §1.1 — Tirzepatide. EMA EPAR cross-validated against
|
||||
// Schneck & Urva 2024 (psp4.13099). Two-compartment first-order
|
||||
// absorption + elimination per the journal-of-record.
|
||||
tirzepatide: {
|
||||
inn: "Tirzepatide",
|
||||
brands: ["Mounjaro", "Zepbound"],
|
||||
route: "subcutaneous",
|
||||
drugClass: "GIP-GLP1 dual agonist",
|
||||
pharmacology: {
|
||||
halfLifeDays: 5.0,
|
||||
tmaxHours: 24,
|
||||
absorptionRateHourlyKa: 0.0373, // psp4.13099 Table 3
|
||||
bioavailability: 0.8, // EMA EPAR §5.2
|
||||
vdLitersPerKg: 0.09, // ~6.45 L / 70 kg per psp4.13099 (Vc + Vp)
|
||||
clearanceLPerHour70kg: 0.0329, // psp4.13099 Table 3
|
||||
steadyStateWeeks: 4, // EMA EPAR §5.2
|
||||
compartmentModel: "two-compartment", // psp4.13099 verbatim
|
||||
},
|
||||
titrationStepsMg: [2.5, 5, 7.5, 10, 12.5, 15], // EMA EPAR §4.2
|
||||
titrationIntervalWeeks: 4, // EMA EPAR §4.2
|
||||
maxDoseMg: 15,
|
||||
standardInjectionFrequency: "weekly",
|
||||
indications: {
|
||||
t2dm: { firstLine: false }, // adjunct, not first-line per §4.1
|
||||
weightManagement: { bmiThreshold: 30 }, // §4.1 (or ≥ 27 with comorbidity)
|
||||
},
|
||||
storage: {
|
||||
unopened: { temperatureCelsius: { min: 2, max: 8 } }, // §6.4
|
||||
inUse: { temperatureCelsius: { min: 2, max: 30 }, maxDays: 30 }, // §6.3 KwikPen
|
||||
},
|
||||
pen: {
|
||||
type: "KwikPen",
|
||||
dosesPerPen: 4, // §6.5
|
||||
},
|
||||
sideEffects: {
|
||||
veryCommon: [
|
||||
"nausea",
|
||||
"diarrhea",
|
||||
"vomiting",
|
||||
"abdominal_pain",
|
||||
"constipation",
|
||||
"decreased_appetite",
|
||||
],
|
||||
common: [
|
||||
"hypoglycaemia",
|
||||
"hypersensitivity",
|
||||
"dizziness",
|
||||
"hypotension",
|
||||
"dyspepsia",
|
||||
"abdominal_distension",
|
||||
"eructation",
|
||||
"flatulence",
|
||||
"gerd",
|
||||
"hair_loss",
|
||||
"fatigue",
|
||||
"injection_site_reaction",
|
||||
"heart_rate_increased",
|
||||
],
|
||||
uncommon: [
|
||||
"weight_decreased",
|
||||
"dysgeusia",
|
||||
"dysaesthesia",
|
||||
"cholelithiasis",
|
||||
"cholecystitis",
|
||||
"acute_pancreatitis",
|
||||
"delayed_gastric_emptying",
|
||||
"injection_site_pain",
|
||||
],
|
||||
rare: ["anaphylactic_reaction", "angioedema"],
|
||||
},
|
||||
contraindications: ["hypersensitivity_active_substance_or_excipients"],
|
||||
warnings: [
|
||||
"pancreatitis_history",
|
||||
"severe_gi_disease",
|
||||
"diabetic_retinopathy_macular_oedema",
|
||||
"pulmonary_aspiration_risk_anaesthesia",
|
||||
"pregnancy_long_half_life_discontinue_one_month_before",
|
||||
],
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/mounjaro-epar-product-information_en.pdf",
|
||||
sourceJournal: {
|
||||
citation:
|
||||
"Schneck KB, Urva S. CPT Pharmacometrics Syst Pharmacol. 2024;13(3):494-503.",
|
||||
doi: "10.1002/psp4.13099",
|
||||
},
|
||||
},
|
||||
|
||||
// §1.2–§1.3 — Semaglutide (Ozempic injectable, Wegovy injectable
|
||||
// weight-mgmt, Rybelsus oral). EMA EPAR §5.2: half-life ≈ 1 week.
|
||||
// Rybelsus is the one mixed-route brand — `brandRoutes` carries
|
||||
// the oral exception while the default stays SC.
|
||||
semaglutide: {
|
||||
inn: "Semaglutide",
|
||||
brands: ["Ozempic", "Wegovy", "Rybelsus"],
|
||||
route: "subcutaneous",
|
||||
brandRoutes: {
|
||||
Ozempic: "subcutaneous",
|
||||
Wegovy: "subcutaneous",
|
||||
Rybelsus: "oral",
|
||||
},
|
||||
drugClass: "GLP-1 receptor agonist",
|
||||
pharmacology: {
|
||||
halfLifeDays: 7.0, // EMA EPAR §5.2 (≈ 1 week)
|
||||
tmaxHours: 24, // EMA EPAR §5.2 (Tmax 1–3 d for SC)
|
||||
absorptionRateHourlyKa: null, // EMA EPAR does not publish a pop-PK Ka
|
||||
bioavailability: 0.89, // EMA Ozempic §5.2
|
||||
vdLitersPerKg: 0.18, // Wegovy §5.2 (~12.4 L / 70 kg)
|
||||
clearanceLPerHour70kg: 0.05, // EMA Ozempic §5.2
|
||||
steadyStateWeeks: 5, // EMA EPAR §5.2 (4–5 weeks)
|
||||
compartmentModel: "two-compartment", // EMA EPAR §5.2 pop-PK
|
||||
},
|
||||
titrationStepsMg: [0.25, 0.5, 1, 2], // Ozempic ladder §4.2
|
||||
titrationIntervalWeeks: 4,
|
||||
maxDoseMg: 2, // Ozempic; Wegovy max 2.4 (then 7.2)
|
||||
standardInjectionFrequency: "weekly",
|
||||
indications: {
|
||||
t2dm: { firstLine: false }, // Ozempic — adjunct
|
||||
weightManagement: { bmiThreshold: 30 }, // Wegovy ≥30 or ≥27 w/ comorbidity
|
||||
},
|
||||
storage: {
|
||||
unopened: { temperatureCelsius: { min: 2, max: 8 } }, // §6.4
|
||||
inUse: { temperatureCelsius: { min: 2, max: 30 }, maxDays: 56 }, // Ozempic §6.3
|
||||
},
|
||||
pen: {
|
||||
type: "FlexTouch",
|
||||
dosesPerPen: 4, // Ozempic 0.25/0.5 mg dose pen
|
||||
},
|
||||
sideEffects: {
|
||||
veryCommon: ["nausea", "diarrhea", "vomiting", "abdominal_pain"],
|
||||
common: [
|
||||
"hypoglycaemia",
|
||||
"decreased_appetite",
|
||||
"dizziness",
|
||||
"dyspepsia",
|
||||
"constipation",
|
||||
"eructation",
|
||||
"flatulence",
|
||||
"abdominal_distension",
|
||||
"gerd",
|
||||
"fatigue",
|
||||
"injection_site_reaction",
|
||||
"lipase_increased",
|
||||
"amylase_increased",
|
||||
"cholelithiasis",
|
||||
],
|
||||
uncommon: [
|
||||
"hypersensitivity",
|
||||
"dysgeusia",
|
||||
"pulse_increased",
|
||||
"diabetic_retinopathy_complications",
|
||||
"acute_pancreatitis",
|
||||
],
|
||||
rare: ["anaphylactic_reaction", "angioedema"],
|
||||
},
|
||||
contraindications: ["hypersensitivity_active_substance_or_excipients"],
|
||||
warnings: [
|
||||
"pancreatitis_history",
|
||||
"diabetic_retinopathy_macular_oedema",
|
||||
"dehydration_risk",
|
||||
"pregnancy_long_half_life_discontinue_two_months_before",
|
||||
],
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/ozempic-epar-product-information_en.pdf",
|
||||
},
|
||||
|
||||
// §1.4 — Liraglutide (Saxenda weight-mgmt, Victoza T2DM). Daily
|
||||
// SC dosing — fundamentally different cadence from the weekly
|
||||
// agonists. Half-life ≈ 13 h per EMA EPAR §5.2.
|
||||
liraglutide: {
|
||||
inn: "Liraglutide",
|
||||
brands: ["Saxenda", "Victoza"],
|
||||
route: "subcutaneous",
|
||||
drugClass: "GLP-1 receptor agonist",
|
||||
pharmacology: {
|
||||
halfLifeDays: 13 / 24, // ≈ 13 h per EMA EPAR §5.2
|
||||
tmaxHours: 11, // EMA EPAR §5.2 (mean ~11 h)
|
||||
absorptionRateHourlyKa: null, // not published in EMA EPAR pop-PK
|
||||
bioavailability: 0.55, // EMA EPAR §5.2 (~55%)
|
||||
vdLitersPerKg: 0.22, // ~22 L / 100 kg per EMA EPAR §5.2
|
||||
clearanceLPerHour70kg: 1.2, // EMA EPAR §5.2 (~0.9–1.4 L/h)
|
||||
steadyStateWeeks: 1, // daily, plateau within ~3 days
|
||||
compartmentModel: "two-compartment",
|
||||
},
|
||||
titrationStepsMg: [0.6, 1.2, 1.8, 2.4, 3.0], // Saxenda §4.2
|
||||
titrationIntervalWeeks: 1, // Saxenda: +0.6 mg/week
|
||||
maxDoseMg: 3.0, // Saxenda; Victoza max 1.8
|
||||
standardInjectionFrequency: "daily",
|
||||
indications: {
|
||||
t2dm: { firstLine: false }, // Victoza — adjunct
|
||||
weightManagement: { bmiThreshold: 30 }, // Saxenda
|
||||
},
|
||||
storage: {
|
||||
unopened: { temperatureCelsius: { min: 2, max: 8 } },
|
||||
inUse: { temperatureCelsius: { min: 2, max: 30 }, maxDays: 30 },
|
||||
},
|
||||
pen: {
|
||||
type: "Pre-filled pen",
|
||||
dosesPerPen: null, // continuous-dial pen, doses vary
|
||||
},
|
||||
sideEffects: {
|
||||
veryCommon: ["nausea", "vomiting", "diarrhea", "constipation"],
|
||||
common: [
|
||||
"hypoglycaemia",
|
||||
"decreased_appetite",
|
||||
"headache",
|
||||
"dizziness",
|
||||
"dyspepsia",
|
||||
"abdominal_pain",
|
||||
"gerd",
|
||||
"abdominal_distension",
|
||||
"eructation",
|
||||
"flatulence",
|
||||
"fatigue",
|
||||
"injection_site_reaction",
|
||||
"asthenia",
|
||||
],
|
||||
uncommon: [
|
||||
"dehydration",
|
||||
"cholelithiasis",
|
||||
"cholecystitis",
|
||||
"acute_pancreatitis",
|
||||
"tachycardia",
|
||||
],
|
||||
rare: ["anaphylactic_reaction", "angioedema", "acute_renal_failure"],
|
||||
},
|
||||
contraindications: ["hypersensitivity_active_substance_or_excipients"],
|
||||
warnings: [
|
||||
"pancreatitis_history",
|
||||
"thyroid_disease",
|
||||
"dehydration_risk",
|
||||
"pregnancy_not_recommended",
|
||||
],
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/saxenda-epar-product-information_en.pdf",
|
||||
},
|
||||
|
||||
// §1.5 — Dulaglutide (Trulicity). Weekly SC, half-life ~5 d per
|
||||
// EMA EPAR §5.2. SC bioavailability ~47–65%.
|
||||
dulaglutide: {
|
||||
inn: "Dulaglutide",
|
||||
brands: ["Trulicity"],
|
||||
route: "subcutaneous",
|
||||
drugClass: "GLP-1 receptor agonist",
|
||||
pharmacology: {
|
||||
halfLifeDays: 5.0, // EMA EPAR §5.2
|
||||
tmaxHours: 48, // EMA EPAR §5.2 (Tmax 24–72 h)
|
||||
absorptionRateHourlyKa: null, // not published in EMA EPAR pop-PK
|
||||
bioavailability: 0.47, // EMA EPAR §5.2 (47% min of 47–65% range)
|
||||
vdLitersPerKg: 0.27, // EMA EPAR §5.2 (Vc/F ~19.2 L → ~0.27 L/kg)
|
||||
clearanceLPerHour70kg: 0.107, // EMA EPAR §5.2 (~0.107 L/h)
|
||||
steadyStateWeeks: 2, // EMA EPAR §5.2 (~2 weeks)
|
||||
compartmentModel: "two-compartment",
|
||||
},
|
||||
titrationStepsMg: [0.75, 1.5, 3.0, 4.5], // EMA EPAR §4.2
|
||||
titrationIntervalWeeks: 4,
|
||||
maxDoseMg: 4.5,
|
||||
standardInjectionFrequency: "weekly",
|
||||
indications: {
|
||||
t2dm: { firstLine: false }, // adjunct
|
||||
weightManagement: { bmiThreshold: null }, // not approved for weight mgmt
|
||||
},
|
||||
storage: {
|
||||
unopened: { temperatureCelsius: { min: 2, max: 8 } },
|
||||
inUse: { temperatureCelsius: { min: 2, max: 30 }, maxDays: 14 }, // EMA §6.3
|
||||
},
|
||||
pen: {
|
||||
type: "Single-dose pen",
|
||||
dosesPerPen: 1, // disposable single-dose
|
||||
},
|
||||
sideEffects: {
|
||||
veryCommon: ["nausea", "diarrhea", "vomiting", "abdominal_pain"],
|
||||
common: [
|
||||
"hypoglycaemia",
|
||||
"decreased_appetite",
|
||||
"dyspepsia",
|
||||
"constipation",
|
||||
"flatulence",
|
||||
"abdominal_distension",
|
||||
"gerd",
|
||||
"eructation",
|
||||
"fatigue",
|
||||
"injection_site_reaction",
|
||||
"sinus_tachycardia",
|
||||
"first_degree_av_block",
|
||||
],
|
||||
uncommon: ["cholelithiasis", "cholecystitis", "acute_pancreatitis"],
|
||||
rare: ["anaphylactic_reaction", "angioedema"],
|
||||
},
|
||||
contraindications: ["hypersensitivity_active_substance_or_excipients"],
|
||||
warnings: [
|
||||
"pancreatitis_history",
|
||||
"severe_gi_disease",
|
||||
"diabetic_retinopathy_macular_oedema",
|
||||
"dehydration_risk",
|
||||
],
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/trulicity-epar-product-information_en.pdf",
|
||||
},
|
||||
|
||||
// Exenatide — twin product lines: Byetta (IR, twice-daily,
|
||||
// t½ ~2.4 h) and Bydureon (extended-release, weekly,
|
||||
// terminal t½ ~2 weeks). The record captures the IR profile as
|
||||
// the default; the brand list flags Bydureon as the ER variant.
|
||||
// The standard frequency reflects the IR formulation; Bydureon
|
||||
// consumers must consult the Bydureon-specific EPAR for cadence.
|
||||
exenatide: {
|
||||
inn: "Exenatide",
|
||||
brands: ["Byetta", "Bydureon"],
|
||||
route: "subcutaneous",
|
||||
drugClass: "GLP-1 receptor agonist",
|
||||
pharmacology: {
|
||||
halfLifeDays: 2.4 / 24, // Byetta IR — ~2.4 h per EMA EPAR §5.2
|
||||
tmaxHours: 2.1, // EMA Byetta §5.2 (median ~2 h)
|
||||
absorptionRateHourlyKa: null, // not published
|
||||
bioavailability: 0.65, // EMA Byetta §5.2 (~65%)
|
||||
vdLitersPerKg: 0.4, // EMA Byetta §5.2 (~28 L / 70 kg)
|
||||
clearanceLPerHour70kg: 9.1, // EMA Byetta §5.2 (~9.1 L/h)
|
||||
steadyStateWeeks: 1,
|
||||
compartmentModel: "two-compartment",
|
||||
},
|
||||
titrationStepsMg: [0.005, 0.01], // Byetta 5 µg → 10 µg twice daily
|
||||
titrationIntervalWeeks: 4, // Byetta §4.2 (escalate after ≥4 wk)
|
||||
maxDoseMg: 0.01,
|
||||
standardInjectionFrequency: "twice-daily",
|
||||
indications: {
|
||||
t2dm: { firstLine: false }, // adjunct only
|
||||
weightManagement: { bmiThreshold: null }, // not approved for weight mgmt
|
||||
},
|
||||
storage: {
|
||||
unopened: { temperatureCelsius: { min: 2, max: 8 } },
|
||||
inUse: { temperatureCelsius: { min: 2, max: 25 }, maxDays: 30 }, // Byetta §6.3
|
||||
},
|
||||
pen: {
|
||||
type: "Pre-filled pen (Byetta) / weekly suspension (Bydureon)",
|
||||
dosesPerPen: 60, // Byetta 60 doses per pen
|
||||
},
|
||||
sideEffects: {
|
||||
veryCommon: [
|
||||
"hypoglycaemia",
|
||||
"nausea",
|
||||
"vomiting",
|
||||
"diarrhea",
|
||||
],
|
||||
common: [
|
||||
"decreased_appetite",
|
||||
"dizziness",
|
||||
"headache",
|
||||
"dyspepsia",
|
||||
"abdominal_pain",
|
||||
"gerd",
|
||||
"flatulence",
|
||||
"abdominal_distension",
|
||||
"constipation",
|
||||
"eructation",
|
||||
"hyperhidrosis",
|
||||
"injection_site_reaction",
|
||||
"fatigue",
|
||||
],
|
||||
uncommon: [
|
||||
"dehydration",
|
||||
"cholelithiasis",
|
||||
"acute_pancreatitis",
|
||||
"renal_failure",
|
||||
],
|
||||
rare: ["anaphylactic_reaction", "angioedema"],
|
||||
},
|
||||
contraindications: [
|
||||
"hypersensitivity_active_substance_or_excipients",
|
||||
"severe_renal_impairment_egfr_below_30",
|
||||
],
|
||||
warnings: [
|
||||
"pancreatitis_history",
|
||||
"severe_gi_disease",
|
||||
"renal_impairment",
|
||||
"pregnancy_not_recommended",
|
||||
],
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/byetta-epar-product-information_en.pdf",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the route for a given drug + brand. Falls back to the
|
||||
* record default when no per-brand override is registered.
|
||||
*/
|
||||
export function routeForBrand(
|
||||
drugId: Glp1DrugId,
|
||||
brand: string,
|
||||
): Glp1Route | null {
|
||||
const record = GLP1_DRUGS[drugId];
|
||||
if (!record) return null;
|
||||
if (!record.brands.includes(brand)) return null;
|
||||
if (record.brandRoutes && brand in record.brandRoutes) {
|
||||
return record.brandRoutes[brand];
|
||||
}
|
||||
return record.route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup helper — given an arbitrary brand string, find the drug
|
||||
* record that lists it (case-insensitive). Returns null when the
|
||||
* brand is unknown; useful for connecting free-text medication
|
||||
* names to the catalog.
|
||||
*/
|
||||
export function findDrugByBrand(brand: string): Glp1DrugRecord | null {
|
||||
const needle = brand.trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
for (const record of Object.values(GLP1_DRUGS)) {
|
||||
if (record.brands.some((b) => b.toLowerCase() === needle)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog's drug-ids in stable order. Useful for iteration in
|
||||
* tests and UI surfaces that need a deterministic display order.
|
||||
*/
|
||||
export const GLP1_DRUG_IDS: readonly Glp1DrugId[] = [
|
||||
"tirzepatide",
|
||||
"semaglutide",
|
||||
"liraglutide",
|
||||
"dulaglutide",
|
||||
"exenatide",
|
||||
] as const;
|
||||
Reference in new issue
Block a user