/** * 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 >; 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).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 }; } function resolveThresholdOverride( thresholdsJson: unknown, metric: ThresholdMetric, ): { min: number; max: number } | null { if (!thresholdsJson || typeof thresholdsJson !== "object") return null; const value = (thresholdsJson as Record)[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 { const metrics = Object.keys(METRIC_BOUNDS) as ThresholdMetric[]; return Object.fromEntries( metrics.map((m) => [m, getEffectiveRange(m, profile, overrides)]), ) as Record; }