mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
* fix(medical): protect against silent DBP hypotension and align BP citations Three correctness issues that the v1.3.3 ecosystem audit surfaced and that we deferred until the v1.4 cycle: 1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the default age-based targets (DBP 70-79), the lower orange wing was computed as `diaLow - 10 = 60`, putting a sustained reading of 60 mmHg into "mildly low" territory — visually a yellow tile, not a red one. 60 mmHg is widely treated as a hypotension / perfusion-risk threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit caution against treating below 70 in the elderly). The orange floor is now clamped at 65 mmHg, so a reading of 60 lands in red. The user-override path stays untouched and is already audit-logged. 2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew from the 2023 authoring; the 2023 hypertension document is ESH-only. The codebase had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI prompts). Both are wrong in different directions. Every site (`bp-targets.ts`, `targets/route.ts`, `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now cites "ESH 2023" with the published source URL. Target bands are unchanged 2018 → 2023 — only the citation moves. 3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's 2020 Physical Activity guidelines publish minutes per week, not a step quota. The cohort study HealthLog actually relies on is Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000 steps). The v1.3.3 fix landed in `effective-range.ts` but four AI prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN) carried the old wording forward. Same fix shape: cite Saint-Maurice for the step number and add an explicit "WHO does not publish a step quota" note so the model doesn't regress. Sleep target moves from "ESC" to AASM 2015 in the same surfaces — ESC publishes no adult sleep duration guideline. Adds `src/lib/medical-citations.ts` as the new single source of truth for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will import these constants instead of duplicating strings, and the new `medical-citations.test.ts` drift-test asserts that every entry has a non-empty url + caveat, that ids are unique, and that the WHO-as-step- source hallucination cannot reappear as a constant. Tests: 358 → 363 (5 new for the master citations list). Typecheck and existing analytics tests pass. No clinical numbers changed beyond the DBP orange-floor clamp. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source Two related medical-truthfulness issues from the v1.3.3 ecosystem audit. **Body-fat classification: ACE bands corrected.** v1.3.3's classifier used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential" category, which is actually ACE's *Athletes* lower bound. Anything below was lumped under "Essential" — including readings (e.g. 1% male) that are clinically concerning, not just "athletically extreme". The classifier now mirrors the ACE table precisely: - Below essential (M <2 / F <10): danger. - Essential (M 2–5 / F 10–13): warning. - Athletic (M 6–13 / F 14–20): normal. - Fitness (M 14–17 / F 21–24): normal. - Acceptable (M 18–24 / F 25–31): warning. - Obese (M ≥25 / F ≥32): danger. **Three-way drift in body-fat target ranges consolidated.** v1.3.3 had three sites with three different green-band numbers: - `value-bands.ts:118-120` returned M{10,20} / F{18,28}. - `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} / F{21,31} with `source: "ACE"`. - `classifications.ts:155-205` used a third overlapping set of thresholds for the band labels. ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy band for a non-athlete adult and is now the single source in `getBodyFatTargetRange`. Targets route and chart value-bands import the helper instead of duplicating the numbers. **Bedtime glucose citation softened.** ADA Standards of Care 2024 §6 publishes pre-prandial 80–130 and post-prandial <180; the document does NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog ships is reasonable advice (within the daytime pre-prandial range, slightly raised floor to avoid nocturnal hypoglycaemia) but it's not ADA-published. The inline comment in `effective-range.ts` now states the absence of an adult guideline explicitly and cites ISPAD 2022 (pediatric T1D, 80–140) as the closest comparator. Numbers unchanged. Tests: 363 → 369 (6 new for the ACE bands). Existing assertions unchanged. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): align step-range target with the effective-range source `getStepsRange()` in classifications.ts returned `{7 000, 10 000}` while `effective-range.ts` (the canonical resolver behind `getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces showed different "green" bands to the same user — `/api/insights/ targets` used the lower pair, the dashboard chart-bands used the upper pair. Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA 2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation header to the steps section so the next audit pass can verify in place. WHO is explicitly called out as NOT a source for a step quota (only an activity-time guideline). The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5 / 10 k bands match Tudor-Locke 2004 pedometer indices and are kept distinct from the two-number "target range" helper. Tests stay at 369/369. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
classifyBMI,
|
|
classifyBP,
|
|
classifyBodyFat,
|
|
generateAlerts,
|
|
} from "../classifications";
|
|
|
|
describe("classifyBMI", () => {
|
|
it("classifies underweight", () => {
|
|
const result = classifyBMI(17.5);
|
|
expect(result.category).toBe("Underweight");
|
|
expect(result.severity).toBe("warning");
|
|
});
|
|
|
|
it("classifies normal weight", () => {
|
|
const result = classifyBMI(22);
|
|
expect(result.category).toBe("Normal");
|
|
expect(result.severity).toBe("normal");
|
|
});
|
|
|
|
it("classifies overweight", () => {
|
|
const result = classifyBMI(27);
|
|
expect(result.category).toBe("Overweight");
|
|
expect(result.severity).toBe("warning");
|
|
});
|
|
|
|
it("classifies obesity grade I", () => {
|
|
const result = classifyBMI(32);
|
|
expect(result.category).toBe("Obesity Grade I");
|
|
expect(result.severity).toBe("danger");
|
|
});
|
|
|
|
it("classifies obesity grade II", () => {
|
|
const result = classifyBMI(37);
|
|
expect(result.category).toBe("Obesity Grade II");
|
|
expect(result.severity).toBe("danger");
|
|
});
|
|
|
|
it("classifies obesity grade III", () => {
|
|
const result = classifyBMI(42);
|
|
expect(result.category).toBe("Obesity Grade III");
|
|
expect(result.severity).toBe("danger");
|
|
});
|
|
|
|
it("handles boundary values", () => {
|
|
expect(classifyBMI(18.5).category).toBe("Normal");
|
|
expect(classifyBMI(25).category).toBe("Overweight");
|
|
expect(classifyBMI(30).category).toBe("Obesity Grade I");
|
|
expect(classifyBMI(35).category).toBe("Obesity Grade II");
|
|
expect(classifyBMI(40).category).toBe("Obesity Grade III");
|
|
});
|
|
});
|
|
|
|
describe("classifyBP", () => {
|
|
it("classifies optimal", () => {
|
|
const result = classifyBP(115, 75);
|
|
expect(result.category).toBe("Optimal");
|
|
expect(result.severity).toBe("normal");
|
|
});
|
|
|
|
it("classifies normal", () => {
|
|
const result = classifyBP(125, 82);
|
|
expect(result.category).toBe("Normal");
|
|
expect(result.severity).toBe("normal");
|
|
});
|
|
|
|
it("classifies high-normal", () => {
|
|
const result = classifyBP(135, 87);
|
|
expect(result.category).toBe("High-normal");
|
|
expect(result.severity).toBe("elevated");
|
|
});
|
|
|
|
it("classifies hypertension grade 1", () => {
|
|
const result = classifyBP(150, 95);
|
|
expect(result.category).toBe("Hypertension Grade 1");
|
|
expect(result.severity).toBe("warning");
|
|
});
|
|
|
|
it("classifies hypertension grade 2", () => {
|
|
const result = classifyBP(170, 105);
|
|
expect(result.category).toBe("Hypertension Grade 2");
|
|
expect(result.severity).toBe("danger");
|
|
});
|
|
|
|
it("classifies hypertension grade 3", () => {
|
|
const result = classifyBP(185, 115);
|
|
expect(result.category).toBe("Hypertension Grade 3");
|
|
expect(result.severity).toBe("danger");
|
|
});
|
|
|
|
it("uses the higher category when sys and dia differ", () => {
|
|
// Sys is optimal but dia is high
|
|
const result = classifyBP(115, 95);
|
|
expect(result.category).toBe("Hypertension Grade 1");
|
|
});
|
|
});
|
|
|
|
describe("generateAlerts", () => {
|
|
it("returns empty for empty input", () => {
|
|
expect(generateAlerts({})).toEqual([]);
|
|
});
|
|
|
|
it("generates BMI success alert for normal BMI", () => {
|
|
const alerts = generateAlerts({ bmi: 22 });
|
|
expect(alerts).toHaveLength(1);
|
|
expect(alerts[0].level).toBe("success");
|
|
expect(alerts[0].title).toContain("normal range");
|
|
});
|
|
|
|
it("generates warning for underweight BMI", () => {
|
|
const alerts = generateAlerts({ bmi: 16 });
|
|
expect(alerts[0].level).toBe("warning");
|
|
expect(alerts[0].title).toContain("below normal");
|
|
});
|
|
|
|
it("generates warning for obese BMI", () => {
|
|
const alerts = generateAlerts({ bmi: 32 });
|
|
expect(alerts[0].level).toBe("warning");
|
|
expect(alerts[0].title).toContain("obesity");
|
|
});
|
|
|
|
it("generates danger for very high BP", () => {
|
|
const alerts = generateAlerts({ bpAvgSys: 175, bpAvgDia: 105 });
|
|
const bpAlert = alerts.find((a) => a.title.includes("Blood pressure"));
|
|
expect(bpAlert?.level).toBe("danger");
|
|
});
|
|
|
|
it("generates success for good BP target adherence", () => {
|
|
const alerts = generateAlerts({ bpPctInTarget: 85 });
|
|
const targetAlert = alerts.find((a) =>
|
|
a.title.includes("target adherence"),
|
|
);
|
|
expect(targetAlert?.level).toBe("success");
|
|
});
|
|
|
|
it("generates warning for low BP target adherence", () => {
|
|
const alerts = generateAlerts({ bpPctInTarget: 40 });
|
|
const targetAlert = alerts.find((a) => a.title.includes("outside"));
|
|
expect(targetAlert?.level).toBe("warning");
|
|
});
|
|
|
|
it("generates info for significant weight increase", () => {
|
|
const alerts = generateAlerts({ weightSlope30: 0.1 }); // 0.7 kg/week
|
|
const weightAlert = alerts.find((a) => a.title.includes("Weight"));
|
|
expect(weightAlert?.level).toBe("info");
|
|
expect(weightAlert?.title).toContain("increasing");
|
|
});
|
|
|
|
it("generates warning for pulse anomalies", () => {
|
|
const alerts = generateAlerts({ pulseAnomalyCount: 5 });
|
|
const pulseAlert = alerts.find((a) => a.title.includes("Pulse"));
|
|
expect(pulseAlert?.level).toBe("warning");
|
|
});
|
|
|
|
it("generates medication compliance alerts", () => {
|
|
const alerts = generateAlerts({
|
|
medications: [
|
|
{ name: "Metoprolol", compliance7: 60, compliance30: 70 },
|
|
{ name: "Ramipril", compliance7: 98, compliance30: 95 },
|
|
],
|
|
});
|
|
const lowAlert = alerts.find(
|
|
(a) => a.title.includes("Metoprolol") && a.level === "warning",
|
|
);
|
|
const highAlert = alerts.find(
|
|
(a) => a.title.includes("Ramipril") && a.level === "success",
|
|
);
|
|
expect(lowAlert).toBeDefined();
|
|
expect(highAlert).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// v1.4 marathon — closes the body-fat ACE drift identified in the
|
|
// medical-truthfulness audit. Below-essential is now a danger band,
|
|
// "Essential" is the warning band proper, and the labels match ACE.
|
|
describe("classifyBodyFat (ACE bands)", () => {
|
|
it("flags below-essential as danger for males", () => {
|
|
expect(classifyBodyFat(1, "MALE").category).toBe("Below essential");
|
|
expect(classifyBodyFat(1, "MALE").severity).toBe("danger");
|
|
});
|
|
|
|
it("flags below-essential as danger for females", () => {
|
|
expect(classifyBodyFat(8, "FEMALE").category).toBe("Below essential");
|
|
expect(classifyBodyFat(8, "FEMALE").severity).toBe("danger");
|
|
});
|
|
|
|
it("classifies the ACE essential band as a warning, not normal", () => {
|
|
expect(classifyBodyFat(4, "MALE").category).toBe("Essential");
|
|
expect(classifyBodyFat(4, "MALE").severity).toBe("warning");
|
|
expect(classifyBodyFat(11, "FEMALE").category).toBe("Essential");
|
|
expect(classifyBodyFat(11, "FEMALE").severity).toBe("warning");
|
|
});
|
|
|
|
it("recognises ACE Athletic and Fitness as normal", () => {
|
|
expect(classifyBodyFat(10, "MALE").category).toBe("Athletic");
|
|
expect(classifyBodyFat(15, "MALE").category).toBe("Fitness");
|
|
expect(classifyBodyFat(17, "FEMALE").category).toBe("Athletic");
|
|
expect(classifyBodyFat(22, "FEMALE").category).toBe("Fitness");
|
|
for (const result of [
|
|
classifyBodyFat(10, "MALE"),
|
|
classifyBodyFat(15, "MALE"),
|
|
classifyBodyFat(17, "FEMALE"),
|
|
classifyBodyFat(22, "FEMALE"),
|
|
]) {
|
|
expect(result.severity).toBe("normal");
|
|
}
|
|
});
|
|
|
|
it("acceptable band uses the new label and warning severity", () => {
|
|
expect(classifyBodyFat(20, "MALE").category).toBe("Acceptable");
|
|
expect(classifyBodyFat(20, "MALE").severity).toBe("warning");
|
|
expect(classifyBodyFat(28, "FEMALE").category).toBe("Acceptable");
|
|
expect(classifyBodyFat(28, "FEMALE").severity).toBe("warning");
|
|
});
|
|
|
|
it("classifies obesity by ACE upper bound", () => {
|
|
expect(classifyBodyFat(30, "MALE").category).toBe("Obese");
|
|
expect(classifyBodyFat(35, "FEMALE").category).toBe("Obese");
|
|
});
|
|
});
|