Files
HealthLog/src/lib/analytics/value-bands.ts
T
Marc-André BombeckandMarc-André Bombeck 8adeb59396 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>
2026-05-08 12:07:46 +02:00

151 lines
4.5 KiB
TypeScript

export interface ValueBand {
min: number;
max: number;
color: string;
opacity?: number;
strokeOpacity?: number;
}
export interface TrafficRange {
greenMin: number;
greenMax: number;
orangeMin: number;
orangeMax: number;
}
interface TrafficBandOptions {
lowerBound?: number;
upperBound?: number;
orangeFactor?: number;
}
export function buildTrafficLightBands(
min: number,
max: number,
options: TrafficBandOptions = {},
): ValueBand[] {
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
return [];
}
const span = max - min;
const orangeFactor = options.orangeFactor ?? 0.3;
const orangeWidth = span * orangeFactor;
const orangeLow = min - orangeWidth;
const orangeHigh = max + orangeWidth;
const lowerBound =
options.lowerBound ?? Math.max(0, orangeLow - Math.max(span, 1) * 2);
const upperBound = options.upperBound ?? orangeHigh + Math.max(span, 1) * 2;
const bands: ValueBand[] = [
{ min: lowerBound, max: orangeLow, color: "#ff5555", opacity: 0.16 },
{ min: orangeLow, max: min, color: "#ffb86c", opacity: 0.18 },
{ min, max, color: "#50fa7b", opacity: 0.2 },
{ min: max, max: orangeHigh, color: "#ffb86c", opacity: 0.18 },
{ min: orangeHigh, max: upperBound, color: "#ff5555", opacity: 0.16 },
];
return bands.filter((band) => band.max > band.min);
}
export function buildTrafficRange(
min: number,
max: number,
orangeFactor = 0.3,
): TrafficRange | null {
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
return null;
}
const span = max - min;
const orangeWidth = span * orangeFactor;
return {
greenMin: min,
greenMax: max,
orangeMin: min - orangeWidth,
orangeMax: max + orangeWidth,
};
}
export function buildWeightRangeFromHeight(heightCm: number): TrafficRange {
const heightM = heightCm / 100;
const h2 = heightM * heightM;
return {
greenMin: 18.5 * h2,
greenMax: 24.9 * h2,
orangeMin: 17 * h2,
orangeMax: 29.9 * h2,
};
}
export function buildWeightBandsFromHeight(
heightCm: number,
options: { lowerBound?: number; upperBound?: number } = {},
): ValueBand[] {
const range = buildWeightRangeFromHeight(heightCm);
const lowerBound = options.lowerBound ?? 30;
const upperBound = options.upperBound ?? 250;
return [
{ min: lowerBound, max: range.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: range.orangeMin,
max: range.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: range.greenMin,
max: range.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: range.greenMax,
max: range.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: range.orangeMax, max: upperBound, color: "#ff5555", opacity: 0.16 },
].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: 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 };
}