Skip to main content
Glama
trainerroad.ts6.9 kB
import ical from 'node-ical'; import { parseISO, isWithinInterval, format } from 'date-fns'; import type { PlannedWorkout, TrainerRoadConfig, Discipline } from '../types/index.js'; interface CalendarEvent { uid: string; start: Date; end: Date; summary: string; description?: string; } export class TrainerRoadClient { private config: TrainerRoadConfig; constructor(config: TrainerRoadConfig) { this.config = config; } /** * Fetch and parse the iCalendar feed (always fresh, no caching) */ private async fetchCalendar(): Promise<CalendarEvent[]> { const response = await fetch(this.config.calendarUrl); if (!response.ok) { throw new Error( `TrainerRoad calendar fetch failed: ${response.status} ${response.statusText}` ); } const icsData = await response.text(); const parsed = ical.parseICS(icsData); const events: CalendarEvent[] = []; for (const [_, component] of Object.entries(parsed)) { if (component.type === 'VEVENT') { const event = component as ical.VEvent; if (event.start && event.summary) { events.push({ uid: event.uid || `trainerroad-${event.start.getTime()}`, start: event.start, end: event.end || event.start, summary: event.summary, description: event.description, }); } } } return events; } /** * Get planned workouts within a date range */ async getPlannedWorkouts( startDate: string, endDate: string ): Promise<PlannedWorkout[]> { const events = await this.fetchCalendar(); const start = parseISO(startDate); const end = parseISO(`${endDate}T23:59:59`); const eventsInRange = events.filter((event) => isWithinInterval(event.start, { start, end }) ); return eventsInRange.map((event) => this.normalizeEvent(event)); } /** * Get today's planned workouts */ async getTodayWorkouts(): Promise<PlannedWorkout[]> { const today = new Date().toISOString().split('T')[0]; return this.getPlannedWorkouts(today, today); } /** * Get upcoming workouts for the next N days */ async getUpcomingWorkouts(days: number): Promise<PlannedWorkout[]> { const today = new Date(); const endDate = new Date(today); endDate.setDate(endDate.getDate() + days); return this.getPlannedWorkouts( format(today, 'yyyy-MM-dd'), format(endDate, 'yyyy-MM-dd') ); } private normalizeEvent(event: CalendarEvent): PlannedWorkout { const parsed = this.parseDescription(event.description); // Calculate duration from event times if not in description let durationMinutes = parsed.duration; if (!durationMinutes && event.start && event.end) { durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60); } const discipline = this.detectDiscipline(event.summary); const durationHuman = durationMinutes ? this.formatDuration(durationMinutes, discipline) : undefined; return { id: event.uid, date: event.start.toISOString(), name: event.summary, description: event.description, expected_tss: parsed.tss, expected_if: parsed.if, expected_duration_minutes: durationMinutes, duration_human: durationHuman, discipline, workout_type: parsed.workoutType, intervals: parsed.intervals, source: 'trainerroad', }; } /** * Detect discipline from workout name * Checks for Run/Swim keywords, defaults to Bike */ private detectDiscipline(name: string): Discipline { const lowerName = name.toLowerCase(); if (lowerName.includes('run') || lowerName.includes('running')) { return 'Run'; } if (lowerName.includes('swim') || lowerName.includes('swimming')) { return 'Swim'; } return 'Bike'; } /** * Format duration as human-readable string * Under 90 minutes: "45-minute ride" * 90+ minutes: "1:30 ride" */ private formatDuration(minutes: number, discipline: Discipline): string { const suffix = discipline === 'Bike' ? 'ride' : discipline.toLowerCase(); if (minutes < 90) { return `${Math.round(minutes)}-minute ${suffix}`; } const hours = Math.floor(minutes / 60); const mins = Math.round(minutes % 60); return `${hours}:${mins.toString().padStart(2, '0')} ${suffix}`; } private parseDescription(description?: string): { tss?: number; if?: number; duration?: number; workoutType?: string; intervals?: string; } { if (!description) { return {}; } const result: ReturnType<typeof this.parseDescription> = {}; // Try to extract TSS (e.g., "TSS: 75" or "TSS 75") const tssMatch = description.match(/TSS[:\s]+(\d+(?:\.\d+)?)/i); if (tssMatch) { result.tss = parseFloat(tssMatch[1]); } // Try to extract IF (e.g., "IF: 0.85" or "Intensity Factor: 85%") const ifMatch = description.match(/(?:IF|Intensity Factor)[:\s]+(\d+(?:\.\d+)?)/i); if (ifMatch) { let ifValue = parseFloat(ifMatch[1]); if (ifValue > 1) { ifValue = ifValue / 100; } result.if = ifValue; } // Try to extract duration - requires "Duration:" prefix or standalone time format at line start const explicitDurationMatch = description.match( /Duration[:\s]+(\d+(?::\d{2})?(?::\d{2})?)\s*(?:minutes?|mins?|hours?|hrs?)?/i ); // Match time format like "1:00" or "1:30:00" only at start of line or after newline const timeFormatMatch = description.match(/(?:^|\n)(\d{1,2}:\d{2}(?::\d{2})?)/); const durationMatch = explicitDurationMatch || timeFormatMatch; if (durationMatch) { const durationStr = durationMatch[1]; if (durationStr.includes(':')) { const parts = durationStr.split(':').map(Number); if (parts.length === 3) { result.duration = parts[0] * 60 + parts[1] + parts[2] / 60; } else if (parts.length === 2) { result.duration = parts[0] * 60 + parts[1]; } } else { const value = parseInt(durationStr, 10); // Check if units indicate hours if (/hours?|hrs?/i.test(durationMatch[0])) { result.duration = value * 60; } else { result.duration = value; } } } // Try to extract workout type from first line const lines = description.split('\n'); if (lines.length > 0) { const firstLine = lines[0].trim(); if (firstLine && !firstLine.match(/^(TSS|IF|Duration)/i)) { result.workoutType = firstLine; } } // Extract intervals section if present const intervalsMatch = description.match(/(?:Intervals?|Workout Structure)[:\s]+([\s\S]+?)(?:\n\n|$)/i); if (intervalsMatch) { result.intervals = intervalsMatch[1].trim(); } return result; } }

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