mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|