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.
- package/LICENSE.md +21 -0
- package/README.md +358 -0
- package/package.json +45 -0
- package/src/TrainingPeaks.ts +674 -0
- package/src/browser.ts +558 -0
- package/src/http.ts +265 -0
- package/src/index.ts +5 -0
- package/src/types.ts +319 -0
|
@@ -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
|