Skip to main content
Glama
current.ts6.74 kB
import { IntervalsClient } from '../clients/intervals.js'; import { WhoopClient } from '../clients/whoop.js'; import { TrainerRoadClient } from '../clients/trainerroad.js'; import { getDaysBackRange, getToday } from '../utils/date-parser.js'; import { findMatchingWhoopActivity } from '../utils/activity-matcher.js'; import type { NormalizedWorkout, WorkoutWithWhoop, WhoopMatchedData, RecoveryData, StrainData, StrainActivity, PlannedWorkout, AthleteProfile, } from '../types/index.js'; import type { GetRecentWorkoutsInput, GetRecentStrainInput } from './types.js'; export class CurrentTools { constructor( private intervals: IntervalsClient, private whoop: WhoopClient | null, private trainerroad: TrainerRoadClient | null ) {} /** * Get today's recovery data from Whoop */ async getTodaysRecovery(): Promise<RecoveryData | null> { if (!this.whoop) { return null; } try { return await this.whoop.getTodayRecovery(); } catch (error) { console.error('Error fetching today\'s recovery:', error); throw error; } } /** * Get recent completed workouts from Intervals.icu with optional Whoop data */ async getRecentWorkouts( params: GetRecentWorkoutsInput ): Promise<WorkoutWithWhoop[]> { const { days, sport } = params; const range = getDaysBackRange(days); try { // Fetch Intervals.icu activities const workouts = await this.intervals.getActivities( range.start, range.end, sport ); // If no Whoop client, return workouts without Whoop data if (!this.whoop) { return workouts.map((workout) => ({ ...workout, whoop: null, })); } // Fetch Whoop activities for the same date range let whoopActivities: StrainActivity[] = []; try { whoopActivities = await this.whoop.getWorkouts(range.start, range.end); } catch (error) { console.error('Error fetching Whoop activities for matching:', error); // Continue without Whoop data rather than failing entirely } // Match and merge return workouts.map((workout) => ({ ...workout, whoop: this.findAndMatchWhoopActivity(workout, whoopActivities), })); } catch (error) { console.error('Error fetching recent workouts:', error); throw error; } } /** * Find matching Whoop activity for an Intervals.icu workout */ private findAndMatchWhoopActivity( workout: NormalizedWorkout, whoopActivities: StrainActivity[] ): WhoopMatchedData | null { const matchResult = findMatchingWhoopActivity(workout, whoopActivities); if (!matchResult) { return null; } // Determine match confidence and method based on the matching logic const workoutStart = new Date(workout.date); const activityStart = new Date(matchResult.start_time); const timeDiffMinutes = Math.abs( (workoutStart.getTime() - activityStart.getTime()) / (1000 * 60) ); let confidence: 'high' | 'medium' | 'low'; let method: 'timestamp' | 'date_and_type' | 'date_only'; if (timeDiffMinutes <= 5) { confidence = 'high'; method = 'timestamp'; } else if (workout.activity_type === matchResult.activity_type) { confidence = 'medium'; method = 'date_and_type'; } else { confidence = 'low'; method = 'date_only'; } return { strain_score: matchResult.strain_score, average_heart_rate: matchResult.average_heart_rate, max_heart_rate: matchResult.max_heart_rate, calories: matchResult.calories, match_confidence: confidence, match_method: method, }; } /** * Get athlete profile including zones and thresholds */ async getAthleteProfile(): Promise<AthleteProfile> { try { return await this.intervals.getAthleteProfile(); } catch (error) { console.error('Error fetching athlete profile:', error); throw error; } } /** * Get recent strain data from Whoop */ async getRecentStrain(params: GetRecentStrainInput): Promise<StrainData[]> { if (!this.whoop) { return []; } const { days } = params; const range = getDaysBackRange(days); try { return await this.whoop.getStrainData(range.start, range.end); } catch (error) { console.error('Error fetching recent strain:', error); throw error; } } /** * Get today's planned workouts from both TrainerRoad and Intervals.icu */ async getTodaysPlannedWorkouts(): Promise<{ trainerroad: PlannedWorkout[]; intervals: PlannedWorkout[]; merged: PlannedWorkout[]; }> { const today = getToday(); const results: { trainerroad: PlannedWorkout[]; intervals: PlannedWorkout[]; merged: PlannedWorkout[]; } = { trainerroad: [], intervals: [], merged: [], }; // Fetch from both sources in parallel const [trainerroadWorkouts, intervalsWorkouts] = await Promise.all([ this.trainerroad?.getTodayWorkouts().catch((e) => { console.error('Error fetching TrainerRoad workouts:', e); return []; }) ?? Promise.resolve([]), this.intervals.getPlannedEvents(today, today).catch((e) => { console.error('Error fetching Intervals.icu events:', e); return []; }), ]); results.trainerroad = trainerroadWorkouts; results.intervals = intervalsWorkouts; // Merge workouts, preferring TrainerRoad for duplicates (has more detail) const merged = [...trainerroadWorkouts]; // Add Intervals.icu workouts that don't seem to be duplicates for (const intervalsWorkout of intervalsWorkouts) { const isDuplicate = trainerroadWorkouts.some((tr) => this.areWorkoutsSimilar(tr, intervalsWorkout) ); if (!isDuplicate) { merged.push(intervalsWorkout); } } results.merged = merged; return results; } /** * Check if two workouts are likely the same (for deduplication) */ private areWorkoutsSimilar(a: PlannedWorkout, b: PlannedWorkout): boolean { // Same day check const dateA = a.date.split('T')[0]; const dateB = b.date.split('T')[0]; if (dateA !== dateB) return false; // Similar name check (fuzzy) const nameA = a.name.toLowerCase().replace(/[^a-z0-9]/g, ''); const nameB = b.name.toLowerCase().replace(/[^a-z0-9]/g, ''); if (nameA.includes(nameB) || nameB.includes(nameA)) return true; // Similar TSS check if (a.expected_tss && b.expected_tss) { const tssDiff = Math.abs(a.expected_tss - b.expected_tss); if (tssDiff < 5) return true; } return false; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gesteves/domestique'

If you have feedback or need assistance with the MCP directory API, please join our Discord server