ts-trainingpeaks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,674 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * TrainingPeaks Client
4
+ * Browser-based authentication with API access
5
+ */
6
+
7
+ import { Browser } from './browser'
8
+ import { HttpClient } from './http'
9
+ import type {
10
+ TPCredentials,
11
+ TPAthlete,
12
+ TPCoachAthlete,
13
+ TPAthleteGroup,
14
+ TPWorkout,
15
+ TPMetrics,
16
+ TPCalendarDay,
17
+ TPPerformanceChart,
18
+ TPUploadResult,
19
+ TPSearchParams,
20
+ TPLibraryWorkout,
21
+ TPCookie,
22
+ } from './types'
23
+
24
+ const TP_HOME_URL = 'https://home.trainingpeaks.com'
25
+ const TP_APP_URL = 'https://app.trainingpeaks.com'
26
+ const TP_API_URL = 'https://tpapi.trainingpeaks.com'
27
+ const _TP_PEAKSWARE_API = 'https://api.peakswaresb.com'
28
+
29
+ export interface TrainingPeaksConfig {
30
+ username: string
31
+ password: string
32
+ cookiePath?: string
33
+ headless?: boolean
34
+ timeout?: number
35
+ }
36
+
37
+ export class TrainingPeaks {
38
+ private credentials: TPCredentials
39
+ private client: HttpClient
40
+ private cookiePath?: string
41
+ private headless: boolean
42
+ private timeout: number
43
+ private authenticated = false
44
+ private currentUser: TPAthlete | null = null
45
+ private isCoach = false
46
+
47
+ constructor(config: TrainingPeaksConfig) {
48
+ this.credentials = {
49
+ username: config.username,
50
+ password: config.password,
51
+ }
52
+ this.cookiePath = config.cookiePath
53
+ this.headless = config.headless ?? false
54
+ this.timeout = config.timeout ?? 60000
55
+ this.client = new HttpClient({
56
+ baseUrl: TP_APP_URL,
57
+ timeout: this.timeout,
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Login using browser automation (handles reCAPTCHA)
63
+ */
64
+ async login(): Promise<TrainingPeaks> {
65
+ // Try to load existing cookies first
66
+ if (this.cookiePath) {
67
+ try {
68
+ await this.client.loadCookies(this.cookiePath)
69
+ // Verify cookies are valid
70
+ if (await this.verifySession()) {
71
+ this.authenticated = true
72
+ return this
73
+ }
74
+ }
75
+ catch {
76
+ // Cookies invalid or expired, need to login
77
+ }
78
+ }
79
+
80
+ const browser = new Browser({
81
+ headless: this.headless,
82
+ timeout: this.timeout,
83
+ incognito: true, // Use isolated profile to avoid session conflicts
84
+ })
85
+
86
+ try {
87
+ await browser.launch()
88
+
89
+ // Clear any existing session data
90
+ console.log('Clearing browser storage...')
91
+ try {
92
+ await browser.clearAllStorage()
93
+ }
94
+ catch {
95
+ // Ignore if storage clearing fails
96
+ }
97
+
98
+ // Navigate to login page
99
+ console.log('Navigating to login page...')
100
+ await browser.goto(`${TP_HOME_URL}/login`)
101
+ await new Promise(r => setTimeout(r, 3000))
102
+
103
+ // Try to dismiss cookie banner if present
104
+ console.log('Checking for cookie banner...')
105
+ try {
106
+ await browser.evaluate(`
107
+ // Common cookie consent selectors
108
+ const selectors = [
109
+ '#onetrust-accept-btn-handler',
110
+ '.onetrust-close-btn-handler',
111
+ '[aria-label="Accept cookies"]',
112
+ '[data-testid="cookie-accept"]',
113
+ '.cookie-accept',
114
+ '.accept-cookies',
115
+ 'button[contains(text(), "Accept")]',
116
+ 'button[contains(text(), "Got it")]',
117
+ ];
118
+ for (const sel of selectors) {
119
+ const btn = document.querySelector(sel);
120
+ if (btn) {
121
+ btn.click()
122
+ break
123
+ }
124
+ }
125
+ `)
126
+ await new Promise(r => setTimeout(r, 1000))
127
+ }
128
+ catch {
129
+ // No cookie banner, continue
130
+ }
131
+
132
+ // Check if already logged in (redirected to app)
133
+ const currentUrl = await browser.evaluate<string>('window.location.href')
134
+ if (currentUrl.includes('app.trainingpeaks.com') && !currentUrl.includes('login')) {
135
+ console.log('Already logged in!')
136
+ }
137
+ else {
138
+ // Fill in credentials using specific selectors
139
+ console.log('Entering username...')
140
+ await browser.type('input[name="Username"]', this.credentials.username)
141
+ await new Promise(r => setTimeout(r, 300))
142
+
143
+ console.log('Entering password...')
144
+ await browser.type('input[name="Password"]', this.credentials.password)
145
+ await new Promise(r => setTimeout(r, 500))
146
+
147
+ // Wait a moment for reCAPTCHA v3 to initialize (it runs invisibly)
148
+ console.log('Waiting for reCAPTCHA to initialize...')
149
+ await new Promise(r => setTimeout(r, 2000))
150
+
151
+ // Click login button using JavaScript click
152
+ console.log('Submitting login form...')
153
+ await browser.jsClick('button[type="submit"]')
154
+
155
+ // If that didn't work, try form submit
156
+ await new Promise(r => setTimeout(r, 1000))
157
+ const urlAfterClick = await browser.evaluate<string>('window.location.href')
158
+ if (urlAfterClick.includes('login')) {
159
+ console.log('Button click may not have worked, trying form submit...')
160
+ await browser.submitForm('form')
161
+ }
162
+
163
+ // Wait for redirect to app or dashboard
164
+ console.log('Waiting for login to complete...')
165
+ await browser.waitForUrl(/app\.trainingpeaks\.com(?!.*login)|home\.trainingpeaks\.com\/(athlete|coach|calendar|dashboard)/, this.timeout)
166
+ }
167
+
168
+ // Check current URL to determine account type
169
+ const finalUrl = await browser.evaluate<string>('window.location.href')
170
+ this.isCoach = finalUrl.includes('/coach') || finalUrl.includes('coach.')
171
+
172
+ // Extract cookies
173
+ const cdpCookies = await browser.getCookies([
174
+ 'https://trainingpeaks.com',
175
+ 'https://home.trainingpeaks.com',
176
+ 'https://app.trainingpeaks.com',
177
+ 'https://tpapi.trainingpeaks.com',
178
+ ])
179
+
180
+ // Convert CDP cookies to our format
181
+ const cookies: TPCookie[] = cdpCookies.map(c => ({
182
+ name: c.name,
183
+ value: c.value,
184
+ domain: c.domain,
185
+ path: c.path,
186
+ expires: c.expires > 0 ? new Date(c.expires * 1000) : undefined,
187
+ secure: c.secure,
188
+ httpOnly: c.httpOnly,
189
+ }))
190
+
191
+ this.client.setCookies(cookies)
192
+ this.authenticated = true
193
+
194
+ // Save cookies if path specified
195
+ if (this.cookiePath) {
196
+ await this.client.saveCookies(this.cookiePath)
197
+ }
198
+
199
+ console.log('Login successful!')
200
+
201
+ }
202
+ finally {
203
+ await browser.close()
204
+ }
205
+
206
+ return this
207
+ }
208
+
209
+ /**
210
+ * Verify session is still valid
211
+ */
212
+ private async verifySession(): Promise<boolean> {
213
+ try {
214
+ const tokenResponse = await this.client.get<{ success: boolean, token?: { access_token: string } }>(`${TP_API_URL}/users/v3/token`)
215
+ return tokenResponse.success === true
216
+ }
217
+ catch {
218
+ return false
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get current logged-in user
224
+ */
225
+ async getCurrentUser(): Promise<TPAthlete> {
226
+ if (this.currentUser) return this.currentUser
227
+
228
+ // Get full user data - response is nested in "user" object
229
+ const response = await this.client.get<{ user: { userId: number, settings?: unknown } }>(`${TP_API_URL}/users/v3/user`)
230
+
231
+ // Map to our athlete type
232
+ this.currentUser = {
233
+ Id: response.user.userId,
234
+ UserId: response.user.userId,
235
+ } as TPAthlete
236
+
237
+ return this.currentUser
238
+ }
239
+
240
+ /**
241
+ * Check if logged in as coach
242
+ */
243
+ isCoachAccount(): boolean {
244
+ return this.isCoach
245
+ }
246
+
247
+ // =====================
248
+ // Athlete Endpoints
249
+ // =====================
250
+
251
+ /**
252
+ * Get athlete profile
253
+ */
254
+ async getAthlete(athleteId?: number): Promise<TPAthlete> {
255
+ const id = athleteId || (await this.getCurrentUser()).Id
256
+ return this.client.get<TPAthlete>(`${TP_API_URL}/fitness/v1/athletes/${id}/settings`)
257
+ }
258
+
259
+ /**
260
+ * Get coached athletes (coach accounts only)
261
+ * Uses the coachedathletesadded endpoint discovered from network traffic
262
+ */
263
+ async getCoachedAthletes(): Promise<TPCoachAthlete[]> {
264
+ const user = await this.getCurrentUser()
265
+ return this.client.get<TPCoachAthlete[]>(`${TP_API_URL}/fitness/v1/coaches/${user.Id}/coachedathletesadded`)
266
+ }
267
+
268
+ /**
269
+ * Get athlete groups/tags for coach
270
+ */
271
+ async getAthleteGroups(): Promise<TPAthleteGroup[]> {
272
+ const user = await this.getCurrentUser()
273
+ return this.client.get<TPAthleteGroup[]>(`${TP_API_URL}/coaches/v2/coaches/${user.Id}/tags`)
274
+ }
275
+
276
+ /**
277
+ * Get all coached athlete IDs from groups
278
+ */
279
+ async getCoachedAthleteIds(): Promise<number[]> {
280
+ const groups = await this.getAthleteGroups()
281
+ const ids = new Set<number>()
282
+ for (const group of groups) {
283
+ for (const id of group.athleteIds) {
284
+ ids.add(id)
285
+ }
286
+ }
287
+ return Array.from(ids)
288
+ }
289
+
290
+ /**
291
+ * Get athlete group feed (athletes in a specific group)
292
+ */
293
+ async getAthleteGroupFeed(groupId: number): Promise<unknown[]> {
294
+ const user = await this.getCurrentUser()
295
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/coaches/${user.Id}/athletegroups/${groupId}/feed`)
296
+ }
297
+
298
+ /**
299
+ * Get assistant coaches
300
+ */
301
+ async getAssistantCoaches(): Promise<unknown[]> {
302
+ const user = await this.getCurrentUser()
303
+ return this.client.get<unknown[]>(`${TP_API_URL}/coaches/v1/coaches/${user.Id}/assistantcoaches`)
304
+ }
305
+
306
+ /**
307
+ * Get coach settings
308
+ */
309
+ async getCoachSettings(): Promise<unknown> {
310
+ const user = await this.getCurrentUser()
311
+ return this.client.get<unknown>(`${TP_API_URL}/coaches/v1/coaches/${user.Id}/settings`)
312
+ }
313
+
314
+ /**
315
+ * Get athletes upgrade status
316
+ */
317
+ async getAthletesUpgradeStatus(): Promise<unknown> {
318
+ const user = await this.getCurrentUser()
319
+ return this.client.get<unknown>(`${TP_API_URL}/coaches/v1/coaches/${user.Id}/athletes/upgradeStatus`)
320
+ }
321
+
322
+ /**
323
+ * Get athlete equipment
324
+ */
325
+ async getEquipment(athleteId?: number): Promise<unknown[]> {
326
+ const id = athleteId || (await this.getCurrentUser()).Id
327
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/athletes/${id}/equipment`)
328
+ }
329
+
330
+ /**
331
+ * Get athlete zones (HR, power, pace)
332
+ */
333
+ async getZones(athleteId?: number): Promise<unknown> {
334
+ const id = athleteId || (await this.getCurrentUser()).Id
335
+ return this.client.get<unknown>(`${TP_API_URL}/fitness/v1/athletes/${id}/defaultZones`)
336
+ }
337
+
338
+ /**
339
+ * Get athlete goals
340
+ */
341
+ async getGoals(athleteId?: number): Promise<unknown[]> {
342
+ const id = athleteId || (await this.getCurrentUser()).Id
343
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/athletes/${id}/goallists`)
344
+ }
345
+
346
+ /**
347
+ * Get athlete's coaches
348
+ */
349
+ async getCoaches(athleteId?: number): Promise<unknown[]> {
350
+ const id = athleteId || (await this.getCurrentUser()).Id
351
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/athletes/${id}/coaches/shared`)
352
+ }
353
+
354
+ /**
355
+ * Get primary coach
356
+ */
357
+ async getPrimaryCoach(athleteId?: number): Promise<unknown> {
358
+ const id = athleteId || (await this.getCurrentUser()).Id
359
+ return this.client.get<unknown>(`${TP_API_URL}/fitness/v1/athletes/${id}/coaches/primary`)
360
+ }
361
+
362
+ // =====================
363
+ // Workout Endpoints
364
+ // =====================
365
+
366
+ /**
367
+ * Get workouts for a date range
368
+ */
369
+ async getWorkouts(startDate: Date, endDate: Date, athleteId?: number): Promise<TPWorkout[]> {
370
+ const id = athleteId || (await this.getCurrentUser()).Id
371
+ const start = this.formatDate(startDate)
372
+ const end = this.formatDate(endDate)
373
+
374
+ return this.client.get<TPWorkout[]>(`${TP_API_URL}/fitness/v6/athletes/${id}/workouts/${start}/${end}`)
375
+ }
376
+
377
+ /**
378
+ * Get a single workout with details
379
+ */
380
+ async getWorkout(workoutId: number, athleteId?: number): Promise<TPWorkout> {
381
+ const id = athleteId || (await this.getCurrentUser()).Id
382
+ return this.client.get<TPWorkout>(`${TP_API_URL}/fitness/v6/athletes/${id}/workouts/${workoutId}/details`)
383
+ }
384
+
385
+ /**
386
+ * Create a new workout
387
+ */
388
+ async createWorkout(workout: Partial<TPWorkout>, athleteId?: number): Promise<TPWorkout> {
389
+ const id = athleteId || (await this.getCurrentUser()).Id
390
+ return this.client.post<TPWorkout>(`${TP_API_URL}/fitness/v6/athletes/${id}/workouts`, workout)
391
+ }
392
+
393
+ /**
394
+ * Update a workout
395
+ */
396
+ async updateWorkout(workoutId: number, updates: Partial<TPWorkout>): Promise<TPWorkout> {
397
+ return this.client.put<TPWorkout>(`${TP_API_URL}/fitness/v6/workouts/${workoutId}`, updates)
398
+ }
399
+
400
+ /**
401
+ * Delete a workout
402
+ */
403
+ async deleteWorkout(workoutId: number): Promise<void> {
404
+ await this.client.delete<void>(`${TP_API_URL}/fitness/v6/workouts/${workoutId}`)
405
+ }
406
+
407
+ /**
408
+ * Mark workout as completed
409
+ */
410
+ async completeWorkout(workoutId: number, completedData?: Partial<TPWorkout>): Promise<TPWorkout> {
411
+ return this.client.put<TPWorkout>(`${TP_API_URL}/fitness/v6/workouts/${workoutId}/complete`, {
412
+ Completed: true,
413
+ CompletedDate: new Date().toISOString(),
414
+ ...completedData,
415
+ })
416
+ }
417
+
418
+ // =====================
419
+ // Calendar Endpoints
420
+ // =====================
421
+
422
+ /**
423
+ * Get calendar notes for a date range
424
+ */
425
+ async getCalendarNotes(startDate: Date, endDate: Date, athleteId?: number): Promise<unknown[]> {
426
+ const id = athleteId || (await this.getCurrentUser()).Id
427
+ const start = this.formatDate(startDate)
428
+ const end = this.formatDate(endDate)
429
+
430
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/athletes/${id}/calendarNote/${start}/${end}`)
431
+ }
432
+
433
+ /**
434
+ * Get events (races, etc.) for a date range
435
+ */
436
+ async getEvents(startDate: Date, endDate: Date, athleteId?: number): Promise<unknown[]> {
437
+ const id = athleteId || (await this.getCurrentUser()).Id
438
+ const start = this.formatDate(startDate)
439
+ const end = this.formatDate(endDate)
440
+
441
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v6/athletes/${id}/events/${start}/${end}`)
442
+ }
443
+
444
+ /**
445
+ * Get focus event (A race)
446
+ */
447
+ async getFocusEvent(athleteId?: number): Promise<unknown> {
448
+ const id = athleteId || (await this.getCurrentUser()).Id
449
+ return this.client.get<unknown>(`${TP_API_URL}/fitness/v6/athletes/${id}/events/focusevent`)
450
+ }
451
+
452
+ /**
453
+ * Get availability for a date range
454
+ */
455
+ async getAvailability(startDate: Date, endDate: Date, athleteId?: number): Promise<unknown[]> {
456
+ const id = athleteId || (await this.getCurrentUser()).Id
457
+ const start = this.formatDate(startDate)
458
+ const end = this.formatDate(endDate)
459
+
460
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v1/athletes/${id}/availability/${start}/${end}`)
461
+ }
462
+
463
+ // =====================
464
+ // Metrics Endpoints
465
+ // =====================
466
+
467
+ /**
468
+ * Get metrics for a date range (consolidated timed metrics)
469
+ */
470
+ async getMetrics(startDate: Date, endDate: Date, athleteId?: number): Promise<TPMetrics[]> {
471
+ const id = athleteId || (await this.getCurrentUser()).Id
472
+ const start = this.formatDate(startDate)
473
+ const end = this.formatDate(endDate)
474
+
475
+ return this.client.get<TPMetrics[]>(`${TP_API_URL}/metrics/v3/athletes/${id}/consolidatedtimedmetrics/${start}/${end}`)
476
+ }
477
+
478
+ /**
479
+ * Save metrics for a date
480
+ */
481
+ async saveMetrics(metrics: TPMetrics, athleteId?: number): Promise<TPMetrics> {
482
+ const id = athleteId || (await this.getCurrentUser()).Id
483
+ return this.client.post<TPMetrics>(`${TP_API_URL}/fitness/v1/athletes/${id}/metrics`, metrics)
484
+ }
485
+
486
+ // =====================
487
+ // Performance Chart
488
+ // =====================
489
+
490
+ /**
491
+ * Get PMC (Performance Management Chart) data
492
+ */
493
+ async getPerformanceChart(startDate: Date, endDate: Date, athleteId?: number): Promise<TPPerformanceChart> {
494
+ const id = athleteId || (await this.getCurrentUser()).Id
495
+ const start = this.formatDate(startDate)
496
+ const end = this.formatDate(endDate)
497
+
498
+ return this.client.get<TPPerformanceChart>(`${TP_API_URL}/fitness/v3/athletes/${id}/pmc/${start}/${end}`)
499
+ }
500
+
501
+ // =====================
502
+ // File Upload
503
+ // =====================
504
+
505
+ /**
506
+ * Upload an activity file (FIT, TCX, GPX)
507
+ */
508
+ async uploadFile(file: ArrayBuffer, fileName: string, athleteId?: number): Promise<TPUploadResult> {
509
+ const id = athleteId || (await this.getCurrentUser()).Id
510
+
511
+ // Compress and encode
512
+ const compressed = await this.gzipEncode(file)
513
+ const base64 = this.arrayBufferToBase64(compressed)
514
+
515
+ return this.client.post<TPUploadResult>(`${TP_API_URL}/fitness/v1/athletes/${id}/fileupload`, {
516
+ FileName: fileName,
517
+ Data: base64,
518
+ })
519
+ }
520
+
521
+ // =====================
522
+ // Library Workouts
523
+ // =====================
524
+
525
+ /**
526
+ * Get workout library
527
+ */
528
+ async getLibraryWorkouts(params?: TPSearchParams): Promise<TPLibraryWorkout[]> {
529
+ const user = await this.getCurrentUser()
530
+ return this.client.get<TPLibraryWorkout[]>(`${TP_API_URL}/fitness/v1/athletes/${user.Id}/library/workouts`, {
531
+ limit: params?.limit,
532
+ offset: params?.offset,
533
+ })
534
+ }
535
+
536
+ /**
537
+ * Create library workout
538
+ */
539
+ async createLibraryWorkout(workout: Partial<TPLibraryWorkout>): Promise<TPLibraryWorkout> {
540
+ const user = await this.getCurrentUser()
541
+ return this.client.post<TPLibraryWorkout>(`${TP_API_URL}/fitness/v1/athletes/${user.Id}/library/workouts`, workout)
542
+ }
543
+
544
+ /**
545
+ * Apply library workout to calendar
546
+ */
547
+ async applyLibraryWorkout(libraryWorkoutId: number, date: Date, athleteId?: number): Promise<TPWorkout> {
548
+ const id = athleteId || (await this.getCurrentUser()).Id
549
+ return this.client.post<TPWorkout>(`${TP_API_URL}/fitness/v1/athletes/${id}/workouts/fromLibrary`, {
550
+ LibraryWorkoutId: libraryWorkoutId,
551
+ WorkoutDay: this.formatDate(date),
552
+ })
553
+ }
554
+
555
+ // =====================
556
+ // Plans Endpoints
557
+ // =====================
558
+
559
+ /**
560
+ * Get applied training plans
561
+ */
562
+ async getAppliedPlans(date?: Date, athleteId?: number): Promise<unknown[]> {
563
+ const id = athleteId || (await this.getCurrentUser()).Id
564
+ const dateStr = this.formatDate(date || new Date())
565
+ return this.client.get<unknown[]>(`${TP_API_URL}/plans/v1/athletes/${id}/appliedplans/${dateStr}`)
566
+ }
567
+
568
+ // =====================
569
+ // System Endpoints
570
+ // =====================
571
+
572
+ /**
573
+ * Get all workout types
574
+ */
575
+ async getWorkoutTypes(): Promise<unknown[]> {
576
+ return this.client.get<unknown[]>(`${TP_API_URL}/fitness/v6/workouttypes`)
577
+ }
578
+
579
+ /**
580
+ * Get user feature flags
581
+ */
582
+ async getFeatureFlags(): Promise<unknown> {
583
+ return this.client.get<unknown>(`${TP_API_URL}/users/v1/featureflags`)
584
+ }
585
+
586
+ /**
587
+ * Get user access rights
588
+ */
589
+ async getAccessRights(): Promise<unknown> {
590
+ return this.client.get<unknown>(`${TP_API_URL}/users/v1/user/accessrights`)
591
+ }
592
+
593
+ /**
594
+ * Get integrations (Garmin, Strava, etc.)
595
+ */
596
+ async getIntegrations(): Promise<unknown[]> {
597
+ return this.client.get<unknown[]>(`${TP_API_URL}/integrations/v1/access/userIntegrations`)
598
+ }
599
+
600
+ // =====================
601
+ // Utility Methods
602
+ // =====================
603
+
604
+ private formatDate(date: Date): string {
605
+ return date.toISOString().split('T')[0]
606
+ }
607
+
608
+ private async gzipEncode(data: ArrayBuffer): Promise<ArrayBuffer> {
609
+ const stream = new ReadableStream({
610
+ start(controller) {
611
+ controller.enqueue(new Uint8Array(data))
612
+ controller.close()
613
+ },
614
+ })
615
+
616
+ const compressedStream = stream.pipeThrough(new CompressionStream('gzip'))
617
+ const reader = compressedStream.getReader()
618
+ const chunks: Uint8Array[] = []
619
+
620
+ while (true) {
621
+ const { done, value } = await reader.read()
622
+ if (done) break
623
+ chunks.push(value)
624
+ }
625
+
626
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
627
+ const result = new Uint8Array(totalLength)
628
+ let offset = 0
629
+ for (const chunk of chunks) {
630
+ result.set(chunk, offset)
631
+ offset += chunk.length
632
+ }
633
+
634
+ return result.buffer
635
+ }
636
+
637
+ private arrayBufferToBase64(buffer: ArrayBuffer): string {
638
+ const bytes = new Uint8Array(buffer)
639
+ let binary = ''
640
+ for (let i = 0; i < bytes.byteLength; i++) {
641
+ binary += String.fromCharCode(bytes[i])
642
+ }
643
+ return btoa(binary)
644
+ }
645
+
646
+ /**
647
+ * Export cookies for persistence
648
+ */
649
+ exportCookies(): TPCookie[] {
650
+ return this.client.getCookies()
651
+ }
652
+
653
+ /**
654
+ * Import cookies (for restoring session)
655
+ */
656
+ importCookies(cookies: TPCookie[]): void {
657
+ this.client.setCookies(cookies)
658
+ }
659
+
660
+ /**
661
+ * Raw API request (for endpoints not covered)
662
+ */
663
+ async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
664
+ const url = path.startsWith('http') ? path : `${TP_API_URL}${path}`
665
+ return this.client.get<T>(url, params)
666
+ }
667
+
668
+ async post<T>(path: string, body?: unknown): Promise<T> {
669
+ const url = path.startsWith('http') ? path : `${TP_API_URL}${path}`
670
+ return this.client.post<T>(url, body)
671
+ }
672
+ }
673
+
674
+ export default TrainingPeaks