From ebdb63f0d86e455af94d40e9be5376c8c456bad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:36:06 +0200 Subject: [PATCH] perf(insights): keep Zod off the first load of the metric pages The value domain read the plausibility band out of the measurement validation module, so every metric sub-page that asks whether a metric can be negative or is integer-valued loaded the measurement schemas and Zod with them. The band and its check live in a Zod-free module now, re-exported by the validation module for the server writers, and the values list checks its segment against the exhaustive type label map instead of the Zod enum. About fifty metric routes drop roughly 88 KB gzip from their first load. The client-route guard walks a metric page and the values list too. --- src/__tests__/client-route-zod-guard.test.ts | 8 +- src/app/insights/values/[type]/page.tsx | 9 +- src/lib/measurements/plausibility-gate.ts | 4 +- src/lib/measurements/value-domain.ts | 4 +- src/lib/measurements/value-ranges.ts | 259 +++++++++++++++++++ src/lib/validations/measurement.ts | 258 +----------------- 6 files changed, 284 insertions(+), 258 deletions(-) create mode 100644 src/lib/measurements/value-ranges.ts diff --git a/src/__tests__/client-route-zod-guard.test.ts b/src/__tests__/client-route-zod-guard.test.ts index e50290239..ad2f39e9a 100644 --- a/src/__tests__/client-route-zod-guard.test.ts +++ b/src/__tests__/client-route-zod-guard.test.ts @@ -1,5 +1,6 @@ /** - * The documents vault and the Coach load no schema library in the browser. + * The documents vault, the Coach and the metric pages load no schema library + * in the browser. * * Both routes reached zod through a constant: the list of document kinds, the * bulk limit, the attachment cap, the Coach data clusters, a conversation @@ -121,6 +122,11 @@ describe("client routes without the schema library", () => { "src/app/documents/page.tsx", "src/app/coach/page.tsx", "src/app/coach/conversations/page.tsx", + // The metric sub-pages read the plausibility band through the value + // domain (`src/lib/measurements/value-ranges.ts`), and the values list + // checks its segment against the type label map. + "src/app/insights/pulse/page.tsx", + "src/app/insights/values/[type]/page.tsx", ]) { it(`${route} ships no zod in its static client graph`, () => { const hits = clientZodImports(route); diff --git a/src/app/insights/values/[type]/page.tsx b/src/app/insights/values/[type]/page.tsx index e54f9868c..0f9c25633 100644 --- a/src/app/insights/values/[type]/page.tsx +++ b/src/app/insights/values/[type]/page.tsx @@ -8,8 +8,8 @@ import { INSIGHTS_OVERVIEW, resolveValuesBackHref, } from "@/lib/insights/values-back-link"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import { MEASUREMENT_TYPE_LABEL_KEYS as EVERY_MEASUREMENT_TYPE } from "@/lib/measurements/type-label-keys"; import { BackLink } from "@/components/ui/back-link"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -38,8 +38,11 @@ export default function InsightsMetricValuesPage({ const searchParams = useSearchParams(); // Reject any segment that is not a real MeasurementType so a typo'd - // deep-link 404s instead of mounting a blank list. - if (!measurementTypeEnum.options.includes(type as never)) { + // deep-link 404s instead of mounting a blank list. The leaf label map is + // typed `Record`, so the compiler holds it to + // exactly the enum, and it answers the question without pulling the + // measurement schemas, and Zod, into the page. + if (!Object.hasOwn(EVERY_MEASUREMENT_TYPE, type)) { notFound(); } diff --git a/src/lib/measurements/plausibility-gate.ts b/src/lib/measurements/plausibility-gate.ts index d079f5391..5fe8197da 100644 --- a/src/lib/measurements/plausibility-gate.ts +++ b/src/lib/measurements/plausibility-gate.ts @@ -1,7 +1,7 @@ /** * The ingest-side half of the app's plausibility domain. * - * `VALUE_RANGES` in `@/lib/validations/measurement` is the band the + * `VALUE_RANGES` in `@/lib/measurements/value-ranges` is the band the * application declares a metric can occupy — PULSE 20–300, WEIGHT 1–500, * SLEEP_DURATION 0–1440 minutes. Every path a person can drive enforces it: * the measurement routes, the batch endpoint, the CSV and Apple-export @@ -46,7 +46,7 @@ */ import { annotate, getEvent } from "@/lib/logging/context"; import { isPlausibleMetricValue } from "@/lib/measurements/value-domain"; -import { VALUE_RANGES } from "@/lib/validations/measurement"; +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; /** * Stable meta key the tally lands under. Pinned so dashboards can count it diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index 1ae492854..eb0021bcd 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -10,7 +10,7 @@ * same question, two answers. The fact is a property of the METRIC, not * of the surface that renders it, so it belongs beside the metric — and * it is already declared there: `VALUE_RANGES` in - * `@/lib/validations/measurement` is the app-wide plausibility domain + * `@/lib/measurements/value-ranges` is the app-wide plausibility domain * every ingest path validates against, and its `min` IS the metric's * floor. Nothing new to maintain; this module only reads it. Two types * declare a genuinely signed floor (`ANS_CHARGE`, @@ -30,7 +30,7 @@ * real quantity too, but a step is indivisible, so the metric is read as a * count and rendered as one. */ -import { VALUE_RANGES } from "@/lib/validations/measurement"; +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; /** * Decimal places used for a metric with no discreteness constraint — the diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts new file mode 100644 index 000000000..e37d1eba7 --- /dev/null +++ b/src/lib/measurements/value-ranges.ts @@ -0,0 +1,259 @@ +/** + * The plausibility band each measurement type may occupy, and the check every + * ingest path runs against it. Zod-free on purpose: the value-domain facts + * (`value-domain.ts`) read the band on the client, and this module keeps that + * read from pulling the measurement schemas, and Zod with them, into every + * metric page. `@/lib/validations/measurement` re-exports both for the + * server-side writers. + */ + +// Plausible ranges per measurement type +export const VALUE_RANGES: Record = { + WEIGHT: { min: 1, max: 500 }, + BLOOD_PRESSURE_SYS: { min: 40, max: 300 }, + BLOOD_PRESSURE_DIA: { min: 20, max: 200 }, + PULSE: { min: 20, max: 300 }, + BODY_FAT: { min: 1, max: 80 }, + // Minutes (v1.4.23 unit change). Per-stage rows can run from a few + // seconds (a brief awakening) to 600+ minutes (a long IN_BED block), + // so the upper bound covers a 24-hour day. + SLEEP_DURATION: { min: 0, max: 1440 }, + ACTIVITY_STEPS: { min: 0, max: 200000 }, + BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia + TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg + BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) + // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the + // healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor + // critically-low reading still gets logged for the doctor to see; below 50% + // is incompatible with sustained life and almost certainly a sensor glitch. + OXYGEN_SATURATION: { min: 50, max: 100 }, + // ── v1.4.23 Apple Health ranges ── + // HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can + // reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper + // bound for unusually relaxed athletic windows. + HEART_RATE_VARIABILITY: { min: 1, max: 200 }, + // Resting HR bpm — endurance athletes can reach the high 30s; severe + // tachycardia caps below 220. + RESTING_HEART_RATE: { min: 25, max: 220 }, + // Active energy kcal/sample — a daily-rollup sample tops out around + // 6–8000 kcal even for ultra-endurance days. + ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, + // Flights — vertical-feet/3 ≈ flights; cap generous. + FLIGHTS_CLIMBED: { min: 0, max: 1000 }, + // Walking/running distance per sample (metres). 200 km covers an + // ultra-marathon or a multi-stage day. + WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 }, + // VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10. + VO2_MAX: { min: 5, max: 100 }, + // Body temperature °C — survivable lows ~28; severe hyperthermia ~45. + BODY_TEMPERATURE: { min: 28, max: 45 }, + // ── v1.4.25 W5d Withings full coverage ── + // Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg + // (large lean athlete). Same bounds as weight minus a fat-mass floor. + FAT_FREE_MASS: { min: 10, max: 250 }, + // Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight. + FAT_MASS: { min: 0, max: 250 }, + // Muscle mass kg — sub-component of fat-free mass; widest sensible bound. + MUSCLE_MASS: { min: 5, max: 200 }, + // Skin temperature °C — surface temps run cooler than core. ScanWatch's + // dermal sensor reports a relative offset that lands in the 25–40 °C + // band; treat anything outside as a sensor glitch. + SKIN_TEMPERATURE: { min: 20, max: 45 }, + // Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher + // numbers indicate stiffer arteries (cardiovascular risk). + PULSE_WAVE_VELOCITY: { min: 1, max: 30 }, + // Vascular age in years — Withings derives this from PWV + chronological + // age; the value is a "biological age" not a chronological one. Hard cap + // at 130 because that's the human longevity record. + VASCULAR_AGE: { min: 10, max: 130 }, + // Visceral fat rating 1–12 (Withings' own scale; not a percent). + VISCERAL_FAT: { min: 0, max: 30 }, + // ── v1.4.25 W8d Apple Health server-prep ── + // Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA; + // concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor + // of 30 dBA covers a quiet bedroom; anything below is silence and + // almost certainly a sensor artefact. + AUDIO_EXPOSURE_ENV: { min: 30, max: 140 }, + // Headphone audio caps slightly below the open-air upper edge because + // sealed-driver listening physically cannot reach a concert PA's SPL. + AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 }, + // Time in daylight (minutes/day) — full 24-hour window. Outdoor work + // tops the range; sedentary indoor days sit near 0. + TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, + // ── v1.5.5 iOS-coord additions ── + // Respiratory rate breaths/min — adult range ~12-20 at rest; severe + // distress can spike past 40; bradypnea floor ~4. Generous bounds. + RESPIRATORY_RATE: { min: 3, max: 60 }, + // BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity. + // Outside the band is almost certainly a height-or-weight typo. + BODY_MASS_INDEX: { min: 8, max: 70 }, + // Lean body mass kg — adult plausibility, ~25 kg (small adult) to + // 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS. + LEAN_BODY_MASS: { min: 10, max: 250 }, + // Walking heart rate average bpm — endurance athletes can sit in + // the high 70s while walking; ailing or post-stimulant readings can + // touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE. + WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 }, + // Gait percent (0-100). Apple ships these as 0..1 fractions; after + // the ×100 server-side scaling the canonical band is 0..100. + WALKING_ASYMMETRY: { min: 0, max: 100 }, + WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, + // Same ×100-scaled 0..100 percent as its two gait siblings above. The + // entry was simply missing; the map is the app's plausibility domain AND + // (via `value-domain.ts`) the answer to "can this metric be negative", + // so an omission reads as "unknown" on both questions. + WALKING_STEADINESS: { min: 0, max: 100 }, + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Step length (metres) — adult walking sits around 0.5–0.8 m; the + // 0.1 floor captures shuffling gait and the 2.0 ceiling covers + // unusually tall sprinters without catching obvious sensor noise. + WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 }, + // Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s; + // brisk walking tops out near 2.2 m/s before transitioning to a + // run. The 0.1 floor captures a very slow shuffle; 3.0 covers + // race-walking record territory. + WALKING_SPEED: { min: 0.1, max: 3.0 }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. + // Trained athletes can drop 50+ bpm; an unfit or autonomically + // impaired recovery sits in the single digits. 0 is a flat (poor) + // recovery; 100 is a generous ceiling beyond any plausible reading. + CARDIO_RECOVERY: { min: 0, max: 100 }, + // Wrist temperature (°C) — overnight skin-side reading. Runs cooler + // than core; the 30–42 band covers the plausible nighttime range and + // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. + WRIST_TEMPERATURE: { min: 30, max: 42 }, + // Fall count — a daily tally. A handful in a bad day is plausible; + // 50 is a generous ceiling that still catches a runaway sensor. + FALL_COUNT: { min: 0, max: 50 }, + // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; + // the 50 m floor captures severe impairment and the 1000 m ceiling + // covers an unusually fit reading without admitting sensor noise. + SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, + // Stair gait speed (m/s) — slower than level walking. Adult stair + // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 + // floor captures a very slow climb; 2.0 is well above any sustained + // stair pace and rejects a free-fall artefact. + STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, + STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, + // Breathing-disturbance index (count). A per-night index; 0 is an + // undisturbed night and the 1000 ceiling is generous headroom over + // any plausible severe-apnea night. + BREATHING_DISTURBANCES: { min: 0, max: 1000 }, + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows carry a fixed `value = 1` (one fired event). The range pins + // the value so a malformed ingest (value 0, or a stray sensor number) + // is rejected rather than stored as a phantom event. + IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, + HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, + LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, + WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, + BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, + // Loud-listening event flag (env + headphone). One fired event per row, so + // the floor is 1 — a non-event is not stored. The entry was missing from + // the map entirely, which let ANY number through ingest, including a + // negative one. The ceiling stays generous rather than pinned at 1 like the + // five events above: this type is written only by the iOS client and + // nothing in this repo asserts the exact value, so the floor is the part + // that is certain. + AUDIO_EXPOSURE_EVENT: { min: 1, max: 1000 }, + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived 0–100 scores. The plausibility band pins them to the + // score range so a malformed store (a stray negative or > 100) is rejected. + RECOVERY_SCORE: { min: 0, max: 100 }, + STRESS_SCORE: { min: 0, max: 100 }, + STRAIN_SCORE: { min: 0, max: 100 }, + // ── v1.11.0 — WHOOP-native score classes ── + // RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs + // higher than SDNN in relaxed, well-trained sleepers, and nightly readings + // above 200 ms are real (#1110), so the ceiling sits at 300 ms. + HRV_RMSSD: { min: 1, max: 300 }, + // Day / workout strain on WHOOP's bounded 0–21 scale. + DAY_STRAIN: { min: 0, max: 21 }, + WORKOUT_STRAIN: { min: 0, max: 21 }, + // Sleep quality percentages (0–100). + SLEEP_PERFORMANCE: { min: 0, max: 100 }, + SLEEP_EFFICIENCY: { min: 0, max: 100 }, + SLEEP_CONSISTENCY: { min: 0, max: 100 }, + // Recommended sleep duration in minutes — 0..1440 covers the 24-hour day. + SLEEP_NEED: { min: 0, max: 1440 }, + // Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous + // ceiling over any plausible ultra-endurance day. + ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 }, + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate bpm — same plausibility band as the + // spot PULSE: endurance athletes touch the 30s at rest, severe + // tachycardia caps below 300. + AVERAGE_HEART_RATE: { min: 20, max: 300 }, + MAX_HEART_RATE: { min: 20, max: 300 }, + // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a + // generous ceiling over any plausible severely-fragmented night. + SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // ANS charge is a baseline-relative autonomic deviation that can run + // negative; a generous symmetric ±100 band covers any plausible reading. + // Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous + // ceiling over any plausible single-day training load. + ANS_CHARGE: { min: -100, max: 100 }, + CARDIO_LOAD: { min: 0, max: 1000 }, + // ── v1.17.1 — Oura coverage completion ── + // Sleep Score (0–100), same band as the other score classes. + SLEEP_SCORE: { min: 0, max: 100 }, + // Body-temperature deviation (°C) — a signed nightly offset centred on 0. + // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that + // rejects an obvious sensor glitch while keeping every real illness / + // luteal-phase swing. + BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, + // ── v1.19.0 — Oura resilience ── + // Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding + // never falls outside this band. + RESILIENCE: { min: 1, max: 5 }, + // ── v1.25 — clinical-signals wave ── + // PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener. + PHQ9_SCORE: { min: 0, max: 27 }, + GAD7_SCORE: { min: 0, max: 21 }, + // ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4), + // SCI total 0–32 — both server-derived, higher = better. + WHO5_SCORE: { min: 0, max: 100 }, + SCI_SCORE: { min: 0, max: 32 }, + // ── v1.44 — the minute rings and mindful minutes are bounded by the day; + // a row is one sample or one day's total, never more than 1440 minutes. + EXERCISE_MINUTES: { min: 0, max: 1440 }, + STAND_MINUTES: { min: 0, max: 1440 }, + MINDFUL_MINUTES: { min: 0, max: 1440 }, + // Stood hours: 1 per hour sample, at most 24 on a day's total. + STAND_HOURS: { min: 0, max: 24 }, + // State of Mind valence: HealthKit's closed −1..1 scale. + STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, + // AFib burden as a percent of the week, after the ×100 scaling. A client + // that already scaled sends 2 for 2 %, which lands at 200 and is refused. + AFIB_BURDEN: { min: 0, max: 100 }, + // ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed + // bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm. + INDOOR_CO2: { min: 0, max: 10000 }, + // Indoor air °C: a cold cellar to an overheated attic. + INDOOR_TEMPERATURE: { min: -20, max: 60 }, + // Relative humidity, percent. + INDOOR_HUMIDITY: { min: 0, max: 100 }, + // Grip strength kg — a child can read in the single digits, an elite athlete + // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. + GRIP_STRENGTH: { min: 0, max: 120 }, + // Pain NRS — the validated 0–10 scale; the closed band pins the integer. + PAIN_NRS: { min: 0, max: 10 }, + // Waist circumference cm — paediatric lows ~30, severe obesity ~250. + WAIST_CIRCUMFERENCE: { min: 30, max: 250 }, + // Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is + // generous either side to admit any plausible body habitus. + WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 }, +}; + +export function validateMeasurementRange( + type: string, + value: number, +): string | null { + const range = VALUE_RANGES[type]; + if (range && (value < range.min || value > range.max)) { + return `Value must be between ${range.min} and ${range.max}`; + } + return null; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index fa6a5692b..93f9322de 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -243,256 +243,14 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [ // display transform can read it without pulling Zod into every chart route. export { getUnitForType } from "@/lib/measurements/unit-map"; -// Plausible ranges per measurement type -export const VALUE_RANGES: Record = { - WEIGHT: { min: 1, max: 500 }, - BLOOD_PRESSURE_SYS: { min: 40, max: 300 }, - BLOOD_PRESSURE_DIA: { min: 20, max: 200 }, - PULSE: { min: 20, max: 300 }, - BODY_FAT: { min: 1, max: 80 }, - // Minutes (v1.4.23 unit change). Per-stage rows can run from a few - // seconds (a brief awakening) to 600+ minutes (a long IN_BED block), - // so the upper bound covers a 24-hour day. - SLEEP_DURATION: { min: 0, max: 1440 }, - ACTIVITY_STEPS: { min: 0, max: 200000 }, - BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia - TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg - BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) - // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the - // healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor - // critically-low reading still gets logged for the doctor to see; below 50% - // is incompatible with sustained life and almost certainly a sensor glitch. - OXYGEN_SATURATION: { min: 50, max: 100 }, - // ── v1.4.23 Apple Health ranges ── - // HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can - // reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper - // bound for unusually relaxed athletic windows. - HEART_RATE_VARIABILITY: { min: 1, max: 200 }, - // Resting HR bpm — endurance athletes can reach the high 30s; severe - // tachycardia caps below 220. - RESTING_HEART_RATE: { min: 25, max: 220 }, - // Active energy kcal/sample — a daily-rollup sample tops out around - // 6–8000 kcal even for ultra-endurance days. - ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, - // Flights — vertical-feet/3 ≈ flights; cap generous. - FLIGHTS_CLIMBED: { min: 0, max: 1000 }, - // Walking/running distance per sample (metres). 200 km covers an - // ultra-marathon or a multi-stage day. - WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 }, - // VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10. - VO2_MAX: { min: 5, max: 100 }, - // Body temperature °C — survivable lows ~28; severe hyperthermia ~45. - BODY_TEMPERATURE: { min: 28, max: 45 }, - // ── v1.4.25 W5d Withings full coverage ── - // Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg - // (large lean athlete). Same bounds as weight minus a fat-mass floor. - FAT_FREE_MASS: { min: 10, max: 250 }, - // Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight. - FAT_MASS: { min: 0, max: 250 }, - // Muscle mass kg — sub-component of fat-free mass; widest sensible bound. - MUSCLE_MASS: { min: 5, max: 200 }, - // Skin temperature °C — surface temps run cooler than core. ScanWatch's - // dermal sensor reports a relative offset that lands in the 25–40 °C - // band; treat anything outside as a sensor glitch. - SKIN_TEMPERATURE: { min: 20, max: 45 }, - // Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher - // numbers indicate stiffer arteries (cardiovascular risk). - PULSE_WAVE_VELOCITY: { min: 1, max: 30 }, - // Vascular age in years — Withings derives this from PWV + chronological - // age; the value is a "biological age" not a chronological one. Hard cap - // at 130 because that's the human longevity record. - VASCULAR_AGE: { min: 10, max: 130 }, - // Visceral fat rating 1–12 (Withings' own scale; not a percent). - VISCERAL_FAT: { min: 0, max: 30 }, - // ── v1.4.25 W8d Apple Health server-prep ── - // Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA; - // concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor - // of 30 dBA covers a quiet bedroom; anything below is silence and - // almost certainly a sensor artefact. - AUDIO_EXPOSURE_ENV: { min: 30, max: 140 }, - // Headphone audio caps slightly below the open-air upper edge because - // sealed-driver listening physically cannot reach a concert PA's SPL. - AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 }, - // Time in daylight (minutes/day) — full 24-hour window. Outdoor work - // tops the range; sedentary indoor days sit near 0. - TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, - // ── v1.5.5 iOS-coord additions ── - // Respiratory rate breaths/min — adult range ~12-20 at rest; severe - // distress can spike past 40; bradypnea floor ~4. Generous bounds. - RESPIRATORY_RATE: { min: 3, max: 60 }, - // BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity. - // Outside the band is almost certainly a height-or-weight typo. - BODY_MASS_INDEX: { min: 8, max: 70 }, - // Lean body mass kg — adult plausibility, ~25 kg (small adult) to - // 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS. - LEAN_BODY_MASS: { min: 10, max: 250 }, - // Walking heart rate average bpm — endurance athletes can sit in - // the high 70s while walking; ailing or post-stimulant readings can - // touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE. - WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 }, - // Gait percent (0-100). Apple ships these as 0..1 fractions; after - // the ×100 server-side scaling the canonical band is 0..100. - WALKING_ASYMMETRY: { min: 0, max: 100 }, - WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, - // Same ×100-scaled 0..100 percent as its two gait siblings above. The - // entry was simply missing; the map is the app's plausibility domain AND - // (via `value-domain.ts`) the answer to "can this metric be negative", - // so an omission reads as "unknown" on both questions. - WALKING_STEADINESS: { min: 0, max: 100 }, - // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── - // Step length (metres) — adult walking sits around 0.5–0.8 m; the - // 0.1 floor captures shuffling gait and the 2.0 ceiling covers - // unusually tall sprinters without catching obvious sensor noise. - WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 }, - // Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s; - // brisk walking tops out near 2.2 m/s before transitioning to a - // run. The 0.1 floor captures a very slow shuffle; 3.0 covers - // race-walking record territory. - WALKING_SPEED: { min: 0.1, max: 3.0 }, - // ── v1.10.0 — additive HealthKit signals (WX-A) ── - // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. - // Trained athletes can drop 50+ bpm; an unfit or autonomically - // impaired recovery sits in the single digits. 0 is a flat (poor) - // recovery; 100 is a generous ceiling beyond any plausible reading. - CARDIO_RECOVERY: { min: 0, max: 100 }, - // Wrist temperature (°C) — overnight skin-side reading. Runs cooler - // than core; the 30–42 band covers the plausible nighttime range and - // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. - WRIST_TEMPERATURE: { min: 30, max: 42 }, - // Fall count — a daily tally. A handful in a bad day is plausible; - // 50 is a generous ceiling that still catches a runaway sensor. - FALL_COUNT: { min: 0, max: 50 }, - // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; - // the 50 m floor captures severe impairment and the 1000 m ceiling - // covers an unusually fit reading without admitting sensor noise. - SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, - // Stair gait speed (m/s) — slower than level walking. Adult stair - // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 - // floor captures a very slow climb; 2.0 is well above any sustained - // stair pace and rejects a free-fall artefact. - STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, - STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, - // Breathing-disturbance index (count). A per-night index; 0 is an - // undisturbed night and the 1000 ceiling is generous headroom over - // any plausible severe-apnea night. - BREATHING_DISTURBANCES: { min: 0, max: 1000 }, - // ── v1.10.0 — categorical events (WX-B) ── - // EVENT rows carry a fixed `value = 1` (one fired event). The range pins - // the value so a malformed ingest (value 0, or a stray sensor number) - // is rejected rather than stored as a phantom event. - IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, - HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, - LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, - WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, - BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, - // Loud-listening event flag (env + headphone). One fired event per row, so - // the floor is 1 — a non-event is not stored. The entry was missing from - // the map entirely, which let ANY number through ingest, including a - // negative one. The ceiling stays generous rather than pinned at 1 like the - // five events above: this type is written only by the iOS client and - // nothing in this repo asserts the exact value, so the floor is the part - // that is certain. - AUDIO_EXPOSURE_EVENT: { min: 1, max: 1000 }, - // ── v1.10.0 — computed scores (WX-C) ── - // Server-derived 0–100 scores. The plausibility band pins them to the - // score range so a malformed store (a stray negative or > 100) is rejected. - RECOVERY_SCORE: { min: 0, max: 100 }, - STRESS_SCORE: { min: 0, max: 100 }, - STRAIN_SCORE: { min: 0, max: 100 }, - // ── v1.11.0 — WHOOP-native score classes ── - // RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs - // higher than SDNN in relaxed, well-trained sleepers, and nightly readings - // above 200 ms are real (#1110), so the ceiling sits at 300 ms. - HRV_RMSSD: { min: 1, max: 300 }, - // Day / workout strain on WHOOP's bounded 0–21 scale. - DAY_STRAIN: { min: 0, max: 21 }, - WORKOUT_STRAIN: { min: 0, max: 21 }, - // Sleep quality percentages (0–100). - SLEEP_PERFORMANCE: { min: 0, max: 100 }, - SLEEP_EFFICIENCY: { min: 0, max: 100 }, - SLEEP_CONSISTENCY: { min: 0, max: 100 }, - // Recommended sleep duration in minutes — 0..1440 covers the 24-hour day. - SLEEP_NEED: { min: 0, max: 1440 }, - // Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous - // ceiling over any plausible ultra-endurance day. - ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 }, - // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── - // Daily average / max heart rate bpm — same plausibility band as the - // spot PULSE: endurance athletes touch the 30s at rest, severe - // tachycardia caps below 300. - AVERAGE_HEART_RATE: { min: 20, max: 300 }, - MAX_HEART_RATE: { min: 20, max: 300 }, - // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a - // generous ceiling over any plausible severely-fragmented night. - SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, - // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── - // ANS charge is a baseline-relative autonomic deviation that can run - // negative; a generous symmetric ±100 band covers any plausible reading. - // Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous - // ceiling over any plausible single-day training load. - ANS_CHARGE: { min: -100, max: 100 }, - CARDIO_LOAD: { min: 0, max: 1000 }, - // ── v1.17.1 — Oura coverage completion ── - // Sleep Score (0–100), same band as the other score classes. - SLEEP_SCORE: { min: 0, max: 100 }, - // Body-temperature deviation (°C) — a signed nightly offset centred on 0. - // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that - // rejects an obvious sensor glitch while keeping every real illness / - // luteal-phase swing. - BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, - // ── v1.19.0 — Oura resilience ── - // Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding - // never falls outside this band. - RESILIENCE: { min: 1, max: 5 }, - // ── v1.25 — clinical-signals wave ── - // PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener. - PHQ9_SCORE: { min: 0, max: 27 }, - GAD7_SCORE: { min: 0, max: 21 }, - // ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4), - // SCI total 0–32 — both server-derived, higher = better. - WHO5_SCORE: { min: 0, max: 100 }, - SCI_SCORE: { min: 0, max: 32 }, - // ── v1.44 — the minute rings and mindful minutes are bounded by the day; - // a row is one sample or one day's total, never more than 1440 minutes. - EXERCISE_MINUTES: { min: 0, max: 1440 }, - STAND_MINUTES: { min: 0, max: 1440 }, - MINDFUL_MINUTES: { min: 0, max: 1440 }, - // Stood hours: 1 per hour sample, at most 24 on a day's total. - STAND_HOURS: { min: 0, max: 24 }, - // State of Mind valence: HealthKit's closed −1..1 scale. - STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, - // AFib burden as a percent of the week, after the ×100 scaling. A client - // that already scaled sends 2 for 2 %, which lands at 200 and is refused. - AFIB_BURDEN: { min: 0, max: 100 }, - // ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed - // bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm. - INDOOR_CO2: { min: 0, max: 10000 }, - // Indoor air °C: a cold cellar to an overheated attic. - INDOOR_TEMPERATURE: { min: -20, max: 60 }, - // Relative humidity, percent. - INDOOR_HUMIDITY: { min: 0, max: 100 }, - // Grip strength kg — a child can read in the single digits, an elite athlete - // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. - GRIP_STRENGTH: { min: 0, max: 120 }, - // Pain NRS — the validated 0–10 scale; the closed band pins the integer. - PAIN_NRS: { min: 0, max: 10 }, - // Waist circumference cm — paediatric lows ~30, severe obesity ~250. - WAIST_CIRCUMFERENCE: { min: 30, max: 250 }, - // Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is - // generous either side to admit any plausible body habitus. - WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 }, -}; - -export function validateMeasurementRange( - type: string, - value: number, -): string | null { - const range = VALUE_RANGES[type]; - if (range && (value < range.min || value > range.max)) { - return `Value must be between ${range.min} and ${range.max}`; - } - return null; -} +// The plausibility band per type, and its check, live in a Zod-free module so +// the client's value-domain reads (the chart floor, the integer set) do not +// pull Zod into every metric page. +import { validateMeasurementRange } from "@/lib/measurements/value-ranges"; +export { + VALUE_RANGES, + validateMeasurementRange, +} from "@/lib/measurements/value-ranges"; export const createMeasurementSchema = z .object({