This is page 2 of 2. Use http://codebase.md/r-huijts/strava-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── setup-auth.ts ├── server.json ├── src │ ├── formatters.ts │ ├── server.ts │ ├── stravaClient.ts │ └── tools │ ├── exploreSegments.ts │ ├── exportRouteGpx.ts │ ├── exportRouteTcx.ts │ ├── formatWorkoutFile.ts │ ├── getActivityDetails.ts │ ├── getActivityLaps.ts │ ├── getActivityStreams.ts │ ├── getAllActivities.ts │ ├── getAthleteProfile.ts │ ├── getAthleteStats.ts │ ├── getAthleteZones.ts │ ├── getRecentActivities.ts │ ├── getRoute.ts │ ├── getSegment.ts │ ├── getSegmentEffort.ts │ ├── listAthleteClubs.ts │ ├── listAthleteRoutes.ts │ ├── listSegmentEfforts.ts │ ├── listStarredSegments.ts │ └── starSegment.ts ├── test-strava-api.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/stravaClient.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from "axios"; 2 | import { z } from "zod"; 3 | import fs from "fs/promises"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | // --- Axios Instance & Interceptor --- 8 | // Create an Axios instance to apply interceptors globally for this client 9 | export const stravaApi = axios.create({ 10 | baseURL: 'https://www.strava.com/api/v3' 11 | }); 12 | 13 | // Add a request interceptor (can be used for logging or modifying requests) 14 | stravaApi.interceptors.request.use(config => { 15 | // REMOVE DEBUG LOGS - Interfere with MCP Stdio transport 16 | // let authHeaderLog = 'Not Set'; 17 | // const authHeaderValue = config.headers?.Authorization; 18 | // if (typeof authHeaderValue === 'string') { 19 | // authHeaderLog = `${authHeaderValue.substring(0, 12)}...[REDACTED]`; 20 | // } 21 | // console.error(`[DEBUG stravaClient] Sending Request: ${config.method?.toUpperCase()} ${config.url}`); 22 | // console.error(`[DEBUG stravaClient] Authorization Header: ${authHeaderLog}` ); 23 | return config; 24 | }, error => { 25 | console.error('[DEBUG stravaClient] Request Error Interceptor:', error); 26 | return Promise.reject(error); 27 | }); 28 | // ---------------------------------- 29 | 30 | // Define the expected structure of a Strava activity (add more fields as needed) 31 | const StravaActivitySchema = z.object({ 32 | id: z.number().int().optional(), // Include ID for recent activities 33 | name: z.string(), 34 | distance: z.number(), 35 | start_date: z.string().datetime(), 36 | // Add other relevant fields from the Strava API response if needed 37 | // e.g., moving_time: z.number(), type: z.string(), ... 38 | }); 39 | 40 | // Define the expected response structure for the activities endpoint 41 | const StravaActivitiesResponseSchema = z.array(StravaActivitySchema); 42 | 43 | // Define the expected structure for the Authenticated Athlete response 44 | const BaseAthleteSchema = z.object({ 45 | id: z.number().int(), 46 | resource_state: z.number().int(), 47 | }); 48 | const DetailedAthleteSchema = BaseAthleteSchema.extend({ 49 | username: z.string().nullable(), 50 | firstname: z.string(), 51 | lastname: z.string(), 52 | city: z.string().nullable(), 53 | state: z.string().nullable(), 54 | country: z.string().nullable(), 55 | sex: z.enum(["M", "F"]).nullable(), 56 | premium: z.boolean(), 57 | summit: z.boolean(), 58 | created_at: z.string().datetime(), 59 | updated_at: z.string().datetime(), 60 | profile_medium: z.string().url(), 61 | profile: z.string().url(), 62 | weight: z.number().nullable(), 63 | measurement_preference: z.enum(["feet", "meters"]).optional().nullable(), 64 | // Add other fields as needed (e.g., follower_count, friend_count, ftp, clubs, bikes, shoes) 65 | }); 66 | 67 | // Type alias for the inferred athlete type 68 | export type StravaAthlete = z.infer<typeof DetailedAthleteSchema>; 69 | 70 | // --- Stats Schemas --- 71 | // Schema for individual activity totals (like runs, rides, swims) 72 | const ActivityTotalSchema = z.object({ 73 | count: z.number().int(), 74 | distance: z.number(), // In meters 75 | moving_time: z.number().int(), // In seconds 76 | elapsed_time: z.number().int(), // In seconds 77 | elevation_gain: z.number(), // In meters 78 | achievement_count: z.number().int().optional().nullable(), // Optional based on Strava docs examples 79 | }); 80 | 81 | // Schema for the overall athlete stats response 82 | const ActivityStatsSchema = z.object({ 83 | biggest_ride_distance: z.number().optional().nullable(), 84 | biggest_climb_elevation_gain: z.number().optional().nullable(), 85 | recent_ride_totals: ActivityTotalSchema, 86 | recent_run_totals: ActivityTotalSchema, 87 | recent_swim_totals: ActivityTotalSchema, 88 | ytd_ride_totals: ActivityTotalSchema, 89 | ytd_run_totals: ActivityTotalSchema, 90 | ytd_swim_totals: ActivityTotalSchema, 91 | all_ride_totals: ActivityTotalSchema, 92 | all_run_totals: ActivityTotalSchema, 93 | all_swim_totals: ActivityTotalSchema, 94 | }); 95 | export type StravaStats = z.infer<typeof ActivityStatsSchema>; 96 | 97 | // --- Club Schema --- 98 | // Based on https://developers.strava.com/docs/reference/#api-models-SummaryClub 99 | const SummaryClubSchema = z.object({ 100 | id: z.number().int(), 101 | resource_state: z.number().int(), 102 | name: z.string(), 103 | profile_medium: z.string().url(), 104 | cover_photo: z.string().url().nullable(), 105 | cover_photo_small: z.string().url().nullable(), 106 | sport_type: z.string(), // cycling, running, triathlon, other 107 | activity_types: z.array(z.string()), // More specific types 108 | city: z.string(), 109 | state: z.string(), 110 | country: z.string(), 111 | private: z.boolean(), 112 | member_count: z.number().int(), 113 | featured: z.boolean(), 114 | verified: z.boolean(), 115 | url: z.string().nullable(), 116 | }); 117 | export type StravaClub = z.infer<typeof SummaryClubSchema>; 118 | const StravaClubsResponseSchema = z.array(SummaryClubSchema); 119 | 120 | // --- Gear Schema --- 121 | const SummaryGearSchema = z.object({ 122 | id: z.string(), 123 | resource_state: z.number().int(), 124 | primary: z.boolean(), 125 | name: z.string(), 126 | distance: z.number(), // Distance in meters for the gear 127 | }).nullable().optional(); // Activity might not have gear or it might be null 128 | 129 | // --- Map Schema --- 130 | const MapSchema = z.object({ 131 | id: z.string(), 132 | summary_polyline: z.string().optional().nullable(), 133 | resource_state: z.number().int(), 134 | }).nullable(); // Activity might not have a map 135 | 136 | // --- Segment Schema --- 137 | const SummarySegmentSchema = z.object({ 138 | id: z.number().int(), 139 | name: z.string(), 140 | activity_type: z.string(), 141 | distance: z.number(), 142 | average_grade: z.number(), 143 | maximum_grade: z.number(), 144 | elevation_high: z.number().optional().nullable(), 145 | elevation_low: z.number().optional().nullable(), 146 | start_latlng: z.array(z.number()).optional().nullable(), 147 | end_latlng: z.array(z.number()).optional().nullable(), 148 | climb_category: z.number().int().optional().nullable(), 149 | city: z.string().optional().nullable(), 150 | state: z.string().optional().nullable(), 151 | country: z.string().optional().nullable(), 152 | private: z.boolean().optional(), 153 | starred: z.boolean().optional(), 154 | }); 155 | 156 | const DetailedSegmentSchema = SummarySegmentSchema.extend({ 157 | created_at: z.string().datetime(), 158 | updated_at: z.string().datetime(), 159 | total_elevation_gain: z.number().optional().nullable(), 160 | map: MapSchema, // Now defined above 161 | effort_count: z.number().int(), 162 | athlete_count: z.number().int(), 163 | hazardous: z.boolean(), 164 | star_count: z.number().int(), 165 | }); 166 | 167 | export type StravaSegment = z.infer<typeof SummarySegmentSchema>; 168 | export type StravaDetailedSegment = z.infer<typeof DetailedSegmentSchema>; 169 | const StravaSegmentsResponseSchema = z.array(SummarySegmentSchema); 170 | 171 | // --- Explorer Schemas --- 172 | // Based on https://developers.strava.com/docs/reference/#api-models-ExplorerSegment 173 | const ExplorerSegmentSchema = z.object({ 174 | id: z.number().int(), 175 | name: z.string(), 176 | climb_category: z.number().int(), 177 | climb_category_desc: z.string(), // e.g., "NC", "4", "3", "2", "1", "HC" 178 | avg_grade: z.number(), 179 | start_latlng: z.array(z.number()), 180 | end_latlng: z.array(z.number()), 181 | elev_difference: z.number(), 182 | distance: z.number(), // meters 183 | points: z.string(), // Encoded polyline 184 | starred: z.boolean().optional(), // Only included if authenticated 185 | }); 186 | 187 | // Based on https://developers.strava.com/docs/reference/#api-models-ExplorerResponse 188 | const ExplorerResponseSchema = z.object({ 189 | segments: z.array(ExplorerSegmentSchema), 190 | }); 191 | export type StravaExplorerSegment = z.infer<typeof ExplorerSegmentSchema>; 192 | export type StravaExplorerResponse = z.infer<typeof ExplorerResponseSchema>; 193 | 194 | // --- Detailed Activity Schema --- 195 | // Based on https://developers.strava.com/docs/reference/#api-models-DetailedActivity 196 | const DetailedActivitySchema = z.object({ 197 | id: z.number().int(), 198 | resource_state: z.number().int(), // Should be 3 for detailed 199 | athlete: BaseAthleteSchema, // Contains athlete ID 200 | name: z.string(), 201 | distance: z.number().optional(), // Optional for stationary activities 202 | moving_time: z.number().int().optional(), 203 | elapsed_time: z.number().int(), 204 | total_elevation_gain: z.number().optional(), 205 | type: z.string(), // e.g., "Run", "Ride" 206 | sport_type: z.string(), 207 | start_date: z.string().datetime(), 208 | start_date_local: z.string().datetime(), 209 | timezone: z.string(), 210 | start_latlng: z.array(z.number()).nullable(), 211 | end_latlng: z.array(z.number()).nullable(), 212 | achievement_count: z.number().int().optional(), 213 | kudos_count: z.number().int(), 214 | comment_count: z.number().int(), 215 | athlete_count: z.number().int().optional(), // Number of athletes on the activity 216 | photo_count: z.number().int(), 217 | map: MapSchema, 218 | trainer: z.boolean(), 219 | commute: z.boolean(), 220 | manual: z.boolean(), 221 | private: z.boolean(), 222 | flagged: z.boolean(), 223 | gear_id: z.string().nullable(), // ID of the gear used 224 | average_speed: z.number().optional(), 225 | max_speed: z.number().optional(), 226 | average_cadence: z.number().optional().nullable(), 227 | average_temp: z.number().int().optional().nullable(), 228 | average_watts: z.number().optional().nullable(), // Rides only 229 | max_watts: z.number().int().optional().nullable(), // Rides only 230 | weighted_average_watts: z.number().int().optional().nullable(), // Rides only 231 | kilojoules: z.number().optional().nullable(), // Rides only 232 | device_watts: z.boolean().optional().nullable(), // Rides only 233 | has_heartrate: z.boolean(), 234 | average_heartrate: z.number().optional().nullable(), 235 | max_heartrate: z.number().optional().nullable(), 236 | calories: z.number().optional(), 237 | description: z.string().nullable(), 238 | // photos: // Add PhotosSummary schema if needed 239 | gear: SummaryGearSchema, 240 | device_name: z.string().optional().nullable(), 241 | // segment_efforts: // Add DetailedSegmentEffort schema if needed 242 | // splits_metric: // Add Split schema if needed 243 | // splits_standard: // Add Split schema if needed 244 | // laps: // Add Lap schema if needed 245 | // best_efforts: // Add DetailedSegmentEffort schema if needed 246 | }); 247 | export type StravaDetailedActivity = z.infer<typeof DetailedActivitySchema>; 248 | 249 | // --- Meta Schemas --- 250 | // Based on https://developers.strava.com/docs/reference/#api-models-MetaActivity 251 | const MetaActivitySchema = z.object({ 252 | id: z.number().int(), 253 | }); 254 | 255 | // BaseAthleteSchema serves as MetaAthleteSchema (id only needed for effort) 256 | 257 | // --- Segment Effort Schema --- 258 | // Based on https://developers.strava.com/docs/reference/#api-models-DetailedSegmentEffort 259 | const DetailedSegmentEffortSchema = z.object({ 260 | id: z.number().int(), 261 | activity: MetaActivitySchema, 262 | athlete: BaseAthleteSchema, 263 | segment: SummarySegmentSchema, // Reuse SummarySegmentSchema 264 | name: z.string(), // Segment name 265 | elapsed_time: z.number().int(), // seconds 266 | moving_time: z.number().int(), // seconds 267 | start_date: z.string().datetime(), 268 | start_date_local: z.string().datetime(), 269 | distance: z.number(), // meters 270 | start_index: z.number().int().optional().nullable(), 271 | end_index: z.number().int().optional().nullable(), 272 | average_cadence: z.number().optional().nullable(), 273 | device_watts: z.boolean().optional().nullable(), 274 | average_watts: z.number().optional().nullable(), 275 | average_heartrate: z.number().optional().nullable(), 276 | max_heartrate: z.number().optional().nullable(), 277 | kom_rank: z.number().int().optional().nullable(), // 1-10, null if not in top 10 278 | pr_rank: z.number().int().optional().nullable(), // 1, 2, 3, or null 279 | hidden: z.boolean().optional().nullable(), 280 | }); 281 | export type StravaDetailedSegmentEffort = z.infer<typeof DetailedSegmentEffortSchema>; 282 | 283 | // --- Route Schema --- 284 | // Based on https://developers.strava.com/docs/reference/#api-models-Route 285 | const RouteSchema = z.object({ 286 | athlete: BaseAthleteSchema, // Reuse BaseAthleteSchema 287 | description: z.string().nullable(), 288 | distance: z.number(), // meters 289 | elevation_gain: z.number().nullable(), // meters 290 | id: z.number().int(), 291 | id_str: z.string(), 292 | map: MapSchema, // Reuse MapSchema 293 | map_urls: z.object({ // Assuming structure based on context 294 | retina_url: z.string().url().optional().nullable(), 295 | url: z.string().url().optional().nullable(), 296 | }).optional().nullable(), 297 | name: z.string(), 298 | private: z.boolean(), 299 | resource_state: z.number().int(), 300 | starred: z.boolean(), 301 | sub_type: z.number().int(), // 1 for "road", 2 for "mtb", 3 for "cx", 4 for "trail", 5 for "mixed" 302 | type: z.number().int(), // 1 for "ride", 2 for "run" 303 | created_at: z.string().datetime(), 304 | updated_at: z.string().datetime(), 305 | estimated_moving_time: z.number().int().optional().nullable(), // seconds 306 | segments: z.array(SummarySegmentSchema).optional().nullable(), // Array of segments within the route 307 | timestamp: z.number().int().optional().nullable(), // Added based on common patterns 308 | }); 309 | export type StravaRoute = z.infer<typeof RouteSchema>; 310 | const StravaRoutesResponseSchema = z.array(RouteSchema); 311 | 312 | // --- Token Refresh Functionality --- 313 | // Calculate path to .env file 314 | const __filename = fileURLToPath(import.meta.url); 315 | const __dirname = path.dirname(__filename); 316 | const projectRoot = path.resolve(__dirname, '..'); 317 | const envPath = path.join(projectRoot, '.env'); 318 | 319 | /** 320 | * Updates the .env file with new access and refresh tokens 321 | * @param accessToken - The new access token 322 | * @param refreshToken - The new refresh token 323 | */ 324 | async function updateTokensInEnvFile(accessToken: string, refreshToken: string): Promise<void> { 325 | try { 326 | let envContent = await fs.readFile(envPath, 'utf-8'); 327 | const lines = envContent.split('\n'); 328 | const newLines: string[] = []; 329 | let accessTokenUpdated = false; 330 | let refreshTokenUpdated = false; 331 | 332 | for (const line of lines) { 333 | if (line.startsWith('STRAVA_ACCESS_TOKEN=')) { 334 | newLines.push(`STRAVA_ACCESS_TOKEN=${accessToken}`); 335 | accessTokenUpdated = true; 336 | } else if (line.startsWith('STRAVA_REFRESH_TOKEN=')) { 337 | newLines.push(`STRAVA_REFRESH_TOKEN=${refreshToken}`); 338 | refreshTokenUpdated = true; 339 | } else if (line.trim() !== '') { 340 | newLines.push(line); 341 | } 342 | } 343 | 344 | if (!accessTokenUpdated) { 345 | newLines.push(`STRAVA_ACCESS_TOKEN=${accessToken}`); 346 | } 347 | if (!refreshTokenUpdated) { 348 | newLines.push(`STRAVA_REFRESH_TOKEN=${refreshToken}`); 349 | } 350 | 351 | await fs.writeFile(envPath, newLines.join('\n').trim() + '\n'); 352 | console.error('✅ Tokens successfully refreshed and updated in .env file.'); 353 | } catch (error) { 354 | console.error('Failed to update tokens in .env file:', error); 355 | // Continue execution even if file update fails 356 | } 357 | } 358 | 359 | /** 360 | * Refreshes the Strava API access token using the refresh token 361 | * @returns The new access token 362 | */ 363 | async function refreshAccessToken(): Promise<string> { 364 | const refreshToken = process.env.STRAVA_REFRESH_TOKEN; 365 | const clientId = process.env.STRAVA_CLIENT_ID; 366 | const clientSecret = process.env.STRAVA_CLIENT_SECRET; 367 | 368 | if (!refreshToken || !clientId || !clientSecret) { 369 | throw new Error("Missing refresh credentials in .env (STRAVA_REFRESH_TOKEN, STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET)"); 370 | } 371 | 372 | try { 373 | console.error('🔄 Refreshing Strava access token...'); 374 | const response = await axios.post('https://www.strava.com/oauth/token', { 375 | client_id: clientId, 376 | client_secret: clientSecret, 377 | refresh_token: refreshToken, 378 | grant_type: 'refresh_token' 379 | }); 380 | 381 | // Update tokens in environment variables for the current process 382 | const newAccessToken = response.data.access_token; 383 | const newRefreshToken = response.data.refresh_token; 384 | 385 | if (!newAccessToken || !newRefreshToken) { 386 | throw new Error('Refresh response missing required tokens'); 387 | } 388 | 389 | process.env.STRAVA_ACCESS_TOKEN = newAccessToken; 390 | process.env.STRAVA_REFRESH_TOKEN = newRefreshToken; 391 | 392 | // Also update .env file for persistence 393 | await updateTokensInEnvFile(newAccessToken, newRefreshToken); 394 | 395 | console.error(`✅ Token refreshed. New token expires: ${new Date(response.data.expires_at * 1000).toLocaleString()}`); 396 | return newAccessToken; 397 | } catch (error) { 398 | console.error('Failed to refresh access token:', error); 399 | throw new Error(`Failed to refresh Strava access token: ${error instanceof Error ? error.message : String(error)}`); 400 | } 401 | } 402 | 403 | /** 404 | * Helper function to handle API errors with token refresh capability 405 | * @param error - The caught error 406 | * @param context - The context in which the error occurred 407 | * @param retryFn - Optional function to retry after token refresh 408 | * @returns Never returns normally, always throws an error or returns via retryFn 409 | */ 410 | export async function handleApiError<T>(error: unknown, context: string, retryFn?: () => Promise<T>): Promise<T> { 411 | // Check if it's an authentication error (401) that might be fixed by refreshing the token 412 | if (axios.isAxiosError(error) && error.response?.status === 401 && retryFn) { 413 | try { 414 | console.error(`🔑 Authentication error in ${context}. Attempting to refresh token...`); 415 | await refreshAccessToken(); 416 | 417 | // Return the result of the retry function if it succeeds 418 | console.error(`🔄 Retrying ${context} after token refresh...`); 419 | return await retryFn(); 420 | } catch (refreshError) { 421 | console.error(`❌ Token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); 422 | // Fall through to normal error handling if refresh fails 423 | } 424 | } 425 | 426 | // Check for subscription error (402) 427 | if (axios.isAxiosError(error) && error.response?.status === 402) { 428 | console.error(`🔒 Subscription Required in ${context}. Status: 402`); 429 | // Throw a specific error type or use a unique message 430 | throw new Error(`SUBSCRIPTION_REQUIRED: Access to this feature requires a Strava subscription. Context: ${context}`); 431 | } 432 | 433 | // Standard error handling (existing code) 434 | if (axios.isAxiosError(error)) { 435 | const status = error.response?.status || 'Unknown'; 436 | const responseData = error.response?.data; 437 | const message = (typeof responseData === 'object' && responseData !== null && 'message' in responseData && typeof responseData.message === 'string') 438 | ? responseData.message 439 | : error.message; 440 | console.error(`Strava API request failed in ${context} with status ${status}: ${message}`); 441 | // Include response data in error log if helpful (be careful with sensitive data) 442 | if (responseData) { 443 | console.error(`Response data (${context}):`, JSON.stringify(responseData, null, 2)); 444 | } 445 | throw new Error(`Strava API Error in ${context} (${status}): ${message}`); 446 | } else if (error instanceof Error) { 447 | console.error(`An unexpected error occurred in ${context}:`, error); 448 | throw new Error(`An unexpected error occurred in ${context}: ${error.message}`); 449 | } else { 450 | console.error(`An unknown error object was caught in ${context}:`, error); 451 | throw new Error(`An unknown error occurred in ${context}: ${String(error)}`); 452 | } 453 | } 454 | 455 | /** 456 | * Fetches recent activities for the authenticated athlete from the Strava API. 457 | * 458 | * @param accessToken - The Strava API access token. 459 | * @param perPage - The number of activities to fetch per page (default: 30). 460 | * @returns A promise that resolves to an array of Strava activities. 461 | * @throws Throws an error if the API request fails or the response format is unexpected. 462 | */ 463 | export async function getRecentActivities(accessToken: string, perPage = 30): Promise<any[]> { 464 | if (!accessToken) { 465 | throw new Error("Strava access token is required."); 466 | } 467 | 468 | try { 469 | const response = await stravaApi.get<unknown>("athlete/activities", { 470 | headers: { Authorization: `Bearer ${accessToken}` }, 471 | params: { per_page: perPage } 472 | }); 473 | 474 | const validationResult = StravaActivitiesResponseSchema.safeParse(response.data); 475 | 476 | if (!validationResult.success) { 477 | console.error("Strava API response validation failed (getRecentActivities):", validationResult.error); 478 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 479 | } 480 | 481 | return validationResult.data; 482 | } catch (error) { 483 | // Pass a retry function to handleApiError 484 | return await handleApiError<any[]>(error, 'getRecentActivities', async () => { 485 | // Use new token from environment after refresh 486 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 487 | return getRecentActivities(newToken, perPage); 488 | }); 489 | } 490 | } 491 | 492 | /** 493 | * Fetches all activities for the authenticated athlete with pagination and date filtering. 494 | * Automatically handles multiple pages to retrieve complete activity history. 495 | * 496 | * @param accessToken - The Strava API access token. 497 | * @param params - Parameters for filtering and pagination. 498 | * @returns A promise that resolves to an array of all matching Strava activities. 499 | * @throws Throws an error if the API request fails or the response format is unexpected. 500 | */ 501 | export async function getAllActivities( 502 | accessToken: string, 503 | params: GetAllActivitiesParams = {} 504 | ): Promise<any[]> { 505 | if (!accessToken) { 506 | throw new Error("Strava access token is required."); 507 | } 508 | 509 | const { 510 | page = 1, 511 | perPage = 200, // Max allowed by Strava 512 | before, 513 | after, 514 | onProgress 515 | } = params; 516 | 517 | const allActivities: any[] = []; 518 | let currentPage = page; 519 | let hasMore = true; 520 | 521 | try { 522 | while (hasMore) { 523 | // Build query parameters 524 | const queryParams: Record<string, any> = { 525 | page: currentPage, 526 | per_page: perPage 527 | }; 528 | 529 | // Add date filters if provided 530 | if (before !== undefined) queryParams.before = before; 531 | if (after !== undefined) queryParams.after = after; 532 | 533 | // Fetch current page 534 | const response = await stravaApi.get<unknown>("athlete/activities", { 535 | headers: { Authorization: `Bearer ${accessToken}` }, 536 | params: queryParams 537 | }); 538 | 539 | const validationResult = StravaActivitiesResponseSchema.safeParse(response.data); 540 | 541 | if (!validationResult.success) { 542 | console.error(`Strava API response validation failed (getAllActivities page ${currentPage}):`, validationResult.error); 543 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 544 | } 545 | 546 | const activities = validationResult.data; 547 | 548 | // Add activities to collection 549 | allActivities.push(...activities); 550 | 551 | // Report progress if callback provided 552 | if (onProgress) { 553 | onProgress(allActivities.length, currentPage); 554 | } 555 | 556 | // Check if we should continue 557 | // Stop if we got fewer activities than requested (indicating last page) 558 | hasMore = activities.length === perPage; 559 | currentPage++; 560 | 561 | // Add a small delay to be respectful of rate limits 562 | if (hasMore) { 563 | await new Promise(resolve => setTimeout(resolve, 100)); 564 | } 565 | } 566 | 567 | return allActivities; 568 | } catch (error) { 569 | // If it's an auth error and we're on first page, try token refresh 570 | if (currentPage === 1) { 571 | return await handleApiError<any[]>(error, 'getAllActivities', async () => { 572 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 573 | return getAllActivities(newToken, params); 574 | }); 575 | } 576 | // For subsequent pages, just throw the error 577 | throw error; 578 | } 579 | } 580 | 581 | /** 582 | * Fetches profile information for the authenticated athlete. 583 | * 584 | * @param accessToken - The Strava API access token. 585 | * @returns A promise that resolves to the detailed athlete profile. 586 | * @throws Throws an error if the API request fails or the response format is unexpected. 587 | */ 588 | export async function getAuthenticatedAthlete(accessToken: string): Promise<StravaAthlete> { 589 | if (!accessToken) { 590 | throw new Error("Strava access token is required."); 591 | } 592 | 593 | try { 594 | const response = await stravaApi.get<unknown>("athlete", { 595 | headers: { Authorization: `Bearer ${accessToken}` } 596 | }); 597 | 598 | // Validate the response data against the Zod schema 599 | const validationResult = DetailedAthleteSchema.safeParse(response.data); 600 | 601 | if (!validationResult.success) { 602 | // Log the raw response data on validation failure for debugging 603 | console.error("Strava API raw response data (getAuthenticatedAthlete):", JSON.stringify(response.data, null, 2)); 604 | console.error("Strava API response validation failed (getAuthenticatedAthlete):", validationResult.error); 605 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 606 | } 607 | // Type assertion is safe here due to successful validation 608 | return validationResult.data; 609 | 610 | } catch (error) { 611 | return await handleApiError<StravaAthlete>(error, 'getAuthenticatedAthlete', async () => { 612 | // Use new token from environment after refresh 613 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 614 | return getAuthenticatedAthlete(newToken); 615 | }); 616 | } 617 | } 618 | 619 | /** 620 | * Fetches activity statistics for a specific athlete. 621 | * 622 | * @param accessToken - The Strava API access token. 623 | * @param athleteId - The ID of the athlete whose stats are being requested. 624 | * @returns A promise that resolves to the athlete's activity statistics. 625 | * @throws Throws an error if the API request fails or the response format is unexpected. 626 | */ 627 | export async function getAthleteStats(accessToken: string, athleteId: number): Promise<StravaStats> { 628 | if (!accessToken) { 629 | throw new Error("Strava access token is required."); 630 | } 631 | if (!athleteId) { 632 | throw new Error("Athlete ID is required to fetch stats."); 633 | } 634 | 635 | try { 636 | const response = await stravaApi.get<unknown>(`athletes/${athleteId}/stats`, { 637 | headers: { Authorization: `Bearer ${accessToken}` } 638 | }); 639 | 640 | const validationResult = ActivityStatsSchema.safeParse(response.data); 641 | 642 | if (!validationResult.success) { 643 | console.error("Strava API response validation failed (getAthleteStats):", validationResult.error); 644 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 645 | } 646 | return validationResult.data; 647 | 648 | } catch (error) { 649 | return await handleApiError<StravaStats>(error, `getAthleteStats for ID ${athleteId}`, async () => { 650 | // Use new token from environment after refresh 651 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 652 | return getAthleteStats(newToken, athleteId); 653 | }); 654 | } 655 | } 656 | 657 | /** 658 | * Fetches detailed information for a specific activity by its ID. 659 | * 660 | * @param accessToken - The Strava API access token. 661 | * @param activityId - The ID of the activity to fetch. 662 | * @returns A promise that resolves to the detailed activity data. 663 | * @throws Throws an error if the API request fails or the response format is unexpected. 664 | */ 665 | export async function getActivityById(accessToken: string, activityId: number): Promise<StravaDetailedActivity> { 666 | if (!accessToken) { 667 | throw new Error("Strava access token is required."); 668 | } 669 | if (!activityId) { 670 | throw new Error("Activity ID is required to fetch details."); 671 | } 672 | 673 | try { 674 | const response = await stravaApi.get<unknown>(`activities/${activityId}`, { 675 | headers: { Authorization: `Bearer ${accessToken}` } 676 | }); 677 | 678 | const validationResult = DetailedActivitySchema.safeParse(response.data); 679 | 680 | if (!validationResult.success) { 681 | console.error(`Strava API validation failed (getActivityById: ${activityId}):`, validationResult.error); 682 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 683 | } 684 | return validationResult.data; 685 | 686 | } catch (error) { 687 | return await handleApiError<StravaDetailedActivity>(error, `getActivityById for ID ${activityId}`, async () => { 688 | // Use new token from environment after refresh 689 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 690 | return getActivityById(newToken, activityId); 691 | }); 692 | } 693 | } 694 | 695 | /** 696 | * Lists the clubs the authenticated athlete belongs to. 697 | * 698 | * @param accessToken - The Strava API access token. 699 | * @returns A promise that resolves to an array of the athlete's clubs. 700 | * @throws Throws an error if the API request fails or the response format is unexpected. 701 | */ 702 | export async function listAthleteClubs(accessToken: string): Promise<StravaClub[]> { 703 | if (!accessToken) { 704 | throw new Error("Strava access token is required."); 705 | } 706 | 707 | try { 708 | const response = await stravaApi.get<unknown>("athlete/clubs", { 709 | headers: { Authorization: `Bearer ${accessToken}` } 710 | }); 711 | 712 | const validationResult = StravaClubsResponseSchema.safeParse(response.data); 713 | 714 | if (!validationResult.success) { 715 | console.error("Strava API validation failed (listAthleteClubs):", validationResult.error); 716 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 717 | } 718 | return validationResult.data; 719 | 720 | } catch (error) { 721 | return await handleApiError<StravaClub[]>(error, 'listAthleteClubs', async () => { 722 | // Use new token from environment after refresh 723 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 724 | return listAthleteClubs(newToken); 725 | }); 726 | } 727 | } 728 | 729 | /** 730 | * Lists the segments starred by the authenticated athlete. 731 | * 732 | * @param accessToken - The Strava API access token. 733 | * @returns A promise that resolves to an array of the athlete's starred segments. 734 | * @throws Throws an error if the API request fails or the response format is unexpected. 735 | */ 736 | export async function listStarredSegments(accessToken: string): Promise<StravaSegment[]> { 737 | if (!accessToken) { 738 | throw new Error("Strava access token is required."); 739 | } 740 | 741 | try { 742 | // Strava API uses page/per_page but often defaults reasonably for lists like this. 743 | // Add pagination parameters if needed later. 744 | const response = await stravaApi.get<unknown>("segments/starred", { 745 | headers: { Authorization: `Bearer ${accessToken}` } 746 | }); 747 | 748 | const validationResult = StravaSegmentsResponseSchema.safeParse(response.data); 749 | 750 | if (!validationResult.success) { 751 | console.error("Strava API validation failed (listStarredSegments):", validationResult.error); 752 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 753 | } 754 | return validationResult.data; 755 | 756 | } catch (error) { 757 | return await handleApiError<StravaSegment[]>(error, 'listStarredSegments', async () => { 758 | // Use new token from environment after refresh 759 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 760 | return listStarredSegments(newToken); 761 | }); 762 | } 763 | } 764 | 765 | /** 766 | * Fetches detailed information for a specific segment by its ID. 767 | * 768 | * @param accessToken - The Strava API access token. 769 | * @param segmentId - The ID of the segment to fetch. 770 | * @returns A promise that resolves to the detailed segment data. 771 | * @throws Throws an error if the API request fails or the response format is unexpected. 772 | */ 773 | export async function getSegmentById(accessToken: string, segmentId: number): Promise<StravaDetailedSegment> { 774 | if (!accessToken) { 775 | throw new Error("Strava access token is required."); 776 | } 777 | if (!segmentId) { 778 | throw new Error("Segment ID is required."); 779 | } 780 | 781 | try { 782 | const response = await stravaApi.get<unknown>(`segments/${segmentId}`, { 783 | headers: { Authorization: `Bearer ${accessToken}` } 784 | }); 785 | 786 | const validationResult = DetailedSegmentSchema.safeParse(response.data); 787 | 788 | if (!validationResult.success) { 789 | console.error(`Strava API validation failed (getSegmentById: ${segmentId}):`, validationResult.error); 790 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 791 | } 792 | return validationResult.data; 793 | 794 | } catch (error) { 795 | return await handleApiError<StravaDetailedSegment>(error, `getSegmentById for ID ${segmentId}`, async () => { 796 | // Use new token from environment after refresh 797 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 798 | return getSegmentById(newToken, segmentId); 799 | }); 800 | } 801 | } 802 | 803 | /** 804 | * Returns the top 10 segments matching a specified query. 805 | * 806 | * @param accessToken - The Strava API access token. 807 | * @param bounds - String representing the latitudes and longitudes for the corners of the search map, `latitude,longitude,latitude,longitude`. 808 | * @param activityType - Optional filter for activity type ("running" or "riding"). 809 | * @param minCat - Optional minimum climb category filter. 810 | * @param maxCat - Optional maximum climb category filter. 811 | * @returns A promise that resolves to the explorer response containing matching segments. 812 | * @throws Throws an error if the API request fails or the response format is unexpected. 813 | */ 814 | export async function exploreSegments( 815 | accessToken: string, 816 | bounds: string, 817 | activityType?: 'running' | 'riding', 818 | minCat?: number, 819 | maxCat?: number 820 | ): Promise<StravaExplorerResponse> { 821 | if (!accessToken) { 822 | throw new Error("Strava access token is required."); 823 | } 824 | if (!bounds || !/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(bounds)) { 825 | throw new Error("Valid bounds (lat,lng,lat,lng) are required for exploring segments."); 826 | } 827 | 828 | const params: Record<string, any> = { 829 | bounds: bounds, 830 | }; 831 | if (activityType) params.activity_type = activityType; 832 | if (minCat !== undefined) params.min_cat = minCat; 833 | if (maxCat !== undefined) params.max_cat = maxCat; 834 | 835 | try { 836 | const response = await stravaApi.get<unknown>("segments/explore", { 837 | headers: { Authorization: `Bearer ${accessToken}` }, 838 | params: params 839 | }); 840 | 841 | const validationResult = ExplorerResponseSchema.safeParse(response.data); 842 | 843 | if (!validationResult.success) { 844 | console.error("Strava API validation failed (exploreSegments):", validationResult.error); 845 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 846 | } 847 | return validationResult.data; 848 | 849 | } catch (error) { 850 | return await handleApiError<StravaExplorerResponse>(error, `exploreSegments with bounds ${bounds}`, async () => { 851 | // Use new token from environment after refresh 852 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 853 | return exploreSegments(newToken, bounds, activityType); 854 | }); 855 | } 856 | } 857 | 858 | /** 859 | * Stars or unstars a segment for the authenticated athlete. 860 | * 861 | * @param accessToken - The Strava API access token. 862 | * @param segmentId - The ID of the segment to star/unstar. 863 | * @param starred - Boolean indicating whether to star (true) or unstar (false) the segment. 864 | * @returns A promise that resolves to the detailed segment data after the update. 865 | * @throws Throws an error if the API request fails or the response format is unexpected. 866 | */ 867 | export async function starSegment(accessToken: string, segmentId: number, starred: boolean): Promise<StravaDetailedSegment> { 868 | if (!accessToken) { 869 | throw new Error("Strava access token is required."); 870 | } 871 | if (!segmentId) { 872 | throw new Error("Segment ID is required to star/unstar."); 873 | } 874 | if (starred === undefined) { 875 | throw new Error("Starred status (true/false) is required."); 876 | } 877 | 878 | try { 879 | const response = await stravaApi.put<unknown>( 880 | `segments/${segmentId}/starred`, 881 | { starred: starred }, // Data payload for the PUT request 882 | { 883 | headers: { 884 | Authorization: `Bearer ${accessToken}`, 885 | 'Content-Type': 'application/json' // Important for PUT requests with body 886 | } 887 | } 888 | ); 889 | 890 | // The response is expected to be the updated DetailedSegment 891 | const validationResult = DetailedSegmentSchema.safeParse(response.data); 892 | 893 | if (!validationResult.success) { 894 | console.error(`Strava API validation failed (starSegment: ${segmentId}):`, validationResult.error); 895 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 896 | } 897 | return validationResult.data; 898 | 899 | } catch (error) { 900 | return await handleApiError<StravaDetailedSegment>(error, `starSegment for ID ${segmentId} with starred=${starred}`, async () => { 901 | // Use new token from environment after refresh 902 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 903 | return starSegment(newToken, segmentId, starred); 904 | }); 905 | } 906 | } 907 | 908 | /** 909 | * Fetches detailed information about a specific segment effort by its ID. 910 | * 911 | * @param accessToken - The Strava API access token. 912 | * @param effortId - The ID of the segment effort to fetch. 913 | * @returns A promise that resolves to the detailed segment effort data. 914 | * @throws Throws an error if the API request fails or the response format is unexpected. 915 | */ 916 | export async function getSegmentEffort(accessToken: string, effortId: number): Promise<StravaDetailedSegmentEffort> { 917 | if (!accessToken) { 918 | throw new Error("Strava access token is required."); 919 | } 920 | if (!effortId) { 921 | throw new Error("Segment Effort ID is required to fetch details."); 922 | } 923 | 924 | try { 925 | const response = await stravaApi.get<unknown>(`segment_efforts/${effortId}`, { 926 | headers: { Authorization: `Bearer ${accessToken}` } 927 | }); 928 | 929 | const validationResult = DetailedSegmentEffortSchema.safeParse(response.data); 930 | 931 | if (!validationResult.success) { 932 | console.error(`Strava API validation failed (getSegmentEffort: ${effortId}):`, validationResult.error); 933 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 934 | } 935 | return validationResult.data; 936 | 937 | } catch (error) { 938 | return await handleApiError<StravaDetailedSegmentEffort>(error, `getSegmentEffort for ID ${effortId}`, async () => { 939 | // Use new token from environment after refresh 940 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 941 | return getSegmentEffort(newToken, effortId); 942 | }); 943 | } 944 | } 945 | 946 | /** 947 | * Fetches a list of segment efforts for a given segment, filtered by date range for the authenticated athlete. 948 | * 949 | * @param accessToken - The Strava API access token. 950 | * @param segmentId - The ID of the segment. 951 | * @param startDateLocal - Optional ISO 8601 start date. 952 | * @param endDateLocal - Optional ISO 8601 end date. 953 | * @param perPage - Optional number of items per page. 954 | * @returns A promise that resolves to an array of segment efforts. 955 | * @throws Throws an error if the API request fails or the response format is unexpected. 956 | */ 957 | export async function listSegmentEfforts( 958 | accessToken: string, 959 | segmentId: number, 960 | params: SegmentEffortsParams = {} 961 | ): Promise<StravaDetailedSegmentEffort[]> { 962 | if (!accessToken) { 963 | throw new Error("Strava access token is required."); 964 | } 965 | if (!segmentId) { 966 | throw new Error("Segment ID is required to list efforts."); 967 | } 968 | 969 | const { startDateLocal, endDateLocal, perPage } = params; 970 | 971 | const queryParams: Record<string, any> = { 972 | segment_id: segmentId, 973 | }; 974 | if (startDateLocal) queryParams.start_date_local = startDateLocal; 975 | if (endDateLocal) queryParams.end_date_local = endDateLocal; 976 | if (perPage) queryParams.per_page = perPage; 977 | 978 | try { 979 | const response = await stravaApi.get<unknown>("segment_efforts", { 980 | headers: { Authorization: `Bearer ${accessToken}` }, 981 | params: queryParams 982 | }); 983 | 984 | // Response is an array of DetailedSegmentEffort 985 | const validationResult = z.array(DetailedSegmentEffortSchema).safeParse(response.data); 986 | 987 | if (!validationResult.success) { 988 | console.error(`Strava API validation failed (listSegmentEfforts: segment ${segmentId}):`, validationResult.error); 989 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 990 | } 991 | return validationResult.data; 992 | 993 | } catch (error) { 994 | return await handleApiError<StravaDetailedSegmentEffort[]>(error, `listSegmentEfforts for segment ID ${segmentId}`, async () => { 995 | // Use new token from environment after refresh 996 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 997 | return listSegmentEfforts(newToken, segmentId, params); 998 | }); 999 | } 1000 | } 1001 | 1002 | // Add the missing interface for segment efforts parameters 1003 | export interface SegmentEffortsParams { 1004 | startDateLocal?: string; 1005 | endDateLocal?: string; 1006 | perPage?: number; 1007 | } 1008 | 1009 | // Interface for getAllActivities parameters 1010 | export interface GetAllActivitiesParams { 1011 | page?: number; 1012 | perPage?: number; 1013 | before?: number; // epoch timestamp in seconds 1014 | after?: number; // epoch timestamp in seconds 1015 | onProgress?: (fetched: number, page: number) => void; 1016 | } 1017 | 1018 | /** 1019 | * Lists routes created by a specific athlete. 1020 | * 1021 | * @param accessToken - The Strava API access token. 1022 | * @param athleteId - The ID of the athlete whose routes are being requested. 1023 | * @param page - Optional page number for pagination. 1024 | * @param perPage - Optional number of items per page. 1025 | * @returns A promise that resolves to an array of the athlete's routes. 1026 | * @throws Throws an error if the API request fails or the response format is unexpected. 1027 | */ 1028 | export async function listAthleteRoutes(accessToken: string, page = 1, perPage = 30): Promise<StravaRoute[]> { 1029 | if (!accessToken) { 1030 | throw new Error("Strava access token is required."); 1031 | } 1032 | 1033 | try { 1034 | const response = await stravaApi.get<unknown>("athlete/routes", { 1035 | headers: { Authorization: `Bearer ${accessToken}` }, 1036 | params: { 1037 | page: page, 1038 | per_page: perPage 1039 | } 1040 | }); 1041 | 1042 | const validationResult = StravaRoutesResponseSchema.safeParse(response.data); 1043 | 1044 | if (!validationResult.success) { 1045 | console.error("Strava API validation failed (listAthleteRoutes):", validationResult.error); 1046 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 1047 | } 1048 | return validationResult.data; 1049 | 1050 | } catch (error) { 1051 | return await handleApiError<StravaRoute[]>(error, 'listAthleteRoutes', async () => { 1052 | // Use new token from environment after refresh 1053 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1054 | return listAthleteRoutes(newToken, page, perPage); 1055 | }); 1056 | } 1057 | } 1058 | 1059 | /** 1060 | * Fetches detailed information for a specific route by its ID. 1061 | * 1062 | * @param accessToken - The Strava API access token. 1063 | * @param routeId - The ID of the route to fetch. 1064 | * @returns A promise that resolves to the detailed route data. 1065 | * @throws Throws an error if the API request fails or the response format is unexpected. 1066 | */ 1067 | export async function getRouteById(accessToken: string, routeId: string): Promise<StravaRoute> { 1068 | const url = `routes/${routeId}`; 1069 | try { 1070 | const response = await stravaApi.get(url, { 1071 | headers: { Authorization: `Bearer ${accessToken}` }, 1072 | }); 1073 | // Validate the response against the Zod schema 1074 | const validatedRoute = RouteSchema.parse(response.data); 1075 | return validatedRoute; 1076 | } catch (error) { 1077 | return await handleApiError<StravaRoute>(error, `fetching route ${routeId}`, async () => { 1078 | // Use new token from environment after refresh 1079 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1080 | return getRouteById(newToken, routeId); 1081 | }); 1082 | } 1083 | } 1084 | 1085 | /** 1086 | * Fetches the GPX data for a specific route. 1087 | * Note: This endpoint returns raw GPX data (XML string), not JSON. 1088 | * @param accessToken Strava API access token 1089 | * @param routeId The ID of the route to export 1090 | * @returns Promise resolving to the GPX data as a string 1091 | */ 1092 | export async function exportRouteGpx(accessToken: string, routeId: string): Promise<string> { 1093 | const url = `routes/${routeId}/export_gpx`; 1094 | try { 1095 | // Expecting text/xml response, Axios should handle it as string 1096 | const response = await stravaApi.get<string>(url, { 1097 | headers: { Authorization: `Bearer ${accessToken}` }, 1098 | // Ensure response is treated as text 1099 | responseType: 'text', 1100 | }); 1101 | if (typeof response.data !== 'string') { 1102 | throw new Error('Invalid response format received from Strava API for GPX export.'); 1103 | } 1104 | return response.data; 1105 | } catch (error) { 1106 | return await handleApiError<string>(error, `exporting route ${routeId} as GPX`, async () => { 1107 | // Use new token from environment after refresh 1108 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1109 | return exportRouteGpx(newToken, routeId); 1110 | }); 1111 | } 1112 | } 1113 | 1114 | /** 1115 | * Fetches the TCX data for a specific route. 1116 | * Note: This endpoint returns raw TCX data (XML string), not JSON. 1117 | * @param accessToken Strava API access token 1118 | * @param routeId The ID of the route to export 1119 | * @returns Promise resolving to the TCX data as a string 1120 | */ 1121 | export async function exportRouteTcx(accessToken: string, routeId: string): Promise<string> { 1122 | const url = `routes/${routeId}/export_tcx`; 1123 | try { 1124 | // Expecting text/xml response, Axios should handle it as string 1125 | const response = await stravaApi.get<string>(url, { 1126 | headers: { Authorization: `Bearer ${accessToken}` }, 1127 | // Ensure response is treated as text 1128 | responseType: 'text', 1129 | }); 1130 | if (typeof response.data !== 'string') { 1131 | throw new Error('Invalid response format received from Strava API for TCX export.'); 1132 | } 1133 | return response.data; 1134 | } catch (error) { 1135 | return await handleApiError<string>(error, `exporting route ${routeId} as TCX`, async () => { 1136 | // Use new token from environment after refresh 1137 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1138 | return exportRouteTcx(newToken, routeId); 1139 | }); 1140 | } 1141 | } 1142 | 1143 | // --- Lap Schema --- 1144 | // Based on https://developers.strava.com/docs/reference/#api-models-Lap and user-provided image 1145 | const LapSchema = z.object({ 1146 | id: z.number().int(), 1147 | resource_state: z.number().int(), 1148 | name: z.string(), 1149 | activity: BaseAthleteSchema, // Reusing BaseAthleteSchema for {id, resource_state} 1150 | athlete: BaseAthleteSchema, // Reusing BaseAthleteSchema for {id, resource_state} 1151 | elapsed_time: z.number().int(), // In seconds 1152 | moving_time: z.number().int(), // In seconds 1153 | start_date: z.string().datetime(), 1154 | start_date_local: z.string().datetime(), 1155 | distance: z.number(), // In meters 1156 | start_index: z.number().int().optional().nullable(), // Index in the activity stream 1157 | end_index: z.number().int().optional().nullable(), // Index in the activity stream 1158 | total_elevation_gain: z.number().optional().nullable(), // In meters 1159 | average_speed: z.number().optional().nullable(), // In meters per second 1160 | max_speed: z.number().optional().nullable(), // In meters per second 1161 | average_cadence: z.number().optional().nullable(), // RPM 1162 | average_watts: z.number().optional().nullable(), // Rides only 1163 | device_watts: z.boolean().optional().nullable(), // Whether power sensor was used 1164 | average_heartrate: z.number().optional().nullable(), // Average heart rate during lap 1165 | max_heartrate: z.number().optional().nullable(), // Max heart rate during lap 1166 | lap_index: z.number().int(), // The position of this lap in the activity 1167 | split: z.number().int().optional().nullable(), // Associated split number (e.g., for marathons) 1168 | }); 1169 | 1170 | export type StravaLap = z.infer<typeof LapSchema>; 1171 | const StravaLapsResponseSchema = z.array(LapSchema); 1172 | 1173 | /** 1174 | * Retrieves the laps for a specific activity. 1175 | * @param accessToken The Strava API access token. 1176 | * @param activityId The ID of the activity. 1177 | * @returns A promise resolving to an array of lap objects. 1178 | */ 1179 | export async function getActivityLaps(accessToken: string, activityId: number | string): Promise<StravaLap[]> { 1180 | if (!accessToken) { 1181 | throw new Error("Strava access token is required."); 1182 | } 1183 | 1184 | try { 1185 | const response = await stravaApi.get(`/activities/${activityId}/laps`, { 1186 | headers: { Authorization: `Bearer ${accessToken}` }, 1187 | }); 1188 | 1189 | const validationResult = StravaLapsResponseSchema.safeParse(response.data); 1190 | 1191 | if (!validationResult.success) { 1192 | console.error(`Strava API validation failed (getActivityLaps: ${activityId}):`, validationResult.error); 1193 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 1194 | } 1195 | 1196 | return validationResult.data; 1197 | } catch (error) { 1198 | return await handleApiError<StravaLap[]>(error, `getActivityLaps(${activityId})`, async () => { 1199 | // Use new token from environment after refresh 1200 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1201 | return getActivityLaps(newToken, activityId); 1202 | }); 1203 | } 1204 | } 1205 | 1206 | // --- Zone Schemas --- 1207 | const DistributionBucketSchema = z.object({ 1208 | max: z.number(), 1209 | min: z.number(), 1210 | time: z.number().int(), // Time in seconds spent in this bucket 1211 | }); 1212 | 1213 | const ZoneSchema = z.object({ 1214 | min: z.number(), 1215 | max: z.number().optional(), // Max might be absent for the last zone 1216 | }); 1217 | 1218 | const HeartRateZoneSchema = z.object({ 1219 | custom_zones: z.boolean(), 1220 | zones: z.array(ZoneSchema), 1221 | distribution_buckets: z.array(DistributionBucketSchema).optional(), // Optional based on sample 1222 | resource_state: z.number().int().optional(), // Optional based on sample 1223 | sensor_based: z.boolean().optional(), // Optional based on sample 1224 | points: z.number().int().optional(), // Optional based on sample 1225 | type: z.literal('heartrate').optional(), // Optional based on sample 1226 | }); 1227 | 1228 | const PowerZoneSchema = z.object({ 1229 | zones: z.array(ZoneSchema), 1230 | distribution_buckets: z.array(DistributionBucketSchema).optional(), // Optional based on sample 1231 | resource_state: z.number().int().optional(), // Optional based on sample 1232 | sensor_based: z.boolean().optional(), // Optional based on sample 1233 | points: z.number().int().optional(), // Optional based on sample 1234 | type: z.literal('power').optional(), // Optional based on sample 1235 | }); 1236 | 1237 | // Combined Zones Response Schema 1238 | const AthleteZonesSchema = z.object({ 1239 | heart_rate: HeartRateZoneSchema.optional(), // Heart rate zones might not be set 1240 | power: PowerZoneSchema.optional(), // Power zones might not be set 1241 | }); 1242 | 1243 | export type StravaAthleteZones = z.infer<typeof AthleteZonesSchema>; 1244 | 1245 | /** 1246 | * Retrieves the heart rate and power zones for the authenticated athlete. 1247 | * @param accessToken The Strava API access token. 1248 | * @returns A promise resolving to the athlete's zone data. 1249 | */ 1250 | export async function getAthleteZones(accessToken: string): Promise<StravaAthleteZones> { 1251 | if (!accessToken) { 1252 | throw new Error("Strava access token is required."); 1253 | } 1254 | 1255 | try { 1256 | const response = await stravaApi.get<unknown>("/athlete/zones", { 1257 | headers: { Authorization: `Bearer ${accessToken}` }, 1258 | }); 1259 | 1260 | const validationResult = AthleteZonesSchema.safeParse(response.data); 1261 | 1262 | if (!validationResult.success) { 1263 | console.error(`Strava API validation failed (getAthleteZones):`, validationResult.error); 1264 | throw new Error(`Invalid data format received from Strava API: ${validationResult.error.message}`); 1265 | } 1266 | 1267 | return validationResult.data; 1268 | } catch (error) { 1269 | // Note: This endpoint requires profile:read_all scope 1270 | // Handle potential 403 Forbidden if scope is missing, or 402 if it becomes sub-only? 1271 | return await handleApiError<StravaAthleteZones>(error, `getAthleteZones`, async () => { 1272 | // Use new token from environment after refresh 1273 | const newToken = process.env.STRAVA_ACCESS_TOKEN!; 1274 | return getAthleteZones(newToken); 1275 | }); 1276 | } 1277 | } 1278 | ```