mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user