Files
HealthLog/src/lib/whoop/sport-map.ts
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

251 lines
13 KiB
TypeScript

/**
* WHOOP `sport_id` / `sport_name` → HealthLog canonical `WorkoutSportType`.
*
* WHOOP is the only workout integration that shipped WITHOUT this mapping —
* Fitbit (`FITBIT_EXERCISE_TYPE_MAP` in `src/lib/fitbit/client.ts`) and
* Google Health (`GOOGLE_HEALTH_EXERCISE_TYPE_MAP` in
* `src/lib/google-health/mappers.ts`) both normalise at ingest time. Before
* this module, `sync-workout.ts` wrote WHOOP's raw `sport_name` or a literal
* `whoop_sport_<id>` placeholder straight into `Workout.sportType`, so WHOOP
* rows never matched a canonical bucket and the `/insights/workouts` list
* couldn't icon/label them (a user's WHOOP-tracked rides rendered generic,
* not "cycling").
*
* `WHOOP_SPORT_TABLE` is the single source of truth — both
* `WHOOP_SPORT_ID_MAP` and `WHOOP_SPORT_NAME_MAP` derive from it, and the
* one-shot data migration `prisma/migrations/0247_backfill_whoop_workout_sport`
* was generated FROM this same table (see the migration file header) so the
* historical backfill and the live mapper never drift apart.
*
* Source: WHOOP's public "Workout" object reference
* (https://developer.whoop.com/docs/developing/user-data/workout/),
* captured 2026-07-17. That page documents:
* - `sport_id` (int) — "ID of the WHOOP Sport performed during the
* workout. Will not exist past 09/01/2025." A ~120-row id → display-name
* table ships alongside it (Running, Cycling, Hiking/Rucking, ...).
* - `sport_name` (string) — "Name of the WHOOP Sport performed during the
* workout", the field that replaces `sport_id` going forward. The docs
* show exactly one example value (`"running"`, lowercase) and don't pin
* the exact separator convention WHOOP uses for multi-word sports (e.g.
* "Hiking/Rucking"), so `sport_name` lookups run through
* `normaliseSportKey()` — lowercase, non-alphanumeric runs collapsed to
* a single `_` — before the table lookup, making the match resilient to
* "Hiking/Rucking" / "hiking/rucking" / "hiking_rucking" all resolving
* to the same row.
*
* `mapWhoopSportType()` prefers `sport_id` when present — an exact integer
* key needs no normalisation guess — and falls back to the normalised
* `sport_name` (the only field WHOOP still sends post-2025-09-01). Anything
* neither table recognises (a WHOOP-added sport, a malformed value) resolves
* to `"other"`; the function NEVER returns a `whoop_sport_<n>` placeholder.
*
* Bucket choices for sports with no clean 1:1 canonical match are a judgment
* call, applied consistently:
* - combat / interval-format classes (Boxing, Martial Arts, Jiu Jitsu,
* Kickboxing, Jumping Rope) → `"hiit"` (typical round-based structure).
* - functional / mixed-modal training (Functional Fitness, Gymnastics,
* Rock Climbing, Obstacle Course Racing, Parkour, F45, Barry's) →
* `"crossTraining"`.
* - paddle sports (Kayaking, Paddleboarding) → `"rowing"` (closest
* upper-body cardio bucket).
* - racket / paddle-court sports (Squash, Pickleball, Table Tennis,
* Badminton, Paddle Tennis, Padel) → `"tennis"`.
* - multi-discipline endurance events (Duathlon, Triathlon) →
* `"mixedCardio"`.
* - team / niche sports with no matching bucket (Baseball, Cricket, Ice
* Hockey, Sailing, Skiing, ...) → `"other"`, same as an unmapped Fitbit
* or Google Health activity — the row still persists, just outside a
* canonical sport bucket.
*/
import type { WorkoutSportType } from "@/lib/validations/workout";
interface WhoopSportTableRow {
/** WHOOP's documented `sport_id`. Retired past 2025-09-01 but historical
* rows (and the 0247 backfill) still need it. */
id: number;
/** WHOOP's documented display name — normalised before lookup. */
name: string;
canonical: WorkoutSportType;
}
/**
* WHOOP `sport_id` → display name → canonical bucket. Ordered by `id`
* ascending, matching the developer-docs table. Keep this list — and ONLY
* this list — in sync when WHOOP adds a sport; both maps below and the 0247
* migration's generator script derive from it.
*/
export const WHOOP_SPORT_TABLE: readonly WhoopSportTableRow[] = [
{ id: -1, name: "Activity", canonical: "other" },
{ id: 0, name: "Running", canonical: "running" },
{ id: 1, name: "Cycling", canonical: "cycling" },
{ id: 16, name: "Baseball", canonical: "other" },
{ id: 17, name: "Basketball", canonical: "basketball" },
{ id: 18, name: "Rowing", canonical: "rowing" },
{ id: 19, name: "Fencing", canonical: "other" },
{ id: 20, name: "Field Hockey", canonical: "other" },
{ id: 21, name: "Football", canonical: "other" },
{ id: 22, name: "Golf", canonical: "golf" },
{ id: 24, name: "Ice Hockey", canonical: "other" },
{ id: 25, name: "Lacrosse", canonical: "other" },
{ id: 27, name: "Rugby", canonical: "other" },
{ id: 28, name: "Sailing", canonical: "other" },
{ id: 29, name: "Skiing", canonical: "other" },
{ id: 30, name: "Soccer", canonical: "soccer" },
{ id: 31, name: "Softball", canonical: "other" },
{ id: 32, name: "Squash", canonical: "tennis" },
{ id: 33, name: "Swimming", canonical: "swimming" },
{ id: 34, name: "Tennis", canonical: "tennis" },
{ id: 35, name: "Track & Field", canonical: "running" },
{ id: 36, name: "Volleyball", canonical: "other" },
{ id: 37, name: "Water Polo", canonical: "swimming" },
{ id: 38, name: "Wrestling", canonical: "strength" },
{ id: 39, name: "Boxing", canonical: "hiit" },
{ id: 42, name: "Dance", canonical: "dance" },
{ id: 43, name: "Pilates", canonical: "mindAndBody" },
{ id: 44, name: "Yoga", canonical: "yoga" },
{ id: 45, name: "Weightlifting", canonical: "strength" },
{ id: 47, name: "Cross Country Skiing", canonical: "other" },
{ id: 48, name: "Functional Fitness", canonical: "crossTraining" },
{ id: 49, name: "Duathlon", canonical: "mixedCardio" },
{ id: 51, name: "Gymnastics", canonical: "crossTraining" },
{ id: 52, name: "Hiking/Rucking", canonical: "hiking" },
{ id: 53, name: "Horseback Riding", canonical: "other" },
{ id: 55, name: "Kayaking", canonical: "rowing" },
{ id: 56, name: "Martial Arts", canonical: "hiit" },
{ id: 57, name: "Mountain Biking", canonical: "cycling" },
{ id: 59, name: "Powerlifting", canonical: "strength" },
{ id: 60, name: "Rock Climbing", canonical: "crossTraining" },
{ id: 61, name: "Paddleboarding", canonical: "rowing" },
{ id: 62, name: "Triathlon", canonical: "mixedCardio" },
{ id: 63, name: "Walking", canonical: "walking" },
{ id: 64, name: "Surfing", canonical: "other" },
{ id: 65, name: "Elliptical", canonical: "elliptical" },
{ id: 66, name: "Stairmaster", canonical: "stairClimber" },
{ id: 70, name: "Meditation", canonical: "mindAndBody" },
{ id: 71, name: "Other", canonical: "other" },
{ id: 73, name: "Diving", canonical: "other" },
{ id: 74, name: "Operations - Tactical", canonical: "other" },
{ id: 75, name: "Operations - Medical", canonical: "other" },
{ id: 76, name: "Operations - Flying", canonical: "other" },
{ id: 77, name: "Operations - Water", canonical: "other" },
{ id: 82, name: "Ultimate", canonical: "other" },
{ id: 83, name: "Climber", canonical: "stairClimber" },
{ id: 84, name: "Jumping Rope", canonical: "hiit" },
{ id: 85, name: "Australian Football", canonical: "other" },
{ id: 86, name: "Skateboarding", canonical: "other" },
{ id: 87, name: "Coaching", canonical: "other" },
{ id: 88, name: "Ice Bath", canonical: "other" },
{ id: 89, name: "Commuting", canonical: "other" },
{ id: 90, name: "Gaming", canonical: "other" },
{ id: 91, name: "Snowboarding", canonical: "other" },
{ id: 92, name: "Motocross", canonical: "other" },
{ id: 93, name: "Caddying", canonical: "other" },
{ id: 94, name: "Obstacle Course Racing", canonical: "crossTraining" },
{ id: 95, name: "Motor Racing", canonical: "other" },
{ id: 96, name: "HIIT", canonical: "hiit" },
{ id: 97, name: "Spin", canonical: "cycling" },
{ id: 98, name: "Jiu Jitsu", canonical: "hiit" },
{ id: 99, name: "Manual Labor", canonical: "other" },
{ id: 100, name: "Cricket", canonical: "other" },
{ id: 101, name: "Pickleball", canonical: "tennis" },
{ id: 102, name: "Inline Skating", canonical: "other" },
{ id: 103, name: "Box Fitness", canonical: "hiit" },
{ id: 104, name: "Spikeball", canonical: "other" },
{ id: 105, name: "Wheelchair Pushing", canonical: "other" },
{ id: 106, name: "Paddle Tennis", canonical: "tennis" },
{ id: 107, name: "Barre", canonical: "mindAndBody" },
{ id: 108, name: "Stage Performance", canonical: "other" },
{ id: 109, name: "High Stress Work", canonical: "other" },
{ id: 110, name: "Parkour", canonical: "crossTraining" },
{ id: 111, name: "Gaelic Football", canonical: "other" },
{ id: 112, name: "Hurling/Camogie", canonical: "other" },
{ id: 113, name: "Circus Arts", canonical: "other" },
{ id: 121, name: "Massage Therapy", canonical: "other" },
{ id: 123, name: "Strength Trainer", canonical: "strength" },
{ id: 125, name: "Watching Sports", canonical: "other" },
{ id: 126, name: "Assault Bike", canonical: "cycling" },
{ id: 127, name: "Kickboxing", canonical: "hiit" },
{ id: 128, name: "Stretching", canonical: "mindAndBody" },
{ id: 230, name: "Table Tennis", canonical: "tennis" },
{ id: 231, name: "Badminton", canonical: "tennis" },
{ id: 232, name: "Netball", canonical: "basketball" },
{ id: 233, name: "Sauna", canonical: "other" },
{ id: 234, name: "Disc Golf", canonical: "golf" },
{ id: 235, name: "Yard Work", canonical: "other" },
{ id: 236, name: "Air Compression", canonical: "other" },
{ id: 237, name: "Percussive Massage", canonical: "other" },
{ id: 238, name: "Paintball", canonical: "other" },
{ id: 239, name: "Ice Skating", canonical: "other" },
{ id: 240, name: "Handball", canonical: "other" },
{ id: 248, name: "F45 Training", canonical: "crossTraining" },
{ id: 249, name: "Padel", canonical: "tennis" },
{ id: 250, name: "Barry's", canonical: "crossTraining" },
{ id: 251, name: "Dedicated Parenting", canonical: "other" },
{ id: 252, name: "Stroller Walking", canonical: "walking" },
{ id: 253, name: "Stroller Jogging", canonical: "running" },
{ id: 254, name: "Toddlerwearing", canonical: "other" },
{ id: 255, name: "Babywearing", canonical: "other" },
{ id: 258, name: "Barre3", canonical: "mindAndBody" },
{ id: 259, name: "Hot Yoga", canonical: "yoga" },
{ id: 261, name: "Stadium Steps", canonical: "stairClimber" },
{ id: 262, name: "Polo", canonical: "other" },
{ id: 263, name: "Musical Performance", canonical: "other" },
{ id: 264, name: "Kite Boarding", canonical: "other" },
{ id: 266, name: "Dog Walking", canonical: "walking" },
{ id: 267, name: "Water Skiing", canonical: "other" },
{ id: 268, name: "Wakeboarding", canonical: "other" },
{ id: 269, name: "Cooking", canonical: "other" },
{ id: 270, name: "Cleaning", canonical: "other" },
{ id: 272, name: "Public Speaking", canonical: "other" },
] as const;
/**
* Lowercase, collapse any run of characters that aren't `[a-z0-9_]` into a
* single `_`, then trim leading/trailing `_`. Applied to BOTH the
* documented display name (at table-build time) and the incoming
* `sport_name` (at lookup time) so "Hiking/Rucking", "hiking/rucking", and
* "hiking_rucking" all resolve to the same key. Existing underscores pass
* through untouched — load-bearing for the `whoop_sport_<id>` legacy
* placeholder values the 0247 backfill also has to recognise.
*/
export function normaliseSportKey(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "");
}
export const WHOOP_SPORT_ID_MAP: ReadonlyMap<number, WorkoutSportType> =
new Map(WHOOP_SPORT_TABLE.map((row) => [row.id, row.canonical]));
export const WHOOP_SPORT_NAME_MAP: ReadonlyMap<string, WorkoutSportType> =
new Map(
WHOOP_SPORT_TABLE.map((row) => [
normaliseSportKey(row.name),
row.canonical,
]),
);
/**
* Resolve a WHOOP workout's sport to a canonical `WorkoutSportType`. Prefers
* `sport_id` (exact integer key, no normalisation guesswork) when present,
* then falls back to the normalised `sport_name` — the only field WHOOP
* still sends since `sport_id` was retired 2025-09-01. Defaults to
* `"other"`; never returns a `whoop_sport_<n>` placeholder.
*/
export function mapWhoopSportType(
sportId?: number,
sportName?: string,
): WorkoutSportType {
if (typeof sportId === "number") {
const byId = WHOOP_SPORT_ID_MAP.get(sportId);
if (byId) return byId;
}
if (typeof sportName === "string" && sportName.trim() !== "") {
const byName = WHOOP_SPORT_NAME_MAP.get(normaliseSportKey(sportName));
if (byName) return byName;
}
return "other";
}