diff --git a/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql new file mode 100644 index 000000000..0ccf55cae --- /dev/null +++ b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql @@ -0,0 +1,103 @@ +-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values. +-- +-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the +-- second workout integration that never normalised its sport to HealthLog's +-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in +-- src/lib/validations/workout.ts). `mapActivity()` in +-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or +-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the +-- literal fallback "workout" when neither field was present — straight into +-- `Workout.sport_type`. Existing Strava rows therefore carry values the +-- `/insights/workouts` list can't map to an icon/label, AND the read-time +-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any +-- two non-canonical ("generic") rows as sport-compatible — a same-source +-- brick session (a Strava "Ride" ending 15:00 immediately followed by a +-- Strava "Run" starting 15:03) could cluster into one and silently drop a +-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write +-- path going forward; this migration re-derives every EXISTING Strava +-- `Workout` row from the same table so historical data and the live mapper +-- never drift apart. +-- +-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in +-- src/lib/strava/sport-map.ts (one JS-side script run against that exact +-- table, not hand-transcribed). The match expression lower-cases the stored +-- column and strips every character outside `[a-zA-Z0-9]`, mirroring +-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun", +-- "trailrun", and any stray-separator variant all resolve to the same key — +-- and so the pre-fix literal fallback "workout" resolves to the same row as +-- Strava's own "Workout" catch-all. +-- +-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback +-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label. +-- +-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY +-- one of the 20 canonical values, so a re-run touches zero rows once the +-- first run has completed. Safe to re-run after a partial failure. +UPDATE "workouts" +SET "sport_type" = CASE + lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g')) + WHEN 'alpineski' THEN 'other' + WHEN 'backcountryski' THEN 'other' + WHEN 'badminton' THEN 'tennis' + WHEN 'basketball' THEN 'basketball' + WHEN 'canoeing' THEN 'rowing' + WHEN 'cricket' THEN 'other' + WHEN 'crossfit' THEN 'crossTraining' + WHEN 'dance' THEN 'dance' + WHEN 'ebikeride' THEN 'cycling' + WHEN 'elliptical' THEN 'elliptical' + WHEN 'emountainbikeride' THEN 'cycling' + WHEN 'golf' THEN 'golf' + WHEN 'gravelride' THEN 'cycling' + WHEN 'handcycle' THEN 'cycling' + WHEN 'highintensityintervaltraining' THEN 'hiit' + WHEN 'hike' THEN 'hiking' + WHEN 'iceskate' THEN 'other' + WHEN 'inlineskate' THEN 'other' + WHEN 'kayaking' THEN 'rowing' + WHEN 'kitesurf' THEN 'other' + WHEN 'mountainbikeride' THEN 'cycling' + WHEN 'nordicski' THEN 'other' + WHEN 'padel' THEN 'tennis' + WHEN 'physicaltherapy' THEN 'mindAndBody' + WHEN 'pickleball' THEN 'tennis' + WHEN 'pilates' THEN 'mindAndBody' + WHEN 'racquetball' THEN 'tennis' + WHEN 'ride' THEN 'cycling' + WHEN 'rockclimbing' THEN 'crossTraining' + WHEN 'rollerski' THEN 'other' + WHEN 'rowing' THEN 'rowing' + WHEN 'run' THEN 'running' + WHEN 'sail' THEN 'other' + WHEN 'skateboard' THEN 'other' + WHEN 'snowboard' THEN 'other' + WHEN 'snowshoe' THEN 'hiking' + WHEN 'soccer' THEN 'soccer' + WHEN 'squash' THEN 'tennis' + WHEN 'stairstepper' THEN 'stairClimber' + WHEN 'standuppaddling' THEN 'rowing' + WHEN 'surfing' THEN 'other' + WHEN 'swim' THEN 'swimming' + WHEN 'tabletennis' THEN 'tennis' + WHEN 'tennis' THEN 'tennis' + WHEN 'trailrun' THEN 'running' + WHEN 'velomobile' THEN 'cycling' + WHEN 'virtualride' THEN 'cycling' + WHEN 'virtualrow' THEN 'rowing' + WHEN 'virtualrun' THEN 'running' + WHEN 'volleyball' THEN 'other' + WHEN 'walk' THEN 'walking' + WHEN 'weighttraining' THEN 'strength' + WHEN 'wheelchair' THEN 'other' + WHEN 'windsurf' THEN 'other' + WHEN 'workout' THEN 'other' + WHEN 'yoga' THEN 'yoga' + ELSE 'other' +END +WHERE "source" = 'STRAVA' + AND "sport_type" NOT IN ( + 'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing', + 'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit', + 'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining', + 'mixedCardio', 'other' + ); diff --git a/src/lib/sources/__tests__/pick-canonical-workout.test.ts b/src/lib/sources/__tests__/pick-canonical-workout.test.ts index e42ed8ef6..64e6309e9 100644 --- a/src/lib/sources/__tests__/pick-canonical-workout.test.ts +++ b/src/lib/sources/__tests__/pick-canonical-workout.test.ts @@ -337,3 +337,42 @@ describe("pickCanonicalWorkout — field-merge (v1.29.x, WHOOP HR-loss fix)", () expect(winner.totalEnergyKcal).toBe(512); // backfilled from WHOOP }); }); + +describe("pickCanonicalWorkout — Strava brick-workout collapse (data-integrity audit H4)", () => { + // Before the Strava sport-map fix, `mapActivity()` wrote the raw Strava + // label ("Ride", "Run") straight into `sportType` — neither string is a + // member of `workoutSportTypeEnum`, so `isSpecificSportType()` classified + // BOTH rows as generic. Two generic rows are always sport-compatible + // (`genericPairing` in pick-canonical-workout.ts), so a same-source brick + // session — a Strava ride ending ~15:00 immediately followed by a Strava + // run starting 15:03 — clustered into ONE cluster and the ladder pick + // kept only one leg, silently dropping the other from every list, tile, + // and total. + it("pre-fix: two raw (non-canonical) Strava labels close in time COLLAPSE into one cluster", () => { + const result = pickCanonicalWorkout([ + row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "Ride"), + row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "Run"), + ]); + // This is the confirmed bug, pinned so a future change can't silently + // "fix" clustering itself and mask a regression in the sport-map layer. + expect(result.clusters).toHaveLength(1); + expect(result.canonical).toHaveLength(1); + }); + + it("post-fix: a canonical Strava Ride + Run pair survives as two distinct rows", () => { + // Same timing as the pre-fix fixture above, but with the sportType + // values `mapActivity()` now writes (via `mapStravaSportType()`): + // "Ride" → "cycling", "Run" → "running". Both are SPECIFIC canonical + // buckets and differ from each other, so `isSpecificSportType()` no + // longer treats them as sport-compatible and the brick session + // survives as two workouts. + const result = pickCanonicalWorkout([ + row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "cycling"), + row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "running"), + ]); + expect(result.clusters).toHaveLength(2); + expect(result.canonical).toHaveLength(2); + const sports = result.canonical.map((w) => w.sportType).sort(); + expect(sports).toEqual(["cycling", "running"]); + }); +}); diff --git a/src/lib/strava/__tests__/client.test.ts b/src/lib/strava/__tests__/client.test.ts index d94eeeab9..a6a470720 100644 --- a/src/lib/strava/__tests__/client.test.ts +++ b/src/lib/strava/__tests__/client.test.ts @@ -29,7 +29,8 @@ describe("mapActivity", () => { }); expect(row).not.toBeNull(); expect(row!.externalId).toBe("123456"); - expect(row!.sportType).toBe("TrailRun"); + // sport_type "TrailRun" maps to the canonical "running" bucket. + expect(row!.sportType).toBe("running"); expect(row!.startedAt.toISOString()).toBe("2026-07-01T06:30:00.000Z"); // endedAt = start + elapsed_time (clock window), not moving_time. expect(row!.endedAt.toISOString()).toBe("2026-07-01T06:56:40.000Z"); @@ -66,15 +67,38 @@ describe("mapActivity", () => { expect((row as unknown as Record).name).toBeUndefined(); }); - it("falls back to `type` then a generic label for the sport", () => { + it("falls back to `type` then a generic label for the sport, always canonical", () => { const noSport: StravaSummaryActivity = { ...baseSummary }; delete (noSport as unknown as Record).sport_type; - expect(mapActivity(noSport)!.sportType).toBe("Run"); + // Falls back to `type: "Run"` → canonical "running". + expect(mapActivity(noSport)!.sportType).toBe("running"); + const meta = mapActivity(noSport)!.metadata as Record; + expect(meta.stravaType).toBe("Run"); const noneAtAll: StravaSummaryActivity = { ...baseSummary }; delete (noneAtAll as unknown as Record).sport_type; delete (noneAtAll as unknown as Record).type; - expect(mapActivity(noneAtAll)!.sportType).toBe("workout"); + // No sport info at all → generic "Workout" fallback → canonical "other". + expect(mapActivity(noneAtAll)!.sportType).toBe("other"); + const noneMeta = mapActivity(noneAtAll)!.metadata as Record< + string, + unknown + >; + expect(noneMeta.stravaType).toBe("Workout"); + }); + + it("keeps the raw sport_type label in metadata even though sportType is canonical", () => { + const row = mapActivity(baseSummary); // sport_type: "TrailRun" + const meta = row!.metadata as Record; + expect(row!.sportType).toBe("running"); + expect(meta.stravaType).toBe("TrailRun"); + }); + + it("never writes a raw Strava sport label into sportType, even for an unrecognised value", () => { + const row = mapActivity({ ...baseSummary, sport_type: "SomeNewSport" }); + expect(row!.sportType).toBe("other"); + const meta = row!.metadata as Record; + expect(meta.stravaType).toBe("SomeNewSport"); }); it("returns null for an activity with no id or no start instant", () => { diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 34b236be2..09b95fc66 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -25,6 +25,8 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import type { Prisma } from "@/generated/prisma/client"; import { StravaApiError, classifyStravaResponse } from "./response-classifier"; +import { mapStravaSportType } from "./sport-map"; +import type { WorkoutSportType } from "@/lib/validations/workout"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize"; @@ -294,7 +296,7 @@ export function fetchActivityById( * `externalId` and upserts on `(userId, source, externalId)`. */ export interface StravaWorkoutRow { externalId: string; - sportType: string; + sportType: WorkoutSportType; startedAt: Date; endedAt: Date; durationSec: number; @@ -318,9 +320,13 @@ function nonNegFloat(n: number | null | undefined): number | null { /** * Map one Strava activity into a `Workout` row. `sport_type` is preferred over - * the legacy `type`; free-text at the DB layer. Duration uses `moving_time` - * (active duration) with `elapsed_time` kept in `metadata`. HR / calories come - * from the summary when present, else from the optional `DetailedActivity`. + * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical + * `WorkoutSportType` — the raw Strava label is never written to `sportType` + * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept + * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses + * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / + * calories come from the summary when present, else from the optional + * `DetailedActivity`. * * The activity `name` / `description` are the user's own free-text DATA — they * are stored in `metadata` only and NEVER interpolated into any prompt as an @@ -355,15 +361,18 @@ export function mapActivity( ); const calories = nonNegFloat(detail?.calories); - const sportType = - summary.sport_type ?? summary.type ?? detail?.sport_type ?? "workout"; + const rawSportType = + summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; + const sportType = mapStravaSportType(rawSportType); const metadata: Prisma.InputJsonValue = { // Activity name/description are user free-text → DATA, never instructions. ...(summary.name ? { stravaName: summary.name } : {}), ...(detail?.description ? { stravaDescription: detail.description } : {}), ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), - ...(summary.type ? { stravaType: summary.type } : {}), + // Raw pre-mapping label, kept for provenance/debugging — never used as + // the canonical `sportType` itself (see `mapStravaSportType()`). + ...(rawSportType ? { stravaType: rawSportType } : {}), ...(typeof summary.average_watts === "number" ? { averageWatts: summary.average_watts } : {}), diff --git a/src/lib/strava/sport-map.ts b/src/lib/strava/sport-map.ts new file mode 100644 index 000000000..c3702e9f8 --- /dev/null +++ b/src/lib/strava/sport-map.ts @@ -0,0 +1,168 @@ +/** + * Strava `sport_type` (and legacy `type`) → HealthLog canonical + * `WorkoutSportType`. + * + * Strava was the second workout integration — after WHOOP — that shipped + * WITHOUT this mapping. `mapActivity()` in `src/lib/strava/client.ts` used + * to write `summary.sport_type ?? summary.type ?? detail?.sport_type ?? + * "workout"` straight into `Workout.sportType`, so a Strava "Run" or + * "VirtualRide" never matched a canonical bucket — `/insights/workouts` + * rendered every Strava row generic, and the read-time picker in + * `src/lib/sources/pick-canonical-workout.ts` treats any two GENERIC rows + * as sport-compatible, so a same-source brick session (a Strava "Ride" + * ending 15:00 immediately followed by a Strava "Run" starting 15:03) + * clustered into one and silently dropped a leg. Fitbit + * (`mapFitbitSportType()` in `src/lib/fitbit/client.ts`), Google Health + * (`mapGoogleHealthSportType()` in `src/lib/google-health/mappers.ts`), and + * WHOOP (`mapWhoopSportType()` in `src/lib/whoop/sport-map.ts`) all + * normalise at ingest time; this module brings Strava to parity. + * + * `STRAVA_SPORT_TABLE` is the single source of truth — `STRAVA_SPORT_MAP` + * derives from it, and the one-shot data migration + * `prisma/migrations/0251_backfill_strava_workout_sport` was generated FROM + * this same table so the historical backfill and the live mapper never + * drift apart. + * + * Source: Strava's public `SportType` model — the documented superset of + * the deprecated `ActivityType` (`type`) enum, i.e. every legacy `type` + * value already appears in this table under the same spelling. Captured + * 2026-07-17 from the actively-maintained `stravalib` Python client's + * generated model (`strava_model.py`), which mirrors Strava's published + * Swagger spec 1:1 — 54 documented values. + * + * Bucket choices for sports with no clean 1:1 canonical match are a + * judgment call, applied consistently with the WHOOP table: + * - paddle sports (Canoeing, Kayaking, StandUpPaddling) → `"rowing"` + * (closest upper-body cardio bucket, same call WHOOP made for + * Kayaking/Paddleboarding). + * - racket / paddle-court sports (Badminton, Padel, Pickleball, + * Racquetball, Squash, TableTennis, Tennis) → `"tennis"`. + * - functional / mixed-modal training (Crossfit, RockClimbing) → + * `"crossTraining"`. + * - recovery / mobility work (Pilates, PhysicalTherapy) → `"mindAndBody"`. + * - human-powered wheeled/pedaled locomotion with no dedicated bucket + * (EBikeRide, EMountainBikeRide, GravelRide, Handcycle, + * MountainBikeRide, Velomobile, VirtualRide) → `"cycling"`. + * - winter-hiking (Snowshoe) → `"hiking"`; snow-sliding sports with no + * bucket (AlpineSki, BackcountrySki, NordicSki, RollerSki, Snowboard) + * → `"other"`, same as WHOOP's Skiing/Snowboarding. + * - `VirtualRow` / `VirtualRun` → the same bucket as their non-virtual + * counterpart (`"rowing"` / `"running"`); `VirtualRide` → `"cycling"`. + * - team / niche sports with no matching bucket (Cricket, IceSkate, + * InlineSkate, Kitesurf, Sail, Skateboard, Surfing, Volleyball, + * Wheelchair, Windsurf) → `"other"`, same as an unmapped Fitbit or + * Google Health activity — the row still persists, just outside a + * canonical sport bucket. + * - the generic `"Workout"` label (Strava's own catch-all, and this + * module's pre-fix fallback) → `"other"`. + */ +import type { WorkoutSportType } from "@/lib/validations/workout"; + +interface StravaSportTableRow { + /** Strava's documented `sport_type` value (PascalCase, no separators). */ + name: string; + canonical: WorkoutSportType; +} + +/** + * Strava `sport_type` display name → canonical bucket. Alphabetical, + * matching the `stravalib` model's declaration order. Keep this list — + * and ONLY this list — in sync when Strava adds a sport; both + * `STRAVA_SPORT_MAP` below and the 0251 migration's generator derive from + * it. + */ +export const STRAVA_SPORT_TABLE: readonly StravaSportTableRow[] = [ + { name: "AlpineSki", canonical: "other" }, + { name: "BackcountrySki", canonical: "other" }, + { name: "Badminton", canonical: "tennis" }, + { name: "Basketball", canonical: "basketball" }, + { name: "Canoeing", canonical: "rowing" }, + { name: "Cricket", canonical: "other" }, + { name: "Crossfit", canonical: "crossTraining" }, + { name: "Dance", canonical: "dance" }, + { name: "EBikeRide", canonical: "cycling" }, + { name: "Elliptical", canonical: "elliptical" }, + { name: "EMountainBikeRide", canonical: "cycling" }, + { name: "Golf", canonical: "golf" }, + { name: "GravelRide", canonical: "cycling" }, + { name: "Handcycle", canonical: "cycling" }, + { name: "HighIntensityIntervalTraining", canonical: "hiit" }, + { name: "Hike", canonical: "hiking" }, + { name: "IceSkate", canonical: "other" }, + { name: "InlineSkate", canonical: "other" }, + { name: "Kayaking", canonical: "rowing" }, + { name: "Kitesurf", canonical: "other" }, + { name: "MountainBikeRide", canonical: "cycling" }, + { name: "NordicSki", canonical: "other" }, + { name: "Padel", canonical: "tennis" }, + { name: "PhysicalTherapy", canonical: "mindAndBody" }, + { name: "Pickleball", canonical: "tennis" }, + { name: "Pilates", canonical: "mindAndBody" }, + { name: "Racquetball", canonical: "tennis" }, + { name: "Ride", canonical: "cycling" }, + { name: "RockClimbing", canonical: "crossTraining" }, + { name: "RollerSki", canonical: "other" }, + { name: "Rowing", canonical: "rowing" }, + { name: "Run", canonical: "running" }, + { name: "Sail", canonical: "other" }, + { name: "Skateboard", canonical: "other" }, + { name: "Snowboard", canonical: "other" }, + { name: "Snowshoe", canonical: "hiking" }, + { name: "Soccer", canonical: "soccer" }, + { name: "Squash", canonical: "tennis" }, + { name: "StairStepper", canonical: "stairClimber" }, + { name: "StandUpPaddling", canonical: "rowing" }, + { name: "Surfing", canonical: "other" }, + { name: "Swim", canonical: "swimming" }, + { name: "TableTennis", canonical: "tennis" }, + { name: "Tennis", canonical: "tennis" }, + { name: "TrailRun", canonical: "running" }, + { name: "Velomobile", canonical: "cycling" }, + { name: "VirtualRide", canonical: "cycling" }, + { name: "VirtualRow", canonical: "rowing" }, + { name: "VirtualRun", canonical: "running" }, + { name: "Volleyball", canonical: "other" }, + { name: "Walk", canonical: "walking" }, + { name: "WeightTraining", canonical: "strength" }, + { name: "Wheelchair", canonical: "other" }, + { name: "Windsurf", canonical: "other" }, + { name: "Workout", canonical: "other" }, + { name: "Yoga", canonical: "yoga" }, +] as const; + +/** + * Lowercase and strip every character outside `[a-z0-9]`. Strava's + * `sport_type` values are PascalCase with no internal separators + * ("VirtualRide"), so this is a plain case-fold in the common case; the + * stripping step keeps the lookup resilient to a stray space/underscore/ + * hyphen variant ("Virtual Ride", "virtual_ride") resolving to the same + * key. Applied to BOTH the documented display name (at table-build time) + * and the incoming raw value (at lookup time). + */ +export function normaliseStravaSportKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +export const STRAVA_SPORT_MAP: ReadonlyMap = new Map( + STRAVA_SPORT_TABLE.map((row) => [ + normaliseStravaSportKey(row.name), + row.canonical, + ]), +); + +/** + * Resolve a Strava activity's sport to a canonical `WorkoutSportType`. + * Accepts whichever raw label the caller has already resolved from the + * `sport_type ?? type` fallback chain. Defaults to `"other"` for anything + * absent, blank, or unrecognised (a Strava-added sport this table hasn't + * caught up with yet); NEVER returns the raw Strava label. + */ +export function mapStravaSportType( + raw: string | null | undefined, +): WorkoutSportType { + if (typeof raw !== "string" || raw.trim() === "") return "other"; + return STRAVA_SPORT_MAP.get(normaliseStravaSportKey(raw)) ?? "other"; +} diff --git a/src/lib/workouts/__tests__/sport-map-parity.test.ts b/src/lib/workouts/__tests__/sport-map-parity.test.ts index da5456906..08fc1e276 100644 --- a/src/lib/workouts/__tests__/sport-map-parity.test.ts +++ b/src/lib/workouts/__tests__/sport-map-parity.test.ts @@ -4,20 +4,21 @@ * 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). + * WHOOP (`mapWhoopSportType()`), and — as of the Strava-brick-collapse fix — + * Strava (`mapStravaSportType()`). This table-driven suite checks all four + * 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 { mapStravaSportType } from "@/lib/strava/sport-map"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; type Mapper = (raw: string) => string; @@ -26,8 +27,9 @@ const MAPPERS: Record = { 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. + // to line up with the other integrations' string-in shape. whoop: (raw) => mapWhoopSportType(undefined, raw), + strava: (raw) => mapStravaSportType(raw), }; describe("sport-map parity — every mapper only emits canonical values", () => { @@ -55,23 +57,48 @@ describe("sport-map parity — every mapper only emits canonical values", () => */ const COMMON_SPORTS: Array<{ expected: string; - labels: { fitbit: string; googleHealth: string; whoop: string }; + labels: { + fitbit: string; + googleHealth: string; + whoop: string; + strava: string; + }; }> = [ { expected: "walking", - labels: { fitbit: "walking", googleHealth: "WALKING", whoop: "Walking" }, + labels: { + fitbit: "walking", + googleHealth: "WALKING", + whoop: "Walking", + strava: "Walk", + }, }, { expected: "running", - labels: { fitbit: "running", googleHealth: "RUNNING", whoop: "Running" }, + labels: { + fitbit: "running", + googleHealth: "RUNNING", + whoop: "Running", + strava: "Run", + }, }, { expected: "cycling", - labels: { fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling" }, + labels: { + fitbit: "biking", + googleHealth: "BIKING", + whoop: "Cycling", + strava: "Ride", + }, }, { expected: "swimming", - labels: { fitbit: "swimming", googleHealth: "SWIMMING", whoop: "Swimming" }, + labels: { + fitbit: "swimming", + googleHealth: "SWIMMING", + whoop: "Swimming", + strava: "Swim", + }, }, { expected: "strength", @@ -79,37 +106,64 @@ const COMMON_SPORTS: Array<{ fitbit: "weights", googleHealth: "STRENGTH_TRAINING", whoop: "Weightlifting", + strava: "WeightTraining", }, }, { expected: "yoga", - labels: { fitbit: "yoga", googleHealth: "YOGA", whoop: "Yoga" }, + labels: { + fitbit: "yoga", + googleHealth: "YOGA", + whoop: "Yoga", + strava: "Yoga", + }, }, { expected: "hiit", - labels: { fitbit: "hiit", googleHealth: "HIIT", whoop: "HIIT" }, + labels: { + fitbit: "hiit", + googleHealth: "HIIT", + whoop: "HIIT", + strava: "HighIntensityIntervalTraining", + }, }, { expected: "golf", - labels: { fitbit: "golf", googleHealth: "GOLF", whoop: "Golf" }, + labels: { + fitbit: "golf", + googleHealth: "GOLF", + whoop: "Golf", + strava: "Golf", + }, }, { expected: "tennis", - labels: { fitbit: "tennis", googleHealth: "TENNIS", whoop: "Tennis" }, + labels: { + fitbit: "tennis", + googleHealth: "TENNIS", + whoop: "Tennis", + strava: "Tennis", + }, }, { expected: "rowing", - labels: { fitbit: "rowing", googleHealth: "ROWING", whoop: "Rowing" }, + labels: { + fitbit: "rowing", + googleHealth: "ROWING", + whoop: "Rowing", + strava: "Rowing", + }, }, ]; -describe("sport-map parity — Fitbit / Google Health / WHOOP agree on common sports", () => { +describe("sport-map parity — Fitbit / Google Health / WHOOP / Strava agree on common sports", () => { it.each(COMMON_SPORTS)( - "$expected: all three integrations map to the same canonical bucket", + "$expected: all four 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); + expect(mapStravaSportType(labels.strava)).toBe(expected); }, ); }); @@ -121,9 +175,20 @@ describe("sport-map parity — the confirmed bug, guarded across integrations", fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling", + strava: "Ride", }; const result = mapper(cyclingLabels[name]!); expect(result).toBe("cycling"); } }); + + it("a Strava workout NEVER lands on the raw PascalCase label (the brick-collapse root cause)", () => { + for (const raw of ["Run", "Ride", "VirtualRide", "TrailRun", "Workout"]) { + const result = mapStravaSportType(raw); + expect(result).not.toBe(raw); + expect( + (workoutSportTypeEnum.options as readonly string[]).includes(result), + ).toBe(true); + } + }); });