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:
Marc-André Bombeck committed 2026-05-14 18:07:15 +02:00
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);
});
});
});
+593
View File
@@ -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;