mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
# Conflicts: # docs/api/openapi.yaml # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # prisma/schema.prisma # src/components/measurements/measurement-list-meta.ts # src/lib/__tests__/measurement-type-enum-coverage.test.ts # src/lib/insights/chart-tokens.ts # src/lib/measurements/__tests__/apple-health-mapping.test.ts # src/lib/measurements/categories.ts # src/lib/personal-records/pr-direction.ts # src/lib/validations/measurement.ts
730 lines
34 KiB
TypeScript
730 lines
34 KiB
TypeScript
import { z } from "zod/v4";
|
||
import { validateEntryInstant } from "./entry-instant";
|
||
|
||
export const measurementTypeEnum = z.enum([
|
||
"WEIGHT",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
"PULSE",
|
||
"BODY_FAT",
|
||
"SLEEP_DURATION",
|
||
"ACTIVITY_STEPS",
|
||
"BLOOD_GLUCOSE",
|
||
"TOTAL_BODY_WATER",
|
||
"BONE_MASS",
|
||
"OXYGEN_SATURATION",
|
||
// ── v1.4.23 Apple Health additions ──
|
||
"HEART_RATE_VARIABILITY",
|
||
"RESTING_HEART_RATE",
|
||
"ACTIVE_ENERGY_BURNED",
|
||
"FLIGHTS_CLIMBED",
|
||
"WALKING_RUNNING_DISTANCE",
|
||
"VO2_MAX",
|
||
"BODY_TEMPERATURE",
|
||
// ── v1.4.25 W5d Withings full coverage ──
|
||
"FAT_FREE_MASS",
|
||
"FAT_MASS",
|
||
"MUSCLE_MASS",
|
||
"SKIN_TEMPERATURE",
|
||
"PULSE_WAVE_VELOCITY",
|
||
"VASCULAR_AGE",
|
||
"VISCERAL_FAT",
|
||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||
"AUDIO_EXPOSURE_ENV",
|
||
"AUDIO_EXPOSURE_HEADPHONE",
|
||
"TIME_IN_DAYLIGHT",
|
||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||
"WALKING_STEADINESS",
|
||
"AUDIO_EXPOSURE_EVENT",
|
||
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired end-to-end ──
|
||
"RESPIRATORY_RATE",
|
||
"BODY_MASS_INDEX",
|
||
"LEAN_BODY_MASS",
|
||
"WALKING_HEART_RATE_AVERAGE",
|
||
"WALKING_ASYMMETRY",
|
||
"WALKING_DOUBLE_SUPPORT",
|
||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||
// Both ship raw SI on the wire (metres / metres-per-second); see
|
||
// the convention block in `apple-health-mapping.ts`.
|
||
"WALKING_STEP_LENGTH",
|
||
"WALKING_SPEED",
|
||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||
// Seven previously-deferred quantity identifiers; each ships raw on
|
||
// the wire (identity conversion). See the convention block in
|
||
// `apple-health-mapping.ts`.
|
||
"CARDIO_RECOVERY",
|
||
"WRIST_TEMPERATURE",
|
||
"FALL_COUNT",
|
||
"SIX_MINUTE_WALK_DISTANCE",
|
||
"STAIR_ASCENT_SPEED",
|
||
"STAIR_DESCENT_SPEED",
|
||
"BREATHING_DISTURBANCES",
|
||
// ── v1.10.0 — categorical events (WX-B) ──
|
||
// Discrete device-flagged EVENT rows (value is always 1). The device's
|
||
// own verdict / severity rides in the `rhythmClassification` column.
|
||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||
"HIGH_HEART_RATE_EVENT",
|
||
"LOW_HEART_RATE_EVENT",
|
||
"WALKING_STEADINESS_EVENT",
|
||
"BREATHING_DISTURBANCE_EVENT",
|
||
// ── v1.10.0 — computed scores (WX-C) ──
|
||
// Server-derived wellness scores (0–100, unit `score`). Minted by a
|
||
// nightly engine from the user's already-stored signals and persisted as a
|
||
// `COMPUTED`-source row — never ingested from a client (the batch +
|
||
// single-POST write surfaces reject the COMPUTED source). Only
|
||
// RECOVERY_SCORE is computed in v1.10.0; the other two are defined now so
|
||
// the later engines that compute them need no schema change.
|
||
"RECOVERY_SCORE",
|
||
"STRESS_SCORE",
|
||
"STRAIN_SCORE",
|
||
// ── v1.11.0 — WHOOP-native score classes (additive) ──
|
||
// Native WHOOP scores ingest server-side as `source = WHOOP`. DAY_STRAIN /
|
||
// HRV_RMSSD are deliberately distinct from STRAIN_SCORE / SDNN
|
||
// HEART_RATE_VARIABILITY so a device-native value never shares a bucket
|
||
// with a derived proxy. See `apple-health-mapping.ts` convention block and
|
||
// `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2.
|
||
"HRV_RMSSD",
|
||
"DAY_STRAIN",
|
||
"WORKOUT_STRAIN",
|
||
"SLEEP_PERFORMANCE",
|
||
"SLEEP_EFFICIENCY",
|
||
"SLEEP_CONSISTENCY",
|
||
"SLEEP_NEED",
|
||
"ENERGY_EXPENDITURE_KJ",
|
||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
|
||
// Daily average / max heart rate ride WHOOP's cycle score (previously
|
||
// fetched but dropped). Kept distinct from spot PULSE / RESTING_HEART_RATE /
|
||
// WALKING_HEART_RATE_AVERAGE: these are the day's whole-cycle aggregates,
|
||
// not a point-in-time or context-specific reading. Sleep disturbance count
|
||
// is the per-night WHOOP `stage_summary.disturbance_count`.
|
||
"AVERAGE_HEART_RATE",
|
||
"MAX_HEART_RATE",
|
||
"SLEEP_DISTURBANCE_COUNT",
|
||
// v1.17.1 — Polar Nightly Recharge + Training Load Pro components ingested
|
||
// server-side as `source = POLAR`. ANS_CHARGE is the HRV-based autonomic
|
||
// charge (distinct from the 1–6 recovery band that maps to RECOVERY_SCORE);
|
||
// CARDIO_LOAD is Polar's device-native cardiovascular-strain figure (distinct
|
||
// from WHOOP DAY_STRAIN and the COMPUTED STRAIN_SCORE).
|
||
"ANS_CHARGE",
|
||
"CARDIO_LOAD",
|
||
// ── v1.17.1 — Oura coverage completion (additive) ──
|
||
// Oura's headline 0–100 Sleep Score (`daily_sleep.score`), distinct from the
|
||
// WHOOP SLEEP_PERFORMANCE / SLEEP_EFFICIENCY sub-scores. The body-temperature
|
||
// deviation is a SIGNED °C offset from the user's baseline
|
||
// (`daily_readiness.temperature_deviation`), not an absolute reading, so it
|
||
// never shares the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
|
||
// buckets. Both ingest server-side as `source = OURA`.
|
||
"SLEEP_SCORE",
|
||
"BODY_TEMPERATURE_DEVIATION",
|
||
]);
|
||
|
||
/**
|
||
* v1.10.0 — categorical events (WX-B). The closed set of EVENT-class
|
||
* MeasurementTypes. An EVENT row carries `value = 1`, an optional
|
||
* `rhythmClassification` verdict, and never participates in trend / rollup
|
||
* analytics (it is a discrete occurrence, not a continuous reading). Kept
|
||
* as a `Set` so ingest + read paths can branch on "is this an event row?".
|
||
*/
|
||
export const EVENT_MEASUREMENT_TYPES: ReadonlySet<string> = new Set<string>([
|
||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||
"HIGH_HEART_RATE_EVENT",
|
||
"LOW_HEART_RATE_EVENT",
|
||
"WALKING_STEADINESS_EVENT",
|
||
"BREATHING_DISTURBANCE_EVENT",
|
||
]);
|
||
|
||
export const glucoseContextEnum = z.enum([
|
||
"FASTING",
|
||
"POSTPRANDIAL",
|
||
"RANDOM",
|
||
"BEDTIME",
|
||
]);
|
||
|
||
export const measurementSourceEnum = z.enum([
|
||
"MANUAL",
|
||
"WITHINGS",
|
||
"IMPORT",
|
||
"APPLE_HEALTH",
|
||
// v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED`
|
||
// row is minted by a nightly engine, never written by a client. It is part
|
||
// of this enum so the read/response shapes (and the iOS decoder) can decode
|
||
// the rows it surfaces; the client-facing write surfaces reject it — see
|
||
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
|
||
"COMPUTED",
|
||
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side (no
|
||
// client write path). Part of this enum so the read/response shapes (and
|
||
// the iOS decoder) can decode the rows it surfaces.
|
||
"WHOOP",
|
||
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
|
||
// (no client write path), exactly like WHOOP. Part of this enum so the
|
||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||
"FITBIT",
|
||
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
|
||
// write path), exactly like WHOOP/FITBIT. Part of this enum so the
|
||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||
"NIGHTSCOUT",
|
||
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
|
||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||
"POLAR",
|
||
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
|
||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||
"OURA",
|
||
]);
|
||
|
||
/**
|
||
* v1.10.0 — computed scores (WX-C). The subset of `MeasurementSource` a
|
||
* client may attribute on a write. `COMPUTED` is server-owned (a nightly
|
||
* engine mints it) and `WITHINGS` / `IMPORT` are owned by the Withings
|
||
* webhook + CSV importer respectively, so all three are excluded — letting a
|
||
* client forge a row attributed to them would pollute the per-source
|
||
* canonical picker with rows the server never produced. The single-entry
|
||
* POST validates `source` against this set; the batch route mirrors the same
|
||
* exclusion with its own narrower `{APPLE_HEALTH, MANUAL}` allowlist.
|
||
*/
|
||
export const WRITABLE_MEASUREMENT_SOURCES = [
|
||
"MANUAL",
|
||
"APPLE_HEALTH",
|
||
] as const;
|
||
|
||
/**
|
||
* v1.10.0 QA — the Zod enum the single-entry POST validates `source` against.
|
||
* Built from `WRITABLE_MEASUREMENT_SOURCES` so the allowlist has one source of
|
||
* truth. A client may only attribute a row it actually owns: `MANUAL` (a
|
||
* hand-entered reading) or `APPLE_HEALTH` (the HealthKit batch's per-row
|
||
* source). `WITHINGS` is owned by the Withings webhook, `IMPORT` by the CSV
|
||
* importer, and `COMPUTED` by the nightly score engines — letting a client
|
||
* forge any of those would pollute the per-source canonical picker with rows
|
||
* the server never produced. The batch route mirrors the same exclusion with
|
||
* its own narrower `{APPLE_HEALTH, MANUAL}` `batchSourceEnum`.
|
||
*/
|
||
export const writableMeasurementSourceEnum = z.enum(
|
||
WRITABLE_MEASUREMENT_SOURCES,
|
||
);
|
||
|
||
const unitMap: Record<string, string> = {
|
||
WEIGHT: "kg",
|
||
BLOOD_PRESSURE_SYS: "mmHg",
|
||
BLOOD_PRESSURE_DIA: "mmHg",
|
||
PULSE: "bpm",
|
||
BODY_FAT: "%",
|
||
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
|
||
// category-sample stages can be stored without precision loss. Older
|
||
// surfaces that need hours convert at read time (`minutes / 60`).
|
||
SLEEP_DURATION: "minutes",
|
||
ACTIVITY_STEPS: "steps",
|
||
BLOOD_GLUCOSE: "mg/dL",
|
||
TOTAL_BODY_WATER: "kg",
|
||
BONE_MASS: "kg",
|
||
OXYGEN_SATURATION: "%",
|
||
// ── v1.4.23 Apple Health canonical units ──
|
||
HEART_RATE_VARIABILITY: "ms",
|
||
RESTING_HEART_RATE: "bpm",
|
||
ACTIVE_ENERGY_BURNED: "kcal",
|
||
FLIGHTS_CLIMBED: "flights",
|
||
WALKING_RUNNING_DISTANCE: "m",
|
||
VO2_MAX: "mL/(kg·min)",
|
||
BODY_TEMPERATURE: "celsius",
|
||
// ── v1.4.25 W5d Withings full coverage ──
|
||
FAT_FREE_MASS: "kg",
|
||
FAT_MASS: "kg",
|
||
MUSCLE_MASS: "kg",
|
||
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
|
||
// the bucket would corrupt analytics. Same canonical unit (°C).
|
||
SKIN_TEMPERATURE: "celsius",
|
||
PULSE_WAVE_VELOCITY: "m/s",
|
||
VASCULAR_AGE: "years",
|
||
// Withings reports visceral fat as a 1–12 rating, not a percent. The
|
||
// string mirrors what Withings prints in Health Mate.
|
||
VISCERAL_FAT: "rating",
|
||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
|
||
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
|
||
// label because the A-weighting is implicit (every HealthKit audio
|
||
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
|
||
AUDIO_EXPOSURE_ENV: "dBA",
|
||
AUDIO_EXPOSURE_HEADPHONE: "dBA",
|
||
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
|
||
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
|
||
// and outdoor athletes accumulate a few hours.
|
||
TIME_IN_DAYLIGHT: "minutes",
|
||
// ── v1.5.5 iOS-coord additions ──
|
||
// Respiratory rate breaths-per-minute — the HK identifier ships as
|
||
// `count/min`; we keep the more conventional clinical label.
|
||
RESPIRATORY_RATE: "breaths/min",
|
||
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
|
||
// display string mirrors clinical convention.
|
||
BODY_MASS_INDEX: "kg/m²",
|
||
// Lean body mass kg — body-composition partner to FAT_MASS.
|
||
LEAN_BODY_MASS: "kg",
|
||
// Walking heart rate average bpm — daily rollup; distinct from
|
||
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
|
||
WALKING_HEART_RATE_AVERAGE: "bpm",
|
||
// Walking gait percent (0-100 after server-side ×100 scaling).
|
||
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
|
||
// — see the project convention block in `apple-health-mapping.ts`.
|
||
WALKING_ASYMMETRY: "%",
|
||
WALKING_DOUBLE_SUPPORT: "%",
|
||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||
// Step length is metres; speed is metres per second. Both flow
|
||
// raw on the wire — no server-side scaling. The unit strings
|
||
// match HealthKit's `m` / `m/s` defaults.
|
||
WALKING_STEP_LENGTH: "m",
|
||
WALKING_SPEED: "m/s",
|
||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||
// Cardio recovery is the bpm drop one minute after peak exercise.
|
||
CARDIO_RECOVERY: "bpm",
|
||
// Overnight wrist temperature in °C (absolute reading; Apple's own
|
||
// display frames it as a baseline deviation, we store the reading).
|
||
WRIST_TEMPERATURE: "celsius",
|
||
// Hard-fall detections — a plain count.
|
||
FALL_COUNT: "count",
|
||
// Apple's estimated six-minute-walk-test distance in metres.
|
||
SIX_MINUTE_WALK_DISTANCE: "m",
|
||
// Stair gait speeds — raw metres-per-second (no scaling).
|
||
STAIR_ASCENT_SPEED: "m/s",
|
||
STAIR_DESCENT_SPEED: "m/s",
|
||
// Per-night breathing-disturbance index — a unitless count Apple
|
||
// classifies as NotElevated / Elevated.
|
||
BREATHING_DISTURBANCES: "count",
|
||
// ── v1.10.0 — categorical events (WX-B) ──
|
||
// EVENT rows are dimensionless occurrences (value is always 1). The
|
||
// canonical unit is the bare "event" so any accidental numeric surfacing
|
||
// reads sensibly; the awareness timeline never displays the value.
|
||
IRREGULAR_RHYTHM_NOTIFICATION: "event",
|
||
HIGH_HEART_RATE_EVENT: "event",
|
||
LOW_HEART_RATE_EVENT: "event",
|
||
WALKING_STEADINESS_EVENT: "event",
|
||
BREATHING_DISTURBANCE_EVENT: "event",
|
||
// ── v1.10.0 — computed scores (WX-C) ──
|
||
// Server-derived 0–100 wellness scores. The canonical unit is the bare
|
||
// "score" so the value reads sensibly anywhere it surfaces.
|
||
RECOVERY_SCORE: "score",
|
||
STRESS_SCORE: "score",
|
||
STRAIN_SCORE: "score",
|
||
// ── v1.11.0 — WHOOP-native score classes ──
|
||
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
|
||
// HEART_RATE_VARIABILITY (different estimator, same dimension).
|
||
HRV_RMSSD: "ms",
|
||
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
|
||
// unit reads sensibly wherever the value surfaces (distinct from the
|
||
// 0–100 COMPUTED STRAIN_SCORE).
|
||
DAY_STRAIN: "score",
|
||
WORKOUT_STRAIN: "score",
|
||
// Sleep quality percentages (0–100).
|
||
SLEEP_PERFORMANCE: "%",
|
||
SLEEP_EFFICIENCY: "%",
|
||
SLEEP_CONSISTENCY: "%",
|
||
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
|
||
SLEEP_NEED: "minutes",
|
||
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
|
||
// device value round-trips rather than being converted to kcal).
|
||
ENERGY_EXPENDITURE_KJ: "kJ",
|
||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
|
||
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
|
||
AVERAGE_HEART_RATE: "bpm",
|
||
MAX_HEART_RATE: "bpm",
|
||
// Per-night sleep disturbance tally — a plain integer count.
|
||
SLEEP_DISTURBANCE_COUNT: "count",
|
||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||
ANS_CHARGE: "score",
|
||
CARDIO_LOAD: "score",
|
||
// ── v1.17.1 — Oura coverage completion ──
|
||
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
|
||
SLEEP_SCORE: "score",
|
||
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
|
||
BODY_TEMPERATURE_DEVIATION: "celsius",
|
||
};
|
||
|
||
export function getUnitForType(type: string): string {
|
||
return unitMap[type] ?? "unknown";
|
||
}
|
||
|
||
// Plausible ranges per measurement type
|
||
const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||
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 },
|
||
// ── 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 },
|
||
// ── 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). Same plausibility band as the SDNN variant: lows reach
|
||
// single digits in stressed samples, 200 ms is a generous upper bound for
|
||
// relaxed athletic windows.
|
||
HRV_RMSSD: { min: 1, max: 200 },
|
||
// 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 },
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* v1.6.0 — notes cap raised from 25 to 200. A 25-char limit could not
|
||
* hold a meaningful clinical note ("took after large meal, felt dizzy
|
||
* standing up"). The DB column is unbounded `String?`; this is the
|
||
* single source of truth the client char-counters import.
|
||
*/
|
||
export const MEASUREMENT_NOTES_MAX_LENGTH = 200;
|
||
|
||
export const createMeasurementSchema = z
|
||
.object({
|
||
type: measurementTypeEnum,
|
||
value: z.number(),
|
||
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
|
||
// future instants beyond a 5-min clock-skew tolerance, no instant before
|
||
// 1900. Closes the data-portability gap where a hand-rolled POST could
|
||
// backdate or forward-date a reading arbitrarily.
|
||
measuredAt: validateEntryInstant(
|
||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||
),
|
||
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
|
||
// v1.10.0 QA — validate against the client-writable subset, not the full
|
||
// `MeasurementSource` enum. A client may attribute only `MANUAL` or
|
||
// `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and
|
||
// forging them would pollute the per-source canonical picker.
|
||
source: writableMeasurementSourceEnum.optional().default("MANUAL"),
|
||
// Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK
|
||
// constraint in Postgres (see migration 0021).
|
||
glucoseContext: glucoseContextEnum.optional(),
|
||
// v1.4.25 W10 reconcile (code-review M4): the single-entry POST
|
||
// dropped `deviceType` silently because the column existed on
|
||
// `Measurement` and was accepted by the batch route, but never
|
||
// declared in this schema. iOS clients that POST one row at a
|
||
// time (and dashboards that backfill manual rows with device
|
||
// metadata) now persist the tag instead of seeing it disappear.
|
||
// Accepts `null` so a client can explicitly clear the column.
|
||
deviceType: z.string().min(1).max(32).nullable().optional(),
|
||
})
|
||
.refine((data) => validateMeasurementRange(data.type, data.value) === null, {
|
||
message: "Value out of plausible range",
|
||
})
|
||
.refine(
|
||
(data) =>
|
||
(data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||
|
||
(data.type !== "BLOOD_GLUCOSE" && data.glucoseContext === undefined),
|
||
{
|
||
message:
|
||
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
|
||
path: ["glucoseContext"],
|
||
},
|
||
);
|
||
|
||
export const updateMeasurementSchema = z.object({
|
||
value: z.number().min(0).max(500000).optional(),
|
||
// v1.17 W1b — same plausibility bound on the edit path; an edit cannot
|
||
// forward-date a reading into the future nor before 1900.
|
||
measuredAt: validateEntryInstant(
|
||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||
).optional(),
|
||
notes: z
|
||
.string()
|
||
.max(
|
||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||
`Note cannot exceed ${MEASUREMENT_NOTES_MAX_LENGTH} characters`,
|
||
)
|
||
.nullable()
|
||
.optional(),
|
||
});
|
||
|
||
export const listMeasurementsSchema = z
|
||
.object({
|
||
type: measurementTypeEnum.optional(),
|
||
from: z.iso
|
||
.datetime({ offset: true })
|
||
.transform((s) => new Date(s))
|
||
.optional(),
|
||
to: z.iso
|
||
.datetime({ offset: true })
|
||
.transform((s) => new Date(s))
|
||
.optional(),
|
||
// v1.4.28 FB-D2 — when the chart sends an explicit from/to window the
|
||
// payload is already bounded; lift the per-request ceiling to 5000
|
||
// (still cheaper than the legacy unbounded `while (true)` walk).
|
||
// Callers that omit from/to keep the historical 500-row cap.
|
||
limit: z.coerce.number().int().min(1).max(5000).optional().default(100),
|
||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||
sortBy: z
|
||
.enum(["type", "value", "measuredAt", "source"])
|
||
.optional()
|
||
.default("measuredAt"),
|
||
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
|
||
// v1.4.28 FB-D2 — bucket hint for range-aware queries. When set the
|
||
// GET handler runs a server-side `date_trunc` aggregation and returns
|
||
// one row per bucket per type rather than the raw measurement rows.
|
||
// Omitting `aggregate` keeps the raw wire shape (iOS contract); the
|
||
// chart-data client must opt in explicitly.
|
||
aggregate: z.enum(["raw", "daily", "weekly", "monthly"]).optional(),
|
||
// v1.4.36 W1 — opt-in source switch for daily-aggregate reads. When
|
||
// `source=rollup` + `aggregate=daily`, the route reads from the
|
||
// persistent `measurement_rollups` DAY buckets instead of running a
|
||
// live `date_trunc` GROUP BY scan over the raw `measurements` table.
|
||
// The chart-data client opts in for the trends-row strip + every
|
||
// sub-page chart so the three parallel daily-aggregate requests stop
|
||
// burning a full table scan each. The route falls back to live SQL
|
||
// when the rollup bucket set is empty for the requested window so
|
||
// brand-new accounts still see correct data on their first chart.
|
||
source: z.enum(["rollup"]).optional(),
|
||
// v1.15.13 — management-list source FILTER. The `source` key above is
|
||
// the rollup-tier opt-in (`["rollup"]`), NOT a source filter, so the
|
||
// filter rides a distinct param. Validated against `MeasurementSource`;
|
||
// threaded into the plain-list `where`. Backed by the `(userId, source,
|
||
// measuredAt)` index (migration 0136).
|
||
sourceEq: measurementSourceEnum.optional(),
|
||
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
|
||
// types (steps, active energy, distance, flights, daylight). When
|
||
// `groupBy=day` is set and `type` is a cumulative HK type, the route
|
||
// returns one synthesised row per user-TZ day with `value` = SUM
|
||
// and `sampleCount` = number of per-sample rows behind the bucket.
|
||
// Omitted = legacy per-sample list behaviour (iOS contract stable).
|
||
//
|
||
// v1.4.37 W10 — the route's groupBy=day branch hard-codes `offset:0`
|
||
// in the meta because the collapse runs after the per-sample scan;
|
||
// real pagination would require Postgres-side `date_trunc` grouping
|
||
// and a separate `prisma.count({ distinct: ["dayKey"] })`. Until
|
||
// that lands, reject any caller threading a non-zero offset so the
|
||
// pagination contract isn't silently dropped.
|
||
groupBy: z.enum(["day"]).optional(),
|
||
// v1.4.37 W7c — drill-down to per-sample rows for a single day in
|
||
// the user's IANA timezone. Format `YYYY-MM-DD`; the route resolves
|
||
// the day boundary against the user's `User.timezone`. Used by the
|
||
// expandable list row to reveal the chunks that contributed to the
|
||
// collapsed daily total.
|
||
//
|
||
// v1.4.37 W10 — same offset restriction as `groupBy=day`; the
|
||
// drill-down branch returns a single bounded page rather than a
|
||
// cursor.
|
||
dayKey: z
|
||
.string()
|
||
.regex(/^\d{4}-\d{2}-\d{2}$/, "dayKey must be YYYY-MM-DD")
|
||
// v1.4.37 W10 — a `YYYY-MM-DD` string can satisfy the regex while
|
||
// still being an impossible calendar date (`2026-02-30`,
|
||
// `2026-13-01`). `new Date("2026-02-30T00:00:00Z")` silently
|
||
// overflows to March 2, so the drill-down would return rows from
|
||
// a different day than the user asked for. The same helper feeds
|
||
// the admin drain route, so a malformed CLI invocation has the
|
||
// same blast radius. Reject the impossible shapes at the
|
||
// validator instead.
|
||
.refine(
|
||
(s) => {
|
||
const parsed = new Date(`${s}T00:00:00Z`);
|
||
if (Number.isNaN(parsed.getTime())) return false;
|
||
return s === parsed.toISOString().slice(0, 10);
|
||
},
|
||
"dayKey must be a real calendar date (YYYY-MM-DD)",
|
||
)
|
||
.optional(),
|
||
})
|
||
.refine(
|
||
({ offset, groupBy, dayKey }) =>
|
||
!(offset > 0 && (groupBy === "day" || dayKey != null)),
|
||
{
|
||
message:
|
||
"offset is not supported with groupBy=day or dayKey; use a smaller window or omit offset",
|
||
path: ["offset"],
|
||
},
|
||
)
|
||
// v1.4.38 — surface the drill-down cap on the validator so a caller
|
||
// asking for more than 1000 rows on a per-day branch sees a 422
|
||
// instead of a silent `Math.min(limit, 1000)` clamp inside the
|
||
// route. The drill-down returns at most one phone-only stepCount
|
||
// day's worth of per-sample chunks; 1000 covers the pathological
|
||
// case with room to spare. Callers that want a wider window should
|
||
// omit `dayKey` and use `from` / `to` instead.
|
||
.refine(({ limit, dayKey }) => !(dayKey != null && limit > 1000), {
|
||
message:
|
||
"limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows",
|
||
path: ["limit"],
|
||
});
|
||
|
||
export const createBatchMeasurementSchema = z.object({
|
||
measurements: z.array(createMeasurementSchema).min(1).max(5),
|
||
});
|
||
|
||
export type CreateMeasurementInput = z.infer<typeof createMeasurementSchema>;
|
||
export type CreateBatchMeasurementInput = z.infer<
|
||
typeof createBatchMeasurementSchema
|
||
>;
|
||
export type UpdateMeasurementInput = z.infer<typeof updateMeasurementSchema>;
|
||
export type ListMeasurementsInput = z.infer<typeof listMeasurementsSchema>;
|