Commit Graph
5 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André BombeckandClaude Opus 4.8 dabedd2edc feat(whoop): map cycle average/max HR and sleep disturbance count
mapCycle now emits AVERAGE_HEART_RATE and MAX_HEART_RATE from the cycle
score's average_heart_rate / max_heart_rate — both were already on the typed
WhoopCycle interface but dropped on the floor. mapSleep now reads
stage_summary.disturbance_count (added to the WhoopSleep interface) and emits
SLEEP_DISTURBANCE_COUNT. All three guard nulls / unscored records and carry
distinct field tags (avg_hr / max_hr / disturbances) so they don't collide
with the existing strain / energy / stage rows under the shared resource
externalId. Update mapping.md to document the three and note the cycle HR
pair were previously fetched-but-dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:46:05 +02:00
Marc-André Bombeck 0458c9aea8 feat(whoop): ingest body measurement — weight, max-HR, profile height
The body-measurement endpoint was fetched by a function nobody called, so WHOOP
weight, max heart rate, and height never reached the database despite being
documented in the mapping table.

Add sync-body.ts and wire it into the per-user sync loop (and, through it, the
backfill):

- weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable
  externalId whoop:body:weight with overwrite semantics. A body measurement is
  a single self-reported profile value, not a time series, so a re-sync updates
  the same row in place rather than accumulating one row per poll. measuredAt is
  the fetch time; the source-priority picker ranks a real scale above WHOOP.
- max_heart_rate → WhoopConnection.maxHeartRate (a profile constant).
- height_meter → User.heightCm (m→cm), written ONLY when the user has no height
  yet — a user-set height is never overwritten and height is never minted as a
  Measurement.

Every write is field-by-field and idempotent across reruns. Document the body
mapping and the watch-only blood-pressure stance in mapping.md.
2026-06-04 15:36:06 +02:00
Marc-André Bombeck 2258cbb489 feat(whoop): WHOOP v2 API client, response classifier, credential resolution
Add the WHOOP integration data layer on copy-of-Withings rails:

- client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize
  URL / code exchange / rotating-refresh, the four v2 collection fetchers
  (recovery, sleep, cycle, workout) with next_token pagination, single
  body-measurement + profile reads, and the field->Measurement mappers
  (RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/
  SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*,
  DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to
  DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never
  share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies.
- response-classifier.ts: HTTP-status-driven success/transient/
  reauth_required/persistent verdicts + WhoopApiError, mirroring the
  Withings reason-tag classifier.
- credentials.ts: per-user BYO-key resolution, decrypting the client
  id/secret from the User columns.
- oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants.
- mapping.md: field->Measurement reference, kept in sync with the mappers.

Unit tests cover the classifier, pagination, every fetcher, the mappers
(ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and
credential resolution against mocked WHOOP JSON. No routes / OAuth flow /
sync workers / migration yet.
2026-06-04 01:05:06 +02:00