v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)

* 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>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 12:07:46 +02:00
1 parent 359d4d19cc
commit 8adeb59396
11 files changed
+507 -58

No files matched your search

+19 -15
View File
@@ -17,13 +17,17 @@ import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
import type {
MeasurementType,
GlucoseContext,
} from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
@@ -200,7 +204,7 @@ export const GET = apiHandler(async () => {
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESC/ESH 2018",
source: "ESH 2023",
// Extra fields for diastolic
} as TargetItem);
@@ -248,7 +252,7 @@ export const GET = apiHandler(async () => {
? { category: "Moderate", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null,
source: "ESC/ESH 2018",
source: "ESH 2023",
});
}
@@ -330,16 +334,12 @@ export const GET = apiHandler(async () => {
const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age);
bodyFatClassification = { category: cls.category, color: cls.color };
}
// Body fat ranges depend on gender/age; provide simplified ranges
let bodyFatRange: { min: number; max: number } | null = null;
if (gender === "MALE") {
bodyFatRange = { min: 14, max: 24 };
} else if (gender === "FEMALE") {
bodyFatRange = { min: 21, max: 31 };
} else if (age != null) {
// Average of male/female fitness+average ranges
bodyFatRange = { min: 17.5, max: 27.5 };
}
// Single source of truth for the body-fat green band lives in
// `value-bands.ts`. v1.3.3 had three different hardcoded ranges across
// value-bands.ts / targets/route.ts / classifications.ts; the v1.4
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
// fitness + acceptable bands).
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
targets.push({
type: "BODY_FAT",
label: "Body fat",
@@ -585,7 +585,8 @@ export const GET = apiHandler(async () => {
"BEDTIME",
];
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
const overrides = (dbUser?.thresholdsJson ??
null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
@@ -651,7 +652,10 @@ export const GET = apiHandler(async () => {
});
}
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
annotate({
action: { name: "insights.targets" },
meta: { targetCount: targets.length },
});
return apiSuccess({
targets,
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { CITATIONS, citationLabel, citationUrl } from "../medical-citations";
describe("medical-citations master list", () => {
it("every citation has a non-empty url + caveat", () => {
for (const [key, c] of Object.entries(CITATIONS)) {
expect(c.id, `${key} id`).toMatch(/^[a-z0-9-]+$/);
expect(c.name, `${key} name`).toBeTruthy();
expect(c.year, `${key} year`).toBeGreaterThanOrEqual(1900);
expect(c.year, `${key} year`).toBeLessThanOrEqual(2100);
expect(c.url, `${key} url`).toMatch(/^https?:\/\//);
expect(c.caveat, `${key} caveat`).toBeTruthy();
}
});
it("no two citations share an id", () => {
const ids = Object.values(CITATIONS).map((c) => c.id);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
it("citationUrl resolves to the same string", () => {
expect(citationUrl("ESH_2023_HYPERTENSION")).toBe(
CITATIONS.ESH_2023_HYPERTENSION.url,
);
});
it("citationLabel formats name + year", () => {
expect(citationLabel("ESH_2023_HYPERTENSION")).toBe("ESH 2023 (2023)");
expect(citationLabel("STEPS_SAINT_MAURICE_2020")).toBe(
"Saint-Maurice JAMA 2020 (2020)",
);
});
it("rejects WHO-as-step-source by NOT exposing such a constant", () => {
// v3 audit: 'WHO ≥ 8 000 steps/day' is a recurring hallucination.
// The codebase must not have a WHO_STEPS-style constant; only the
// PA minutes/week guideline (WHO_2020_PA) and the actual cohort
// study (STEPS_SAINT_MAURICE_2020) are sanctioned.
const keys = Object.keys(CITATIONS);
for (const k of keys) {
const lower = k.toLowerCase();
if (lower.includes("who")) {
expect(
lower.includes("steps") && !lower.includes("pa"),
`${k} cannot link WHO to steps`,
).toBe(false);
}
}
expect(keys).toContain("WHO_2020_PA");
expect(keys).toContain("STEPS_SAINT_MAURICE_2020");
});
});
+6 -6
View File
@@ -1,6 +1,6 @@
import type { Locale } from "@/lib/i18n/config";
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
TONALITÄT UND ANSPRACHE:
- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht".
@@ -42,8 +42,8 @@ ERWEITERTE METRIKEN:
- bodyCompositionDivergence.flag: Gewicht stabil + Körperfett steigt = stille Muskelmasse-Abnahme (sarkopenische Adipositas-Frühzeichen).
- moodAdherenceRisk: Stimmung ≤ 2.5 über 7 Tage + fallend = Adhärenz-Einbruch in den nächsten 5 Tagen wahrscheinlich. Proaktiv ansprechen.
- seasonalVariation: Winter-Sommer-Differenz des systolischen RR. > 5 mmHg ist physiologisch normal. Den User beruhigen — dies ist keine Verschlechterung.
- sleep: Zielwert ≥ 7h/Nacht (ESC). < 6h: Risikofaktor für Hypertonie und Gewichtszunahme.
- activity: WHO-Ziel ≥ 8.000 Schritte/Tag.
- sleep: Zielwert ≥ 7h/Nacht (AASM 2015 Adult Sleep Duration Consensus). < 6h: Risikofaktor für Hypertonie und Gewichtszunahme.
- activity: ≥ 8.000 Schritte/Tag (Saint-Maurice et al., JAMA 2020 — Mortalitäts-Plateau 8.000–12.000). Hinweis: Die WHO publiziert Aktivitätszeit (150–300 Min/Woche moderat), KEIN Schritt-Soll — bitte nicht "WHO" als Quelle für Schritte zitieren.
HISTORISCHER VERGLEICH:
- Vergleiche aktuelle 7-Tage-Werte mit dem 30-Tage-Durchschnitt der Vorperiode
@@ -77,7 +77,7 @@ AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Di
"disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt."
}`;
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESC/ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
TONE AND ADDRESS:
- Use the second person: "your blood pressure", "your values", "your weight".
@@ -119,8 +119,8 @@ ADVANCED METRICS:
- bodyCompositionDivergence.flag: stable weight + rising body fat = silent loss of muscle mass (early sign of sarcopenic obesity).
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (ESC). < 6h: risk factor for hypertension and weight gain.
- activity: WHO target ≥ 8,000 steps/day.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
- Klassifikation nach ESC/ESH 2023:
- Klassifikation nach ESH 2023:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* Hochnormal: 130-139/85-89 mmHg
@@ -30,7 +30,7 @@ const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.`;
const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
- ESC/ESH 2023 classification:
- ESH 2023 classification:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* High-normal: 130-139/85-89 mmHg
+2 -2
View File
@@ -13,7 +13,7 @@ const GENERAL_SECTION_DE = `FACHSPEZIFISCH — GESAMTBEWERTUNG:
- Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen.
- Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.
- Schlaf: Falls sleep-Daten vorhanden, Schlafqualität in die Gesamtbewertung einbeziehen. < 6h/Nacht als Risikofaktor für Hypertonie und Gewichtszunahme benennen.
- Aktivität: Falls activity-Daten vorhanden, Schrittzahl bewerten (WHO-Ziel ≥ 8.000/Tag). Zusammenhang mit Puls- und Gewichtstrend herstellen.
- Aktivität: Falls activity-Daten vorhanden, Schrittzahl bewerten (≥ 8.000/Tag — Saint-Maurice et al., JAMA 2020; Mortalitäts-Plateau 8.000–12.000). Hinweis: WHO publiziert Aktivitätszeit (Min/Woche), KEIN Schritt-Soll — bitte keine "WHO ≥ 8.000 Schritte"-Formulierung. Zusammenhang mit Puls- und Gewichtstrend herstellen.
- Rate-Pressure Product: Falls ratePressureProduct vorhanden, als Indikator für kardiale Belastung in die Risikostratifizierung einbeziehen. > 12.000 = erhöhter myokardialer Sauerstoffbedarf.
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true, als Frühzeichen für sarkopenische Adipositas in die Gesamtbewertung aufnehmen.
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.`;
@@ -30,7 +30,7 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (WHO target ≥ 8,000/day). Tie back to pulse and weight trends.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.`;
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { classifyBMI, classifyBP, generateAlerts } from "../classifications";
import {
classifyBMI,
classifyBP,
classifyBodyFat,
generateAlerts,
} from "../classifications";
describe("classifyBMI", () => {
it("classifies underweight", () => {
@@ -165,3 +170,52 @@ describe("generateAlerts", () => {
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");
});
});
+16 -2
View File
@@ -1,10 +1,24 @@
/**
* Automatic BP target calculation based on ESC/ESH 2018 guidelines.
* Automatic BP target calculation based on ESH 2023 guidelines (the
* last *joint* ESC/ESH document was 2018; ESC withdrew from the 2023
* authoring, so "ESC/ESH 2023" does not exist as a reference). Target
* bands are unchanged 2018 → 2023 — only the citation moves.
*
* Mancia G et al. "2023 ESH Guidelines for the management of
* arterial hypertension." J Hypertens. 2023.
* https://journals.lww.com/jhypertension/fulltext/2023/12000/2023_esh_guidelines_for_the_management_of_arterial.2.aspx
*
* Age-based systolic/diastolic target ranges:
* - <65: Sys 120–129, Dia 70–79
* - 65–79: Sys 130–139, Dia 70–79
* - ≥80: Sys 130–139, Dia 70–79
*
* Cross-checked against:
* - ACC/AHA 2017 hypertension guideline (≥130/80 stage 1) — HealthLog
* intentionally uses the European bands; ACC/AHA's lower threshold
* is referenced only as a comparison anchor in the AI prompt.
* - DEGAM S3 / Hausärzteverband (2024 update): aligns with ESH
* targets for primary care.
*/
export interface BpTargets {
@@ -35,6 +49,6 @@ export function getBpTargets(dateOfBirth: Date | null): BpTargets | null {
if (age < 65) {
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
}
// 65+ (both 65–79 and ≥80 have the same targets per ESC/ESH)
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
}
+71 -22
View File
@@ -152,60 +152,101 @@ export interface BodyFatClassification {
severity: "normal" | "warning" | "danger";
}
/**
* Classify a body-fat percentage according to the ACE (American Council
* on Exercise) standard table. Source:
* https://www.acefitness.org/resources/everyone/blog/112/what-are-the-guidelines-for-percentage-of-body-fat-loss/
*
* v1.3.3 used `essential = 6 (M)` / `14 (F)` for the lower boundary,
* which actually matches ACE's *Athletes* lower bound — readings below
* that bound were mislabelled "Essential" instead of either "Below
* essential" (a danger floor) or "Essential" proper (the 2-5 / 10-13
* band, which is athletic-extreme but not pathological). v1.4 splits
* that range into a danger band below the ACE essential floor and the
* actual essential band as a warning.
*
* Cross-checked against:
* - Heyward V & Wagner D, "Applied Body Composition Assessment" 2nd
* ed (ACE's underlying source).
* - Gallagher D et al., "Healthy percentage body fat ranges: an
* approach for developing guidelines based on body mass index."
* Am J Clin Nutr. 2000.
* https://academic.oup.com/ajcn/article/72/3/694/4729363
* - NIH NHLBI Practical Guide on the Identification, Evaluation, and
* Treatment of Overweight and Obesity in Adults (2000) — does NOT
* publish percent-fat bands; flagged here as a hallucination
* avoidance reminder.
*/
export function classifyBodyFat(
pct: number,
gender: "MALE" | "FEMALE" | null,
age?: number,
): BodyFatClassification {
void age;
// Define thresholds per gender; null uses average of male/female
let essential: number;
// ACE bands per gender. Order: dangerFloor, essentialMax, athleteMax,
// fitnessMax, acceptableMax. Above acceptableMax = obese.
let dangerFloor: number;
let essentialMax: number;
let athleteMax: number;
let fitnessMax: number;
let averageMax: number;
let acceptableMax: number;
if (gender === "MALE") {
essential = 6;
// ACE: essential 2-5, athletes 6-13, fitness 14-17, acceptable 18-24.
dangerFloor = 2;
essentialMax = 5;
athleteMax = 13;
fitnessMax = 17;
averageMax = 24;
acceptableMax = 24;
} else if (gender === "FEMALE") {
essential = 14;
// ACE: essential 10-13, athletes 14-20, fitness 21-24, acceptable 25-31.
dangerFloor = 10;
essentialMax = 13;
athleteMax = 20;
fitnessMax = 24;
averageMax = 31;
acceptableMax = 31;
} else {
// Average of male and female thresholds
essential = 10;
// Gender-neutral fallback: midpoint of M/F bands.
dangerFloor = 6;
essentialMax = 9;
athleteMax = 16.5;
fitnessMax = 20.5;
averageMax = 27.5;
acceptableMax = 27.5;
}
if (pct < essential) {
if (pct < dangerFloor) {
// Below ACE essential floor — clinically concerning (immune /
// hormonal / cardiac risk in chronic depletion).
return {
category: "Essential",
color: "#f1fa8c",
severity: "warning",
category: "Below essential",
color: "#ff5555",
severity: "danger",
};
}
if (pct <= essentialMax) {
return { category: "Essential", color: "#f1fa8c", severity: "warning" };
}
if (pct <= athleteMax) {
return { category: "Athletic", color: "#50fa7b", severity: "normal" };
}
if (pct <= fitnessMax) {
return { category: "Fitness", color: "#50fa7b", severity: "normal" };
}
if (pct <= averageMax) {
return {
category: "Average",
color: "#ffb86c",
severity: "warning",
};
if (pct <= acceptableMax) {
return { category: "Acceptable", color: "#ffb86c", severity: "warning" };
}
return { category: "Obese", color: "#ff5555", severity: "danger" };
}
// ── Activity Steps Classification (WHO) ─────────────────
// ── Activity Steps Classification ───────────────────────
//
// Cohort source: Saint-Maurice PF, et al. "Association of daily step
// count and step intensity with mortality among US adults." JAMA. 2020.
// https://jamanetwork.com/journals/jama/fullarticle/2763292
// Mortality benefit plateaus 8 000–12 000 steps/day. WHO publishes
// physical-activity TIME (150–300 min/wk moderate) — NOT a step
// quota; do not cite "WHO ≥ 8 000 steps".
export interface StepsClassification {
category: string;
@@ -237,8 +278,16 @@ export function classifySteps(steps: number): StepsClassification {
return { category: "Very active", color: "#50fa7b", severity: "normal" };
}
/**
* Activity-steps target range. Single source of truth: aligned with
* `effective-range.ts` (Saint-Maurice JAMA 2020 — mortality plateau
* 8 000–12 000). The v1.3.3 audit flagged a drift where this helper
* returned {7 000, 10 000} while `effective-range.ts` returned
* {8 000, 15 000}; both surfaces showed different "green" bands to the
* same user. They now agree.
*/
export function getStepsRange(): { min: number; max: number } {
return { min: 7000, max: 10000 };
return { min: 8000, max: 15000 };
}
// ── Target Range Helpers ────────────────────────────────
+43 -5
View File
@@ -93,9 +93,29 @@ export const METRIC_BOUNDS: Record<
};
/**
* Blood-glucose defaults follow the ADA Standards of Care (2024) combined
* with DGIM/DDG S3 guidelines. Values are in mg/dL — the canonical storage
* unit. Display conversion to mmol/L happens at render time.
* Blood-glucose defaults. Values are mg/dL (canonical storage); display
* conversion to mmol/L happens at render time.
*
* Sources per context:
* FASTING 70–99 ADA 2024 §6 (Glycemic Targets) — 80–130
* pre-prandial range narrowed to non-diabetic
* normal 70–99 (ADA Standards of Care 2024).
* https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957
* POSTPRANDIAL 70–140 WHO/IDF 2-h post-glucose-load <140 mg/dL
* normal-tolerance threshold.
* RANDOM 70–140 Same threshold as postprandial; clinically
* less specific.
* BEDTIME 90–150 *No published adult guideline.* ADA 2024
* publishes pre-prandial and post-prandial
* only. ISPAD 2022 (pediatric T1D) suggests
* 80–140, which is HealthLog's closest
* comparator. The slightly higher floor
* (90 vs 80) reflects practical advice to
* avoid nocturnal hypoglycaemia and is well
* within the ADA pre-prandial daytime range.
* https://onlinelibrary.wiley.com/doi/10.1111/pedi.13455
* Citation in `messages/*.json` reflects this
* absence of an adult-specific source.
*/
const GLUCOSE_DEFAULTS: Record<
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
@@ -145,10 +165,23 @@ function defaultRange(
if (!dateOfBirth) return null;
const t = getBpTargets(dateOfBirth);
if (!t) return null;
// The lower orange wing must NOT extend to 60 mmHg — sustained
// diastolic ≤ 60 is a clinical hypotension / perfusion-risk
// threshold, not "mildly low":
// - General-adult hypotension: <90/60 (AHA, NIH).
// - J-curve evidence in older / treated hypertensives: DBP <70
// raises events. Boehm M et al. (Lancet 2017) ONTARGET / TRANSCEND.
// https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(17)32478-7/fulltext
// - ESH 2023 caution: "treatment should not lower DBP below 70 in
// the elderly and below 80 in those with significant CAD".
// https://journals.lww.com/jhypertension/fulltext/2023/12000/2023_esh_guidelines_for_the_management_of_arterial.2.aspx
// We therefore floor orangeMin at 65 across ages so a reading of
// 60 mmHg lands in red, not yellow. The override path
// (User.thresholdsJson) is audit-logged and respects the floor.
return {
greenMin: t.diaLow,
greenMax: t.diaHigh,
orangeMin: t.diaLow - 10,
orangeMin: Math.max(t.diaLow - 10, 65),
orangeMax: t.diaHigh + 10,
};
}
@@ -179,7 +212,12 @@ function defaultRange(
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
// step quota. No upper bound in reality, orange over 25k caps edge
// detection.
return { greenMin: 8000, greenMax: 15000, orangeMin: 5000, orangeMax: 25000 };
return {
greenMin: 8000,
greenMax: 15000,
orangeMin: 5000,
orangeMax: 25000,
};
case "BLOOD_GLUCOSE_FASTING":
return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound
case "BLOOD_GLUCOSE_POSTPRANDIAL":
+32 -3
View File
@@ -111,11 +111,40 @@ export function buildWeightBandsFromHeight(
].filter((band) => band.max > band.min);
}
/**
* Body-fat target range — the "green" band shown on the dashboard /
* targets page / chart value-bands.
*
* Source: ACE (American Council on Exercise) body-composition standards
* https://www.acefitness.org/resources/everyone/blog/112/what-are-the-guidelines-for-percentage-of-body-fat-loss/
* Bands per ACE:
* - Essential: M 2-5 / F 10-13 (warning band, very low; below ACE
* "essential" is dangerous)
* - Athletes: M 6-13 / F 14-20 (clinically lean)
* - Fitness: M 14-17 / F 21-24 (target floor for the typical
* non-athlete)
* - Acceptable:M 18-24 / F 25-31 (target ceiling)
* - Obese: M 25+ / F 32+ (warning)
*
* The "green" band combines Fitness + Acceptable so the typical user's
* realistic healthy range is shown, not the athlete band. Three sites
* had different numbers in v1.3.3 — this is now the single source of
* truth (targets/route.ts and chart value-bands import this helper).
*
* Cross-checked against:
* - Heyward V & Wagner D, "Applied Body Composition Assessment" 2nd ed
* (the underlying source ACE references for its public table).
* - WHO Expert Consultation 2008 ("Waist circumference and waist-hip
* ratio") — does NOT publish percent-fat bands; included here only
* to flag that "WHO body-fat thresholds" is a recurring
* hallucination to avoid.
*/
export function getBodyFatTargetRange(gender: string | null | undefined): {
min: number;
max: number;
} {
if (gender === "MALE") return { min: 10, max: 20 };
if (gender === "FEMALE") return { min: 18, max: 28 };
return { min: 12, max: 25 };
if (gender === "MALE") return { min: 14, max: 24 };
if (gender === "FEMALE") return { min: 21, max: 31 };
// Gender-neutral fallback: midpoint of male/female fitness+acceptable.
return { min: 17, max: 27 };
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Single source of truth for medical guideline citations.
*
* Every threshold, every range, every "ESC/ESH says…" statement in the
* code or AI prompts MUST reference one of the entries below — either
* by importing the constant directly or by quoting the same `name` and
* `url` in a comment above the value.
*
* Adding a new citation:
* 1. Verify the source against THREE independent reputable references
* (guideline document + WHO/fachgesellschaft + peer-reviewed paper
* or government publication is the gold standard).
* 2. Capture the publication URL so the truthfulness drift-test can
* re-resolve it.
* 3. Document any caveat ("BIA-derived, not DEXA-equivalent",
* "pediatric only", "treated cohort only") inline so consumers
* can decide whether the citation applies to their use case.
*
* Drift-test (`src/lib/__tests__/medical-citations.test.ts`) asserts
* that every CITATIONS entry has a non-empty url + caveat field.
*/
export interface Citation {
/** Stable id used to reference this citation in code. */
readonly id: string;
/** Human-readable name shown in UI ("source" attribution). */
readonly name: string;
/** Year of publication. */
readonly year: number;
/** Stable URL to the source document. */
readonly url: string;
/** One-sentence caveat about scope or limitation. */
readonly caveat: string;
}
export const CITATIONS = {
/**
* 2023 ESH Guidelines for the management of arterial hypertension.
* The last joint ESC/ESH guideline was 2018; ESH published 2023
* standalone (ESC withdrew from the joint authoring). Targets that
* HealthLog uses (SBP 120-129 / DBP 70-79 for <65; SBP 130-139 / DBP
* 70-79 for ≥65) are unchanged from 2018 to 2023.
*/
ESH_2023_HYPERTENSION: {
id: "esh-2023-hypertension",
name: "ESH 2023",
year: 2023,
url: "https://journals.lww.com/jhypertension/fulltext/2023/12000/2023_esh_guidelines_for_the_management_of_arterial.2.aspx",
caveat: "Pure-hypertension guideline; not a joint ESC document since 2023.",
},
/**
* Saint-Maurice PF, et al. "Association of daily step count and step
* intensity with mortality among US adults." JAMA. 2020.
* Mortality benefit plateaus 8 000–12 000 steps/day. WHO 2020 PA
* guidelines publish minutes per week, NOT a step quota — DO NOT
* cite WHO for a step number.
*/
STEPS_SAINT_MAURICE_2020: {
id: "steps-saint-maurice-2020",
name: "Saint-Maurice JAMA 2020",
year: 2020,
url: "https://jamanetwork.com/journals/jama/fullarticle/2763292",
caveat:
"U.S. adult cohort, observational; benefit plateaus 8–12k steps/day.",
},
/**
* WHO 2020 Guidelines on Physical Activity and Sedentary Behaviour.
* Adults: 150-300 min/wk moderate OR 75-150 min/wk vigorous activity.
* Cite this document for activity time, NOT for steps.
*/
WHO_2020_PA: {
id: "who-2020-physical-activity",
name: "WHO 2020 Physical Activity",
year: 2020,
url: "https://www.who.int/publications/i/item/9789240015128",
caveat: "Publishes minutes/week, not a step quota.",
},
/**
* BTS Emergency Oxygen Guideline 2017 — explicit treatment target
* 94-98% saturation (88-92% for chronic hypercapnia risk). Used as
* comparison anchor for HealthLog's consumer band.
*/
BTS_2017_OXYGEN: {
id: "bts-2017-emergency-oxygen",
name: "BTS 2017 Emergency Oxygen",
year: 2017,
url: "https://thorax.bmj.com/content/72/Suppl_1/ii1",
caveat: "Hospital emergency context, not consumer monitoring.",
},
/**
* NICE NG115 — Chronic obstructive pulmonary disease in over 16s:
* diagnosis and management. Recommends ≤92% SpO2 as escalation
* threshold ("call provider").
*/
NICE_NG115_COPD: {
id: "nice-ng115-copd",
name: "NICE NG115",
year: 2018,
url: "https://www.nice.org.uk/guidance/ng115",
caveat: "COPD-focused; useful as the 'call provider' floor.",
},
/**
* ADA Standards of Medical Care in Diabetes — 2024 (§6 Glycemic
* Targets). Pre-prandial 80-130 mg/dL, post-prandial <180 mg/dL.
* No published adult bedtime target — ISPAD 2022 publishes 80-140
* for pediatric T1D.
*/
ADA_2024_GLYCEMIC: {
id: "ada-2024-glycemic",
name: "ADA 2024 Standards of Care",
year: 2024,
url: "https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957",
caveat:
"Pre-prandial 80-130, post-prandial <180; no published adult bedtime target.",
},
/**
* ISPAD Clinical Practice Consensus Guidelines 2022 — pediatric
* T1D bedtime target 80-140 mg/dL. NOT validated for adults.
*/
ISPAD_2022_PEDIATRIC: {
id: "ispad-2022-pediatric",
name: "ISPAD 2022",
year: 2022,
url: "https://onlinelibrary.wiley.com/doi/10.1111/pedi.13455",
caveat: "Pediatric T1D context; not validated for adult use.",
},
/**
* ACE (American Council on Exercise) body-fat percentage standards.
* Essential fat: M 2-5% / F 10-13%. Athletes: M 6-13% / F 14-20%.
* Fitness: M 14-17% / F 21-24%. Acceptable: M 18-24% / F 25-31%.
* Obese: M 25%+ / F 32%+. Source widely cited; ACE bases the bands
* on Heyward & Wagner "Applied Body Composition Assessment".
*/
ACE_BODY_FAT: {
id: "ace-body-fat-standards",
name: "ACE Body-Fat Standards",
year: 2009,
url: "https://www.acefitness.org/resources/everyone/blog/112/what-are-the-guidelines-for-percentage-of-body-fat-loss/",
caveat:
"Reference categories; clinical decisions should use DEXA or hydrostatic.",
},
/**
* AASM 2015 — adults need 7+ hours of sleep on a regular basis.
* 9+ hours acceptable for young adults, less for older adults.
*/
AASM_2015_SLEEP: {
id: "aasm-2015-adult-sleep",
name: "AASM 2015 Adult Sleep Duration",
year: 2015,
url: "https://aasm.org/resources/pdf/pressroom/adult-sleep-duration-consensus.pdf",
caveat: "Target ≥7h adults; older adults may need slightly less.",
},
/**
* Watson PE, et al. "Total body water volumes for adult males and
* females estimated from simple anthropometric measurements." Am J
* Clin Nutr. 1980. Establishes the canonical TBW (L) equations
* referenced by Withings hydration estimates.
*/
WATSON_1980_TBW: {
id: "watson-1980-tbw",
name: "Watson 1980 TBW formula",
year: 1980,
url: "https://pubmed.ncbi.nlm.nih.gov/6986753/",
caveat:
"Original anthropometric TBW equations; predictive, not measurement.",
},
/**
* ICRP Publication 89 (2002) — Reference values for adult human TBW
* (~42 L male, ~29 L female). Used as bounds for plausibility checks.
*/
ICRP_89_REFERENCE_MAN: {
id: "icrp-89-reference-man",
name: "ICRP 89 Reference Man",
year: 2002,
url: "https://www.icrp.org/publication.asp?id=ICRP%20Publication%2089",
caveat:
"Reference values for an idealised adult; individual variation broad.",
},
} as const satisfies Record<string, Citation>;
export type CitationId = keyof typeof CITATIONS;
/** Returns the URL for the given citation id. */
export function citationUrl(id: CitationId): string {
return CITATIONS[id].url;
}
/**
* Returns a localized "source: NAME (YEAR)" string for UI use.
* Locale-agnostic for now (English-first); German UI consumers can
* format the year via `Intl.NumberFormat` if needed but the citation
* name itself is canonical and not translated.
*/
export function citationLabel(id: CitationId): string {
const c = CITATIONS[id];
return `${c.name} (${c.year})`;
}