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>
151 lines
4.5 KiB
TypeScript
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 };
|
|
}
|