#
tokens: 16001/50000 1/31 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 2/2FirstPrevNextLast