mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(insights): add a cited reference-range backbone and richer metric explainers
Introduce src/lib/reference-ranges.ts: a structured, citation-backed per-metric reference-range backbone (band + source body + year) with a deterministic four-state placement helper, read by the UI surfaces. Each band traces to the medical-citations registry, which gains the supporting sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity, FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature, RCP NEWS2 and American Lung Association respiratory rate, visceral-fat imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV, Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep). Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and ESC 2024 appear only as displayed placement context. Enrich the metric explainers with concise cited reference bands and a general-guidance tooltip on every metric page: a darker-skin pulse-oximeter over-read caveat, device-proxy honesty for readiness/recovery/strain/stress, an HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill the empty registry normal ranges, reconcile the steps green floor to 8,000, and tighten the body-temperature band below the fever line. Add free pulse-pressure and mean-arterial-pressure context on the blood- pressure detail page, plus cycle-physiology and sleep-hygiene copy across all six locales. Cycle thresholds carry a verification marker pending an ACOG source check.
This commit is contained in:
1 parent
1d83e10764
commit
9be30897fa
12 files changed
+985
-29
No files matched your search
@@ -170,6 +170,32 @@ export default function InsightsBlutdruckPage() {
|
||||
average). */}
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
|
||||
{/* v1.18.6 — free derived BP context: pulse pressure (SBP − DBP) and
|
||||
mean arterial pressure ((SBP + 2·DBP)/3), computed from the most
|
||||
recent reading. Pure derivation of stored BP — no schema, no extra
|
||||
fetch. Calm, cited, non-alarming. */}
|
||||
{(() => {
|
||||
const sbp = sysSummary?.latest ?? null;
|
||||
const dbp = diaSummary?.latest ?? null;
|
||||
if (sbp == null || dbp == null || sbp <= dbp) return null;
|
||||
const pp = Math.round(sbp - dbp);
|
||||
const map = Math.round((sbp + 2 * dbp) / 3);
|
||||
return (
|
||||
<p
|
||||
data-slot="bp-derived-caption"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t("insights.subPage.bpDerived.pulsePressure", {
|
||||
value: pp,
|
||||
})}{" "}
|
||||
{t("insights.subPage.bpDerived.meanArterialPressure", {
|
||||
value: map,
|
||||
})}{" "}
|
||||
{t("insights.subPage.bpDerived.caveat")}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* v1.12.4 — Einschätzung is the last content block; the stat strip
|
||||
(rendered by the shell) closes the spine below it. */}
|
||||
<SlugInsightStatusCard
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { Info, ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
@@ -291,6 +297,33 @@ export function SubPageShell({
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
{/* v1.18.6 — a discreet general-guidance tooltip travels with
|
||||
every metric definition. The reference bands in the body
|
||||
above are population anchors, not medical advice; this
|
||||
caption pins that framing without crowding the prose. */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-reference-guidance"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
|
||||
"align-middle focus-visible:ring-ring/50 rounded-full",
|
||||
"focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={t(
|
||||
"insights.subPage.explainer.referenceGuidance",
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-pretty">
|
||||
{t("insights.subPage.explainer.referenceGuidance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CITATIONS } from "../medical-citations";
|
||||
import {
|
||||
REFERENCE_RANGES,
|
||||
REFERENCE_METRICS,
|
||||
classifyReference,
|
||||
getReferenceRange,
|
||||
isReferenceMetric,
|
||||
referenceCitation,
|
||||
referenceLabel,
|
||||
type ReferenceMetric,
|
||||
} from "../reference-ranges";
|
||||
|
||||
describe("reference-ranges backbone", () => {
|
||||
it("every band + conflict + headline resolves a real citation", () => {
|
||||
for (const metric of REFERENCE_METRICS) {
|
||||
const range = REFERENCE_RANGES[metric];
|
||||
expect(CITATIONS[range.referenceId], `${metric} referenceId`).toBeTruthy();
|
||||
expect(range.unit, `${metric} unit`).toBeTruthy();
|
||||
expect(range.guidanceCaveat, `${metric} guidanceCaveat`).toBeTruthy();
|
||||
for (const band of range.bands) {
|
||||
expect(CITATIONS[band.citation], `${metric} band ${band.label}`).toBeTruthy();
|
||||
// A band must pin at least one bound.
|
||||
expect(band.low != null || band.high != null, `${metric} ${band.label}`).toBe(true);
|
||||
}
|
||||
for (const conflict of range.conflicts ?? []) {
|
||||
expect(CITATIONS[conflict.citation], `${metric} conflict`).toBeTruthy();
|
||||
expect(conflict.note).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps ESH 2023 as the BP headline anchor and surfaces US/EU context", () => {
|
||||
const bp = REFERENCE_RANGES.BLOOD_PRESSURE;
|
||||
expect(bp.referenceId).toBe("ESH_2023_HYPERTENSION");
|
||||
expect(referenceLabel("BLOOD_PRESSURE")).toContain("ESH 2023");
|
||||
// ACC/AHA + ESC 2024 ride only as context, never the headline.
|
||||
const conflictCitations = (bp.conflicts ?? []).map((c) => c.citation);
|
||||
expect(conflictCitations).toContain("ACC_AHA_2017_BP");
|
||||
expect(conflictCitations).toContain("ESC_2024_BP");
|
||||
});
|
||||
|
||||
it("classifies values into the four-state contract", () => {
|
||||
// Within the normal band.
|
||||
expect(classifyReference("RESTING_HEART_RATE", 70)).toBe("within");
|
||||
expect(classifyReference("BLOOD_GLUCOSE", 90)).toBe("within");
|
||||
// Adjacent watch band.
|
||||
expect(classifyReference("BLOOD_GLUCOSE", 110)).toBe("slightly-outside");
|
||||
// Attention band beyond the watch tier.
|
||||
expect(classifyReference("BLOOD_GLUCOSE", 150)).toBe("outside");
|
||||
// SpO2 below the healthy floor is attention.
|
||||
expect(classifyReference("OXYGEN_SATURATION", 88)).toBe("outside");
|
||||
expect(classifyReference("OXYGEN_SATURATION", 98)).toBe("within");
|
||||
});
|
||||
|
||||
it("returns insufficient for absent values and band-less metrics", () => {
|
||||
expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
|
||||
expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
|
||||
// HRV carries no fixed population band — baseline-only.
|
||||
expect(REFERENCE_RANGES.HEART_RATE_VARIABILITY.bands).toHaveLength(0);
|
||||
expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe("insufficient");
|
||||
});
|
||||
|
||||
it("steps backbone uses the canonical 8,000 green floor", () => {
|
||||
const steps = REFERENCE_RANGES.STEPS;
|
||||
expect(steps.bands[0].low).toBe(8000);
|
||||
expect(steps.referenceId).toBe("STEPS_SAINT_MAURICE_2020");
|
||||
});
|
||||
|
||||
it("guards + resolvers narrow correctly", () => {
|
||||
expect(isReferenceMetric("BLOOD_PRESSURE")).toBe(true);
|
||||
expect(isReferenceMetric("NOT_A_METRIC")).toBe(false);
|
||||
expect(getReferenceRange("NOT_A_METRIC")).toBeNull();
|
||||
expect(getReferenceRange("HBA1C")).toBe(REFERENCE_RANGES.HBA1C);
|
||||
expect(referenceCitation("BLOOD_PRESSURE")).toBe(
|
||||
CITATIONS.ESH_2023_HYPERTENSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("no commercial brand appears in any band label or caveat", () => {
|
||||
const banned = /apple|withings|oura|whoop|garmin|fitbit|polar|samsung/i;
|
||||
for (const metric of REFERENCE_METRICS) {
|
||||
const range = REFERENCE_RANGES[metric];
|
||||
expect(range.guidanceCaveat).not.toMatch(banned);
|
||||
for (const band of range.bands) expect(band.label).not.toMatch(banned);
|
||||
for (const conflict of range.conflicts ?? []) {
|
||||
expect(conflict.note).not.toMatch(banned);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("metric ids stay a closed, non-empty set", () => {
|
||||
expect(REFERENCE_METRICS.length).toBeGreaterThan(10);
|
||||
const ids = new Set<ReferenceMetric>(REFERENCE_METRICS);
|
||||
expect(ids.size).toBe(REFERENCE_METRICS.length);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,11 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
normalRange: { low: 50, high: 100 },
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// HRV — no universal "normal" band (ESC/NASPE 1996): only the personal
|
||||
// trend is meaningful and overnight wearable RMSSD is not interchangeable
|
||||
// with the clinical 24-h SDNN norms. No `normalRange` by design; the user's
|
||||
// own baseline leads and single-night swings of ~±10% are routine noise
|
||||
// (see `reference-ranges.ts HEART_RATE_VARIABILITY.guidanceCaveat`).
|
||||
HEART_RATE_VARIABILITY: {
|
||||
id: "HEART_RATE_VARIABILITY",
|
||||
measurementType: "HEART_RATE_VARIABILITY",
|
||||
@@ -163,6 +168,13 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// SpO₂ — healthy room-air 95–100% (StatPearls/NIH 2023). C4 equity note:
|
||||
// optical pulse oximeters can OVER-READ true saturation in people with
|
||||
// darker skin pigmentation (~3× more often than in lighter skin per the
|
||||
// US FDA 2024 review), so a single near-normal reading is not a clearance.
|
||||
// The band is unchanged; the caveat lives in the explainer copy +
|
||||
// `reference-ranges.ts OXYGEN_SATURATION.guidanceCaveat`. Do NOT set a
|
||||
// "higher is always better" goal — above ~98% adds no benefit.
|
||||
OXYGEN_SATURATION: {
|
||||
id: "OXYGEN_SATURATION",
|
||||
measurementType: "OXYGEN_SATURATION",
|
||||
@@ -181,13 +193,18 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
normalRange: { low: 12, high: 20 },
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Body temperature — the population oral-equivalent mean is ~36.6 °C, not
|
||||
// 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥38.0 °C (J Gen
|
||||
// Intern Med systematic review, 2019). C6: the high anchor tightens
|
||||
// 37.5 → 37.2 so the band sits below the fever line; sites differ by up
|
||||
// to ~1 °C, so the read is a coarse placement only.
|
||||
BODY_TEMPERATURE: {
|
||||
id: "BODY_TEMPERATURE",
|
||||
measurementType: "BODY_TEMPERATURE",
|
||||
displayName: "Body temperature",
|
||||
unit: "°C",
|
||||
direction: "target-band",
|
||||
normalRange: { low: 36.1, high: 37.5 },
|
||||
normalRange: { low: 36.1, high: 37.2 },
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
SKIN_TEMPERATURE: {
|
||||
@@ -215,12 +232,17 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Pulse-wave velocity — the cf-PWV >10 m/s organ-damage marker is the
|
||||
// European clinical reference (ESC/ESH 2018). A consumer estimate is a
|
||||
// PROXY, not interchangeable with clinical cf-PWV tonometry; the band is
|
||||
// a coarse "below 10 is the reference side" placement only.
|
||||
PULSE_WAVE_VELOCITY: {
|
||||
id: "PULSE_WAVE_VELOCITY",
|
||||
measurementType: "PULSE_WAVE_VELOCITY",
|
||||
displayName: "Pulse-wave velocity",
|
||||
unit: "m/s",
|
||||
direction: "lower-better",
|
||||
normalRange: { low: 0, high: 10 },
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
VASCULAR_AGE: {
|
||||
@@ -232,13 +254,18 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// ── activity-fitness ──
|
||||
// Steps — C2/D4 reconcile: the canonical "green" floor is 8,000/day
|
||||
// (Saint-Maurice JAMA 2020: mortality risk falls steeply toward ~8k with
|
||||
// continued benefit through ~12k), aligning this display band with the
|
||||
// resolver + classifier so the cross-surface number stops contradicting.
|
||||
// The "10,000" target is a marketing slogan, not research.
|
||||
STEPS: {
|
||||
id: "STEPS",
|
||||
measurementType: "ACTIVITY_STEPS",
|
||||
displayName: "Steps",
|
||||
unit: "steps/day",
|
||||
direction: "higher-better",
|
||||
normalRange: { low: 7000, high: 10000 },
|
||||
normalRange: { low: 8000, high: 15000 },
|
||||
archetype: "activity-fitness",
|
||||
},
|
||||
ACTIVE_ENERGY: {
|
||||
|
||||
@@ -189,6 +189,293 @@ export const CITATIONS = {
|
||||
caveat:
|
||||
"Reference values for an idealised adult; individual variation broad.",
|
||||
},
|
||||
|
||||
// ── v1.18.6 — reference-range backbone sources ──
|
||||
|
||||
/**
|
||||
* AHA — All About Heart Rate (resting + target heart rates), 2024.
|
||||
* Normal adult resting 60–100 bpm; endurance athletes benignly 40–60.
|
||||
* Consumer-facing orientation; symptomatic concern is the trigger for
|
||||
* care, not the number alone.
|
||||
*/
|
||||
AHA_2024_RHR: {
|
||||
id: "aha-2024-rhr",
|
||||
name: "AHA 2024 Heart Rate",
|
||||
year: 2024,
|
||||
url: "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
|
||||
caveat:
|
||||
"General adult band; a trained heart can sit 40–60, and a new low with dizziness/fainting is the concern, not the number alone.",
|
||||
},
|
||||
|
||||
/**
|
||||
* StatPearls/NIH — Pulse Oximetry (2023). Healthy adult room-air SpO₂
|
||||
* 95–100% (bulk 96–99%); mild hypoxaemia 91–94%; clinically significant
|
||||
* <90%. Consumer wrist/finger optical readers are wellness-grade
|
||||
* (±2–4%) and a single low reading is often artefact.
|
||||
*/
|
||||
STATPEARLS_PULSE_OX: {
|
||||
id: "statpearls-pulse-ox",
|
||||
name: "StatPearls Pulse Oximetry",
|
||||
year: 2023,
|
||||
url: "https://www.ncbi.nlm.nih.gov/books/NBK470348/",
|
||||
caveat:
|
||||
"Healthy room-air band; consumer optical SpO₂ is ±2–4% and a single low reading is often artefact — sustained runs are the signal.",
|
||||
},
|
||||
|
||||
/**
|
||||
* 2017 ACC/AHA Guideline for the Prevention, Detection, Evaluation and
|
||||
* Management of High Blood Pressure in Adults. Lowers the US disease
|
||||
* line: Normal <120/<80; Elevated 120–129/<80; Stage 1 130–139 or
|
||||
* 80–89; Stage 2 ≥140 or ≥90. Surfaced only as the stricter COMPARISON
|
||||
* anchor next to the shipped ESH default — never the labelled band.
|
||||
*/
|
||||
ACC_AHA_2017_BP: {
|
||||
id: "acc-aha-2017-bp",
|
||||
name: "ACC/AHA 2017",
|
||||
year: 2017,
|
||||
url: "https://www.ahajournals.org/doi/10.1161/HYP.0000000000000065",
|
||||
caveat:
|
||||
"US guideline; labels 130–139/80–89 as Stage 1 — a stricter line than the ESH default, shown for placement only.",
|
||||
},
|
||||
|
||||
/**
|
||||
* 2024 ESC Guidelines for the management of elevated blood pressure and
|
||||
* hypertension. Adds an "Elevated" diagnostic band (120–139/70–89) and a
|
||||
* treated SBP target of 120–129. Disease line stays ≥140/90 office.
|
||||
* Displayed as context alongside the ESH 2023 default.
|
||||
*/
|
||||
ESC_2024_BP: {
|
||||
id: "esc-2024-bp",
|
||||
name: "ESC 2024",
|
||||
year: 2024,
|
||||
url: "https://academic.oup.com/eurheartj/article/45/38/3912/7741010",
|
||||
caveat:
|
||||
"European; introduces an 'Elevated' 120–139/70–89 band and a treated 120–129 target — context, not the shipped classification.",
|
||||
},
|
||||
|
||||
/**
|
||||
* StatPearls/NCBI — Physiology, Pulse Pressure (NBK482408). PP = SBP −
|
||||
* DBP; ~40 mmHg typical at rest, wide ≳60 mmHg marks large-artery
|
||||
* stiffening, narrow ≲25 mmHg can accompany low stroke volume. A
|
||||
* "discuss with a clinician" signal, not a diagnosis.
|
||||
*/
|
||||
STATPEARLS_PULSE_PRESSURE: {
|
||||
id: "statpearls-pulse-pressure",
|
||||
name: "StatPearls Pulse Pressure",
|
||||
year: 2023,
|
||||
url: "https://www.ncbi.nlm.nih.gov/books/NBK482408/",
|
||||
caveat:
|
||||
"Educational physiology reference; PP is a discussion prompt, never a standalone diagnosis.",
|
||||
},
|
||||
|
||||
/**
|
||||
* StatPearls/NCBI — Physiology, Mean Arterial Pressure (NBK538226).
|
||||
* MAP ≈ (SBP + 2·DBP)/3; resting band ~70–100 mmHg, ~60 mmHg the
|
||||
* conventional organ-perfusion floor — an acute-care concept, NOT a
|
||||
* consumer self-alarm threshold.
|
||||
*/
|
||||
STATPEARLS_MAP: {
|
||||
id: "statpearls-map",
|
||||
name: "StatPearls Mean Arterial Pressure",
|
||||
year: 2023,
|
||||
url: "https://www.ncbi.nlm.nih.gov/books/NBK538226/",
|
||||
caveat:
|
||||
"The ~60 mmHg floor is an acute-care perfusion concept, not a consumer alarm threshold.",
|
||||
},
|
||||
|
||||
/**
|
||||
* 2018 ESC/ESH Guidelines for the management of arterial hypertension.
|
||||
* Source of the carotid-femoral PWV >10 m/s organ-damage cut-off and
|
||||
* the BP grade bands still widely cited. The PWV number is European;
|
||||
* AHA/ACC do not enshrine an equivalent consumer PWV threshold.
|
||||
*/
|
||||
ESC_ESH_2018_PWV: {
|
||||
id: "esc-esh-2018-pwv",
|
||||
name: "ESC/ESH 2018",
|
||||
year: 2018,
|
||||
url: "https://academic.oup.com/eurheartj/article/39/33/3021/5079119",
|
||||
caveat:
|
||||
"cf-PWV >10 m/s is the European clinical reference; consumer PWV / vascular-age estimates are proxies, not clinical cf-PWV.",
|
||||
},
|
||||
|
||||
/**
|
||||
* US FDA — pulse-oximeter accuracy and limitations (Feb 2024 advisory
|
||||
* committee review + safety communication). Oximeters can overestimate
|
||||
* true saturation in people with darker skin, roughly 3× more often
|
||||
* than in lighter-skinned patients (occult hypoxaemia).
|
||||
*/
|
||||
FDA_2024_PULSE_OX: {
|
||||
id: "fda-2024-pulse-ox",
|
||||
name: "FDA 2024 Pulse Oximeter Review",
|
||||
year: 2024,
|
||||
url: "https://www.fda.gov/medical-devices/safety-communications/pulse-oximeter-accuracy-and-limitations-fda-safety-communication",
|
||||
caveat:
|
||||
"Documents over-reading in darker skin; a single near-normal reading is not a clearance — confirm when concerned.",
|
||||
},
|
||||
|
||||
/**
|
||||
* "Normal Body Temperature: A Systematic Review" — J Gen Intern Med,
|
||||
* 2019. Population oral-equivalent mean ~36.6 °C (normal ~35.7–37.4 °C),
|
||||
* superseding the conventional 37.0 °C set point. Fever ≥38.0 °C oral.
|
||||
*/
|
||||
JGIM_2019_TEMPERATURE: {
|
||||
id: "jgim-2019-temperature",
|
||||
name: "J Gen Intern Med 2019",
|
||||
year: 2019,
|
||||
url: "https://link.springer.com/article/10.1007/s11606-019-05148-7",
|
||||
caveat:
|
||||
"Oral-equivalent mean; sites differ by up to ~1 °C — tag the measurement site before comparing.",
|
||||
},
|
||||
|
||||
/**
|
||||
* Royal College of Physicians — National Early Warning Score 2 (NEWS2),
|
||||
* 2017. Respiratory-rate scoring: highest-risk ≤8 and ≥25/min. A
|
||||
* hospital deterioration tool, not a consumer alarm.
|
||||
*/
|
||||
RCP_2017_NEWS2: {
|
||||
id: "rcp-2017-news2",
|
||||
name: "RCP NEWS2 2017",
|
||||
year: 2017,
|
||||
url: "https://www.rcp.ac.uk/improving-care/resources/national-early-warning-score-news-2/",
|
||||
caveat:
|
||||
"Hospital deterioration score; the ≥25/min cutoff is clinical context, not a self-monitoring alarm.",
|
||||
},
|
||||
|
||||
/**
|
||||
* American Lung Association — resting respiratory rate reference
|
||||
* (12–20 breaths/min for healthy adults). Consumer-facing vital-sign
|
||||
* orientation.
|
||||
*/
|
||||
ALA_RESPIRATORY_RATE: {
|
||||
id: "ala-respiratory-rate",
|
||||
name: "American Lung Association",
|
||||
year: 2024,
|
||||
url: "https://www.lung.org/lung-health-diseases/how-lungs-work/breathing-rate",
|
||||
caveat:
|
||||
"General adult resting band; rates run higher in children and shift with activity, fever and emotion.",
|
||||
},
|
||||
|
||||
/**
|
||||
* Peer-reviewed visceral-adipose-tissue imaging literature (2014–2025):
|
||||
* CT/MRI/DXA VAT area >~100 cm² increased-risk, >~160 cm² high-risk.
|
||||
* Ethnicity-dependent; the consumer scale "rating" is NOT a cm² value.
|
||||
*/
|
||||
VAT_IMAGING_THRESHOLD: {
|
||||
id: "vat-imaging-threshold",
|
||||
name: "VAT imaging literature",
|
||||
year: 2025,
|
||||
url: "https://pubmed.ncbi.nlm.nih.gov/24008002/",
|
||||
caveat:
|
||||
"Imaging cm² thresholds; the consumer scale 'visceral fat rating' is a separate unitless scale, not interchangeable.",
|
||||
},
|
||||
|
||||
/**
|
||||
* Wilcox AJ, Dunson D, Baird DD. "The timing of the 'fertile window' in
|
||||
* the menstrual cycle." BMJ, 2000. The 6-day fertile window ends on
|
||||
* ovulation day; ovulation timing is highly variable even in regular
|
||||
* cyclers.
|
||||
*/
|
||||
WILCOX_2000_FERTILE_WINDOW: {
|
||||
id: "wilcox-2000-fertile-window",
|
||||
name: "Wilcox BMJ 2000",
|
||||
year: 2000,
|
||||
url: "https://www.bmj.com/content/321/7271/1259",
|
||||
caveat:
|
||||
"Fertile-window timing is highly variable; calendar estimates approximate and are not a contraceptive method.",
|
||||
},
|
||||
|
||||
/**
|
||||
* ACOG Committee Opinion 651 (2015) — "Menstruation in Girls and
|
||||
* Adolescents: Using the Menstrual Cycle as a Vital Sign." Adult cycle
|
||||
* 24–38 days, bleeding ~5 days (up to ~7).
|
||||
*
|
||||
* NEEDS-VERIFY ACOG — the cycle-length / bleeding-duration figures cited
|
||||
* in the `insights.subPage.explainer.cycleEducationBody` copy, and any
|
||||
* "when to see a clinician" thresholds (cycle <21 / >35, amenorrhea ≥3
|
||||
* months, heavy bleeding >7 days), must be confirmed against the live
|
||||
* ACOG FAQ before shipping that copy to users (automated fetch was
|
||||
* rate-limited during research).
|
||||
*/
|
||||
ACOG_CO651_2015: {
|
||||
id: "acog-co651-2015",
|
||||
name: "ACOG CO 651 2015",
|
||||
year: 2015,
|
||||
url: "https://www.acog.org/clinical/clinical-guidance/committee-opinion/articles/2015/12/menstruation-in-girls-and-adolescents-using-the-menstrual-cycle-as-a-vital-sign",
|
||||
caveat:
|
||||
"Cycle as a vital sign; exact clinician-consultation thresholds should be confirmed against the live ACOG FAQ.",
|
||||
},
|
||||
|
||||
/**
|
||||
* Phillips AJK, et al. "Irregular sleep/wake patterns are associated
|
||||
* with poorer academic performance and delayed circadian and sleep/wake
|
||||
* timing." Sci Rep, 2017 — origin of the Sleep Regularity Index (SRI).
|
||||
* UK Biobank (Windred 2023/24) tied higher regularity to lower mortality.
|
||||
*/
|
||||
PHILLIPS_2017_SRI: {
|
||||
id: "phillips-2017-sri",
|
||||
name: "Phillips Sci Rep 2017",
|
||||
year: 2017,
|
||||
url: "https://www.nature.com/articles/s41598-017-03171-4",
|
||||
caveat:
|
||||
"Sleep-regularity construct; no universal good/bad threshold — present as a personal trend.",
|
||||
},
|
||||
|
||||
/**
|
||||
* US CDC — About Sleep / tips for better sleep (2024). Evidence-based
|
||||
* sleep-hygiene behaviours (consistent schedule, morning light, dim
|
||||
* evenings, caffeine timing, cool dark room).
|
||||
*/
|
||||
CDC_2024_SLEEP: {
|
||||
id: "cdc-2024-sleep",
|
||||
name: "CDC 2024 Sleep",
|
||||
year: 2024,
|
||||
url: "https://www.cdc.gov/sleep/about/index.html",
|
||||
caveat: "General sleep-hygiene guidance; individual needs vary.",
|
||||
},
|
||||
|
||||
/**
|
||||
* WHO — Obesity: preventing and managing the global epidemic (TRS 894),
|
||||
* 2000. The BMI classification still in use: underweight <18.5, normal
|
||||
* 18.5–24.9, overweight 25.0–29.9, obesity ≥30. Asian-population risk
|
||||
* rises at lower cut-offs (WHO 2004).
|
||||
*/
|
||||
WHO_2000_BMI: {
|
||||
id: "who-2000-bmi",
|
||||
name: "WHO 2000 BMI",
|
||||
year: 2000,
|
||||
url: "https://www.who.int/publications/i/item/WHO_TRS_894",
|
||||
caveat:
|
||||
"Population screen, not a diagnosis; Asian-population risk rises at lower cut-offs and BMI does not distinguish muscle from fat.",
|
||||
},
|
||||
|
||||
/**
|
||||
* WHO/IDF — Definition and Diagnosis of Diabetes Mellitus and
|
||||
* Intermediate Hyperglycaemia, 2006. Impaired fasting glucose floor of
|
||||
* 110 mg/dL (vs ADA's 100); the diabetes line (≥126) is shared.
|
||||
*/
|
||||
WHO_IDF_2006_GLUCOSE: {
|
||||
id: "who-idf-2006-glucose",
|
||||
name: "WHO/IDF 2006",
|
||||
year: 2006,
|
||||
url: "https://www.who.int/publications/i/item/definition-and-diagnosis-of-diabetes-mellitus-and-intermediate-hyperglycaemia",
|
||||
caveat:
|
||||
"Sets the impaired-fasting-glucose floor at 110 mg/dL; the diabetes threshold (≥126) is shared with ADA.",
|
||||
},
|
||||
|
||||
/**
|
||||
* ESC/NASPE Task Force — Heart Rate Variability: Standards of
|
||||
* Measurement, Physiological Interpretation and Clinical Use.
|
||||
* Circulation / Eur Heart J, 1996. Defines SDNN/RMSSD norms and the
|
||||
* prognostic 24-h SDNN bands; day-to-day swings of ~±10% are routine.
|
||||
*/
|
||||
ESC_NASPE_1996_HRV: {
|
||||
id: "esc-naspe-1996-hrv",
|
||||
name: "ESC/NASPE HRV Standards 1996",
|
||||
year: 1996,
|
||||
url: "https://www.ahajournals.org/doi/10.1161/01.CIR.93.5.1043",
|
||||
caveat:
|
||||
"Clinical 24-h SDNN norms are NOT interchangeable with an overnight wearable RMSSD; only the personal trend is meaningful.",
|
||||
},
|
||||
} as const satisfies Record<string, Citation>;
|
||||
|
||||
export type CitationId = keyof typeof CITATIONS;
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 first is the "good"/normal placement. */
|
||||
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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
],
|
||||
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" },
|
||||
{ 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. ──
|
||||
// Bands are authored normal-first so `classifyReference` treats the
|
||||
// population-normal band as the "within" placement. 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
],
|
||||
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" },
|
||||
{ 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" },
|
||||
{ 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.
|
||||
*/
|
||||
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";
|
||||
}
|
||||
// Find the first band whose half-open interval contains the value.
|
||||
// Bands are authored in ascending order; the FIRST band is the
|
||||
// normal/good placement, so its membership maps to `within`.
|
||||
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) {
|
||||
if (i === 0) return "within";
|
||||
// The band immediately adjacent to normal is the watch tier; any
|
||||
// band beyond it is the attention tier.
|
||||
return i === 1 ? "slightly-outside" : "outside";
|
||||
}
|
||||
}
|
||||
// Value sits beyond every authored band (e.g. below an open-low normal,
|
||||
// or above the top band's open-high). Treat an under-shoot of the first
|
||||
// band's low as within when the first band is open-low; otherwise the
|
||||
// value is outside the covered range → attention.
|
||||
const first = range.bands[0];
|
||||
if (first.low != null && value < first.low) return "outside";
|
||||
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];
|
||||
}
|
||||
Reference in new issue
Block a user