From 3381026acb6e81d2756186018232334c6f424c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 12:22:03 +0200 Subject: [PATCH] fix(insights): give a personal band a minimum width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When most days in the window carry the same value, the median ± k·MAD band has no width: the coach strip read "Your usual range is 61–61 bpm" and the next ordinary reading of 62 counted as outside it. The shared band builder now keeps the edges at least the measurement's own day-to-day wobble apart (per metric, in its canonical unit, falling back to one display step), so every surface built on it reads a real range and classifies against it. The reported spread stays the measured dispersion. --- .../derived/__tests__/baseline.test.ts | 44 +++++++++++++++++++ src/lib/insights/derived/baseline.ts | 15 +++++-- src/lib/measurements/value-domain.ts | 32 ++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts index dd30f888f..0789542ee 100644 --- a/src/lib/insights/derived/__tests__/baseline.test.ts +++ b/src/lib/insights/derived/__tests__/baseline.test.ts @@ -70,6 +70,50 @@ describe("pure statistics", () => { expect(band!.k).toBe(3); }); + // A run of identical days has no dispersion at all: the bare MAD band + // collapsed to one value and every surface read "your usual range is + // 61–61 bpm", while a reading of 62 counted as outside it. + it("never collapses to a single value when the days are identical", () => { + const band = buildBaselineBand(Array(20).fill(61), "RESTING_HEART_RATE"); + expect(band).not.toBeNull(); + expect(band!.center).toBe(61); + expect(band!.low).toBeLessThan(61); + expect(band!.high).toBeGreaterThan(61); + // Distinct at the precision any surface prints a heart rate. + expect(Math.round(band!.low)).not.toBe(Math.round(band!.high)); + // An ordinary next-day reading sits inside the band, not above it. + expect(62).toBeLessThanOrEqual(band!.high); + // The dispersion itself is reported as it is. + expect(band!.spread).toBe(0); + }); + + it.each([ + ["RESTING_HEART_RATE", 61], + ["PULSE", 72], + ["HEART_RATE_VARIABILITY", 45], + ["RESPIRATORY_RATE", 14], + ["OXYGEN_SATURATION", 97], + ["BODY_TEMPERATURE", 36.6], + ["SKIN_TEMPERATURE", 34.1], + ["BLOOD_PRESSURE_SYS", 118], + ["BLOOD_PRESSURE_DIA", 76], + ["BLOOD_GLUCOSE", 95], + ["WEIGHT", 80], + ["VO2_MAX", 42], + ["ACTIVITY_STEPS", 8000], + ] as const)( + "gives %s a band that reads as a range at one decimal", + (type, value) => { + const band = buildBaselineBand(Array(10).fill(value), type); + expect(band!.low.toFixed(1)).not.toBe(band!.high.toFixed(1)); + }, + ); + + it("leaves a band wider than the floor exactly as the spread makes it", () => { + const band = buildBaselineBand([50, 52, 54, 56, 58], "PULSE", 3); + expect(band!.high - band!.center).toBeCloseTo(band!.spread, 10); + }); + it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => { // Same per-day means whether they arrive as raw rows or DAY rollups. const perDayMeans = [60, 61, 62, 59, 63, 58, 64]; diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index a53f5675b..4aa3ea559 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -34,6 +34,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { toProfileSex } from "@/lib/profile/sex"; import { clampDerivedLowerBound, + minimumBandHalfWidth, isPlausibleMetricValue, plausibleMetricRange, } from "@/lib/measurements/value-domain"; @@ -70,12 +71,13 @@ export interface VitalsBaselineValue { /** Robust center (median of the per-day means). */ center: number; /** - * Band lower edge (center − k·MAD·scale), floored at 0 for a metric whose + * Band lower edge (center − k·MAD·scale, at least the metric's + * `minimumBandHalfWidth` below center), floored at 0 for a metric whose * plausibility domain forbids negative values. `spread` below stays the * unclamped dispersion, so a deviation-in-σ calculation is unaffected. */ low: number; - /** Band upper edge (center + k·MAD·scale). */ + /** Band upper edge (center + k·MAD·scale, with the same minimum width). */ high: number; /** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */ spread: number; @@ -204,10 +206,15 @@ export function buildBaselineBand( const mad = medianAbsoluteDeviation(dayMeans); // Scale MAD to a σ-equivalent so the band reads like a robust ±kσ. const spread = k * mad * MAD_SIGMA_SCALE; + // The edges never sit closer than the measurement's own day-to-day wobble: + // a run of identical days has no dispersion, and a zero-width band read + // "61–61 bpm" and put the next ordinary reading outside it. `spread` stays + // the measured dispersion for anything that reasons in σ. + const halfWidth = Math.max(spread, minimumBandHalfWidth(type)); return { center, - low: clampDerivedLowerBound(type, center - spread), - high: center + spread, + low: clampDerivedLowerBound(type, center - halfWidth), + high: center + halfWidth, spread, sampleDays: dayMeans.length, k, diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index d6e485827..0f071e6f9 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -153,3 +153,35 @@ export function metricFractionDigits(type: string | null | undefined): number { if (type && INTEGER_VALUED_MEASUREMENT_TYPES.has(type)) return 0; return DEFAULT_METRIC_FRACTION_DIGITS; } + +/** + * The smallest half-width a personal band may have, in the metric's canonical + * unit: roughly the day-to-day wobble of the measurement itself. + * + * A median ± k·MAD band has no width at all when most days carry the same + * value (a device that reports a whole-number resting rate does this often), + * and then reads "your usual range is 61–61 bpm" while a reading of 62 counts + * as outside it. Below this width a band says nothing a single reading could + * not, so the band is widened to it. Metrics not listed take one step of + * their own display precision, which is the least that keeps the two edges + * apart on screen. + */ +const MIN_BAND_HALF_WIDTH: Readonly> = { + RESTING_HEART_RATE: 2, // bpm + PULSE: 3, // bpm + HEART_RATE_VARIABILITY: 3, // ms + RESPIRATORY_RATE: 0.5, // breaths/min + OXYGEN_SATURATION: 1, // % + BODY_TEMPERATURE: 0.2, // °C + SKIN_TEMPERATURE: 0.2, // °C + WRIST_TEMPERATURE: 0.2, // °C + BLOOD_PRESSURE_SYS: 4, // mmHg + BLOOD_PRESSURE_DIA: 3, // mmHg + BLOOD_GLUCOSE: 5, // mg/dL + WEIGHT: 0.3, // kg +}; + +export function minimumBandHalfWidth(type: string | null | undefined): number { + if (!type) return 0; + return MIN_BAND_HALF_WIDTH[type] ?? 10 ** -metricFractionDigits(type); +}