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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 14:28:56 +02:00
1 parent 63cdd709af
commit 43884569d3
6 files changed
+642 -13

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
WHOOP_SPORT_ID_MAP,
WHOOP_SPORT_NAME_MAP,
WHOOP_SPORT_TABLE,
mapWhoopSportType,
normaliseSportKey,
} from "../sport-map";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
/**
* `mapWhoopSportType()` fixes the confirmed prod bug: a WHOOP cycling
* workout wrote a non-canonical `sportType` (`whoop_sport_<id>` or the raw
* `sport_name`) and never rendered as "cycling" on `/insights/workouts`.
*/
describe("WHOOP_SPORT_TABLE", () => {
it("maps every documented entry to a value in workoutSportTypeEnum", () => {
const valid = new Set(workoutSportTypeEnum.options);
for (const entry of WHOOP_SPORT_TABLE) {
expect(valid.has(entry.canonical)).toBe(true);
}
});
it("has no duplicate sport_id", () => {
const ids = WHOOP_SPORT_TABLE.map((r) => r.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("normaliseSportKey", () => {
it("lowercases and collapses whitespace / punctuation to a single underscore", () => {
expect(normaliseSportKey("Running")).toBe("running");
expect(normaliseSportKey("Track & Field")).toBe("track_field");
expect(normaliseSportKey("Hiking/Rucking")).toBe("hiking_rucking");
expect(normaliseSportKey("hiking_rucking")).toBe("hiking_rucking");
expect(normaliseSportKey(" Hot Yoga ")).toBe("hot_yoga");
});
it("leaves an existing whoop_sport_<id> placeholder untouched (case aside)", () => {
expect(normaliseSportKey("whoop_sport_1")).toBe("whoop_sport_1");
});
});
describe("mapWhoopSportType — the confirmed cycling bug", () => {
it("resolves WHOOP's cycling sport_id (1) to the canonical bucket", () => {
expect(mapWhoopSportType(1)).toBe("cycling");
});
it("resolves WHOOP's cycling sport_name to the canonical bucket", () => {
expect(mapWhoopSportType(undefined, "Cycling")).toBe("cycling");
expect(mapWhoopSportType(undefined, "cycling")).toBe("cycling");
});
it("resolves Mountain Biking and Spin sport_ids to cycling too", () => {
expect(mapWhoopSportType(57)).toBe("cycling");
expect(mapWhoopSportType(97)).toBe("cycling");
});
});
describe("mapWhoopSportType — id preferred over name", () => {
it("prefers sport_id when both fields are present and disagree", () => {
// Contrived: id says running, name says something unmapped — id wins
// because it needs no normalisation guesswork.
expect(
mapWhoopSportType(0, "some future sport WHOOP hasn't announced"),
).toBe("running");
});
it("falls back to sport_name when sport_id is absent (the post-2025-09-01 shape)", () => {
expect(mapWhoopSportType(undefined, "Hiking/Rucking")).toBe("hiking");
});
});
describe("mapWhoopSportType — unmapped input never falls through to a placeholder", () => {
it("defaults to other for an unknown sport_id", () => {
expect(mapWhoopSportType(999_999)).toBe("other");
});
it("defaults to other for an unknown sport_name", () => {
expect(mapWhoopSportType(undefined, "quantum tunnelling")).toBe("other");
});
it("defaults to other when neither field is present", () => {
expect(mapWhoopSportType(undefined, undefined)).toBe("other");
});
it("never returns a whoop_sport_<n> string for any input", () => {
for (const id of [-1, 0, 1, 999_999]) {
expect(mapWhoopSportType(id)).not.toMatch(/^whoop_sport_/);
}
expect(mapWhoopSportType(undefined, "totally unknown")).not.toMatch(
/^whoop_sport_/,
);
});
});
describe("WHOOP_SPORT_ID_MAP / WHOOP_SPORT_NAME_MAP", () => {
it("expose the same size as the source table (id map)", () => {
expect(WHOOP_SPORT_ID_MAP.size).toBe(WHOOP_SPORT_TABLE.length);
});
it("expose the same size as the source table (name map — every name is unique after normalisation)", () => {
expect(WHOOP_SPORT_NAME_MAP.size).toBe(WHOOP_SPORT_TABLE.length);
});
});
@@ -0,0 +1,121 @@
/**
* `upsertWhoopWorkout` — canonical sport mapping + metadata traceability.
*
* Regression coverage for the confirmed prod bug: a WHOOP cycling workout
* wrote `Workout.sportType = "whoop_sport_1"` (or the raw `sport_name`)
* instead of the canonical `"cycling"`, so it never rendered as cycling on
* `/insights/workouts`. `upsertWhoopWorkout` must now route through
* `mapWhoopSportType()` and keep the raw WHOOP fields in `metadata` for
* traceability.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../client", () => ({
fetchWorkouts: vi.fn(),
fetchWorkoutById: vi.fn(),
KJ_TO_KCAL: 0.239006,
}));
vi.mock("../sync", async () => {
const actual = await vi.importActual<typeof import("../sync")>("../sync");
return {
...actual,
getValidToken: vi.fn(),
markResourceSynced: vi.fn(),
};
});
const workoutUpsert = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
whoopConnection: { findUnique: vi.fn() },
workout: { upsert: (...a: unknown[]) => workoutUpsert(...a) },
},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: vi.fn() }),
}));
import { upsertWhoopWorkout } from "../sync-workout";
import type { WhoopWorkout } from "../client";
function scoredWorkout(overrides: Partial<WhoopWorkout>): WhoopWorkout {
return {
id: "w-1",
user_id: 1,
created_at: "2026-06-14T07:00:00.000Z",
updated_at: "2026-06-14T08:00:00.000Z",
start: "2026-06-14T07:00:00.000Z",
end: "2026-06-14T08:00:00.000Z",
score_state: "SCORED",
score: {
strain: 12.3,
average_heart_rate: 130,
max_heart_rate: 165,
kilojoule: 1500,
percent_recorded: 99,
},
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
workoutUpsert.mockResolvedValue({});
});
describe("upsertWhoopWorkout — canonical sportType", () => {
it("writes canonical 'cycling' for sport_id 1, never whoop_sport_1", async () => {
await upsertWhoopWorkout("user-1", scoredWorkout({ sport_id: 1 }));
const row = workoutUpsert.mock.calls[0]![0].create;
expect(row.sportType).toBe("cycling");
});
it("writes canonical 'cycling' when WHOOP sends sport_name instead of sport_id", async () => {
await upsertWhoopWorkout(
"user-1",
scoredWorkout({ sport_id: undefined, sport_name: "cycling" }),
);
const row = workoutUpsert.mock.calls[0]![0].create;
expect(row.sportType).toBe("cycling");
});
it("falls back to 'other' for an unrecognised sport, never a whoop_sport_<n> placeholder", async () => {
await upsertWhoopWorkout(
"user-1",
scoredWorkout({ sport_id: 999_999, sport_name: undefined }),
);
const row = workoutUpsert.mock.calls[0]![0].create;
expect(row.sportType).toBe("other");
expect(row.sportType).not.toMatch(/^whoop_sport_/);
});
it("uses the same mapped sportType on the update branch (re-sync / re-score)", async () => {
await upsertWhoopWorkout("user-1", scoredWorkout({ sport_id: 1 }));
const update = workoutUpsert.mock.calls[0]![0].update;
expect(update.sportType).toBe("cycling");
});
});
describe("upsertWhoopWorkout — raw sport fields kept in metadata for traceability", () => {
it("stamps whoopSportId and whoopSportName onto metadata", async () => {
await upsertWhoopWorkout(
"user-1",
scoredWorkout({ sport_id: 1, sport_name: "cycling" }),
);
const row = workoutUpsert.mock.calls[0]![0].create;
expect(row.metadata.whoopSportId).toBe(1);
expect(row.metadata.whoopSportName).toBe("cycling");
});
it("omits whoopSportId / whoopSportName from metadata when WHOOP sent neither", async () => {
await upsertWhoopWorkout(
"user-1",
scoredWorkout({ sport_id: undefined, sport_name: undefined }),
);
const row = workoutUpsert.mock.calls[0]![0].create;
expect(row.metadata).not.toHaveProperty("whoopSportId");
expect(row.metadata).not.toHaveProperty("whoopSportName");
});
});
+21 -5
View File
@@ -53,15 +53,31 @@ plain integer, stored as-is.
## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement`
| Source field | Destination | Unit | Note |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------- |
| `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. |
| 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`
+250
View File
@@ -0,0 +1,250 @@
/**
* 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";
}
+14 -8
View File
@@ -12,6 +12,12 @@
* A WHOOP run and the same run via Apple Health remain two distinct `Workout`
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
* (E-slice, W6) collapses the cross-source twin at read time.
*
* `sportType` writes the canonical `WorkoutSportType` bucket via
* `mapWhoopSportType()` (`./sport-map.ts`) — mirroring the Fitbit / Google
* Health mappers — so a WHOOP row clusters and icons the same as any other
* source's. Raw `sport_id` / `sport_name` still live in `Workout.metadata`
* for traceability.
*/
import {
fetchWorkouts,
@@ -27,17 +33,11 @@ import {
resolveResourceCursor,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
} from "./sync";
import { mapWhoopSportType } from "./sport-map";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { Prisma } from "@/generated/prisma/client";
/** WHOOP reports a numeric `sport_id`; fall back to a generic label. */
function sportLabel(sportId: number | undefined, sportName?: string): string {
if (sportName) return sportName;
if (typeof sportId === "number") return `whoop_sport_${sportId}`;
return "workout";
}
export async function syncUserWorkout(
userId: string,
opts: { fullSync?: boolean } = {},
@@ -140,10 +140,16 @@ export async function upsertWhoopWorkout(
...(w.score.zone_durations
? { zoneDurations: w.score.zone_durations }
: {}),
// Raw WHOOP sport fields, kept for traceability once mapWhoopSportType()
// has resolved `Workout.sportType` to a canonical bucket — a support
// request about a misclassified sport can trace back to exactly what
// WHOOP sent without re-fetching the workout.
...(w.sport_id != null ? { whoopSportId: w.sport_id } : {}),
...(w.sport_name ? { whoopSportName: w.sport_name } : {}),
};
const row = {
sportType: sportLabel(w.sport_id, w.sport_name),
sportType: mapWhoopSportType(w.sport_id, w.sport_name),
startedAt,
endedAt,
durationSec,
@@ -0,0 +1,129 @@
/**
* Cross-integration sport-mapping parity.
*
* Every workout integration normalises its raw sport label to HealthLog's
* canonical `WorkoutSportType` at ingest time: Fitbit
* (`mapFitbitSportType()`), Google Health (`mapGoogleHealthSportType()`),
* and — as of the WHOOP-cycling fix — WHOOP (`mapWhoopSportType()`). This
* table-driven suite checks all three mappers together so a future
* integration or a re-tuned mapping can't silently drift out of parity:
* every mapper must (a) only ever return a value from
* `workoutSportTypeEnum`, (b) default unmapped/garbage input to `"other"`,
* never throw or return the raw input, and (c) agree on the handful of
* sports every wearable reports (walking, running, cycling, swimming,
* strength, yoga, hiit).
*/
import { describe, expect, it } from "vitest";
import { mapFitbitSportType } from "@/lib/fitbit/client";
import { mapGoogleHealthSportType } from "@/lib/google-health/mappers";
import { mapWhoopSportType } from "@/lib/whoop/sport-map";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
type Mapper = (raw: string) => string;
const MAPPERS: Record<string, Mapper> = {
fitbit: (raw) => mapFitbitSportType(raw),
googleHealth: (raw) => mapGoogleHealthSportType(raw),
// WHOOP's mapper takes (sportId, sportName) — exercise it via sport_name
// to line up with the other two integrations' string-in shape.
whoop: (raw) => mapWhoopSportType(undefined, raw),
};
describe("sport-map parity — every mapper only emits canonical values", () => {
const valid = new Set(workoutSportTypeEnum.options);
it.each(Object.entries(MAPPERS))(
"%s: garbage input resolves to 'other', never throws, never echoes the raw string",
(_name, mapper) => {
for (const garbage of ["", " ", "not-a-real-sport", "🚴", "NaN"]) {
expect(() => mapper(garbage)).not.toThrow();
const result = mapper(garbage);
expect(
valid.has(result as (typeof workoutSportTypeEnum.options)[number]),
).toBe(true);
}
},
);
});
/**
* Common-sport agreement table. Each row is one real-world activity every
* wearable reports; `labels` gives each mapper its OWN vocabulary for that
* activity (integrations don't share a wire format), and `expected` is the
* canonical bucket all three must land on.
*/
const COMMON_SPORTS: Array<{
expected: string;
labels: { fitbit: string; googleHealth: string; whoop: string };
}> = [
{
expected: "walking",
labels: { fitbit: "walking", googleHealth: "WALKING", whoop: "Walking" },
},
{
expected: "running",
labels: { fitbit: "running", googleHealth: "RUNNING", whoop: "Running" },
},
{
expected: "cycling",
labels: { fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling" },
},
{
expected: "swimming",
labels: { fitbit: "swimming", googleHealth: "SWIMMING", whoop: "Swimming" },
},
{
expected: "strength",
labels: {
fitbit: "weights",
googleHealth: "STRENGTH_TRAINING",
whoop: "Weightlifting",
},
},
{
expected: "yoga",
labels: { fitbit: "yoga", googleHealth: "YOGA", whoop: "Yoga" },
},
{
expected: "hiit",
labels: { fitbit: "hiit", googleHealth: "HIIT", whoop: "HIIT" },
},
{
expected: "golf",
labels: { fitbit: "golf", googleHealth: "GOLF", whoop: "Golf" },
},
{
expected: "tennis",
labels: { fitbit: "tennis", googleHealth: "TENNIS", whoop: "Tennis" },
},
{
expected: "rowing",
labels: { fitbit: "rowing", googleHealth: "ROWING", whoop: "Rowing" },
},
];
describe("sport-map parity — Fitbit / Google Health / WHOOP agree on common sports", () => {
it.each(COMMON_SPORTS)(
"$expected: all three integrations map to the same canonical bucket",
({ expected, labels }) => {
expect(mapFitbitSportType(labels.fitbit)).toBe(expected);
expect(mapGoogleHealthSportType(labels.googleHealth)).toBe(expected);
expect(mapWhoopSportType(undefined, labels.whoop)).toBe(expected);
},
);
});
describe("sport-map parity — the confirmed bug, guarded across integrations", () => {
it("a cycling workout NEVER lands on a non-canonical / provider-specific string", () => {
for (const [name, mapper] of Object.entries(MAPPERS)) {
const cyclingLabels: Record<string, string> = {
fitbit: "biking",
googleHealth: "BIKING",
whoop: "Cycling",
};
const result = mapper(cyclingLabels[name]!);
expect(result).toBe("cycling");
}
});
});