diff --git a/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql b/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql new file mode 100644 index 000000000..d166b75ad --- /dev/null +++ b/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql @@ -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_` 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_` 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_` +-- 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' + );