UNPKG

@vreippainen/hevy-mcp-server

Version:
387 lines 16.2 kB
import hevyApi from './hevyApi.js'; import { calculateEstimated1RM } from '../utils/oneRepMaxCalculator.js'; /** * Calculate statistics for a workout */ function calculateWorkoutStats(workout) { // Calculate duration in minutes const startTime = new Date(workout.start_time).getTime(); const endTime = new Date(workout.end_time).getTime(); const durationMinutes = Math.round((endTime - startTime) / (1000 * 60)); // Calculate total volume (weight × reps) let totalVolume = 0; let totalSets = 0; let exerciseCount = workout.exercises.length; for (const exercise of workout.exercises) { for (const set of exercise.sets) { totalSets++; if (set.weight_kg && set.reps) { totalVolume += set.weight_kg * set.reps; } } } return { durationMinutes, exerciseCount, totalSets, totalVolume, }; } /** * Analyze progress for a specific exercise across multiple workouts */ function analyzeProgressForExercise(exerciseId, workouts) { // Use filter to get only workouts containing the exercise, then map to transform them const exerciseDataFromWorkouts = workouts .filter((workout) => workout.exercises.some((ex) => ex.exercise_template_id === exerciseId)) .map((workout) => { const exercise = workout.exercises.find((ex) => ex.exercise_template_id === exerciseId); return { date: workout.start_time, sets: exercise.sets.map((set) => ({ index: set.index, type: set.type, weightKg: set.weight_kg, reps: set.reps, })), }; }); // Sort by date exerciseDataFromWorkouts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return exerciseDataFromWorkouts; } /** * Fetch all workouts by handling pagination * @returns Promise with array of all workouts */ async function fetchAllWorkouts() { try { const startTime = new Date(); const MAX_PAGE_SIZE = 10; // Get the first page to determine total page count const firstPageResponse = await hevyApi.getWorkouts({ page: 1, pageSize: MAX_PAGE_SIZE }); const totalPages = firstPageResponse.pageCount; // If there's only one page, return it directly if (totalPages <= 1) { return firstPageResponse.workouts; } // Create an array of promises for pages 2 through totalPages const pagePromises = Array.from({ length: totalPages - 1 }, (_, i) => { return hevyApi.getWorkouts({ page: i + 2, pageSize: MAX_PAGE_SIZE }); }); // Fetch all pages concurrently const pageResponses = await Promise.all(pagePromises); // Combine all workouts const allWorkouts = [ ...firstPageResponse.workouts, ...pageResponses.flatMap((response) => response.workouts), ]; const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); console.warn(`Fetched ${allWorkouts.length} workouts in ${duration}ms`); return allWorkouts; } catch (error) { console.error('Error fetching all workouts:', error); throw error; } } /** * Fetch all exercise templates by handling pagination * @returns Promise with array of all exercise templates */ async function fetchAllExerciseTemplates() { try { const startTime = new Date(); const MAX_PAGE_SIZE = 10; // Get the first page to determine total page count const firstPageResponse = await hevyApi.getExercises({ page: 1, pageSize: MAX_PAGE_SIZE }); const totalPages = firstPageResponse.pageCount; // If there's only one page, return it directly if (totalPages <= 1) { return firstPageResponse.exercises; } // Create an array of promises for pages 2 through totalPages const pagePromises = Array.from({ length: totalPages - 1 }, (_, i) => { return hevyApi.getExercises({ page: i + 2, pageSize: MAX_PAGE_SIZE }); }); // Fetch all pages concurrently const pageResponses = await Promise.all(pagePromises); // Combine all exercises const allExercises = [ ...firstPageResponse.exercises, ...pageResponses.flatMap((response) => response.exercises), ]; const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); console.warn(`Fetched ${allExercises.length} exercise templates in ${duration}ms`); return allExercises; } catch (error) { console.error('Error fetching all exercise templates:', error); throw error; } } /** * Fetch all routines by handling pagination * @returns Promise with array of all routines */ async function fetchAllRoutines() { try { const MAX_PAGE_SIZE = 10; // Get the first page to determine total page count const firstPageResponse = await hevyApi.getRoutines({ page: 1, pageSize: MAX_PAGE_SIZE }); const totalPages = firstPageResponse.pageCount; // If there's only one page, return it directly if (totalPages <= 1) { return firstPageResponse.routines; } // Create an array of promises for pages 2 through totalPages const pagePromises = Array.from({ length: totalPages - 1 }, (_, i) => { return hevyApi.getRoutines({ page: i + 2, pageSize: MAX_PAGE_SIZE }); }); // Fetch all pages concurrently const pageResponses = await Promise.all(pagePromises); // Combine all routines const allRoutines = [ ...firstPageResponse.routines, ...pageResponses.flatMap((response) => response.routines), ]; return allRoutines; } catch (error) { console.error('Error fetching all routines:', error); throw error; } } /** * Get workouts within a specific timeframe */ async function getWorkouts(startDate, endDate) { try { // Get all workouts const allWorkouts = await fetchAllWorkouts(); // Filter workouts by both dates in a single pass const filteredWorkouts = allWorkouts.filter((workout) => { const workoutDate = new Date(workout.start_time); const afterStartDate = !startDate || workoutDate >= startDate; const beforeEndDate = !endDate || workoutDate <= endDate; return afterStartDate && beforeEndDate; }); // Sort by date descending (most recent first) const sortedWorkouts = [...filteredWorkouts].sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime()); return sortedWorkouts; } catch (error) { console.error('Error fetching workouts in timeframe:', error); throw error; } } /** * Get comprehensive exercise data sorted by frequency of use * @param {string} [searchTerm] - Optional search term to filter exercises by name * @param {boolean} [excludeUnused=false] - If true, exclude exercises with 0 frequency (never used) * @param {string} [startDate] - Optional ISO date string to filter workouts after this date * @param {string} [endDate] - Optional ISO date string to filter workouts before this date * @returns Array of objects containing exercise data, sorted by frequency */ async function getExercises(searchTerm, excludeUnused = false, startDate, endDate) { try { // Get all exercise templates and workouts (filtered by date if provided) const [allExerciseTemplates, allWorkouts] = await Promise.all([ fetchAllExerciseTemplates(), getWorkouts(startDate ? new Date(startDate) : undefined, endDate ? new Date(endDate) : undefined), ]); // If either API call returned empty arrays, return empty result if (!allWorkouts.length || !allExerciseTemplates.length) { return []; } // Filter exercise templates by search term if provided (early filtering) const filteredExerciseTemplates = searchTerm ? allExerciseTemplates.filter((template) => template.title.toLowerCase().includes(searchTerm.toLowerCase())) : allExerciseTemplates; // If no matches found, return early if (!filteredExerciseTemplates.length) { return []; } // Create a map of exercise IDs for quick lookup of filtered exercises const filteredExerciseIds = new Set(filteredExerciseTemplates.map((template) => template.id)); // Create a map to count exercise frequencies and track max weights by rep const exerciseFrequency = new Map(); const exerciseRecords = new Map(); // Count exercises across filtered workouts and track records for (const workout of allWorkouts) { // Use a set to count each exercise only once per workout for frequency const exercisesInWorkout = new Set(); for (const exercise of workout.exercises) { const exerciseId = exercise.exercise_template_id; // Skip if not in our filtered set if (!filteredExerciseIds.has(exerciseId)) continue; exercisesInWorkout.add(exerciseId); // Initialize records map for this exercise if it doesn't exist if (!exerciseRecords.has(exerciseId)) { exerciseRecords.set(exerciseId, new Map()); } // Track max weights by rep count for (const set of exercise.sets) { if (set.type === 'warmup' || !set.weight_kg || !set.reps) continue; const repCount = set.reps; const weight = set.weight_kg; const recordsMap = exerciseRecords.get(exerciseId); // Update if no record exists for this rep count or if weight is higher if (!recordsMap.has(repCount) || weight > recordsMap.get(repCount).weight) { recordsMap.set(repCount, { weight, date: workout.start_time, }); } } } // Increment the frequency for each unique exercise in this workout for (const exerciseId of exercisesInWorkout) { exerciseFrequency.set(exerciseId, (exerciseFrequency.get(exerciseId) || 0) + 1); } } // Calculate estimated 1RM for each exercise using the utility function const exerciseOneRepMax = new Map(); exerciseRecords.forEach((records, exerciseId) => { let highestEstimatedOneRM = null; let highestEstimatedDate = null; // Calculate 1RM for each rep/weight record and keep the highest valid estimate records.forEach(({ weight, date }, reps) => { // Use the utility function with default Brzycki formula const oneRM = calculateEstimated1RM(weight, reps); if (oneRM !== null && (highestEstimatedOneRM === null || oneRM > highestEstimatedOneRM)) { highestEstimatedOneRM = oneRM; highestEstimatedDate = date; } }); if (highestEstimatedOneRM !== null && highestEstimatedDate !== null) { // Round to 1 decimal place exerciseOneRepMax.set(exerciseId, { weightKg: Math.round(highestEstimatedOneRM * 10) / 10, date: highestEstimatedDate, }); } }); // Get actual 1RM (max weight lifted for 1 rep) const exerciseActualOneRM = new Map(); exerciseRecords.forEach((records, exerciseId) => { // Find the maximum weight lifted across all rep counts let maxWeight = 0; let maxWeightDate = ''; records.forEach(({ weight, date }) => { if (weight > maxWeight) { maxWeight = weight; maxWeightDate = date; } }); if (maxWeight > 0) { exerciseActualOneRM.set(exerciseId, { weightKg: maxWeight, date: maxWeightDate, }); } }); // Create result array with exercise details, frequency and 1RM data let exerciseData = filteredExerciseTemplates.map((template) => { return { id: template.id, name: template.title, frequency: exerciseFrequency.get(template.id) || 0, estimated1RM: exerciseOneRepMax.get(template.id) || null, actual1RM: exerciseActualOneRM.get(template.id) || null, type: template.type, primary_muscle_group: template.primary_muscle_group, secondary_muscle_groups: template.secondary_muscle_groups, equipment: template.equipment, }; }); // Filter out exercises with zero frequency if requested if (excludeUnused) { exerciseData = exerciseData.filter((exercise) => exercise.frequency > 0); } // Sort by frequency in descending order exerciseData.sort((a, b) => b.frequency - a.frequency); return exerciseData; } catch (error) { console.error('Error getting exercises data:', error); throw error; } } /** * Populate the cache with initial data * Pre-fetches all exercise templates, routines, and workouts */ async function populateCache() { await Promise.all([fetchAllExerciseTemplates(), fetchAllRoutines(), fetchAllWorkouts()]); } /** * Calculate records by reps from progress data * @param progressData Array of exercise progress data * @returns Records for each rep count */ function calculateRecordsByReps(progressData) { // Create a map to track best weight for each rep count const recordsByReps = new Map(); // Process all workout data for (const workout of progressData) { // Process each set to find PRs for (const set of workout.sets) { // Skip warmup sets for PR calculations if (set.type === 'warmup') continue; const repCount = set.reps; const weight = set.weightKg; // Update record if this weight is heavier for this rep count if (!recordsByReps.has(repCount) || weight > recordsByReps.get(repCount).weight_kg) { recordsByReps.set(repCount, { weight_kg: weight, date: workout.date, }); } } } // Convert to array format and sort by rep count return Array.from(recordsByReps.entries()) .map(([reps, record]) => ({ reps, weight_kg: record.weight_kg, date: record.date, })) .sort((a, b) => a.reps - b.reps); } /** * Process progress data for a single exercise * @param exerciseId Exercise ID to analyze * @param allExercises List of all exercise templates * @param workouts List of all workouts * @param limit Number of latest sessions to return * @returns Progress data and records for the exercise */ function processExerciseProgress(exercise, workouts, limit) { // Get progress data for this exercise const progress = analyzeProgressForExercise(exercise.id, workouts); const personalRecords = calculateRecordsByReps(progress); return { exercise, personalRecords, sessions: progress.slice(0, limit), }; } export default { calculateWorkoutStats, analyzeProgressForExercise, fetchAllExerciseTemplates, fetchAllRoutines, getWorkouts, fetchAllWorkouts, populateCache, calculateRecordsByReps, getExercises, processExerciseProgress, }; //# sourceMappingURL=hevyService.js.map