Files
HealthLog/src/lib/whoop/mapping.md
T
Marc-André Bombeck 43884569d3 fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.

sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.

Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
2026-07-17 14:28:56 +02:00

11 KiB
Raw Blame History

WHOOP field → Measurement mapping

Single source of truth for the WHOOP v2 field → HealthLog Measurement mapping. Keep this in sync with WHOOP_FIELD_MAP and the mapRecovery / mapSleep / mapCycle mappers in client.ts. Every WHOOP row ingests server-side with source = WHOOP and externalId = <resource-uuid>:<fieldTag> (the field-tag disambiguates the several measurements derived from one WHOOP resource). WHOOP rows tag deviceType = band.

Stance reference: .planning/v1.11-build/epic-A-whoop-buildspec.md §3.2 + §5.

Recovery (/v2/recovery) — record updated_at is measuredAt

Source field MeasurementType Unit fieldTag Note
recovery.score.recovery_score RECOVERY_SCORE score recovery Native WHOOP recovery. Same type as the COMPUTED proxy, distinguished by source = WHOOP.
recovery.score.hrv_rmssd_milli HRV_RMSSD ms hrv_rmssd RMSSD — kept distinct from the SDNN HEART_RATE_VARIABILITY.
recovery.score.resting_heart_rate RESTING_HEART_RATE bpm rhr Cross-source picker resolves WHOOP vs Apple vs Withings.
recovery.score.spo2_percentage OXYGEN_SATURATION % spo2 Optional; already 0–100.
recovery.score.skin_temp_celsius SKIN_TEMPERATURE celsius skin_temp Optional; distinct from BODY_TEMPERATURE.

A recovery record with score === null (WHOOP still scoring) maps to nothing.

Sleep (/v2/activity/sleep) — record end is measuredAt

Source field MeasurementType Unit fieldTag sleepStage
stage_summary.total_light_sleep_time_milli SLEEP_DURATION minutes sleep_core CORE
stage_summary.total_slow_wave_sleep_time_milli SLEEP_DURATION minutes sleep_deep DEEP
stage_summary.total_rem_sleep_time_milli SLEEP_DURATION minutes sleep_rem REM
stage_summary.total_awake_time_milli SLEEP_DURATION minutes sleep_awake AWAKE
stage_summary.total_in_bed_time_milli SLEEP_DURATION minutes sleep_in_bed IN_BED
sleep_needed.{baseline,debt,strain,nap}_milli (summed) SLEEP_NEED minutes sleep_need —
sleep_performance_percentage SLEEP_PERFORMANCE % sleep_perf —
sleep_efficiency_percentage SLEEP_EFFICIENCY % sleep_eff —
sleep_consistency_percentage SLEEP_CONSISTENCY % sleep_consistency —
respiratory_rate RESPIRATORY_RATE breaths/min resp_rate —
stage_summary.disturbance_count SLEEP_DISTURBANCE_COUNT count disturbances —

Stage durations are ms→minutes (÷ 60000). One row per stage per night (same pattern as Apple Health). Percentage / respiratory / disturbance-count fields are optional. disturbance_count (v1.12.8) is a per-night tally — a plain integer, stored as-is.

Cycle (/v2/cycle) — record start is measuredAt

Source field MeasurementType Unit fieldTag Note
cycle.score.strain DAY_STRAIN score day_strain 0–21 WHOOP scale. Distinct from the COMPUTED STRAIN_SCORE (0–100 TRIMP proxy).
cycle.score.kilojoule ENERGY_EXPENDITURE_KJ kJ energy_kj Native kJ — NOT converted to kcal (the workout path converts).
cycle.score.average_heart_rate AVERAGE_HEART_RATE bpm avg_hr v1.12.8 — the day's whole-cycle average HR. Fetched but dropped before v1.12.8; now mapped. Distinct from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
cycle.score.max_heart_rate MAX_HEART_RATE bpm max_hr v1.12.8 — the day's whole-cycle peak HR. Fetched but dropped before v1.12.8; now mapped.

Workout (/v2/activity/workout) — into Workout, not Measurement

Source field Destination Unit Note
workout.sport_id / workout.sport_name Workout.sportType — Canonical WorkoutSportType via mapWhoopSportType() (sport-map.ts) — see below. Raw values also kept in Workout.metadata (whoopSportId / whoopSportName).
workout.score.strain Workout.metadata (WORKOUT_STRAIN type exists for the rare detached case) score Tied to the workout row so a phantom strain row never survives workout dedup.
workout.score.kilojoule Workout.totalEnergyKcal kcal kJ→kcal (÷ 4.184).
workout.score.{zone_durations,percent_recorded,distance_meter,altitude_*} Workout.metadata — HR-zone durations + recording quality + altitude.

Workout ingest + dedup lands in W3/W6 (the E-slice); this module only owns the fetch + the score→energy conversion factor (KJ_TO_KCAL).

Sport mapping (sport-map.ts)

WHOOP's sport_id (int, documented as retired past 2025-09-01) and sport_name (string, its replacement) both resolve to a canonical WorkoutSportType via WHOOP_SPORT_TABLE in sport-map.ts — ~120 entries sourced from WHOOP's public Workout-object reference (https://developer.whoop.com/docs/developing/user-data/workout/). sport_id is preferred when present (exact key, no normalisation guess); sport_name is normalised (lowercased, non-alphanumeric runs collapsed to _) before the fallback lookup. Anything neither table recognises resolves to "other" — the mapper never emits a whoop_sport_<n> placeholder. The one-shot backfill at prisma/migrations/0247_backfill_whoop_workout_sport re-derives every pre-existing WHOOP Workout row from the same table so the historical data and the live mapper never drift apart.

Body / profile (single objects, no pagination)

Ingested by sync-body.ts (syncUserBody), wired into the syncUserWhoop loop + the backfill. The body endpoint is a single object, not a paginated collection.

Source field Destination Unit Note
body.weight_kilogram WEIGHT kg Source = WHOOP, STABLE externalId whoop:body:weight with overwrite semantics — a single self-reported profile value, not a time series, so a re-sync updates the same row rather than accumulating duplicates. measuredAt = the fetch time. Picker ranks a real scale above WHOOP.
body.max_heart_rate WhoopConnection.maxHeartRate bpm Profile constant — stored on the connection, not a Measurement.
body.height_meter User.heightCm cm Profile seed (m→cm). Written ONLY when User.heightCm is currently null — never overwrites a user-set height, never minted as a Measurement.

Blood pressure — watch-only

Blood pressure is API-invisible: the WHOOP public developer API (v2) exposes no BP field, endpoint, or scope. WHOOP's Blood Pressure Insights are WHOOP MG hardware + WHOOP Life membership + cuff-calibrated and live only in the WHOOP app. No server-side code. If WHOOP ever ships an API surface it reuses the existing BLOOD_PRESSURE_SYS / BLOOD_PRESSURE_DIA types — no new enum, no migration. Re-check developer.whoop.com/docs/api-changelog periodically.