Files
HealthLog/src/lib/analytics/effective-range.ts
T

412 lines
16 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.
/**
* Effective-range resolver — the single entry point for "what's the target
* range for THIS user on THIS metric, right now?"
*
* Layering:
* 1. Evidence-based default computed from the user profile (age/height/
* gender/gender-neutral) via the existing analytics helpers.
* 2. Optional user override stored in User.thresholdsJson.
* 3. For blood glucose the range also depends on the measurement context
* (fasting / postprandial / random / bedtime).
*
* Every consumer — /targets page, insight status prompts, doctor-report PDF,
* chart value-bands — should call `getEffectiveRange()` instead of the raw
* default computers directly. That way a user-override affects every surface
* consistently.
*/
import { binaryReferenceSex } from "@/lib/profile/sex";
import { getBpTargets, type BpTargets } from "./bp-targets";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "./pulse-targets";
import {
buildWeightRangeFromHeight,
getBodyFatTargetRange,
type TrafficRange,
} from "./value-bands";
export type ThresholdMetric =
| "WEIGHT"
| "BLOOD_PRESSURE_SYS"
| "BLOOD_PRESSURE_DIA"
| "PULSE"
| "BODY_FAT"
| "SLEEP_DURATION"
| "ACTIVITY_STEPS"
| "BLOOD_GLUCOSE_FASTING"
| "BLOOD_GLUCOSE_POSTPRANDIAL"
| "BLOOD_GLUCOSE_RANDOM"
| "BLOOD_GLUCOSE_BEDTIME"
| "TOTAL_BODY_WATER"
| "BONE_MASS"
| "OXYGEN_SATURATION";
/**
* Shape persisted in User.thresholdsJson. Any metric missing = use default.
*/
export type ThresholdOverridesJson = Partial<
Record<ThresholdMetric, { min: number; max: number }>
>;
export interface UserProfileForRange {
heightCm: number | null;
dateOfBirth: Date | string | null;
gender: string | null;
}
/**
* Per-metric guardrails used both for validation (Zod) and for "is this
* unhealthy to override" warnings in the UI. Min/max here are the outer
* physiological bounds, not defaults.
*/
export const METRIC_BOUNDS: Record<
ThresholdMetric,
{ min: number; max: number; unit: string }
> = {
WEIGHT: { min: 30, max: 300, unit: "kg" },
BLOOD_PRESSURE_SYS: { min: 80, max: 220, unit: "mmHg" },
BLOOD_PRESSURE_DIA: { min: 40, max: 140, unit: "mmHg" },
PULSE: { min: 30, max: 220, unit: "bpm" },
BODY_FAT: { min: 3, max: 60, unit: "%" },
SLEEP_DURATION: { min: 3, max: 14, unit: "h" },
ACTIVITY_STEPS: { min: 0, max: 50_000, unit: "steps" },
BLOOD_GLUCOSE_FASTING: { min: 40, max: 400, unit: "mg/dL" },
BLOOD_GLUCOSE_POSTPRANDIAL: { min: 40, max: 500, unit: "mg/dL" },
BLOOD_GLUCOSE_RANDOM: { min: 40, max: 500, unit: "mg/dL" },
BLOOD_GLUCOSE_BEDTIME: { min: 40, max: 400, unit: "mg/dL" },
// Body composition (Withings type 77 / 88, stored canonically in kg).
// Bounds match VALUE_RANGES in src/lib/validations/measurement.ts.
TOTAL_BODY_WATER: { min: 5, max: 100, unit: "kg" },
BONE_MASS: { min: 0.5, max: 8, unit: "kg" },
// Pulse oximetry (Withings ScanWatch reports type 54). Plausibility floor
// 50% to allow truly critical readings to be logged; saturation cannot
// exceed 100% by physical definition.
OXYGEN_SATURATION: { min: 50, max: 100, unit: "%" },
};
/**
* Blood-glucose defaults. Values are mg/dL (canonical storage); display
* conversion to mmol/L happens at render time.
*
* Sources per context:
* FASTING 70–99 ADA 2024 §6 (Glycemic Targets) — 80–130
* pre-prandial range narrowed to non-diabetic
* normal 70–99 (ADA Standards of Care 2024).
* https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957
* POSTPRANDIAL 70–140 WHO/IDF 2-h post-glucose-load <140 mg/dL
* normal-tolerance threshold.
* RANDOM 70–140 Same threshold as postprandial; clinically
* less specific.
* BEDTIME 90–150 *No published adult guideline.* ADA 2024
* publishes pre-prandial and post-prandial
* only. ISPAD 2022 (pediatric T1D) suggests
* 80–140, which is HealthLog's closest
* comparator. The slightly higher floor
* (90 vs 80) reflects practical advice to
* avoid nocturnal hypoglycaemia and is well
* within the ADA pre-prandial daytime range.
* https://onlinelibrary.wiley.com/doi/10.1111/pedi.13455
* Citation in `messages/*.json` reflects this
* absence of an adult-specific source.
*/
const GLUCOSE_DEFAULTS: Record<
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
{ min: number; max: number }
> = {
FASTING: { min: 70, max: 99 },
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
};
function toDate(value: Date | string | null): Date | null {
if (!value) return null;
return value instanceof Date ? value : new Date(value);
}
function defaultRange(
metric: ThresholdMetric,
profile: UserProfileForRange,
): TrafficRange | null {
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
const gender = binaryReferenceSex(profile.gender);
const dateOfBirth = toDate(profile.dateOfBirth);
switch (metric) {
case "WEIGHT":
return profile.heightCm
? buildWeightRangeFromHeight(profile.heightCm)
: null;
case "BLOOD_PRESSURE_SYS": {
if (!dateOfBirth) return null;
const t = getBpTargets(dateOfBirth);
if (!t) return null;
return {
greenMin: t.sysLow,
greenMax: t.sysHigh,
orangeMin: t.sysLow - 10,
orangeMax: t.sysHigh + 20,
};
}
case "BLOOD_PRESSURE_DIA": {
if (!dateOfBirth) return null;
const t = getBpTargets(dateOfBirth);
if (!t) return null;
// The lower orange wing must NOT extend to 60 mmHg — sustained
// diastolic ≤ 60 is a clinical hypotension / perfusion-risk
// threshold, not "mildly low":
// - General-adult hypotension: <90/60 (AHA, NIH).
// - J-curve evidence in older / treated hypertensives: DBP <70
// raises events. Boehm M et al. (Lancet 2017) ONTARGET / TRANSCEND.
// https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(17)32478-7/fulltext
// - ESH 2023 caution: "treatment should not lower DBP below 70 in
// the elderly and below 80 in those with significant CAD".
// https://journals.lww.com/jhypertension/fulltext/2023/12000/2023_esh_guidelines_for_the_management_of_arterial.2.aspx
// We therefore floor orangeMin at 65 across ages so a reading of
// 60 mmHg lands in red, not yellow. The override path
// (User.thresholdsJson) is audit-logged and respects the floor.
return {
greenMin: t.diaLow,
greenMax: t.diaHigh,
orangeMin: Math.max(t.diaLow - 10, 65),
orangeMax: t.diaHigh + 10,
};
}
case "PULSE": {
const p = getPersonalizedPulseTarget(age, gender);
return {
greenMin: p.greenMin,
greenMax: p.greenMax,
orangeMin: p.orangeMin,
orangeMax: p.orangeMax,
};
}
case "BODY_FAT": {
const r = getBodyFatTargetRange(gender);
return {
greenMin: r.min,
greenMax: r.max,
orangeMin: Math.max(0, r.min - 3),
orangeMax: r.max + 5,
};
}
case "SLEEP_DURATION":
// AASM: 7–9h for adults; warning yellow either side.
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
case "ACTIVITY_STEPS":
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (continued
// dose-response benefit through ~12000 steps/day, not a plateau —
// HR 0.49 at 8k vs 4k, HR 0.35 at 12k). WHO 2020 PA guidelines publish minutes
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
// step quota. No upper bound in reality, orange over 25k caps edge
// detection.
return {
greenMin: 8000,
greenMax: 15000,
orangeMin: 5000,
orangeMax: 25000,
};
case "BLOOD_GLUCOSE_FASTING":
return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound
case "BLOOD_GLUCOSE_POSTPRANDIAL":
return glucoseRange(GLUCOSE_DEFAULTS.POSTPRANDIAL, 199); // impaired glucose tolerance upper
case "BLOOD_GLUCOSE_RANDOM":
return glucoseRange(GLUCOSE_DEFAULTS.RANDOM, 199);
case "BLOOD_GLUCOSE_BEDTIME":
return glucoseRange(
GLUCOSE_DEFAULTS.BEDTIME,
GLUCOSE_DEFAULTS.BEDTIME.max + 30,
);
case "TOTAL_BODY_WATER":
// Adult total body water typically ~50% of body weight in kg
// (Watson formula / ICRP Reference Man: ~42 L male, ~30 L female).
// Without per-user weight context the gender-neutral band is wide
// by design; users tighten via threshold override.
return { greenMin: 28, greenMax: 50, orangeMin: 22, orangeMax: 55 };
case "BONE_MASS":
// Bioimpedance-estimated bone mass from a Withings-class scale —
// NOT DEXA-comparable (BIA-derived values typically run 5–7% below
// DEXA bone-mineral-content). Adult BIA-typical 2.0–4.0 kg (women
// slightly lower than men). Wide orange band before surfacing as a
// concern given BIA's intrinsic noise.
return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 };
case "OXYGEN_SATURATION":
// Conservative band aligned with consumer pulse-oximeter consensus
// (≥95% normal at rest); slightly tighter than the BTS Emergency
// Oxygen Guideline 2017 explicit treatment target of 94–98%, looser
// than the WHO ≥90% acute-care floor. Lower-only concern: ≤92% is
// the NICE NG115 escalation threshold ("call provider"); ≤88%
// (BMJ panel / FDA pulse-oximeter labelling) is the ER threshold.
// Saturation is bounded above by 100% physically, so we collapse
// the upper orange wing onto greenMax — severity logic only fires
// for hypoxemia.
// COPD / chronic-respiratory-failure / high-altitude users
// typically run 88–92% and should personalise via the
// threshold-override UI (saved in User.thresholdsJson).
return { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 };
}
}
function glucoseRange(
d: { min: number; max: number },
orangeMax: number,
): TrafficRange {
return {
greenMin: d.min,
greenMax: d.max,
orangeMin: d.min - 10,
orangeMax,
};
}
export interface EffectiveRange {
/** Resolved range — override if set, else computed default. */
range: TrafficRange | null;
/** True when the user has overridden this metric. */
isOverride: boolean;
/** The unmodified computed default (useful for diff display). */
default: TrafficRange | null;
/** Metric-specific physiological bounds for validation hints. */
bounds: { min: number; max: number; unit: string };
}
/**
* Resolve the effective range for a single metric for a user.
*
* `overrides` is the parsed User.thresholdsJson value (may be null).
*/
export function getEffectiveRange(
metric: ThresholdMetric,
profile: UserProfileForRange,
overrides: ThresholdOverridesJson | null | undefined,
): EffectiveRange {
const fallback = defaultRange(metric, profile);
const override = overrides?.[metric];
const bounds = METRIC_BOUNDS[metric];
if (!override) {
return { range: fallback, isOverride: false, default: fallback, bounds };
}
// User override replaces the green band. Orange wings stretch 15% below/above,
// clamped to the metric's physiological bounds — without the clamp, an
// override like SpO2 {95,100} would emit orangeMax = 100.75 (impossible
// saturation) and BODY_FAT {2,80} would emit orangeMin = -9.7.
const span = Math.max(1, override.max - override.min);
const orangeWidth = span * 0.15;
return {
range: {
greenMin: override.min,
greenMax: override.max,
orangeMin: Math.max(bounds.min, override.min - orangeWidth),
orangeMax: Math.min(bounds.max, override.max + orangeWidth),
},
isOverride: true,
default: fallback,
bounds,
};
}
/**
* Narrow a raw `User.thresholdsJson` blob down to the user's WEIGHT override.
*
* The column is untyped JSON, so every consumer that wants the weight target
* has to validate it. v1.34 gave weight targets three new consumers at once
* (the Health Score, the dashboard bands, the PR direction) and one shared
* narrowing beats three hand-rolled casts. Returns `null` for anything that
* is not a well-formed, ordered pair of finite numbers — a malformed blob
* reads as "no target set", never as a target of `NaN`.
*/
export function resolveWeightTargetOverride(
thresholdsJson: unknown,
): { min: number; max: number } | null {
if (!thresholdsJson || typeof thresholdsJson !== "object") return null;
const weight = (thresholdsJson as Record<string, unknown>).WEIGHT;
if (!weight || typeof weight !== "object") return null;
const { min, max } = weight as { min?: unknown; max?: unknown };
if (typeof min !== "number" || typeof max !== "number") return null;
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
return { min, max };
}
/**
* Narrow one metric's own target out of a raw `User.thresholdsJson`, the same
* way `resolveWeightTargetOverride` does for weight. `null` for anything that
* is not a well-formed, ordered pair of finite numbers.
*/
export function resolveThresholdOverride(
thresholdsJson: unknown,
metric: ThresholdMetric,
): { min: number; max: number } | null {
if (!thresholdsJson || typeof thresholdsJson !== "object") return null;
const value = (thresholdsJson as Record<string, unknown>)[metric];
if (!value || typeof value !== "object") return null;
const { min, max } = value as { min?: unknown; max?: unknown };
if (typeof min !== "number" || typeof max !== "number") return null;
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
return { min, max };
}
export function resolveEffectiveBpTargets(
profile: UserProfileForRange,
thresholdsJson: unknown,
): BpTargets | null {
const sysOverride = resolveThresholdOverride(
thresholdsJson,
"BLOOD_PRESSURE_SYS",
);
const diaOverride = resolveThresholdOverride(
thresholdsJson,
"BLOOD_PRESSURE_DIA",
);
const overrides: ThresholdOverridesJson = {};
if (sysOverride) overrides.BLOOD_PRESSURE_SYS = sysOverride;
if (diaOverride) overrides.BLOOD_PRESSURE_DIA = diaOverride;
const sys = getEffectiveRange("BLOOD_PRESSURE_SYS", profile, overrides).range;
const dia = getEffectiveRange("BLOOD_PRESSURE_DIA", profile, overrides).range;
if (!sys || !dia) return null;
return {
sysLow: sys.greenMin,
sysHigh: sys.greenMax,
diaLow: dia.greenMin,
diaHigh: dia.greenMax,
};
}
/**
* The user's own blood-pressure band, or `null` when they have not set one.
*
* `resolveEffectiveBpTargets` answers "which band applies" and falls back to
* the age default; this answers "did the person pick it", which is what a
* surface needs to label the band as their own rather than as the guideline.
* A one-sided override (systolic only) still counts: the other half is the
* age default, exactly as `resolveEffectiveBpTargets` fills it.
*/
export function resolveBpTargetOverride(
profile: UserProfileForRange,
thresholdsJson: unknown,
): BpTargets | null {
const hasOverride =
resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_SYS") != null ||
resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_DIA") != null;
if (!hasOverride) return null;
return resolveEffectiveBpTargets(profile, thresholdsJson);
}
/**
* Convenience: resolve every supported metric at once. Useful for /targets
* and the insight generator.
*/
export function getAllEffectiveRanges(
profile: UserProfileForRange,
overrides: ThresholdOverridesJson | null | undefined,
): Record<ThresholdMetric, EffectiveRange> {
const metrics = Object.keys(METRIC_BOUNDS) as ThresholdMetric[];
return Object.fromEntries(
metrics.map((m) => [m, getEffectiveRange(m, profile, overrides)]),
) as Record<ThresholdMetric, EffectiveRange>;
}