Files
HealthLog/src/lib/analytics/effective-range.ts
T
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00

330 lines
13 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 { getBpTargets } 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 normalizedGender(value: string | null): "MALE" | "FEMALE" | null {
if (value === "MALE" || value === "FEMALE") return value;
return null;
}
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 = normalizedGender(profile.gender ?? null);
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,
};
}
/**
* 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>;
}