Files
HealthLog/src/lib/reference-ranges.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

525 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.18.6 — citation-backed reference-range backbone.
*
* A single structured source of truth for the population reference bands
* the UI surfaces (explainer captions, metric tiles, the glossary, the
* "where your value sits" tooltip). Each metric carries:
* - the canonical display unit,
* - one or more named `bands` (low/high half-open, with the body + year
* that publishes them via a `CitationId`),
* - the primary `referenceId` (the body the headline band is sourced to),
* - an optional `conflicts[]` of alternative-guideline context (e.g. the
* BP US/EU disagreement — shown as placement context, never the
* shipped classification),
* - a `guidanceCaveat` that frames the band as general guidance.
*
* This module is intentionally read-only data + pure helpers — no DB, no
* LLM, no per-user state. The metric-status registry fills its coarse
* `normalRange` from these bands; the BP detail surface reads the PP / MAP
* anchors; the explainers read `referenceLabel()` for the cited sentence.
*
* Every band traces to `src/lib/medical-citations.ts`. No commercial brand
* is named anywhere. Ranges are population anchors that shift with age,
* sex and condition — the user's own baseline always leads the read; these
* are a placement aid, not a diagnosis. Where authoritative bodies
* disagree the disagreement is surfaced (`conflicts`), never silently
* resolved.
*/
import { CITATIONS, type CitationId, citationLabel } from "./medical-citations";
/**
* A single named reference band. `low`/`high` are inclusive display
* anchors in the metric's unit; either bound may be omitted for an
* open-ended band (e.g. "≥7 h", "<100 mg/dL").
*/
export interface ReferenceBand {
/** Stable, human-readable band label ("Normal", "Elevated", "Fever"). */
readonly label: string;
readonly low?: number;
readonly high?: number;
/** The body + year that publishes this band. */
readonly citation: CitationId;
/**
* Marks THE population-normal / target band — the placement
* `classifyReference` treats as "within" and `headlineBandText` renders
* as the headline. Exactly one band per metric (with at least one band)
* carries this. Bands are NOT assumed normal by index: a metric whose
* first band is abnormal (BMI → Underweight first; pulse pressure →
* Narrow first) marks the correct interior band instead. A test asserts
* the one-marker-per-banded-metric invariant.
*/
readonly normal?: boolean;
}
/**
* An alternative-guideline framing shown as CONTEXT next to the default
* band — never the shipped classification. The BP US/EU split is the
* canonical use.
*/
export interface ReferenceConflict {
/** Short note describing the alternative framing (general education). */
readonly note: string;
readonly citation: CitationId;
}
export interface ReferenceRange {
/** Canonical display unit (matches the metric tile + registry). */
readonly unit: string;
/**
* Ordered population bands. The "good"/normal placement is the band
* flagged `normal: true` — NOT necessarily the first entry. Some metrics
* (BMI, pulse pressure) author an abnormal band first, so consumers must
* resolve the normal band by the marker, never by index.
*/
readonly bands: readonly ReferenceBand[];
/** The body the headline band is sourced to (drives the cited sentence). */
readonly referenceId: CitationId;
/** Alternative-guideline context, shown for placement only. */
readonly conflicts?: readonly ReferenceConflict[];
/** One-sentence general-guidance frame surfaced under the band. */
readonly guidanceCaveat: string;
}
/**
* The reference-metric slugs the backbone covers. Stable string keys (not
* the DB enum) so the UI, the registry and the explainer can all read one
* vocabulary. A superset of the universal metrics + the cardiovascular
* derivations the explainers cite.
*/
export type ReferenceMetric =
| "BLOOD_PRESSURE"
| "PULSE_PRESSURE"
| "MEAN_ARTERIAL_PRESSURE"
| "PULSE_WAVE_VELOCITY"
| "RESTING_HEART_RATE"
| "HEART_RATE_VARIABILITY"
| "OXYGEN_SATURATION"
| "RESPIRATORY_RATE"
| "BODY_TEMPERATURE"
| "BLOOD_GLUCOSE"
| "HBA1C"
| "BMI"
| "STEPS"
| "VISCERAL_FAT"
| "SLEEP_DURATION";
export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
// ── Blood pressure — ESH 2023 is the SHIPPED default (do not flip). ──
// ACC/AHA 2017 + ESC 2024 are surfaced only as placement CONTEXT.
BLOOD_PRESSURE: {
unit: "mmHg",
bands: [
// ESH grade bands (office, mmHg, systolic anchors shown).
{
label: "Optimal",
high: 120,
citation: "ESH_2023_HYPERTENSION",
normal: true,
},
{
label: "Normal",
low: 120,
high: 129,
citation: "ESH_2023_HYPERTENSION",
},
{
label: "High-normal",
low: 130,
high: 139,
citation: "ESH_2023_HYPERTENSION",
},
{ label: "Hypertension", low: 140, citation: "ESH_2023_HYPERTENSION" },
],
referenceId: "ESH_2023_HYPERTENSION",
conflicts: [
{
note: "ACC/AHA (US) labels 130–139/80–89 as Stage 1 hypertension — a stricter line than the European disease threshold of 140/90.",
citation: "ACC_AHA_2017_BP",
},
{
note: "ESC 2024 adds an 'Elevated' band at 120–139/70–89 and a treated target of 120–129.",
citation: "ESC_2024_BP",
},
],
guidanceCaveat:
"Categorised from a standardised seated reading or a validated home average — never a single reading. Targets individualise by age and condition.",
},
// Pulse pressure = SBP − DBP. Pure derivation of stored BP.
PULSE_PRESSURE: {
unit: "mmHg",
bands: [
{ label: "Narrow", high: 25, citation: "STATPEARLS_PULSE_PRESSURE" },
{
label: "Typical at rest",
low: 25,
high: 60,
citation: "STATPEARLS_PULSE_PRESSURE",
normal: true,
},
{ label: "Wide", low: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
],
referenceId: "STATPEARLS_PULSE_PRESSURE",
guidanceCaveat:
"A wide gap (≳60) in middle age can reflect arterial stiffening; a narrow gap (≲25) can accompany low stroke volume. A discussion prompt, not a diagnosis.",
},
// Mean arterial pressure ≈ (SBP + 2·DBP)/3.
MEAN_ARTERIAL_PRESSURE: {
unit: "mmHg",
bands: [
{
label: "Resting band",
low: 70,
high: 100,
citation: "STATPEARLS_MAP",
normal: true,
},
],
referenceId: "STATPEARLS_MAP",
guidanceCaveat:
"The average pressure your organs see across a heartbeat. The ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.",
},
PULSE_WAVE_VELOCITY: {
unit: "m/s",
bands: [
{
label: "Reference",
high: 10,
citation: "ESC_ESH_2018_PWV",
normal: true,
},
{ label: "Elevated stiffness", low: 10, citation: "ESC_ESH_2018_PWV" },
],
referenceId: "ESC_ESH_2018_PWV",
guidanceCaveat:
"The cf-PWV >10 m/s organ-damage marker is a European clinical reference measured by tonometry; a consumer estimate is a proxy, not interchangeable.",
},
// ── Resting heart rate — uses RESTING_HEART_RATE, never workout pulse. ──
// The 60–100 band carries `normal: true`, so `classifyReference` treats it
// as the "within" placement regardless of band order. The athletic band
// (40–60) is benign for a trained heart but sits below the standard
// normal range — the caveat copy carries that nuance.
RESTING_HEART_RATE: {
unit: "bpm",
bands: [
{
label: "Normal adult resting",
low: 60,
high: 100,
citation: "AHA_2024_RHR",
normal: true,
},
{
label: "Athletic (benign)",
low: 40,
high: 60,
citation: "AHA_2024_RHR",
},
],
referenceId: "AHA_2024_RHR",
guidanceCaveat:
"Measured at rest, ideally on waking. A trained heart can sit 40–60 bpm; the trend over weeks beats any single reading. Caffeine, stress, fever and medication all shift it.",
},
HEART_RATE_VARIABILITY: {
unit: "ms",
bands: [],
referenceId: "ESC_NASPE_1996_HRV",
guidanceCaveat:
"There is no universal 'normal' — only your own trend is meaningful. Single-night swings of about ±10% are routine; track a 7-day rolling average and read changes smaller than that as noise.",
},
// ── SpO2 — healthy room-air band + darker-skin over-read caveat (C4). ──
OXYGEN_SATURATION: {
unit: "%",
bands: [
{
label: "Healthy room air",
low: 95,
high: 100,
citation: "STATPEARLS_PULSE_OX",
normal: true,
},
{
label: "Mild hypoxaemia",
low: 91,
high: 94,
citation: "STATPEARLS_PULSE_OX",
},
],
referenceId: "STATPEARLS_PULSE_OX",
guidanceCaveat:
"95–100% is normal at rest; above ~98% adds no benefit. Optical readers can over-read true saturation in people with darker skin, so a single near-normal reading is not a clearance — confirm when warm, still and concerned.",
},
RESPIRATORY_RATE: {
unit: "breaths/min",
bands: [
{
label: "Normal resting adult",
low: 12,
high: 20,
citation: "ALA_RESPIRATORY_RATE",
normal: true,
},
{ label: "Worth attention", low: 22, citation: "RCP_2017_NEWS2" },
],
referenceId: "ALA_RESPIRATORY_RATE",
guidanceCaveat:
"Meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal; clinical escalation cutoffs are hospital tools, not self-alarms.",
},
// ── Body temperature — evidence-based 36.6 °C mean, fever ≥38 (C6). ──
BODY_TEMPERATURE: {
unit: "°C",
bands: [
{
label: "Normal oral range",
low: 35.7,
high: 37.4,
citation: "JGIM_2019_TEMPERATURE",
normal: true,
},
{ label: "Fever", low: 38.0, citation: "JGIM_2019_TEMPERATURE" },
],
referenceId: "JGIM_2019_TEMPERATURE",
guidanceCaveat:
"The population mean is ~36.6 °C, not 37.0 °C, and swings ~0.5 °C across the day. Sites differ by up to ~1 °C, so tag the measurement site. Wearable wrist readings track relative change, not a fever value.",
},
// ── Glucose — GENERAL non-diabetic bands only (diabetes opt-in = W6). ──
BLOOD_GLUCOSE: {
unit: "mg/dL",
bands: [
{
label: "Normal fasting",
high: 100,
citation: "ADA_2024_GLYCEMIC",
normal: true,
},
{
label: "Prediabetes (fasting)",
low: 100,
high: 125,
citation: "ADA_2024_GLYCEMIC",
},
{
label: "Diabetes range (fasting)",
low: 126,
citation: "ADA_2024_GLYCEMIC",
},
],
referenceId: "ADA_2024_GLYCEMIC",
conflicts: [
{
note: "WHO/IDF set the prediabetes fasting floor at 110 mg/dL rather than 100; the diabetes line (≥126) is shared.",
citation: "WHO_IDF_2006_GLUCOSE",
},
],
guidanceCaveat:
"Fasting bands for adults without diabetes. A single high reading does not diagnose — a clinician confirms with a repeat test. People managing diabetes use different, clinician-set targets.",
},
HBA1C: {
unit: "%",
bands: [
{
label: "Normal",
high: 5.7,
citation: "ADA_2024_GLYCEMIC",
normal: true,
},
{
label: "Prediabetes",
low: 5.7,
high: 6.4,
citation: "ADA_2024_GLYCEMIC",
},
{ label: "Diabetes", low: 6.5, citation: "ADA_2024_GLYCEMIC" },
],
referenceId: "ADA_2024_GLYCEMIC",
guidanceCaveat:
"Reflects the average glucose over ~2–3 months. Unreliable with altered red-cell turnover; when discordant with glucose, glucose takes precedence.",
},
BMI: {
unit: "kg/m²",
bands: [
{ label: "Underweight", high: 18.5, citation: "WHO_2020_PA" },
{
label: "Normal",
low: 18.5,
high: 24.9,
citation: "WHO_2020_PA",
normal: true,
},
{ label: "Overweight", low: 25.0, high: 29.9, citation: "WHO_2020_PA" },
{ label: "Obesity", low: 30.0, citation: "WHO_2020_PA" },
],
referenceId: "WHO_2000_BMI",
guidanceCaveat:
"A population-level screen, not a diagnosis — it does not distinguish muscle from fat or where fat sits. Asian-population risk rises at lower cut-offs.",
},
// ── Steps — canonical green floor = 8,000 (C2/D4 reconcile). ──
STEPS: {
unit: "steps/day",
bands: [
{
label: "Higher mortality benefit",
low: 8000,
citation: "STEPS_SAINT_MAURICE_2020",
normal: true,
},
],
referenceId: "STEPS_SAINT_MAURICE_2020",
guidanceCaveat:
"Mortality risk falls steeply toward ~8,000 steps/day with continued benefit through ~12,000; the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.",
},
VISCERAL_FAT: {
unit: "rating",
bands: [
{
label: "Healthy rating",
low: 1,
high: 12,
citation: "VAT_IMAGING_THRESHOLD",
normal: true,
},
{ label: "Elevated rating", low: 13, citation: "VAT_IMAGING_THRESHOLD" },
],
referenceId: "VAT_IMAGING_THRESHOLD",
guidanceCaveat:
"The consumer scale 'rating' (≈1–12 healthy) is a unitless scale, not the imaging cm² value (>100 cm² increased risk). High visceral fat is closely tied to heart disease and type 2 diabetes.",
},
// ── Sleep — adult 7–9 h; total sleep time beats the stage split. ──
SLEEP_DURATION: {
unit: "h",
bands: [
{
label: "Adult 18–64",
low: 7,
high: 9,
citation: "AASM_2015_SLEEP",
normal: true,
},
{
label: "Older adult 65+",
low: 7,
high: 8,
citation: "AASM_2015_SLEEP",
},
],
referenceId: "AASM_2015_SLEEP",
guidanceCaveat:
"Both short and long habitual sleep carry risk. Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep/REM split.",
},
};
/** All reference-metric slugs the backbone covers. */
export const REFERENCE_METRICS = Object.keys(
REFERENCE_RANGES,
) as ReferenceMetric[];
/** Type guard narrowing an arbitrary string to a covered reference metric. */
export function isReferenceMetric(value: string): value is ReferenceMetric {
return Object.prototype.hasOwnProperty.call(REFERENCE_RANGES, value);
}
/** Resolve the reference range for a metric, or null when uncovered. */
export function getReferenceRange(metric: string): ReferenceRange | null {
return isReferenceMetric(metric) ? REFERENCE_RANGES[metric] : null;
}
/**
* Localised "ESH 2023 (2023)"-style label for the headline band's source.
* The citation name is canonical and not translated (locale-agnostic).
*/
export function referenceLabel(metric: ReferenceMetric): string {
return citationLabel(REFERENCE_RANGES[metric].referenceId);
}
/**
* The four-state "where your value sits" phrasing contract (UI-1). A
* deterministic placement of a value against the metric's population
* bands, ALWAYS framed as general guidance. Consumers map each state to a
* cited i18n string; the state itself never asserts a diagnosis.
*
* - `within` — inside the normal/good band.
* - `slightly-outside`— in an adjacent watch band (e.g. high-normal,
* prediabetes, mild hypoxaemia).
* - `outside` — in a band the guideline flags for attention.
* - `insufficient` — no value, or the metric carries no population band
* (device-derived / baseline-only signals).
*/
export type ReferencePlacement =
| "within"
| "slightly-outside"
| "outside"
| "insufficient";
/**
* Place a value against a metric's population bands. Returns
* `insufficient` when the value is absent or the metric has no fixed band
* (HRV and other device-derived signals defer wholly to the personal
* baseline). Pure + side-effect free; the user's own baseline still leads
* the read — this is only a coarse placement aid.
*/
/**
* Resolve the index of a metric's population-normal band — the band the
* placement contract treats as "within". Returns the index of the band
* flagged `normal: true`; falls back to band 0 only for the legacy
* (untagged) shape so an unmarked metric degrades to the old normal-first
* assumption rather than throwing. A metric with no bands returns -1.
*/
export function normalBandIndex(metric: ReferenceMetric): number {
const bands = REFERENCE_RANGES[metric].bands;
if (bands.length === 0) return -1;
const marked = bands.findIndex((b) => b.normal === true);
return marked === -1 ? 0 : marked;
}
export function classifyReference(
metric: ReferenceMetric,
value: number | null | undefined,
): ReferencePlacement {
const range = REFERENCE_RANGES[metric];
if (value == null || !Number.isFinite(value) || range.bands.length === 0) {
return "insufficient";
}
const normalIdx = normalBandIndex(metric);
// Find the band whose half-open interval contains the value. Bands are
// authored in ascending order; the placement is the band's DISTANCE from
// the normal band (resolved by the `normal` marker, NOT by index 0 —
// some metrics author an abnormal band first, e.g. BMI Underweight,
// pulse-pressure Narrow). One step from normal = watch tier; two or more
// = attention tier.
for (let i = 0; i < range.bands.length; i++) {
const band = range.bands[i];
const aboveLow = band.low == null || value >= band.low;
const belowHigh = band.high == null || value <= band.high;
if (aboveLow && belowHigh) {
const distance = Math.abs(i - normalIdx);
if (distance === 0) return "within";
return distance === 1 ? "slightly-outside" : "outside";
}
}
// Value sits beyond every authored band (below an open-low first band, or
// above an open-high last band). Either way it is outside the covered
// bands → attention.
return "outside";
}
/**
* Convenience: the citation entry backing a metric's headline band, for
* surfaces that want the url + caveat rather than the formatted label.
*/
export function referenceCitation(metric: ReferenceMetric) {
return CITATIONS[REFERENCE_RANGES[metric].referenceId];
}