mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.
Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
This commit is contained in:
1 parent
8902d3985c
commit
4f7420905a
6 files changed
+440
-32
No files matched your search
@@ -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'
|
||||
);
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>;
|
||||
expect(meta.stravaType).toBe("Run");
|
||||
|
||||
const noneAtAll: StravaSummaryActivity = { ...baseSummary };
|
||||
delete (noneAtAll as unknown as Record<string, unknown>).sport_type;
|
||||
delete (noneAtAll as unknown as Record<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(meta.stravaType).toBe("SomeNewSport");
|
||||
});
|
||||
|
||||
it("returns null for an activity with no id or no start instant", () => {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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<string, WorkoutSportType> = 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";
|
||||
}
|
||||
@@ -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<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.
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user