fix(db): backfill non-canonical WHOOP workout sport_type values

Existing WHOOP Workout rows still carry whatever the pre-fix write path
stored — a whoop_sport_<id> placeholder or WHOOP's raw sport_name — so
the sport-map fix only corrects new syncs. This one-shot UPDATE
re-derives every existing WHOOP row's sport_type from the same table
sport-map.ts uses, generated directly from that table so the historical
data and the live mapper can't drift apart. Unmatched values default to
"other", matching the mapper's fallback. Idempotent: the WHERE clause
skips rows already on a canonical value, so a re-run touches zero rows.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 14:28:56 +02:00
1 parent 43884569d3
commit 86e92f38ca
1 file changed
+289
@@ -0,0 +1,289 @@
-- v1.29.x — backfill non-canonical WHOOP `Workout.sport_type` values.
--
-- Root cause (confirmed from prod data): WHOOP was the only workout
-- integration that never normalised its sport to HealthLog's canonical
-- `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `upsertWhoopWorkout()` in
-- src/lib/whoop/sync-workout.ts wrote WHOOP's raw `sport_name` verbatim, or
-- a literal `whoop_sport_<sport_id>` placeholder when WHOOP sent only the
-- (now-retired) numeric id — so existing WHOOP rows carry values the
-- `/insights/workouts` list can't map to an icon/label (a WHOOP-tracked bike
-- ride never rendered as "cycling"). `mapWhoopSportType()`
-- (src/lib/whoop/sport-map.ts) fixes the write path going forward; this
-- migration re-derives every EXISTING WHOOP `Workout` row from the same
-- table so historical data and the live mapper never drift apart.
--
-- The WHEN arms below were generated FROM `WHOOP_SPORT_TABLE` in
-- src/lib/whoop/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed) and cover both shapes a pre-fix row could
-- carry:
-- - the literal `whoop_sport_<id>` placeholder (id present, no name), and
-- - the raw `sport_name` WHOOP sent (name present), normalised the same
-- way the SQL expression below normalises the stored column: lowercase,
-- any run of characters outside `[a-z0-9_]` collapsed to a single `_`.
-- (`normaliseSportKey()` in sport-map.ts mirrors this exact rule so a
-- future re-generation of this list stays consistent with the SQL.)
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapWhoopSportType()` uses, never leaves a row on a `whoop_sport_<n>`
-- placeholder.
--
-- 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 'whoop_sport__1' THEN 'other'
WHEN 'whoop_sport_0' THEN 'running'
WHEN 'whoop_sport_1' THEN 'cycling'
WHEN 'whoop_sport_16' THEN 'other'
WHEN 'whoop_sport_17' THEN 'basketball'
WHEN 'whoop_sport_18' THEN 'rowing'
WHEN 'whoop_sport_19' THEN 'other'
WHEN 'whoop_sport_20' THEN 'other'
WHEN 'whoop_sport_21' THEN 'other'
WHEN 'whoop_sport_22' THEN 'golf'
WHEN 'whoop_sport_24' THEN 'other'
WHEN 'whoop_sport_25' THEN 'other'
WHEN 'whoop_sport_27' THEN 'other'
WHEN 'whoop_sport_28' THEN 'other'
WHEN 'whoop_sport_29' THEN 'other'
WHEN 'whoop_sport_30' THEN 'soccer'
WHEN 'whoop_sport_31' THEN 'other'
WHEN 'whoop_sport_32' THEN 'tennis'
WHEN 'whoop_sport_33' THEN 'swimming'
WHEN 'whoop_sport_34' THEN 'tennis'
WHEN 'whoop_sport_35' THEN 'running'
WHEN 'whoop_sport_36' THEN 'other'
WHEN 'whoop_sport_37' THEN 'swimming'
WHEN 'whoop_sport_38' THEN 'strength'
WHEN 'whoop_sport_39' THEN 'hiit'
WHEN 'whoop_sport_42' THEN 'dance'
WHEN 'whoop_sport_43' THEN 'mindAndBody'
WHEN 'whoop_sport_44' THEN 'yoga'
WHEN 'whoop_sport_45' THEN 'strength'
WHEN 'whoop_sport_47' THEN 'other'
WHEN 'whoop_sport_48' THEN 'crossTraining'
WHEN 'whoop_sport_49' THEN 'mixedCardio'
WHEN 'whoop_sport_51' THEN 'crossTraining'
WHEN 'whoop_sport_52' THEN 'hiking'
WHEN 'whoop_sport_53' THEN 'other'
WHEN 'whoop_sport_55' THEN 'rowing'
WHEN 'whoop_sport_56' THEN 'hiit'
WHEN 'whoop_sport_57' THEN 'cycling'
WHEN 'whoop_sport_59' THEN 'strength'
WHEN 'whoop_sport_60' THEN 'crossTraining'
WHEN 'whoop_sport_61' THEN 'rowing'
WHEN 'whoop_sport_62' THEN 'mixedCardio'
WHEN 'whoop_sport_63' THEN 'walking'
WHEN 'whoop_sport_64' THEN 'other'
WHEN 'whoop_sport_65' THEN 'elliptical'
WHEN 'whoop_sport_66' THEN 'stairClimber'
WHEN 'whoop_sport_70' THEN 'mindAndBody'
WHEN 'whoop_sport_71' THEN 'other'
WHEN 'whoop_sport_73' THEN 'other'
WHEN 'whoop_sport_74' THEN 'other'
WHEN 'whoop_sport_75' THEN 'other'
WHEN 'whoop_sport_76' THEN 'other'
WHEN 'whoop_sport_77' THEN 'other'
WHEN 'whoop_sport_82' THEN 'other'
WHEN 'whoop_sport_83' THEN 'stairClimber'
WHEN 'whoop_sport_84' THEN 'hiit'
WHEN 'whoop_sport_85' THEN 'other'
WHEN 'whoop_sport_86' THEN 'other'
WHEN 'whoop_sport_87' THEN 'other'
WHEN 'whoop_sport_88' THEN 'other'
WHEN 'whoop_sport_89' THEN 'other'
WHEN 'whoop_sport_90' THEN 'other'
WHEN 'whoop_sport_91' THEN 'other'
WHEN 'whoop_sport_92' THEN 'other'
WHEN 'whoop_sport_93' THEN 'other'
WHEN 'whoop_sport_94' THEN 'crossTraining'
WHEN 'whoop_sport_95' THEN 'other'
WHEN 'whoop_sport_96' THEN 'hiit'
WHEN 'whoop_sport_97' THEN 'cycling'
WHEN 'whoop_sport_98' THEN 'hiit'
WHEN 'whoop_sport_99' THEN 'other'
WHEN 'whoop_sport_100' THEN 'other'
WHEN 'whoop_sport_101' THEN 'tennis'
WHEN 'whoop_sport_102' THEN 'other'
WHEN 'whoop_sport_103' THEN 'hiit'
WHEN 'whoop_sport_104' THEN 'other'
WHEN 'whoop_sport_105' THEN 'other'
WHEN 'whoop_sport_106' THEN 'tennis'
WHEN 'whoop_sport_107' THEN 'mindAndBody'
WHEN 'whoop_sport_108' THEN 'other'
WHEN 'whoop_sport_109' THEN 'other'
WHEN 'whoop_sport_110' THEN 'crossTraining'
WHEN 'whoop_sport_111' THEN 'other'
WHEN 'whoop_sport_112' THEN 'other'
WHEN 'whoop_sport_113' THEN 'other'
WHEN 'whoop_sport_121' THEN 'other'
WHEN 'whoop_sport_123' THEN 'strength'
WHEN 'whoop_sport_125' THEN 'other'
WHEN 'whoop_sport_126' THEN 'cycling'
WHEN 'whoop_sport_127' THEN 'hiit'
WHEN 'whoop_sport_128' THEN 'mindAndBody'
WHEN 'whoop_sport_230' THEN 'tennis'
WHEN 'whoop_sport_231' THEN 'tennis'
WHEN 'whoop_sport_232' THEN 'basketball'
WHEN 'whoop_sport_233' THEN 'other'
WHEN 'whoop_sport_234' THEN 'golf'
WHEN 'whoop_sport_235' THEN 'other'
WHEN 'whoop_sport_236' THEN 'other'
WHEN 'whoop_sport_237' THEN 'other'
WHEN 'whoop_sport_238' THEN 'other'
WHEN 'whoop_sport_239' THEN 'other'
WHEN 'whoop_sport_240' THEN 'other'
WHEN 'whoop_sport_248' THEN 'crossTraining'
WHEN 'whoop_sport_249' THEN 'tennis'
WHEN 'whoop_sport_250' THEN 'crossTraining'
WHEN 'whoop_sport_251' THEN 'other'
WHEN 'whoop_sport_252' THEN 'walking'
WHEN 'whoop_sport_253' THEN 'running'
WHEN 'whoop_sport_254' THEN 'other'
WHEN 'whoop_sport_255' THEN 'other'
WHEN 'whoop_sport_258' THEN 'mindAndBody'
WHEN 'whoop_sport_259' THEN 'yoga'
WHEN 'whoop_sport_261' THEN 'stairClimber'
WHEN 'whoop_sport_262' THEN 'other'
WHEN 'whoop_sport_263' THEN 'other'
WHEN 'whoop_sport_264' THEN 'other'
WHEN 'whoop_sport_266' THEN 'walking'
WHEN 'whoop_sport_267' THEN 'other'
WHEN 'whoop_sport_268' THEN 'other'
WHEN 'whoop_sport_269' THEN 'other'
WHEN 'whoop_sport_270' THEN 'other'
WHEN 'whoop_sport_272' THEN 'other'
WHEN 'activity' THEN 'other'
WHEN 'running' THEN 'running'
WHEN 'cycling' THEN 'cycling'
WHEN 'baseball' THEN 'other'
WHEN 'basketball' THEN 'basketball'
WHEN 'rowing' THEN 'rowing'
WHEN 'fencing' THEN 'other'
WHEN 'field_hockey' THEN 'other'
WHEN 'football' THEN 'other'
WHEN 'golf' THEN 'golf'
WHEN 'ice_hockey' THEN 'other'
WHEN 'lacrosse' THEN 'other'
WHEN 'rugby' THEN 'other'
WHEN 'sailing' THEN 'other'
WHEN 'skiing' THEN 'other'
WHEN 'soccer' THEN 'soccer'
WHEN 'softball' THEN 'other'
WHEN 'squash' THEN 'tennis'
WHEN 'swimming' THEN 'swimming'
WHEN 'tennis' THEN 'tennis'
WHEN 'track_field' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'water_polo' THEN 'swimming'
WHEN 'wrestling' THEN 'strength'
WHEN 'boxing' THEN 'hiit'
WHEN 'dance' THEN 'dance'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'yoga' THEN 'yoga'
WHEN 'weightlifting' THEN 'strength'
WHEN 'cross_country_skiing' THEN 'other'
WHEN 'functional_fitness' THEN 'crossTraining'
WHEN 'duathlon' THEN 'mixedCardio'
WHEN 'gymnastics' THEN 'crossTraining'
WHEN 'hiking_rucking' THEN 'hiking'
WHEN 'horseback_riding' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'martial_arts' THEN 'hiit'
WHEN 'mountain_biking' THEN 'cycling'
WHEN 'powerlifting' THEN 'strength'
WHEN 'rock_climbing' THEN 'crossTraining'
WHEN 'paddleboarding' THEN 'rowing'
WHEN 'triathlon' THEN 'mixedCardio'
WHEN 'walking' THEN 'walking'
WHEN 'surfing' THEN 'other'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'stairmaster' THEN 'stairClimber'
WHEN 'meditation' THEN 'mindAndBody'
WHEN 'other' THEN 'other'
WHEN 'diving' THEN 'other'
WHEN 'operations_tactical' THEN 'other'
WHEN 'operations_medical' THEN 'other'
WHEN 'operations_flying' THEN 'other'
WHEN 'operations_water' THEN 'other'
WHEN 'ultimate' THEN 'other'
WHEN 'climber' THEN 'stairClimber'
WHEN 'jumping_rope' THEN 'hiit'
WHEN 'australian_football' THEN 'other'
WHEN 'skateboarding' THEN 'other'
WHEN 'coaching' THEN 'other'
WHEN 'ice_bath' THEN 'other'
WHEN 'commuting' THEN 'other'
WHEN 'gaming' THEN 'other'
WHEN 'snowboarding' THEN 'other'
WHEN 'motocross' THEN 'other'
WHEN 'caddying' THEN 'other'
WHEN 'obstacle_course_racing' THEN 'crossTraining'
WHEN 'motor_racing' THEN 'other'
WHEN 'hiit' THEN 'hiit'
WHEN 'spin' THEN 'cycling'
WHEN 'jiu_jitsu' THEN 'hiit'
WHEN 'manual_labor' THEN 'other'
WHEN 'cricket' THEN 'other'
WHEN 'pickleball' THEN 'tennis'
WHEN 'inline_skating' THEN 'other'
WHEN 'box_fitness' THEN 'hiit'
WHEN 'spikeball' THEN 'other'
WHEN 'wheelchair_pushing' THEN 'other'
WHEN 'paddle_tennis' THEN 'tennis'
WHEN 'barre' THEN 'mindAndBody'
WHEN 'stage_performance' THEN 'other'
WHEN 'high_stress_work' THEN 'other'
WHEN 'parkour' THEN 'crossTraining'
WHEN 'gaelic_football' THEN 'other'
WHEN 'hurling_camogie' THEN 'other'
WHEN 'circus_arts' THEN 'other'
WHEN 'massage_therapy' THEN 'other'
WHEN 'strength_trainer' THEN 'strength'
WHEN 'watching_sports' THEN 'other'
WHEN 'assault_bike' THEN 'cycling'
WHEN 'kickboxing' THEN 'hiit'
WHEN 'stretching' THEN 'mindAndBody'
WHEN 'table_tennis' THEN 'tennis'
WHEN 'badminton' THEN 'tennis'
WHEN 'netball' THEN 'basketball'
WHEN 'sauna' THEN 'other'
WHEN 'disc_golf' THEN 'golf'
WHEN 'yard_work' THEN 'other'
WHEN 'air_compression' THEN 'other'
WHEN 'percussive_massage' THEN 'other'
WHEN 'paintball' THEN 'other'
WHEN 'ice_skating' THEN 'other'
WHEN 'handball' THEN 'other'
WHEN 'f45_training' THEN 'crossTraining'
WHEN 'padel' THEN 'tennis'
WHEN 'barry_s' THEN 'crossTraining'
WHEN 'dedicated_parenting' THEN 'other'
WHEN 'stroller_walking' THEN 'walking'
WHEN 'stroller_jogging' THEN 'running'
WHEN 'toddlerwearing' THEN 'other'
WHEN 'babywearing' THEN 'other'
WHEN 'barre3' THEN 'mindAndBody'
WHEN 'hot_yoga' THEN 'yoga'
WHEN 'stadium_steps' THEN 'stairClimber'
WHEN 'polo' THEN 'other'
WHEN 'musical_performance' THEN 'other'
WHEN 'kite_boarding' THEN 'other'
WHEN 'dog_walking' THEN 'walking'
WHEN 'water_skiing' THEN 'other'
WHEN 'wakeboarding' THEN 'other'
WHEN 'cooking' THEN 'other'
WHEN 'cleaning' THEN 'other'
WHEN 'public_speaking' THEN 'other'
ELSE 'other'
END
WHERE "source" = 'WHOOP'
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'
);