diff --git a/src/lib/medications/side-effects/__tests__/taxonomy.test.ts b/src/lib/medications/side-effects/__tests__/taxonomy.test.ts new file mode 100644 index 000000000..3defcfdcf --- /dev/null +++ b/src/lib/medications/side-effects/__tests__/taxonomy.test.ts @@ -0,0 +1,185 @@ +/** + * v1.4.25 W19d — taxonomy pure-module tests. + * + * The taxonomy is a contract the API validator, the picker UI, and + * the Coach snapshot all depend on. These tests pin the shape: + * - every entry maps to exactly one category + * - exactly 21 entries across 5 categories + * - every category has at least one entry + * - severity labels are deterministic and ordered + */ + +import { describe, expect, it } from "vitest"; + +import { + MedicationSideEffectCategory, + MedicationSideEffectEntry, +} from "@/generated/prisma/client"; +import { + SIDE_EFFECT_CATEGORIES, + SIDE_EFFECT_CATEGORY_ORDER, + SIDE_EFFECT_ENTRIES_BY_CATEGORY, + SIDE_EFFECT_ENTRY_COUNT, + SIDE_EFFECT_SEVERITY_LADDER, + categoryForEntry, + entriesByCategory, + isSideEffectSeverity, + severityLikertLabel, +} from "../taxonomy"; + +const ALL_ENTRIES: MedicationSideEffectEntry[] = [ + "NAUSEA", + "VOMITING", + "DIARRHEA", + "CONSTIPATION", + "ABDOMINAL_PAIN", + "HYPOGLYCEMIA_SYMPTOMS", + "DEHYDRATION", + "ANOREXIA", + "ELECTROLYTE_FATIGUE", + "INJECTION_REDNESS", + "INJECTION_SWELLING", + "INJECTION_BRUISING", + "INJECTION_INDURATION", + "BRAIN_FOG", + "DIZZINESS", + "LOW_MOOD", + "LOW_ENERGY", + "EARLY_SATIETY", + "GASTROPARESIS_LIKE", + "DYSGEUSIA", + "GALLBLADDER_DISCOMFORT", +]; + +const ALL_CATEGORIES: MedicationSideEffectCategory[] = [ + "GI", + "METABOLIC", + "INJECTION_SITE", + "COGNITIVE", + "GLP1_SPECIFIC", +]; + +describe("SIDE_EFFECT_CATEGORIES — entry → category mapping", () => { + it("declares exactly 21 entries", () => { + expect(Object.keys(SIDE_EFFECT_CATEGORIES)).toHaveLength(21); + expect(SIDE_EFFECT_ENTRY_COUNT).toBe(21); + }); + + it("maps every entry to a known category", () => { + for (const entry of ALL_ENTRIES) { + const cat = SIDE_EFFECT_CATEGORIES[entry]; + expect(ALL_CATEGORIES).toContain(cat); + } + }); + + it("categoryForEntry returns the same mapping as the table", () => { + for (const entry of ALL_ENTRIES) { + expect(categoryForEntry(entry)).toBe(SIDE_EFFECT_CATEGORIES[entry]); + } + }); +}); + +describe("SIDE_EFFECT_ENTRIES_BY_CATEGORY — category → entries reverse index", () => { + it("covers all five categories", () => { + expect(Object.keys(SIDE_EFFECT_ENTRIES_BY_CATEGORY).sort()).toEqual( + [...ALL_CATEGORIES].sort(), + ); + }); + + it("every category has at least one entry", () => { + for (const category of ALL_CATEGORIES) { + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY[category].length).toBeGreaterThan( + 0, + ); + } + }); + + it("matches the EMA-derived counts (GI 5, METABOLIC 4, INJECTION_SITE 4, COGNITIVE 4, GLP1_SPECIFIC 4)", () => { + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY.GI).toHaveLength(5); + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY.METABOLIC).toHaveLength(4); + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY.INJECTION_SITE).toHaveLength(4); + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY.COGNITIVE).toHaveLength(4); + expect(SIDE_EFFECT_ENTRIES_BY_CATEGORY.GLP1_SPECIFIC).toHaveLength(4); + }); + + it("reverse index is consistent with the forward mapping", () => { + for (const category of ALL_CATEGORIES) { + for (const entry of SIDE_EFFECT_ENTRIES_BY_CATEGORY[category]) { + expect(SIDE_EFFECT_CATEGORIES[entry]).toBe(category); + } + } + }); + + it("every entry appears in exactly one category bucket", () => { + const seen = new Set(); + for (const category of ALL_CATEGORIES) { + for (const entry of SIDE_EFFECT_ENTRIES_BY_CATEGORY[category]) { + expect(seen.has(entry)).toBe(false); + seen.add(entry); + } + } + expect(seen.size).toBe(21); + }); + + it("entriesByCategory helper is a thin wrapper", () => { + for (const category of ALL_CATEGORIES) { + expect(entriesByCategory(category)).toEqual( + SIDE_EFFECT_ENTRIES_BY_CATEGORY[category], + ); + } + }); +}); + +describe("SIDE_EFFECT_CATEGORY_ORDER — UI category sequence", () => { + it("contains every category exactly once", () => { + expect([...SIDE_EFFECT_CATEGORY_ORDER].sort()).toEqual( + [...ALL_CATEGORIES].sort(), + ); + }); + + it("leads with GI — the EMA most-common cluster during titration", () => { + expect(SIDE_EFFECT_CATEGORY_ORDER[0]).toBe("GI"); + }); +}); + +describe("severityLikertLabel — 1-5 Likert → semantic label", () => { + it("returns mild / moderate / significant / severe / verySevere", () => { + expect(severityLikertLabel(1)).toBe("mild"); + expect(severityLikertLabel(2)).toBe("moderate"); + expect(severityLikertLabel(3)).toBe("significant"); + expect(severityLikertLabel(4)).toBe("severe"); + expect(severityLikertLabel(5)).toBe("verySevere"); + }); + + it("is deterministic — same input twice yields the same output", () => { + expect(severityLikertLabel(3)).toBe(severityLikertLabel(3)); + }); + + it("ladder is monotonic — index matches severity - 1", () => { + for (let s = 1; s <= 5; s++) { + expect(SIDE_EFFECT_SEVERITY_LADDER[s - 1]).toBe( + severityLikertLabel(s as 1 | 2 | 3 | 4 | 5), + ); + } + }); + + it("ladder length is 5", () => { + expect(SIDE_EFFECT_SEVERITY_LADDER).toHaveLength(5); + }); +}); + +describe("isSideEffectSeverity — type guard", () => { + it("accepts 1 through 5", () => { + expect(isSideEffectSeverity(1)).toBe(true); + expect(isSideEffectSeverity(5)).toBe(true); + }); + + it("rejects 0 and 6", () => { + expect(isSideEffectSeverity(0)).toBe(false); + expect(isSideEffectSeverity(6)).toBe(false); + }); + + it("rejects non-integer", () => { + expect(isSideEffectSeverity(2.5)).toBe(false); + }); +}); diff --git a/src/lib/medications/side-effects/taxonomy.ts b/src/lib/medications/side-effects/taxonomy.ts new file mode 100644 index 000000000..7948cd6db --- /dev/null +++ b/src/lib/medications/side-effects/taxonomy.ts @@ -0,0 +1,196 @@ +/** + * v1.4.25 W19d — GLP-1 side-effect taxonomy (pure module). + * + * Single source of truth for the entry → category mapping the picker + * UI, the API validator, and the Coach snapshot all read. Derived + * from EMA EPAR §4.8 ("Undesirable effects") for tirzepatide / + * semaglutide / liraglutide / dulaglutide, clustered into HealthLog's + * five surface categories: + * + * - GI — gastrointestinal (very common during titration) + * - METABOLIC — hypo / dehydration / appetite-suppression edges + * - INJECTION_SITE — local-site reactions (EMA "common") + * - COGNITIVE — brain-fog / dizziness / mood / energy (HealthLog + * keeps these on the medication record because + * the user wants the drug-correlation surface; + * the existing MoodEntry stream is independent). + * - GLP1_SPECIFIC — the four drug-mechanism signatures (early + * satiety, gastroparesis-like fullness, taste + * change, gallbladder discomfort). + * + * The DB stores `category` denormalised so range / category-filter + * queries don't need a JOIN, but this module is the authority — the + * API write path derives the category from the entry instead of + * trusting the client. + * + * Severity is 1-5 Likert. The semantic-label helper returns a + * locale-agnostic ladder key (`mild` → `verySevere`); the UI maps each + * key to a translated label. + */ + +import { + MedicationSideEffectCategory, + MedicationSideEffectEntry, +} from "@/generated/prisma/client"; + +export const SIDE_EFFECT_CATEGORIES: Record< + MedicationSideEffectEntry, + MedicationSideEffectCategory +> = { + // GI (5) + NAUSEA: "GI", + VOMITING: "GI", + DIARRHEA: "GI", + CONSTIPATION: "GI", + ABDOMINAL_PAIN: "GI", + // Metabolic (4) + HYPOGLYCEMIA_SYMPTOMS: "METABOLIC", + DEHYDRATION: "METABOLIC", + ANOREXIA: "METABOLIC", + ELECTROLYTE_FATIGUE: "METABOLIC", + // Injection-site (4) + INJECTION_REDNESS: "INJECTION_SITE", + INJECTION_SWELLING: "INJECTION_SITE", + INJECTION_BRUISING: "INJECTION_SITE", + INJECTION_INDURATION: "INJECTION_SITE", + // Cognitive (4) + BRAIN_FOG: "COGNITIVE", + DIZZINESS: "COGNITIVE", + LOW_MOOD: "COGNITIVE", + LOW_ENERGY: "COGNITIVE", + // GLP-1 specific (4) + EARLY_SATIETY: "GLP1_SPECIFIC", + GASTROPARESIS_LIKE: "GLP1_SPECIFIC", + DYSGEUSIA: "GLP1_SPECIFIC", + GALLBLADDER_DISCOMFORT: "GLP1_SPECIFIC", +}; + +/** + * Total entry count exposed as a const so callers (validators, + * snapshot aggregators) can assert against drift without re-counting. + */ +export const SIDE_EFFECT_ENTRY_COUNT = 21; + +/** + * Ordered category list — defines the picker UI's category-tab + * sequence. Stable across versions; new categories append. + */ +export const SIDE_EFFECT_CATEGORY_ORDER: readonly MedicationSideEffectCategory[] = + [ + "GI", + "METABOLIC", + "INJECTION_SITE", + "COGNITIVE", + "GLP1_SPECIFIC", + ] as const; + +/** + * Entries grouped under each category in picker-UI order. The order + * within a category mirrors the EMA EPAR §4.8 listing where one + * exists, then EMA-derived HealthLog clustering for the rest. + */ +export const SIDE_EFFECT_ENTRIES_BY_CATEGORY: Record< + MedicationSideEffectCategory, + readonly MedicationSideEffectEntry[] +> = { + GI: ["NAUSEA", "VOMITING", "DIARRHEA", "CONSTIPATION", "ABDOMINAL_PAIN"], + METABOLIC: [ + "HYPOGLYCEMIA_SYMPTOMS", + "DEHYDRATION", + "ANOREXIA", + "ELECTROLYTE_FATIGUE", + ], + INJECTION_SITE: [ + "INJECTION_REDNESS", + "INJECTION_SWELLING", + "INJECTION_BRUISING", + "INJECTION_INDURATION", + ], + COGNITIVE: ["BRAIN_FOG", "DIZZINESS", "LOW_MOOD", "LOW_ENERGY"], + GLP1_SPECIFIC: [ + "EARLY_SATIETY", + "GASTROPARESIS_LIKE", + "DYSGEUSIA", + "GALLBLADDER_DISCOMFORT", + ], +} as const; + +/** + * Lookup helper — returns the entries for a category. Used by the + * picker UI to filter the chip list when the user changes category. + */ +export function entriesByCategory( + category: MedicationSideEffectCategory, +): readonly MedicationSideEffectEntry[] { + return SIDE_EFFECT_ENTRIES_BY_CATEGORY[category]; +} + +/** + * Lookup helper — returns the category for an entry. The API write + * path uses this instead of trusting a client-supplied category, so a + * mismatched payload never lands in the DB. + */ +export function categoryForEntry( + entry: MedicationSideEffectEntry, +): MedicationSideEffectCategory { + return SIDE_EFFECT_CATEGORIES[entry]; +} + +/** 1-5 Likert severity scale. */ +export type SideEffectSeverity = 1 | 2 | 3 | 4 | 5; + +/** Locale-agnostic semantic labels for the severity ladder. */ +export type SideEffectSeverityLabel = + | "mild" + | "moderate" + | "significant" + | "severe" + | "verySevere"; + +/** + * Maps a 1-5 severity integer to its locale-agnostic semantic label. + * The UI looks up the translated label via + * `t("medications.sideEffects.severity.