Files
HealthLog/src/lib/personal-records/pr-direction.ts
T
Marc-André Bombeck 03d4496d3a feat(measurements): surface the WHO5_SCORE and SCI_SCORE trends
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
2026-07-05 20:33:44 +02:00

227 lines
10 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":
// v1.25 — grip strength is a clean strength-achievement axis (higher is the
// record), the strength limb of the EWGSOP2 sarcopenia algorithm.
case "GRIP_STRENGTH":
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":
// v1.19.0 — Oura resilience. An ordinal-encoded categorical band
// (limited=1 … exceptional=5) recomputed daily from recovery / stress
// inputs — a derived composite with no clean goal axis, so it stays null
// like the other device-derived scores.
case "RESILIENCE":
// v1.25 — clinical-signals wave. The mental-health screener totals are
// deliberately NOT gamified (no streaks / "best score" framing around a
// depression / anxiety screen — research safety rule). Pain NRS is a
// symptom, not an achievement axis. Waist circumference + waist-to-height
// are goal-dependent like WEIGHT (a lower waist is a goal for some, not
// all). All defer to a null PR direction.
case "PHQ9_SCORE":
case "GAD7_SCORE":
// v1.27.9 — WHO-5 / SCI screening totals follow the same no-gamification
// safety rule (no streaks / "best score" framing around a screener).
case "WHO5_SCORE":
case "SCI_SCORE":
case "PAIN_NRS":
case "WAIST_CIRCUMFERENCE":
case "WAIST_TO_HEIGHT":
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;
}