feat(medications): pure side-effect taxonomy + severity Likert helpers

Single source of truth for the GLP-1 side-effect taxonomy — entry →
category mapping plus the reverse index, ordered category list, and
a 1-5 Likert → semantic-label helper. The DB stores `category`
denormalised for cheap range scans; this module is the authority and
the API write path uses `categoryForEntry()` instead of trusting a
client-supplied category.

Severity helpers return locale-agnostic ladder keys (`mild` …
`verySevere`); the UI maps each to a translated label so the picker
stays purely structural. Tests pin every contract the API validator
and picker depend on: 21 entries × 5 categories with the EMA-derived
counts (GI 5, METABOLIC 4, INJECTION_SITE 4, COGNITIVE 4,
GLP1_SPECIFIC 4); every entry maps to exactly one category;
severity-ladder index is monotonic.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:54:35 +02:00
1 parent 7d56f6f51f
commit 4e2b7be472
2 files changed
+381

No files matched your search

@@ -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<MedicationSideEffectEntry>();
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);
});
});
@@ -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.<label>")`.
*
* Ordering is fixed: mild < moderate < significant < severe <
* verySevere. Tests assert determinism + ordering.
*/
export function severityLikertLabel(
severity: SideEffectSeverity,
): SideEffectSeverityLabel {
switch (severity) {
case 1:
return "mild";
case 2:
return "moderate";
case 3:
return "significant";
case 4:
return "severe";
case 5:
return "verySevere";
}
}
/**
* Ordered severity-label ladder — useful for the picker UI which
* renders five Likert buttons in ascending severity order.
*/
export const SIDE_EFFECT_SEVERITY_LADDER: readonly SideEffectSeverityLabel[] = [
"mild",
"moderate",
"significant",
"severe",
"verySevere",
] as const;
/**
* Type guard — narrows a raw number into the SideEffectSeverity union.
* The Zod schema enforces this on the wire; the guard is for code
* paths that read the persisted integer back.
*/
export function isSideEffectSeverity(
value: number,
): value is SideEffectSeverity {
return value === 1 || value === 2 || value === 3 || value === 4 || value === 5;
}