mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
63cdd709af
commit
43884569d3
6 files changed
+642
-13
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user