Skip to main content
Glama
intervals.ts18.7 kB
import type { NormalizedWorkout, FitnessMetrics, PlannedWorkout, IntervalsConfig, AthleteProfile, SportSettings, TrainingZone, PowerCurve, PowerCurvePoint, PaceCurve, PaceCurvePoint, DailyTrainingLoad, TrainingLoadTrends, TrainingLoadSummary, CTLTrend, ACWRStatus, } from '../types/index.js'; import { normalizeActivityType } from '../utils/activity-matcher.js'; const INTERVALS_API_BASE = 'https://intervals.icu/api/v1'; interface IntervalsActivity { id: string; start_date_local: string; type: string; name?: string; description?: string; moving_time?: number; elapsed_time?: number; distance?: number; icu_training_load?: number; icu_intensity?: number; weighted_avg_watts?: number; average_watts?: number; average_heartrate?: number; max_heartrate?: number; total_elevation_gain?: number; calories?: number; pace?: number; // Speed metrics average_speed?: number; // m/s max_speed?: number; // Coasting coasting_time?: number; // seconds // Training load & feel rpe?: number; feel?: number; // Sweetspot & classification icu_zone_times?: number[]; gap?: number; // gradient adjusted pace workout_doc?: { class?: string; }; // HR metrics hrrc?: number; trimp?: number; // Power efficiency variability_index?: number; decoupling?: number; efficiency_factor?: number; // Fitness at activity time ctl?: number; atl?: number; // Cadence average_cadence?: number; max_cadence?: number; // Thresholds for this activity icu_ftp?: number; icu_eftp?: number; icu_pm_ftp?: number; // activity-derived eFTP // Energy joules?: number; carbs_used?: number; carbs_ingested?: number; // Intervals/laps icu_intervals?: unknown[]; laps?: unknown[]; } interface IntervalsWellness { id: string; date: string; ctl: number; atl: number; rampRate?: number; load?: number; // daily training load } // Athlete profile response interface IntervalsAthlete { id: string; name?: string; weight?: number; sportSettings?: IntervalsSportSettings[]; } interface IntervalsSportSettings { id?: number; type: string; // "Ride", "Run", "Swim" ftp?: number; indoor_ftp?: number; icu_eftp?: number; lthr?: number; max_hr?: number; resting_hr?: number; threshold_pace?: number; w_prime?: number; pmax?: number; weight?: number; power_zones?: Array<{ id: number; name: string; min: number; max: number; color?: string; }>; hr_zones?: Array<{ id: number; name: string; min: number; max: number; }>; pace_zones?: Array<{ id: number; name: string; min: number; max: number; }>; } // Power curve response interface IntervalsPowerCurve { secs: number[]; watts: number[]; wattsPerKg?: number[]; dates?: string[]; } // Pace curve response interface IntervalsPaceCurve { secs: number[]; value: number[]; // pace in seconds/meter dates?: string[]; } interface IntervalsEvent { id: number; uid?: string; start_date_local: string; name: string; description?: string; type: string; category?: string; icu_training_load?: number; icu_intensity?: number; moving_time?: number; duration?: number; } export class IntervalsClient { private config: IntervalsConfig; private authHeader: string; constructor(config: IntervalsConfig) { this.config = config; // Intervals.icu uses API key as password with "API_KEY" as username const credentials = Buffer.from(`API_KEY:${config.apiKey}`).toString('base64'); this.authHeader = `Basic ${credentials}`; } private async fetch<T>(endpoint: string, params?: Record<string, string>): Promise<T> { const url = new URL(`${INTERVALS_API_BASE}/athlete/${this.config.athleteId}${endpoint}`); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.set(key, value); }); } const response = await fetch(url.toString(), { headers: { Authorization: this.authHeader, Accept: 'application/json', }, }); if (!response.ok) { throw new Error( `Intervals.icu API error: ${response.status} ${response.statusText}` ); } return response.json() as Promise<T>; } /** * Get completed activities within a date range */ async getActivities( startDate: string, endDate: string, sport?: string ): Promise<NormalizedWorkout[]> { const activities = await this.fetch<IntervalsActivity[]>('/activities', { oldest: startDate, newest: endDate, }); let filtered = activities; if (sport) { const normalizedSport = normalizeActivityType(sport); filtered = activities.filter( (a) => normalizeActivityType(a.type) === normalizedSport ); } return filtered.map((a) => this.normalizeActivity(a)); } /** * Get a single activity by ID */ async getActivity(activityId: string): Promise<NormalizedWorkout> { const activity = await this.fetch<IntervalsActivity>(`/activities/${activityId}`); return this.normalizeActivity(activity); } /** * Get fitness metrics (CTL/ATL/TSB) for a date range */ async getFitnessMetrics( startDate: string, endDate: string ): Promise<FitnessMetrics[]> { const wellness = await this.fetch<IntervalsWellness[]>('/wellness', { oldest: startDate, newest: endDate, }); return wellness.map((w) => ({ date: w.date, ctl: w.ctl, atl: w.atl, tsb: w.ctl - w.atl, // Training Stress Balance = CTL - ATL ramp_rate: w.rampRate, })); } /** * Get planned events/workouts from calendar */ async getPlannedEvents( startDate: string, endDate: string ): Promise<PlannedWorkout[]> { const events = await this.fetch<IntervalsEvent[]>('/events', { oldest: startDate, newest: endDate, }); // Filter to only planned workouts (not completed activities) const plannedEvents = events.filter( (e) => e.category === 'WORKOUT' || e.category === 'RACE' || e.category === 'NOTE' ); return plannedEvents.map((e) => this.normalizePlannedEvent(e)); } /** * Get today's fitness metrics */ async getTodayFitness(): Promise<FitnessMetrics | null> { const today = new Date().toISOString().split('T')[0]; const metrics = await this.getFitnessMetrics(today, today); return metrics.length > 0 ? metrics[0] : null; } private normalizeActivity(activity: IntervalsActivity): NormalizedWorkout { // Calculate coasting percentage if we have both values const coastingPercentage = activity.coasting_time && activity.moving_time ? (activity.coasting_time / activity.moving_time) * 100 : undefined; // Convert speed from m/s to km/h const avgSpeedKph = activity.average_speed ? activity.average_speed * 3.6 : undefined; const maxSpeedKph = activity.max_speed ? activity.max_speed * 3.6 : undefined; return { id: activity.id, date: activity.start_date_local, activity_type: normalizeActivityType(activity.type), name: activity.name, description: activity.description, duration_seconds: activity.moving_time ?? activity.elapsed_time ?? 0, distance_km: activity.distance ? activity.distance / 1000 : undefined, tss: activity.icu_training_load, normalized_power: activity.weighted_avg_watts, average_power: activity.average_watts, average_heart_rate: activity.average_heartrate, max_heart_rate: activity.max_heartrate, intensity_factor: activity.icu_intensity, elevation_gain_m: activity.total_elevation_gain, calories: activity.calories, source: 'intervals.icu', // Speed metrics average_speed_kph: avgSpeedKph, max_speed_kph: maxSpeedKph, // Coasting coasting_time_seconds: activity.coasting_time, coasting_percentage: coastingPercentage, // Training load & feel load: activity.icu_training_load, rpe: activity.rpe, feel: activity.feel, // Classification workout_class: activity.workout_doc?.class, // HR metrics hrrc: activity.hrrc, trimp: activity.trimp, // Power efficiency variability_index: activity.variability_index, power_hr_ratio: activity.decoupling, efficiency_factor: activity.efficiency_factor, // Fitness snapshot ctl_at_activity: activity.ctl, atl_at_activity: activity.atl, tsb_at_activity: activity.ctl !== undefined && activity.atl !== undefined ? activity.ctl - activity.atl : undefined, // Cadence average_cadence: activity.average_cadence, max_cadence: activity.max_cadence, // Thresholds ftp: activity.icu_ftp, eftp: activity.icu_eftp, activity_eftp: activity.icu_pm_ftp, // Energy work_kj: activity.joules ? activity.joules / 1000 : undefined, cho_used_g: activity.carbs_used, cho_intake_g: activity.carbs_ingested, // Intervals/laps count intervals_count: activity.icu_intervals?.length, laps_count: activity.laps?.length, }; } private normalizePlannedEvent(event: IntervalsEvent): PlannedWorkout { return { id: event.uid ?? String(event.id), date: event.start_date_local, name: event.name, description: event.description, expected_tss: event.icu_training_load, expected_if: event.icu_intensity, expected_duration_minutes: event.moving_time ? Math.round(event.moving_time / 60) : event.duration, workout_type: event.type, source: 'intervals.icu', }; } // ============================================ // Athlete Profile // ============================================ /** * Get athlete profile including sport settings, zones, and thresholds */ async getAthleteProfile(): Promise<AthleteProfile> { const athlete = await this.fetch<IntervalsAthlete>(''); const sports: SportSettings[] = (athlete.sportSettings ?? []).map( (settings) => this.normalizeSportSettings(settings) ); // Find primary cycling sport for default values const cyclingSport = sports.find( (s) => s.sport_type === 'Ride' || s.sport_type.toLowerCase().includes('cycling') ); return { athlete_id: this.config.athleteId, name: athlete.name, weight_kg: athlete.weight, sports, primary_ftp: cyclingSport?.ftp, primary_lthr: cyclingSport?.lthr, primary_max_hr: cyclingSport?.max_hr, }; } private normalizeSportSettings( settings: IntervalsSportSettings ): SportSettings { return { sport_type: settings.type, ftp: settings.ftp, indoor_ftp: settings.indoor_ftp, eftp: settings.icu_eftp, w_prime: settings.w_prime, pmax: settings.pmax, lthr: settings.lthr, max_hr: settings.max_hr, resting_hr: settings.resting_hr, threshold_pace: settings.threshold_pace, weight_kg: settings.weight, power_zones: settings.power_zones?.map((z, i) => ({ zone_number: i + 1, name: z.name, min_value: z.min, max_value: z.max === 0 ? null : z.max, color: z.color, })), heart_rate_zones: settings.hr_zones?.map((z, i) => ({ zone_number: i + 1, name: z.name, min_value: z.min, max_value: z.max === 0 ? null : z.max, })), pace_zones: settings.pace_zones?.map((z, i) => ({ zone_number: i + 1, name: z.name, min_value: z.min, max_value: z.max === 0 ? null : z.max, })), }; } // ============================================ // Power Curves // ============================================ /** * Get power curve (best efforts at various durations) * @param sport - Sport type, defaults to 'Ride' * @param period - Time period: '42d', '90d', '1y', 'all' */ async getPowerCurve( sport: string = 'Ride', period: string = '90d' ): Promise<PowerCurve> { const data = await this.fetch<IntervalsPowerCurve>('/power-curves', { curves: period, type: sport, }); // Build curve points from parallel arrays const curve: PowerCurvePoint[] = data.secs.map((sec, i) => ({ duration_seconds: sec, watts: data.watts[i], watts_per_kg: data.wattsPerKg?.[i], date: data.dates?.[i], })); // Extract key durations const findWattsAtDuration = (targetSecs: number): number | undefined => { const idx = data.secs.indexOf(targetSecs); return idx >= 0 ? data.watts[idx] : undefined; }; // Get athlete weight for context let athleteWeight: number | undefined; let athleteFtp: number | undefined; try { const profile = await this.getAthleteProfile(); athleteWeight = profile.weight_kg; athleteFtp = profile.primary_ftp; } catch { // Ignore if profile fetch fails } return { sport, period, athlete_ftp: athleteFtp, athlete_weight_kg: athleteWeight, curve, peak_5s: findWattsAtDuration(5), peak_1min: findWattsAtDuration(60), peak_5min: findWattsAtDuration(300), peak_20min: findWattsAtDuration(1200), peak_60min: findWattsAtDuration(3600), }; } // ============================================ // Pace Curves // ============================================ /** * Get pace curve (best paces at various durations) * @param period - Time period: '42d', '90d', '1y', 'all' * @param gap - Use gradient-adjusted pace */ async getPaceCurve( period: string = '90d', gap: boolean = false ): Promise<PaceCurve> { const params: Record<string, string> = { curves: period, type: 'Run', }; if (gap) { params.gap = 'true'; } const data = await this.fetch<IntervalsPaceCurve>('/pace-curves', params); // Convert pace from sec/meter to sec/km and speed to km/h const curve: PaceCurvePoint[] = data.secs.map((sec, i) => { const paceSecPerMeter = data.value[i]; const paceSecPerKm = paceSecPerMeter * 1000; const speedKph = 3600 / paceSecPerKm; return { duration_seconds: sec, pace_per_km: paceSecPerKm, speed_kph: speedKph, date: data.dates?.[i], }; }); // Helper to find pace at specific duration const findPaceAtDuration = (targetSecs: number): number | undefined => { const idx = data.secs.indexOf(targetSecs); if (idx < 0) return undefined; return data.value[idx] * 1000; // Convert to sec/km }; return { period, gradient_adjusted: gap, curve, // Approximate durations for common race distances (at typical paces) peak_400m_pace: findPaceAtDuration(60), // ~1min effort peak_1km_pace: findPaceAtDuration(180), // ~3min effort peak_5km_pace: findPaceAtDuration(1200), // ~20min effort peak_10km_pace: findPaceAtDuration(2400), // ~40min effort peak_half_marathon_pace: findPaceAtDuration(5400), // ~90min effort }; } // ============================================ // Training Load Trends // ============================================ /** * Get training load trends (CTL/ATL/TSB over time) * @param days - Number of days of history */ async getTrainingLoadTrends(days: number = 42): Promise<TrainingLoadTrends> { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const wellness = await this.fetch<IntervalsWellness[]>('/wellness', { oldest: startDate, newest: endDate, }); const data: DailyTrainingLoad[] = wellness.map((w) => ({ date: w.date, ctl: w.ctl, atl: w.atl, tsb: w.ctl - w.atl, ramp_rate: w.rampRate, daily_tss: w.load, })); // Calculate summary const summary = this.calculateTrainingLoadSummary(data); return { period_days: days, sport: 'all', data, summary, }; } private calculateTrainingLoadSummary( data: DailyTrainingLoad[] ): TrainingLoadSummary { if (data.length === 0) { return { current_ctl: 0, current_atl: 0, current_tsb: 0, ctl_trend: 'stable', avg_ramp_rate: 0, peak_ctl: 0, peak_ctl_date: '', acwr: 0, acwr_status: 'low_risk', }; } const latest = data[data.length - 1]; const currentCtl = latest.ctl; const currentAtl = latest.atl; const currentTsb = latest.tsb; // Calculate CTL trend (compare last 7 days vs previous 7) let ctlTrend: CTLTrend = 'stable'; if (data.length >= 14) { const recent7 = data.slice(-7); const previous7 = data.slice(-14, -7); const recentAvg = recent7.reduce((sum, d) => sum + d.ctl, 0) / recent7.length; const previousAvg = previous7.reduce((sum, d) => sum + d.ctl, 0) / previous7.length; const diff = recentAvg - previousAvg; if (diff > 2) ctlTrend = 'increasing'; else if (diff < -2) ctlTrend = 'decreasing'; } // Average ramp rate const rampRates = data .filter((d) => d.ramp_rate !== undefined) .map((d) => d.ramp_rate!); const avgRampRate = rampRates.length > 0 ? rampRates.reduce((sum, r) => sum + r, 0) / rampRates.length : 0; // Peak CTL let peakCtl = 0; let peakCtlDate = ''; for (const d of data) { if (d.ctl > peakCtl) { peakCtl = d.ctl; peakCtlDate = d.date; } } // ACWR (Acute:Chronic Workload Ratio) const acwr = currentCtl > 0 ? currentAtl / currentCtl : 0; // Determine ACWR status let acwrStatus: ACWRStatus; if (acwr < 0.8) { acwrStatus = 'low_risk'; // Undertrained } else if (acwr <= 1.3) { acwrStatus = 'optimal'; // Sweet spot } else if (acwr <= 1.5) { acwrStatus = 'caution'; // Getting risky } else { acwrStatus = 'high_risk'; // Injury risk } return { current_ctl: Math.round(currentCtl * 10) / 10, current_atl: Math.round(currentAtl * 10) / 10, current_tsb: Math.round(currentTsb * 10) / 10, ctl_trend: ctlTrend, avg_ramp_rate: Math.round(avgRampRate * 10) / 10, peak_ctl: Math.round(peakCtl * 10) / 10, peak_ctl_date: peakCtlDate, acwr: Math.round(acwr * 100) / 100, acwr_status: acwrStatus, }; } }

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