Files
HealthLog/src/lib/personal-records/pr-direction.ts
T
Marc-André Bombeck 2736e23092 merge(oura): real sleep timeline, SpO2, sleep score, temperature deviation, contributors + reconcile
# 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
2026-06-15 00:17:51 +02:00

204 lines
9.3 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.
/**
* MeasurementType → PersonalRecordDirection resolver.
*
* Stored vocabulary is `MAX` (higher is the record) or `MIN` (lower is
* the record). A `null` return value means "this metric does not have
* a PersonalRecord" — the future detection worker short-circuits on
* those types so it never writes a row that has ambiguous semantics.
*
* Buckets (full rationale in W8d implementation outline §5):
*
* MAX direction:
* * Activity & daylight — more is the achievement.
* * VO2 max, HRV, fat-free / muscle mass, total body water,
* bone mass — biomarkers where a higher value is the goal.
*
* MIN direction:
* * Resting heart rate — lower = better cardiovascular fitness.
* * Body fat, fat mass, visceral fat — composition metrics
* where lower is the goal.
* * Vascular age, pulse-wave velocity — lower = healthier
* arteries.
* * Audio exposure — lower = less hearing damage. The "record"
* is the quietest day (kept here for symmetry; the detection
* worker can still flag a too-loud day separately).
*
* null (no PR):
* * Blood pressure (high reading isn't an achievement; low can
* also be clinically bad), blood glucose (extremes both ways),
* body & skin temperature (homeostatic, not a goal), pulse
* (spot-reading), oxygen saturation (homeostatic), weight
* (direction is user-goal-dependent — to defer until the
* v1.4.26 worker can read User.thresholdsJson), sleep duration
* (longer is not strictly better), fat-free mass when treated
* as a derived (weight − fat) value with the same ambiguity.
*
* Adding a new MeasurementType: extend the switch below. The
* drift-guard test in `__tests__/pr-direction.test.ts` asserts every
* canonical enum value lands in exactly one branch.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { PersonalRecordDirection } from "@/generated/prisma/client";
export function getPRDirection(
metricType: MeasurementType,
): PersonalRecordDirection | null {
switch (metricType) {
// MAX direction — higher value is the record.
case "ACTIVITY_STEPS":
case "ACTIVE_ENERGY_BURNED":
case "FLIGHTS_CLIMBED":
case "WALKING_RUNNING_DISTANCE":
case "VO2_MAX":
case "HEART_RATE_VARIABILITY":
case "TOTAL_BODY_WATER":
case "BONE_MASS":
case "MUSCLE_MASS":
case "TIME_IN_DAYLIGHT":
// v1.4.30 — walking steadiness on a 0-100 scale; Apple's own
// Mobility section treats higher as the achievement (the
// recovery direction from a low-steadiness window).
case "WALKING_STEADINESS":
// v1.5.5 — lean body mass is the muscle-mass-adjacent body-comp
// axis where higher is the goal.
case "LEAN_BODY_MASS":
// v1.5.5 follow-up — walking speed is a well-established
// clinical fitness marker (often called the "sixth vital
// sign" in older-adult medicine); faster gait correlates
// with cardiovascular fitness, sarcopenia recovery, and
// overall mobility resilience. MAX direction matches Apple's
// own Mobility-section framing.
case "WALKING_SPEED":
// v1.10.0 — cardio recovery (a larger one-minute HR drop is the
// fitter result), six-minute-walk distance (the classic endurance
// achievement), and the stair gait speeds (faster climb/descent =
// more leg strength + mobility) all read MAX.
case "CARDIO_RECOVERY":
case "SIX_MINUTE_WALK_DISTANCE":
case "STAIR_ASCENT_SPEED":
case "STAIR_DESCENT_SPEED":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
case "RESTING_HEART_RATE":
case "BODY_FAT":
case "FAT_MASS":
case "VISCERAL_FAT":
case "VASCULAR_AGE":
case "PULSE_WAVE_VELOCITY":
case "AUDIO_EXPOSURE_ENV":
case "AUDIO_EXPOSURE_HEADPHONE":
// v1.5.5 — gait asymmetry + double-support percentages: Apple's
// Mobility section frames lower as the achievement (more
// symmetric gait + shorter double-support fraction = healthier).
case "WALKING_ASYMMETRY":
case "WALKING_DOUBLE_SUPPORT":
// v1.10.0 — fall count and the sleep-breathing-disturbance index
// both read MIN: the "record" is the day/night with the fewest
// events, and fewer is unambiguously the goal.
case "FALL_COUNT":
case "BREATHING_DISTURBANCES":
// v1.12.8 — the per-night sleep disturbance count reads MIN: the
// "record" is the night with the fewest disturbances, and fewer is
// unambiguously the goal (mirrors BREATHING_DISTURBANCES).
case "SLEEP_DISTURBANCE_COUNT":
return PersonalRecordDirection.MIN;
// Explicitly no PersonalRecord — see comment block above.
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
case "BLOOD_GLUCOSE":
case "BODY_TEMPERATURE":
case "SKIN_TEMPERATURE":
case "PULSE":
case "OXYGEN_SATURATION":
case "WEIGHT":
case "SLEEP_DURATION":
case "FAT_FREE_MASS":
// v1.4.30 — audio-exposure events fire on a threshold-cross, not
// on a goal axis. A "record" for "fewest loud-listening days in
// a row" is interesting but better surfaced as a streak than a
// PR direction. Defer.
case "AUDIO_EXPOSURE_EVENT":
// v1.5.5 — homeostatic / display-only metrics. Respiratory rate
// is goal-neutral (homeostatic), walking HR average is fitness
// dependent without a clean direction (a higher value can be
// either ill or harder-effort), and BMI direction is user-goal
// dependent — defer until the worker can read User.thresholdsJson.
case "RESPIRATORY_RATE":
case "WALKING_HEART_RATE_AVERAGE":
case "BODY_MASS_INDEX":
// v1.5.5 follow-up — walking step length is a state metric:
// taller users have longer strides regardless of fitness, and
// there is no clean "higher is better" axis (very long strides
// can also signal an unsafe overstride). Defer to a null PR
// direction; the future worker can still surface trend deltas
// without claiming a record direction.
case "WALKING_STEP_LENGTH":
// v1.10.0 — wrist temperature is homeostatic and Apple frames it as
// a baseline deviation, not a goal axis; neither a higher nor a
// lower reading is an achievement. Defer to a null PR direction.
case "WRIST_TEMPERATURE":
// v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
// discrete occurrences (value is always 1), never a goal axis. A
// "personal record" for a health-notification event would be both
// meaningless and alarming, so they explicitly have no PR direction.
case "IRREGULAR_RHYTHM_NOTIFICATION":
case "HIGH_HEART_RATE_EVENT":
case "LOW_HEART_RATE_EVENT":
case "WALKING_STEADINESS_EVENT":
case "BREATHING_DISTURBANCE_EVENT":
// v1.10.0 — computed scores (WX-C). The server-derived wellness scores
// are themselves derived composites recomputed nightly from the
// underlying signals; a "personal record" on a derived score would
// double-count the PRs of its own inputs and read as noise. They
// explicitly have no PR direction, like the categorical events above.
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
// v1.11.0 — WHOOP-native score classes. These are device-derived daily
// composites (recovery/strain/sleep-quality indices) and bounded /
// homeostatic metrics, not a goal axis: a higher day-strain is neither
// good nor bad, sleep-need is a recommendation rather than an achievement,
// and RMSSD HRV is one nightly composite — they stay null like the other
// derived scores rather than minting a duplicate HRV "record".
case "HRV_RMSSD":
case "DAY_STRAIN":
case "WORKOUT_STRAIN":
case "SLEEP_PERFORMANCE":
case "SLEEP_EFFICIENCY":
case "SLEEP_CONSISTENCY":
case "SLEEP_NEED":
case "ENERGY_EXPENDITURE_KJ":
// v1.12.8 — the daily average / max heart rate are whole-cycle
// aggregates without a clean goal axis: a higher value can mean either
// a harder-effort day or an ailing one, exactly like the
// WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction.
case "AVERAGE_HEART_RATE":
case "MAX_HEART_RATE":
// v1.17.1 — Polar-native recovery / strain components. ANS_CHARGE is a
// baseline-relative autonomic deviation (homeostatic, not a goal axis) and
// CARDIO_LOAD is a device-derived cardiovascular-load composite — neither a
// higher nor a lower value is an achievement, so they stay null like the
// other device-derived scores (HRV_RMSSD / DAY_STRAIN).
case "ANS_CHARGE":
case "CARDIO_LOAD":
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
// (like the other sleep-quality indices), and the body-temperature
// deviation is a homeostatic signed offset centred on 0 — neither has a
// goal axis, so both stay null like the rest of the derived/homeostatic set.
case "SLEEP_SCORE":
case "BODY_TEMPERATURE_DEVIATION":
return null;
}
}
/**
* Convenience predicate for the future detection worker:
* `if (!isPRTrackable(type)) return;` keeps the worker from writing
* rows that wouldn't have a defined direction anyway.
*/
export function isPRTrackable(metricType: MeasurementType): boolean {
return getPRDirection(metricType) !== null;
}