mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120
"Tachycardia", the latter painted destructive-red. The interpretation layer
already frames the same signal calmly for the same range ("below the
standard resting range (common in trained hearts)", neutral valence) — a
trained user at 55 bpm resting read a diagnosis-shaped verdict on one
surface and a calm contextual band on the other.
Rename the categories to "Below typical range" / "Above typical range"
(matching the interpretation layer's own wording) and step the high band
down from destructive/danger to warning/amber — still flagged, never a
verdict noun. Neither category paints the pill red anymore.
530 lines
15 KiB
TypeScript
530 lines
15 KiB
TypeScript
/**
|
||
* Health metric classifications and rule-based alerts.
|
||
* WHO BMI categories, ESC/ESH BP classification, and smart alerts.
|
||
*/
|
||
|
||
// ── BMI Classification (WHO) ────────────────────────────
|
||
|
||
export interface BmiClassification {
|
||
category: string;
|
||
color: string;
|
||
severity: "normal" | "warning" | "danger";
|
||
}
|
||
|
||
export function classifyBMI(bmi: number): BmiClassification {
|
||
if (bmi < 18.5) {
|
||
return {
|
||
category: "Underweight",
|
||
color: "var(--dracula-yellow)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (bmi < 25) {
|
||
return { category: "Normal", color: "var(--success)", severity: "normal" };
|
||
}
|
||
if (bmi < 30) {
|
||
return {
|
||
category: "Overweight",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (bmi < 35) {
|
||
return {
|
||
category: "Obesity Grade I",
|
||
color: "var(--chart-3)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
if (bmi < 40) {
|
||
return {
|
||
category: "Obesity Grade II",
|
||
color: "var(--destructive)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
return {
|
||
category: "Obesity Grade III",
|
||
color: "var(--destructive)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
|
||
// ── BP Classification (ESH 2023) ────────────────────────
|
||
|
||
export interface BpClassification {
|
||
category: string;
|
||
color: string;
|
||
severity: "normal" | "elevated" | "warning" | "danger";
|
||
}
|
||
|
||
export function classifyBP(sys: number, dia: number): BpClassification {
|
||
if (sys < 120 && dia < 80) {
|
||
return { category: "Optimal", color: "var(--success)", severity: "normal" };
|
||
}
|
||
if (sys < 130 && dia < 85) {
|
||
return { category: "Normal", color: "var(--info)", severity: "normal" };
|
||
}
|
||
if (sys < 140 && dia < 90) {
|
||
return {
|
||
category: "High-normal",
|
||
color: "var(--dracula-yellow)",
|
||
severity: "elevated",
|
||
};
|
||
}
|
||
if (sys < 160 && dia < 100) {
|
||
return {
|
||
category: "Hypertension Grade 1",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (sys < 180 && dia < 110) {
|
||
return {
|
||
category: "Hypertension Grade 2",
|
||
color: "var(--chart-3)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
return {
|
||
category: "Hypertension Grade 3",
|
||
color: "var(--destructive)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
|
||
// ── Pulse Classification (AHA) ──────────────────────────
|
||
|
||
export interface PulseClassification {
|
||
category: string;
|
||
color: string;
|
||
severity: "info" | "normal" | "warning" | "danger";
|
||
}
|
||
|
||
/**
|
||
* v1.29.6 — dropped the bare clinical-diagnosis nouns ("Bradycardia" /
|
||
* "Tachycardia"). This is the same resting-pulse signal the interpretation
|
||
* layer (`insight-interpretation.ts` → `RESTING_HEART_RATE`) already frames
|
||
* neutrally ("below the standard resting range (common in trained hearts)",
|
||
* valence *neutral*) — a trained user at 55 bpm resting must not read a
|
||
* diagnosis-shaped verdict on one surface and a calm contextual band on the
|
||
* other. Category wording now mirrors that framing (and the "typical range"
|
||
* phrasing this codebase already uses in `pulse-targets.ts`); the < 60 band
|
||
* keeps its existing non-danger "info" tone, and the > 120 band steps down
|
||
* from destructive/"danger" to warning/amber — still flagged, never a verdict.
|
||
*/
|
||
export function classifyPulse(bpm: number): PulseClassification {
|
||
if (bpm < 60) {
|
||
return {
|
||
category: "Below typical range",
|
||
color: "var(--info)",
|
||
severity: "info",
|
||
};
|
||
}
|
||
if (bpm <= 100) {
|
||
return { category: "Normal", color: "var(--success)", severity: "normal" };
|
||
}
|
||
if (bpm <= 120) {
|
||
return {
|
||
category: "Elevated",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
return {
|
||
category: "Above typical range",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
|
||
// ── Sleep Duration Classification (AASM/SRS) ───────────
|
||
|
||
export interface SleepDurationClassification {
|
||
category: string;
|
||
color: string;
|
||
severity: "normal" | "info" | "warning";
|
||
}
|
||
|
||
export function classifySleepDuration(
|
||
hours: number,
|
||
): SleepDurationClassification {
|
||
if (hours < 6) {
|
||
return {
|
||
category: "Far too short",
|
||
color: "var(--destructive)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (hours < 7) {
|
||
return {
|
||
category: "Too short",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (hours <= 9) {
|
||
return {
|
||
category: "On target",
|
||
color: "var(--success)",
|
||
severity: "normal",
|
||
};
|
||
}
|
||
if (hours <= 10) {
|
||
return {
|
||
category: "Slightly long",
|
||
color: "var(--info)",
|
||
severity: "info",
|
||
};
|
||
}
|
||
return {
|
||
category: "Far too long",
|
||
color: "var(--chart-3)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
|
||
// ── Body Fat Classification (ACE) ───────────────────────
|
||
|
||
export interface BodyFatClassification {
|
||
category: string;
|
||
color: string;
|
||
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;
|
||
|
||
// 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 acceptableMax: number;
|
||
|
||
if (gender === "MALE") {
|
||
// ACE: essential 2-5, athletes 6-13, fitness 14-17, acceptable 18-24.
|
||
dangerFloor = 2;
|
||
essentialMax = 5;
|
||
athleteMax = 13;
|
||
fitnessMax = 17;
|
||
acceptableMax = 24;
|
||
} else if (gender === "FEMALE") {
|
||
// ACE: essential 10-13, athletes 14-20, fitness 21-24, acceptable 25-31.
|
||
dangerFloor = 10;
|
||
essentialMax = 13;
|
||
athleteMax = 20;
|
||
fitnessMax = 24;
|
||
acceptableMax = 31;
|
||
} else {
|
||
// Gender-neutral fallback: midpoint of M/F bands.
|
||
dangerFloor = 6;
|
||
essentialMax = 9;
|
||
athleteMax = 16.5;
|
||
fitnessMax = 20.5;
|
||
acceptableMax = 27.5;
|
||
}
|
||
|
||
if (pct < dangerFloor) {
|
||
// Below ACE essential floor — clinically concerning (immune /
|
||
// hormonal / cardiac risk in chronic depletion).
|
||
return {
|
||
category: "Below essential",
|
||
color: "var(--destructive)",
|
||
severity: "danger",
|
||
};
|
||
}
|
||
if (pct <= essentialMax) {
|
||
return {
|
||
category: "Essential",
|
||
color: "var(--dracula-yellow)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (pct <= athleteMax) {
|
||
return {
|
||
category: "Athletic",
|
||
color: "var(--success)",
|
||
severity: "normal",
|
||
};
|
||
}
|
||
if (pct <= fitnessMax) {
|
||
return { category: "Fitness", color: "var(--success)", severity: "normal" };
|
||
}
|
||
if (pct <= acceptableMax) {
|
||
return {
|
||
category: "Acceptable",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
return { category: "Obese", color: "var(--destructive)", severity: "danger" };
|
||
}
|
||
|
||
// ── 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 accumulates strongly through ~12 000 steps/day
|
||
// (HR 0.49 at 8k vs 4k, HR 0.35 at 12k — continued dose-response,
|
||
// not a plateau). 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;
|
||
color: string;
|
||
severity: "normal" | "info" | "warning";
|
||
}
|
||
|
||
export function classifySteps(steps: number): StepsClassification {
|
||
if (steps < 3000) {
|
||
return {
|
||
category: "Very low",
|
||
color: "var(--destructive)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (steps < 5000) {
|
||
return {
|
||
category: "Low active",
|
||
color: "var(--warning)",
|
||
severity: "warning",
|
||
};
|
||
}
|
||
if (steps < 7500) {
|
||
return {
|
||
category: "Moderately active",
|
||
color: "var(--dracula-yellow)",
|
||
severity: "info",
|
||
};
|
||
}
|
||
if (steps < 10000) {
|
||
return { category: "Active", color: "var(--success)", severity: "normal" };
|
||
}
|
||
return {
|
||
category: "Very active",
|
||
color: "var(--success)",
|
||
severity: "normal",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Activity-steps target range. Single source of truth: aligned with
|
||
* `effective-range.ts` (Saint-Maurice JAMA 2020 — continued mortality
|
||
* dose-response through ~12 000 steps/day). 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: 8000, max: 15000 };
|
||
}
|
||
|
||
// ── Target Range Helpers ────────────────────────────────
|
||
|
||
/**
|
||
* Healthy weight range based on BMI 18.5–24.9 (WHO).
|
||
*/
|
||
export function getWeightRange(heightCm: number): { min: number; max: number } {
|
||
const heightM = heightCm / 100;
|
||
const heightSq = heightM * heightM;
|
||
return {
|
||
min: Math.round(18.5 * heightSq * 10) / 10,
|
||
max: Math.round(24.9 * heightSq * 10) / 10,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recommended nightly sleep duration for adults (AASM/SRS).
|
||
*/
|
||
export function getSleepDurationRange(): { min: number; max: number } {
|
||
return { min: 7, max: 9 };
|
||
}
|
||
|
||
/**
|
||
* BP target ranges based on ESH 2023 guidelines (numerically
|
||
* unchanged from the joint ESC/ESH 2018 document; the 2023 update
|
||
* is ESH-only since ESC withdrew from joint authoring).
|
||
*/
|
||
export function getBpTargetsByAge(
|
||
age: number,
|
||
gender?: "MALE" | "FEMALE" | null,
|
||
): { sysLow: number; sysHigh: number; diaLow: number; diaHigh: number } {
|
||
void gender;
|
||
if (age < 65) {
|
||
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
|
||
}
|
||
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
|
||
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
|
||
}
|
||
|
||
// ── Health Alerts ────────────────────────────────────────
|
||
|
||
export interface HealthAlert {
|
||
level: "success" | "info" | "warning" | "danger";
|
||
title: string;
|
||
message: string;
|
||
}
|
||
|
||
export interface AlertInput {
|
||
bmi?: number | null;
|
||
bpAvgSys?: number | null;
|
||
bpAvgDia?: number | null;
|
||
bpPctInTarget?: number | null;
|
||
weightSlope30?: number | null;
|
||
pulseAvg30?: number | null;
|
||
pulseAnomalyCount?: number;
|
||
medications?: Array<{
|
||
name: string;
|
||
compliance7: number;
|
||
compliance30: number;
|
||
}>;
|
||
}
|
||
|
||
export function generateAlerts(input: AlertInput): HealthAlert[] {
|
||
const alerts: HealthAlert[] = [];
|
||
|
||
// BMI alerts
|
||
if (input.bmi != null) {
|
||
if (input.bmi < 18.5) {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: "BMI below normal range",
|
||
message: `Your BMI of ${input.bmi} is below the normal range (18.5–24.9).`,
|
||
});
|
||
} else if (input.bmi >= 25 && input.bmi < 30) {
|
||
alerts.push({
|
||
level: "info",
|
||
title: "BMI in overweight range",
|
||
message: `Your BMI of ${input.bmi} is in the overweight range. Regular exercise and a balanced diet can help.`,
|
||
});
|
||
} else if (input.bmi >= 30) {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: "BMI in obesity range",
|
||
message: `Your BMI of ${input.bmi} is in the obesity range.`,
|
||
});
|
||
} else {
|
||
alerts.push({
|
||
level: "success",
|
||
title: "BMI in normal range",
|
||
message: `Your BMI of ${input.bmi} is in the healthy range.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// BP alerts
|
||
if (input.bpAvgSys != null && input.bpAvgDia != null) {
|
||
const bpClass = classifyBP(input.bpAvgSys, input.bpAvgDia);
|
||
if (bpClass.severity === "danger") {
|
||
alerts.push({
|
||
level: "danger",
|
||
title: "Blood pressure significantly elevated",
|
||
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is classified as "${bpClass.category}".`,
|
||
});
|
||
} else if (bpClass.severity === "warning") {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: "Blood pressure slightly elevated",
|
||
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is in the "${bpClass.category}" range.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// BP target adherence
|
||
if (input.bpPctInTarget != null) {
|
||
if (input.bpPctInTarget >= 80) {
|
||
alerts.push({
|
||
level: "success",
|
||
title: "Good blood pressure target adherence",
|
||
message: `${input.bpPctInTarget}% of your readings are within the target range.`,
|
||
});
|
||
} else if (input.bpPctInTarget < 50) {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: "Blood pressure often outside target range",
|
||
message: `Only ${input.bpPctInTarget}% of your readings are within the target range.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Weight trend
|
||
if (input.weightSlope30 != null) {
|
||
const weeklyChange = input.weightSlope30 * 7;
|
||
if (weeklyChange > 0.5) {
|
||
alerts.push({
|
||
level: "info",
|
||
title: "Weight increasing",
|
||
message: `Your weight is increasing by approx. ${weeklyChange.toFixed(1)} kg per week over the last 30 days.`,
|
||
});
|
||
} else if (weeklyChange < -0.5) {
|
||
alerts.push({
|
||
level: "info",
|
||
title: "Weight decreasing",
|
||
message: `Your weight is decreasing by approx. ${Math.abs(weeklyChange).toFixed(1)} kg per week over the last 30 days.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Pulse anomalies
|
||
if (input.pulseAnomalyCount != null && input.pulseAnomalyCount > 2) {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: "Pulse outliers detected",
|
||
message: `${input.pulseAnomalyCount} unusual pulse readings in the last 30 days.`,
|
||
});
|
||
}
|
||
|
||
// Medication compliance
|
||
if (input.medications) {
|
||
for (const med of input.medications) {
|
||
if (med.compliance7 < 80) {
|
||
alerts.push({
|
||
level: "warning",
|
||
title: `Low compliance: ${med.name}`,
|
||
message: `Your 7-day compliance for ${med.name} is only ${med.compliance7}%. Regular intake is important.`,
|
||
});
|
||
} else if (med.compliance7 >= 95) {
|
||
alerts.push({
|
||
level: "success",
|
||
title: `Excellent compliance: ${med.name}`,
|
||
message: `${med.compliance7}% compliance over the last 7 days. Keep it up!`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return alerts;
|
||
}
|