Sharing in case others find it useful
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial check-in

Mark Bennett 931a52f4

+414
+1
.gitignore
··· 1 + WorkoutExport.csv
+66
README.md
··· 1 + # Fitbod → Hevy Import 2 + 3 + Scripts to migrate workout history from a Fitbod CSV export into Hevy via its API. 4 + 5 + ## Prerequisites 6 + 7 + - [Deno](https://deno.com) installed 8 + - A Fitbod CSV export (`WorkoutExport.csv`) in this directory 9 + - A [Hevy API key](https://hevy.com/settings?tab=developer) 10 + 11 + ## Setup 12 + 13 + Add your Hevy API key to [config.tsm](config.tsm): 14 + 15 + ```ts 16 + export const HEVY_API_KEY = 'your-key-here'; 17 + ``` 18 + 19 + ## Workflow 20 + 21 + ### Step 1 — Generate the exercise map template 22 + 23 + Reads your CSV and outputs a `exercise-map.tsm` file listing every unique exercise name: 24 + 25 + ```sh 26 + deno run --allow-read generate-map.tsm 27 + ``` 28 + 29 + ### Step 2 — Get your Hevy exercise template IDs 30 + 31 + Fetches all exercise templates from your Hevy account and prints them as mappable key/value pairs: 32 + 33 + ```sh 34 + deno run --allow-net get-templates.tsm 35 + ``` 36 + 37 + ### Step 3 — Fill in the exercise map 38 + 39 + Open `exercise-map.tsm` and paste in the Hevy template ID for each exercise. Any exercise left as an empty string will be skipped during import. 40 + 41 + ```ts 42 + export const EXERCISE_MAP: Record<string, string> = { 43 + "Running": "abc123", 44 + "Dumbbell Row": "def456", 45 + // ... 46 + }; 47 + ``` 48 + 49 + ### Step 4 — Run the import 50 + 51 + ```sh 52 + deno run --allow-read --allow-net import.tsm 53 + ``` 54 + 55 + Workouts are sent one day at a time with a 1.5-second delay between requests to respect Hevy's rate limits. Exercises not found in the map are skipped with a warning. 56 + 57 + ## Files 58 + 59 + | File | Purpose | 60 + |------|---------| 61 + | `config.tsm` | API key, CSV path, API base URL | 62 + | `generate-map.tsm` | Generates `exercise-map.tsm` from the CSV | 63 + | `get-templates.tsm` | Lists Hevy exercise templates and their IDs | 64 + | `exercise-map.tsm` | Maps Fitbod exercise names → Hevy template IDs | 65 + | `import.tsm` | Main import script | 66 + | `WorkoutExport.csv` | Your Fitbod data export |
+5
config.ts
··· 1 + // config.tsm 2 + 3 + export const FITBOD_CSV_PATH = 'WorkoutExport.csv'; 4 + export const HEVY_API_KEY = 'YOUR_HEVY_API_KEY_HERE'; 5 + export const HEVY_API_BASE = 'https://api.hevyapp.com/v1';
+7
deno.json
··· 1 + { 2 + "tasks": { 3 + "generate-map": "deno run --allow-read --allow-write generate-map.ts", 4 + "get-templates": "deno run --allow-net get-templates.ts", 5 + "import": "deno run --allow-read --allow-net import.ts" 6 + } 7 + }
+105
exercise-map.ts
··· 1 + // exercise-map.ts 2 + // Replace the empty strings with your actual Hevy exercise_template_ids from get-templates.ts 3 + 4 + export const EXERCISE_MAP: Record<string, string> = { 5 + "Running": "", 6 + "Rowing": "", 7 + "Dumbbell Row": "", 8 + "Dumbbell Bicep Curl": "", 9 + "Dumbbell Skullcrusher": "", 10 + "Dumbbell Fly": "", 11 + "Dumbbell Incline Bench Press": "", 12 + "Bench Dip": "", 13 + "Dumbbell Goblet Squat": "", 14 + "Dumbbell Bulgarian Split Squat": "", 15 + "Kettlebell Sumo Squat": "", 16 + "Dumbbell Lunge": "", 17 + "Standing Dumbbell Calf Raise": "", 18 + "Crunches": "", 19 + "Dumbbell Bench Press": "", 20 + "Dumbbell Bent Over Row": "", 21 + "Weighted Ball Hyperextension": "", 22 + "Walking": "", 23 + "Dumbbell Shoulder Press": "", 24 + "Dumbbell Front Raise": "", 25 + "Exercise Ball Crunch": "", 26 + "Dumbbell Rear Delt Raise": "", 27 + "Dumbbell Squat": "", 28 + "Stability Ball Back Extension": "", 29 + "Jumping Jack": "", 30 + "Dumbbell Romanian Deadlift": "", 31 + "Russian Twist": "", 32 + "Ab Rollout": "", 33 + "Plank": "", 34 + "Swimming": "", 35 + "Running - Treadmill": "", 36 + "Gorilla Rows": "", 37 + "Dumbbell Tricep Extension": "", 38 + "Dumbbell Kickbacks": "", 39 + "Dumbbell Squat To Shoulder Press": "", 40 + "Single Leg Romanian Deadlift": "", 41 + "Kettlebell Swing": "", 42 + "Bicycle Crunch": "", 43 + "Iron Cross": "", 44 + "Single Leg Hip Thrust": "", 45 + "Dumbbell Superman": "", 46 + "Hammer Curls": "", 47 + "Superman with Scaption": "", 48 + "Dumbbell Hip Thrust": "", 49 + "Dumbbell Glute Bridge": "", 50 + "Loop Band Romanian Deadlift": "", 51 + "Alternating Superman": "", 52 + "Sit Up": "", 53 + "Burpee": "", 54 + "Tricep Push Up": "", 55 + "Kettlebell Single Arm Farmer Walk": "", 56 + "Cross Body Hammer Curls": "", 57 + "Push Up": "", 58 + "Lunge": "", 59 + "Single Dumbbell Sumo Squat": "", 60 + "Single Leg Glute Bridge": "", 61 + "Superman Hold": "", 62 + "Kettlebell Row": "", 63 + "Lunge Twist": "", 64 + "Standing Leg Side Circle": "", 65 + "Butt Kick": "", 66 + "Inchworm": "", 67 + "Single Arm Overhead Dumbbell Lunge": "", 68 + "Dumbbell Shrug": "", 69 + "Superman": "", 70 + "Handle Band Forward Raise": "", 71 + "Handle Band Squat": "", 72 + "Scissor Kick": "", 73 + "Dumbbell Squeeze Press": "", 74 + "Side Lunge": "", 75 + "Handle Band Rear Deltoid Fly": "", 76 + "Leg Pull-In": "", 77 + "Jump Rope": "", 78 + "Mini Loop Band Overhead Fly": "", 79 + "Mini Loop Band Lateral Shoulder Raise": "", 80 + "Handle Band Upright Row": "", 81 + "Handle Band Tricep Extension": "", 82 + "Handle Band Bicep Curl": "", 83 + "Seated Dumbbell Curl": "", 84 + "Alternating Dumbbell Bench Press": "", 85 + "Incline Tricep Push Up": "", 86 + "Mini Loop Band Kneeling Bicep Curl": "", 87 + "Leg Raise": "", 88 + "Oblique Crunch": "", 89 + "Mountain Climber": "", 90 + "Mini Loop Band Single Leg Romanian Deadlift with Row": "", 91 + "Walking Lunge": "", 92 + "Handle Band Bent Over Row": "", 93 + "Lunge Jump": "", 94 + "Handle Band Lat Pulldown": "", 95 + "Jackknife Sit-Up": "", 96 + "Cycling": "", 97 + "Single Arm Dumbbell Tricep Extension": "", 98 + "Dumbbell No Money Curls": "", 99 + "Flutter Kicks": "", 100 + "Dumbbell Lateral Raise": "", 101 + "Incline Push Up": "", 102 + "Kettlebell Upright Row": "", 103 + "Mini Loop Band Kneeling Row": "", 104 + "Air Squats": "", 105 + };
+51
generate-map.ts
··· 1 + // generate-map.ts 2 + import * as fs from 'node:fs'; 3 + import { FITBOD_CSV_PATH } from './config.ts'; 4 + 5 + function generateExerciseMapTemplate() { 6 + console.log(`Reading CSV from ${FITBOD_CSV_PATH}...`); 7 + const content = fs.readFileSync(FITBOD_CSV_PATH, 'utf-8'); 8 + const lines = content.trim().split('\n'); 9 + 10 + if (lines.length === 0) { 11 + console.error("CSV file is empty."); 12 + return; 13 + } 14 + 15 + const headers = lines[0].split(',').map(h => h.trim()); 16 + const exerciseIndex = headers.indexOf('Exercise'); 17 + 18 + if (exerciseIndex === -1) { 19 + console.error("Could not find 'Exercise' column in the CSV."); 20 + return; 21 + } 22 + 23 + const uniqueExercises = new Set<string>(); 24 + 25 + // Start at 1 to skip header row 26 + for (let i = 1; i < lines.length; i++) { 27 + const row = lines[i].split(','); 28 + if (row.length < headers.length) continue; 29 + 30 + const exerciseName = row[exerciseIndex].trim(); 31 + if (exerciseName) { 32 + uniqueExercises.add(exerciseName); 33 + } 34 + } 35 + 36 + let fileContent = `// exercise-map.ts\n`; 37 + fileContent += `// Replace the empty strings with your actual Hevy exercise_template_ids from get-templates.ts\n\n`; 38 + fileContent += `export const EXERCISE_MAP: Record<string, string> = {\n`; 39 + 40 + for (const ex of uniqueExercises) { 41 + fileContent += ` "${ex}": "",\n`; 42 + } 43 + 44 + fileContent += `};\n`; 45 + 46 + fs.writeFileSync('exercise-map.ts', fileContent); 47 + console.log(`Successfully generated 'exercise-map.ts' with ${uniqueExercises.size} unique exercises.`); 48 + console.log(`Please fill in the template IDs before running the import script.`); 49 + } 50 + 51 + generateExerciseMapTemplate();
+38
get-templates.ts
··· 1 + // get-templates.tsm 2 + import { HEVY_API_KEY, HEVY_API_BASE } from './config.ts'; 3 + 4 + async function getTemplates() { 5 + if (HEVY_API_KEY === 'YOUR_HEVY_API_KEY_HERE') { 6 + console.error("Please set your HEVY_API_KEY in config.tsm first."); 7 + return; 8 + } 9 + 10 + try { 11 + console.log("--- Your Hevy Exercise Templates ---"); 12 + let page = 1; 13 + let pageCount = 1; 14 + 15 + while (page <= pageCount) { 16 + const response = await fetch(`${HEVY_API_BASE}/exercise_templates?page=${page}&pageSize=100`, { 17 + headers: { 'api-key': HEVY_API_KEY } 18 + }); 19 + 20 + if (!response.ok) { 21 + throw new Error(`Failed to fetch templates: ${response.status} ${response.statusText}`); 22 + } 23 + 24 + const data = await response.json(); 25 + pageCount = data.page_count ?? 1; 26 + 27 + for (const ex of data.exercise_templates) { 28 + console.log(`"${ex.title}": "${ex.id}",`); 29 + } 30 + 31 + page++; 32 + } 33 + } catch (error) { 34 + console.error("Error fetching templates:", error); 35 + } 36 + } 37 + 38 + getTemplates();
+141
import.ts
··· 1 + // import.tsm 2 + import * as fs from 'node:fs'; 3 + import { FITBOD_CSV_PATH, HEVY_API_KEY, HEVY_API_BASE } from './config.ts'; 4 + import { EXERCISE_MAP } from './exercise-map.ts'; 5 + 6 + // --- Types --- 7 + type HevySet = { 8 + type: "normal" | "warmup" | "failure" | "drop"; 9 + weight_kg?: number; 10 + reps?: number; 11 + distance_meters?: number; 12 + duration_seconds?: number; 13 + }; 14 + 15 + type WorkoutData = Record<string, HevySet[]>; 16 + 17 + function parseFitbodCSV(filePath: string): Map<string, WorkoutData> { 18 + const content = fs.readFileSync(filePath, 'utf-8'); 19 + const lines = content.trim().split('\n'); 20 + const headers = lines[0].split(',').map(h => h.trim()); 21 + 22 + const idxDate = headers.indexOf('Date'); 23 + const idxExercise = headers.indexOf('Exercise'); 24 + const idxReps = headers.indexOf('Reps'); 25 + const idxWeight = headers.indexOf('Weight(kg)'); 26 + const idxDuration = headers.indexOf('Duration(s)'); 27 + const idxDistance = headers.indexOf('Distance(m)'); 28 + const idxWarmup = headers.indexOf('isWarmup'); 29 + 30 + const workouts = new Map<string, WorkoutData>(); 31 + 32 + for (let i = 1; i < lines.length; i++) { 33 + const row = lines[i].split(','); 34 + if (row.length < headers.length) continue; 35 + 36 + const dateStr = row[idxDate]; 37 + const day = dateStr.split(' ')[0]; 38 + const exercise = row[idxExercise].trim(); 39 + 40 + const reps = parseInt(row[idxReps], 10) || 0; 41 + const weightKg = parseFloat(row[idxWeight]) || 0; 42 + const durationSec = parseFloat(row[idxDuration]) || 0; 43 + const distanceM = parseFloat(row[idxDistance]) || 0; 44 + const isWarmup = row[idxWarmup].toLowerCase() === 'true'; 45 + 46 + const setData: HevySet = { type: isWarmup ? 'warmup' : 'normal' }; 47 + 48 + // Determine if this is a cardio/distance set or a weight/reps set 49 + if (distanceM > 0) { 50 + setData.distance_meters = distanceM; 51 + setData.duration_seconds = Math.round(durationSec); 52 + } else if (durationSec > 0 && weightKg === 0 && reps === 0) { 53 + setData.duration_seconds = Math.round(durationSec); 54 + } else { 55 + setData.weight_kg = weightKg; 56 + setData.reps = reps; 57 + } 58 + 59 + if (!workouts.has(day)) { 60 + workouts.set(day, {}); 61 + } 62 + 63 + const dayWorkouts = workouts.get(day)!; 64 + if (!dayWorkouts[exercise]) { 65 + dayWorkouts[exercise] = []; 66 + } 67 + 68 + dayWorkouts[exercise].push(setData); 69 + } 70 + 71 + return workouts; 72 + } 73 + 74 + async function pushToHevy(dayStr: string, exercisesData: WorkoutData) { 75 + // Convert YYYY-MM-DD to ISO 8601 string defaulting to noon UTC 76 + const isoDate = `${dayStr}T12:00:00Z`; 77 + 78 + const payload = { 79 + workout: { 80 + title: "Fitbod Import", 81 + start_time: isoDate, 82 + end_time: isoDate, 83 + exercises: [] as any[] 84 + } 85 + }; 86 + 87 + for (const [exName, sets] of Object.entries(exercisesData)) { 88 + const templateId = EXERCISE_MAP[exName]; 89 + if (!templateId) { 90 + console.log(` [!] Skipping unknown/unmapped exercise: '${exName}'`); 91 + continue; 92 + } 93 + 94 + payload.workout.exercises.push({ 95 + exercise_template_id: templateId, 96 + sets: sets 97 + }); 98 + } 99 + 100 + if (payload.workout.exercises.length === 0) { 101 + console.log(`Skipping ${dayStr} - No mapped exercises found.`); 102 + return; 103 + } 104 + 105 + const response = await fetch(`${HEVY_API_BASE}/workouts`, { 106 + method: 'POST', 107 + headers: { 108 + 'api-key': HEVY_API_KEY, 109 + 'Content-Type': 'application/json' 110 + }, 111 + body: JSON.stringify(payload) 112 + }); 113 + 114 + if (response.ok) { 115 + console.log(`Successfully imported workout for ${dayStr}`); 116 + } else { 117 + const errText = await response.text(); 118 + console.log(`Failed to import ${dayStr}: ${response.status} - ${errText}`); 119 + } 120 + } 121 + 122 + async function main() { 123 + if (HEVY_API_KEY === 'YOUR_HEVY_API_KEY_HERE') { 124 + console.error("Please set your HEVY_API_KEY in config.tsm first."); 125 + return; 126 + } 127 + 128 + console.log("Parsing Fitbod CSV..."); 129 + const workouts = parseFitbodCSV(FITBOD_CSV_PATH); 130 + 131 + console.log(`Found ${workouts.size} workout days. Starting import...`); 132 + 133 + for (const [dayStr, exercisesData] of workouts.entries()) { 134 + await pushToHevy(dayStr, exercisesData); 135 + 136 + // 1.5-second delay to respect Hevy API rate limits 137 + await new Promise(resolve => setTimeout(resolve, 1500)); 138 + } 139 + } 140 + 141 + main().catch(console.error);