@dlwiest/ts-tonal-client 0.4.0 → 0.5.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/README.md +86 -12
- package/dist/index.cjs +90 -6
- package/dist/index.d.ts +40 -1
- package/dist/index.esm.js +90 -6
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ console.log(`You have ${workouts.length} workouts`)
|
|
|
54
54
|
|
|
55
55
|
### Client setup
|
|
56
56
|
|
|
57
|
-
`TonalClient.create()` accepts credentials and an optional
|
|
57
|
+
`TonalClient.create()` accepts credentials and an optional cache directory:
|
|
58
58
|
|
|
59
59
|
```typescript
|
|
60
60
|
TonalClient.create(credentials: {
|
|
@@ -64,9 +64,9 @@ TonalClient.create(credentials: {
|
|
|
64
64
|
}): Promise<TonalClient>
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Without `cacheDir`,
|
|
67
|
+
Without `cacheDir`, cached movements and completed workout activities are stored in `$XDG_CACHE_HOME/ts-tonal-client` when `XDG_CACHE_HOME` is set. Otherwise, the cache lives at `~/.cache/ts-tonal-client`. The directory is created on the first successful cache write, not when the client is created.
|
|
68
68
|
|
|
69
|
-
Pass `cacheDir` to store
|
|
69
|
+
Pass `cacheDir` to store both caches somewhere else:
|
|
70
70
|
|
|
71
71
|
```typescript
|
|
72
72
|
const client = await TonalClient.create({
|
|
@@ -78,7 +78,9 @@ const client = await TonalClient.create({
|
|
|
78
78
|
|
|
79
79
|
### Cache invalidation
|
|
80
80
|
|
|
81
|
-
`
|
|
81
|
+
`await client.invalidateMovementsCache()` clears the movements cache only. It does not clear cached workout activities.
|
|
82
|
+
|
|
83
|
+
`client.invalidateUserInfo()` clears the in-memory user profile. Call it before the next `getUserInfo()` request to fetch fresh data.
|
|
82
84
|
|
|
83
85
|
## Examples
|
|
84
86
|
|
|
@@ -222,6 +224,86 @@ const updatedWorkout = await client.updateWorkout({
|
|
|
222
224
|
await client.deleteWorkout('workout-uuid')
|
|
223
225
|
```
|
|
224
226
|
|
|
227
|
+
### Performed workout activities
|
|
228
|
+
|
|
229
|
+
A workout activity is one performed workout session, not a workout template. Get an activity ID from `getActivitySummaries()`, then fetch its detail from `/users/{userId}/workout-activities/{activityId}`:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
const summaries = await client.getActivitySummaries()
|
|
233
|
+
const recent = [...summaries]
|
|
234
|
+
.filter(summary => summary.completed)
|
|
235
|
+
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))[0]
|
|
236
|
+
|
|
237
|
+
if (recent) {
|
|
238
|
+
const activity = await client.getWorkoutActivityById(recent.id)
|
|
239
|
+
console.log(`${activity.totalSets} sets, ${activity.totalReps} reps`)
|
|
240
|
+
console.log(`Completed: ${activity.completed === true}`)
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The method signature is:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
async getWorkoutActivityById(
|
|
248
|
+
activityId: string,
|
|
249
|
+
useCache: boolean = true
|
|
250
|
+
): Promise<TonalWorkoutActivity>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Completed activities are immutable, so results with `completed === true` are cached permanently with no TTL. In-progress activities are never cached. Cache keys include both the user and activity IDs, which prevents collisions between accounts. Cache reads and writes are best-effort: a cache failure never fails the API request or hides a successful response. Pass `false` for `useCache` to force a fresh request.
|
|
254
|
+
|
|
255
|
+
Activity entries use the same `cacheDir` and default cache location described under [Client setup](#client-setup). Permanent entries never expire and nothing evicts them automatically, so the cache grows by one small file for each completed activity viewed. `invalidateMovementsCache()` does not clear these entries.
|
|
256
|
+
|
|
257
|
+
#### Activity response types
|
|
258
|
+
|
|
259
|
+
`TonalWorkoutActivity` contains the performed session's IDs, timestamps, duration and aggregate totals, completion state, device metadata, and its set activity records:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
interface TonalWorkoutActivity {
|
|
263
|
+
id: string
|
|
264
|
+
userId: string
|
|
265
|
+
workoutId: string
|
|
266
|
+
workoutType?: string | null
|
|
267
|
+
beginTime: string
|
|
268
|
+
endTime?: string | null
|
|
269
|
+
totalDuration: number
|
|
270
|
+
activeDuration?: number | null
|
|
271
|
+
restDuration?: number | null
|
|
272
|
+
totalMovements?: number | null
|
|
273
|
+
totalSets: number
|
|
274
|
+
totalReps: number
|
|
275
|
+
totalVolume: number
|
|
276
|
+
totalConcentricWork?: number | null
|
|
277
|
+
percentCompleted?: number | null
|
|
278
|
+
completed?: boolean | null
|
|
279
|
+
workoutSetActivity: TonalWorkoutSetActivity[]
|
|
280
|
+
contentCard?: unknown
|
|
281
|
+
deviceId?: string | null
|
|
282
|
+
timezone?: string | null
|
|
283
|
+
appVersion?: string | null
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Each `TonalWorkoutSetActivity` identifies a movement and includes the performed or prescribed values available for that set:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
interface TonalWorkoutSetActivity {
|
|
291
|
+
movementId: string
|
|
292
|
+
prescribedReps?: number | null
|
|
293
|
+
repetition?: number | null
|
|
294
|
+
repetitionTotal?: number | null
|
|
295
|
+
weightPercentage?: number | null
|
|
296
|
+
baseWeight?: number | null
|
|
297
|
+
eccentricWeight?: number | null
|
|
298
|
+
chainsWeight?: number | null
|
|
299
|
+
blockNumber?: number | null
|
|
300
|
+
blockStart?: boolean | null
|
|
301
|
+
sideNumber?: number | null
|
|
302
|
+
setId?: string | null
|
|
303
|
+
spotter?: boolean | null
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
225
307
|
### User Information
|
|
226
308
|
|
|
227
309
|
```typescript
|
|
@@ -278,14 +360,6 @@ console.log(`Current streak: ${streak.currentStreak} workouts`)
|
|
|
278
360
|
console.log(`Personal best: ${streak.maxStreak} workouts`)
|
|
279
361
|
console.log(`Progress to personal best: ${Math.round((streak.currentStreak / streak.maxStreak) * 100)}%`)
|
|
280
362
|
|
|
281
|
-
// Get comprehensive workout activity history
|
|
282
|
-
const activities = await client.getActivitySummaries()
|
|
283
|
-
console.log(`Total workouts completed: ${activities.length}`)
|
|
284
|
-
const totalVolume = activities.reduce((sum, activity) => sum + activity.totalVolume, 0)
|
|
285
|
-
console.log(`Total volume lifted: ${totalVolume.toLocaleString()} lbs`)
|
|
286
|
-
const guidedWorkouts = activities.filter(a => a.isGuidedWorkout).length
|
|
287
|
-
console.log(`Guided vs Free Lift: ${guidedWorkouts}/${activities.length - guidedWorkouts}`)
|
|
288
|
-
|
|
289
363
|
// Get lifetime statistics and achievements
|
|
290
364
|
const stats = await client.getUserStatistics()
|
|
291
365
|
console.log(`Total volume: ${stats.volume.total.toLocaleString()} lbs over ${stats.workouts.total} workouts`)
|
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
5
5
|
var fs = require('fs');
|
|
6
6
|
var os = require('os');
|
|
7
7
|
var path = require('path');
|
|
8
|
+
var crypto = require('crypto');
|
|
8
9
|
|
|
9
10
|
class TonalClientError extends Error {
|
|
10
11
|
constructor(message, statusCode, originalError) {
|
|
@@ -72,9 +73,49 @@ class AuthManager {
|
|
|
72
73
|
const tokenData = await response.json();
|
|
73
74
|
this.idToken = tokenData.id_token;
|
|
74
75
|
this.refreshToken = tokenData.refresh_token ?? '';
|
|
75
|
-
|
|
76
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
77
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
76
78
|
return this.idToken;
|
|
77
79
|
}
|
|
80
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
81
|
+
try {
|
|
82
|
+
if (!idToken) {
|
|
83
|
+
return expiresInDeadline;
|
|
84
|
+
}
|
|
85
|
+
const segments = idToken.split('.');
|
|
86
|
+
if (segments.length !== 3) {
|
|
87
|
+
return expiresInDeadline;
|
|
88
|
+
}
|
|
89
|
+
const payloadSegment = segments[1];
|
|
90
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
91
|
+
return expiresInDeadline;
|
|
92
|
+
}
|
|
93
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
94
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
95
|
+
return expiresInDeadline;
|
|
96
|
+
}
|
|
97
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
98
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
99
|
+
return expiresInDeadline;
|
|
100
|
+
}
|
|
101
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
102
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
103
|
+
return expiresInDeadline;
|
|
104
|
+
}
|
|
105
|
+
const expDeadline = exp * 1000;
|
|
106
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
107
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
108
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
109
|
+
return expiresInDeadline;
|
|
110
|
+
}
|
|
111
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
112
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
113
|
+
return Math.min(expDeadline, expiresInDeadline);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return expiresInDeadline;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
78
119
|
invalidateToken() {
|
|
79
120
|
this.tokenExpiresAt = 0;
|
|
80
121
|
}
|
|
@@ -133,7 +174,8 @@ class AuthManager {
|
|
|
133
174
|
const tokenData = await response.json();
|
|
134
175
|
this.idToken = tokenData.id_token;
|
|
135
176
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
136
|
-
|
|
177
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
178
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
137
179
|
}
|
|
138
180
|
}
|
|
139
181
|
|
|
@@ -391,7 +433,8 @@ class CacheManager {
|
|
|
391
433
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
392
434
|
const now = Date.now();
|
|
393
435
|
const age = now - cachedAt;
|
|
394
|
-
if (!Number.isFinite(cachedAt) ||
|
|
436
|
+
if (!Number.isFinite(cachedAt) ||
|
|
437
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
395
438
|
return null;
|
|
396
439
|
}
|
|
397
440
|
return entry.data;
|
|
@@ -402,11 +445,17 @@ class CacheManager {
|
|
|
402
445
|
}
|
|
403
446
|
}
|
|
404
447
|
async set(key, data, ttl) {
|
|
448
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
449
|
+
}
|
|
450
|
+
async setPermanent(key, data) {
|
|
451
|
+
this.write(key, data, null);
|
|
452
|
+
}
|
|
453
|
+
write(key, data, ttl) {
|
|
405
454
|
const cachePath = this.getCachePath(key);
|
|
406
455
|
this.ensureCacheDir();
|
|
407
456
|
const entry = {
|
|
408
457
|
cachedAt: new Date().toISOString(),
|
|
409
|
-
ttl
|
|
458
|
+
ttl,
|
|
410
459
|
data,
|
|
411
460
|
};
|
|
412
461
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -456,8 +505,9 @@ class MovementService {
|
|
|
456
505
|
}
|
|
457
506
|
|
|
458
507
|
class UserService {
|
|
459
|
-
constructor(httpClient) {
|
|
508
|
+
constructor(httpClient, cacheDir) {
|
|
460
509
|
this.httpClient = httpClient;
|
|
510
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
461
511
|
}
|
|
462
512
|
async getUserInfo() {
|
|
463
513
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -528,6 +578,36 @@ class UserService {
|
|
|
528
578
|
// Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
|
|
529
579
|
return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
|
|
530
580
|
}
|
|
581
|
+
async getWorkoutActivityById(userId, activityId, useCache = true) {
|
|
582
|
+
const canonicalActivityId = activityId.trim();
|
|
583
|
+
if (!canonicalActivityId) {
|
|
584
|
+
throw new TonalClientError('Workout activity id must not be empty');
|
|
585
|
+
}
|
|
586
|
+
const cacheKey = `workout-activity-v1-${crypto.createHash('sha256')
|
|
587
|
+
.update(`${userId}\0${canonicalActivityId}`)
|
|
588
|
+
.digest('hex')}`;
|
|
589
|
+
if (useCache) {
|
|
590
|
+
try {
|
|
591
|
+
const cached = await this.cacheManager.get(cacheKey);
|
|
592
|
+
if (cached !== null) {
|
|
593
|
+
return cached;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Cache reads are best-effort and must not prevent a fresh request.
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const activity = await this.httpClient.request(`/users/${userId}/workout-activities/${encodeURIComponent(canonicalActivityId)}`);
|
|
601
|
+
if (activity.completed === true) {
|
|
602
|
+
try {
|
|
603
|
+
await this.cacheManager.setPermanent(cacheKey, activity);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Cache writes are best-effort and must not hide a successful API response.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return activity;
|
|
610
|
+
}
|
|
531
611
|
async getActivitySummaries(userId) {
|
|
532
612
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
533
613
|
}
|
|
@@ -566,7 +646,7 @@ class TonalClient {
|
|
|
566
646
|
this.httpClient = new HttpClient(this.authManager);
|
|
567
647
|
this.workoutService = new WorkoutService(this.httpClient);
|
|
568
648
|
this.movementService = new MovementService(this.httpClient, cacheDir);
|
|
569
|
-
this.userService = new UserService(this.httpClient);
|
|
649
|
+
this.userService = new UserService(this.httpClient, cacheDir);
|
|
570
650
|
}
|
|
571
651
|
static async create(credentials) {
|
|
572
652
|
const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
|
|
@@ -661,6 +741,10 @@ class TonalClient {
|
|
|
661
741
|
}
|
|
662
742
|
return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
|
|
663
743
|
}
|
|
744
|
+
async getWorkoutActivityById(activityId, useCache = true) {
|
|
745
|
+
const userInfo = await this.getUserInfo();
|
|
746
|
+
return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
|
|
747
|
+
}
|
|
664
748
|
async getActivitySummaries() {
|
|
665
749
|
const userInfo = await this.getUserInfo();
|
|
666
750
|
return this.userService.getActivitySummaries(userInfo.id);
|
package/dist/index.d.ts
CHANGED
|
@@ -120,6 +120,44 @@ interface TonalWorkout {
|
|
|
120
120
|
isImported: boolean;
|
|
121
121
|
createdSource?: unknown | null;
|
|
122
122
|
}
|
|
123
|
+
interface TonalWorkoutSetActivity {
|
|
124
|
+
movementId: string;
|
|
125
|
+
prescribedReps?: number | null;
|
|
126
|
+
repetition?: number | null;
|
|
127
|
+
repetitionTotal?: number | null;
|
|
128
|
+
weightPercentage?: number | null;
|
|
129
|
+
baseWeight?: number | null;
|
|
130
|
+
eccentricWeight?: number | null;
|
|
131
|
+
chainsWeight?: number | null;
|
|
132
|
+
blockNumber?: number | null;
|
|
133
|
+
blockStart?: boolean | null;
|
|
134
|
+
sideNumber?: number | null;
|
|
135
|
+
setId?: string | null;
|
|
136
|
+
spotter?: boolean | null;
|
|
137
|
+
}
|
|
138
|
+
interface TonalWorkoutActivity {
|
|
139
|
+
id: string;
|
|
140
|
+
userId: string;
|
|
141
|
+
workoutId: string;
|
|
142
|
+
workoutType?: string | null;
|
|
143
|
+
beginTime: string;
|
|
144
|
+
endTime?: string | null;
|
|
145
|
+
totalDuration: number;
|
|
146
|
+
activeDuration?: number | null;
|
|
147
|
+
restDuration?: number | null;
|
|
148
|
+
totalMovements?: number | null;
|
|
149
|
+
totalSets: number;
|
|
150
|
+
totalReps: number;
|
|
151
|
+
totalVolume: number;
|
|
152
|
+
totalConcentricWork?: number | null;
|
|
153
|
+
percentCompleted?: number | null;
|
|
154
|
+
completed?: boolean | null;
|
|
155
|
+
workoutSetActivity: TonalWorkoutSetActivity[];
|
|
156
|
+
contentCard?: unknown;
|
|
157
|
+
deviceId?: string | null;
|
|
158
|
+
timezone?: string | null;
|
|
159
|
+
appVersion?: string | null;
|
|
160
|
+
}
|
|
123
161
|
interface TonalSharedWorkout {
|
|
124
162
|
id: string;
|
|
125
163
|
sharerUserId: string;
|
|
@@ -687,6 +725,7 @@ declare class TonalClient {
|
|
|
687
725
|
getCurrentStreak(): Promise<TonalCurrentStreak>;
|
|
688
726
|
getCurrentStrengthScores(): Promise<TonalStrengthScore[]>;
|
|
689
727
|
getStrengthScoreHistory(days?: TonalStrengthScoreHistoryLookback): Promise<TonalStrengthScoreHistoryEntry[]>;
|
|
728
|
+
getWorkoutActivityById(activityId: string, useCache?: boolean): Promise<TonalWorkoutActivity>;
|
|
690
729
|
getActivitySummaries(): Promise<TonalActivitySummary[]>;
|
|
691
730
|
getUserStatistics(): Promise<TonalUserStatistics>;
|
|
692
731
|
getAchievementStats(): Promise<TonalAchievementStats>;
|
|
@@ -706,4 +745,4 @@ declare class TonalClient {
|
|
|
706
745
|
deleteWorkout(workoutId: string): Promise<void>;
|
|
707
746
|
}
|
|
708
747
|
|
|
709
|
-
export { MuscleGroup, OAuthTokenResponse, TonalAchievementCategory, TonalAchievementDefinition, TonalAchievementMilestone, TonalAchievementStats, TonalActivitySummary, TonalApiError, TonalCalendarTile, TonalClient, TonalClientError, TonalCompatibilityStatus, TonalCurrentStreak, TonalDailyMetrics, TonalDailySchedule, TonalDeviceRegistration, TonalEarnedAchievement, TonalGoal, TonalGoalMetric, TonalHomeCalendar, TonalMetricScore, TonalMetricScoresResponse, TonalMovement, TonalMuscleReadiness, TonalMuscleUtilization, TonalProgram, TonalProgramTrainingEffectGoal, TonalProgramWorkout, TonalSharedWorkout, TonalStrengthBodyRegion, TonalStrengthScore, TonalStrengthScoreHistoryEntry, TonalStrengthScoreHistoryLookback, TonalTargetScore, TonalTargetScoresResponse, TonalTrainingEffectGoal, TonalTrainingEffectGoalRelation, TonalTrainingEffectGoalsResponse, TonalTrainingType, TonalUserDevice, TonalUserGoal, TonalUserInfo, TonalUserPermissions, TonalUserSettings, TonalUserStatistics, TonalWorkout, TonalWorkoutCreateRequest, TonalWorkoutEstimateResponse, TonalWorkoutEstimateSet, TonalWorkoutSummaryData, TonalWorkoutUpdateRequest, WorkoutPublishState, WorkoutSet, TonalClient as default };
|
|
748
|
+
export { MuscleGroup, OAuthTokenResponse, TonalAchievementCategory, TonalAchievementDefinition, TonalAchievementMilestone, TonalAchievementStats, TonalActivitySummary, TonalApiError, TonalCalendarTile, TonalClient, TonalClientError, TonalCompatibilityStatus, TonalCurrentStreak, TonalDailyMetrics, TonalDailySchedule, TonalDeviceRegistration, TonalEarnedAchievement, TonalGoal, TonalGoalMetric, TonalHomeCalendar, TonalMetricScore, TonalMetricScoresResponse, TonalMovement, TonalMuscleReadiness, TonalMuscleUtilization, TonalProgram, TonalProgramTrainingEffectGoal, TonalProgramWorkout, TonalSharedWorkout, TonalStrengthBodyRegion, TonalStrengthScore, TonalStrengthScoreHistoryEntry, TonalStrengthScoreHistoryLookback, TonalTargetScore, TonalTargetScoresResponse, TonalTrainingEffectGoal, TonalTrainingEffectGoalRelation, TonalTrainingEffectGoalsResponse, TonalTrainingType, TonalUserDevice, TonalUserGoal, TonalUserInfo, TonalUserPermissions, TonalUserSettings, TonalUserStatistics, TonalWorkout, TonalWorkoutActivity, TonalWorkoutCreateRequest, TonalWorkoutEstimateResponse, TonalWorkoutEstimateSet, TonalWorkoutSetActivity, TonalWorkoutSummaryData, TonalWorkoutUpdateRequest, WorkoutPublishState, WorkoutSet, TonalClient as default };
|
package/dist/index.esm.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
4
5
|
|
|
5
6
|
class TonalClientError extends Error {
|
|
6
7
|
constructor(message, statusCode, originalError) {
|
|
@@ -68,9 +69,49 @@ class AuthManager {
|
|
|
68
69
|
const tokenData = await response.json();
|
|
69
70
|
this.idToken = tokenData.id_token;
|
|
70
71
|
this.refreshToken = tokenData.refresh_token ?? '';
|
|
71
|
-
|
|
72
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
73
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
72
74
|
return this.idToken;
|
|
73
75
|
}
|
|
76
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
77
|
+
try {
|
|
78
|
+
if (!idToken) {
|
|
79
|
+
return expiresInDeadline;
|
|
80
|
+
}
|
|
81
|
+
const segments = idToken.split('.');
|
|
82
|
+
if (segments.length !== 3) {
|
|
83
|
+
return expiresInDeadline;
|
|
84
|
+
}
|
|
85
|
+
const payloadSegment = segments[1];
|
|
86
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
87
|
+
return expiresInDeadline;
|
|
88
|
+
}
|
|
89
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
90
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
91
|
+
return expiresInDeadline;
|
|
92
|
+
}
|
|
93
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
94
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
95
|
+
return expiresInDeadline;
|
|
96
|
+
}
|
|
97
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
98
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
99
|
+
return expiresInDeadline;
|
|
100
|
+
}
|
|
101
|
+
const expDeadline = exp * 1000;
|
|
102
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
103
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
104
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
105
|
+
return expiresInDeadline;
|
|
106
|
+
}
|
|
107
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
108
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
109
|
+
return Math.min(expDeadline, expiresInDeadline);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return expiresInDeadline;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
74
115
|
invalidateToken() {
|
|
75
116
|
this.tokenExpiresAt = 0;
|
|
76
117
|
}
|
|
@@ -129,7 +170,8 @@ class AuthManager {
|
|
|
129
170
|
const tokenData = await response.json();
|
|
130
171
|
this.idToken = tokenData.id_token;
|
|
131
172
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
132
|
-
|
|
173
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
174
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
133
175
|
}
|
|
134
176
|
}
|
|
135
177
|
|
|
@@ -387,7 +429,8 @@ class CacheManager {
|
|
|
387
429
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
388
430
|
const now = Date.now();
|
|
389
431
|
const age = now - cachedAt;
|
|
390
|
-
if (!Number.isFinite(cachedAt) ||
|
|
432
|
+
if (!Number.isFinite(cachedAt) ||
|
|
433
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
391
434
|
return null;
|
|
392
435
|
}
|
|
393
436
|
return entry.data;
|
|
@@ -398,11 +441,17 @@ class CacheManager {
|
|
|
398
441
|
}
|
|
399
442
|
}
|
|
400
443
|
async set(key, data, ttl) {
|
|
444
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
445
|
+
}
|
|
446
|
+
async setPermanent(key, data) {
|
|
447
|
+
this.write(key, data, null);
|
|
448
|
+
}
|
|
449
|
+
write(key, data, ttl) {
|
|
401
450
|
const cachePath = this.getCachePath(key);
|
|
402
451
|
this.ensureCacheDir();
|
|
403
452
|
const entry = {
|
|
404
453
|
cachedAt: new Date().toISOString(),
|
|
405
|
-
ttl
|
|
454
|
+
ttl,
|
|
406
455
|
data,
|
|
407
456
|
};
|
|
408
457
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -452,8 +501,9 @@ class MovementService {
|
|
|
452
501
|
}
|
|
453
502
|
|
|
454
503
|
class UserService {
|
|
455
|
-
constructor(httpClient) {
|
|
504
|
+
constructor(httpClient, cacheDir) {
|
|
456
505
|
this.httpClient = httpClient;
|
|
506
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
457
507
|
}
|
|
458
508
|
async getUserInfo() {
|
|
459
509
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -524,6 +574,36 @@ class UserService {
|
|
|
524
574
|
// Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
|
|
525
575
|
return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
|
|
526
576
|
}
|
|
577
|
+
async getWorkoutActivityById(userId, activityId, useCache = true) {
|
|
578
|
+
const canonicalActivityId = activityId.trim();
|
|
579
|
+
if (!canonicalActivityId) {
|
|
580
|
+
throw new TonalClientError('Workout activity id must not be empty');
|
|
581
|
+
}
|
|
582
|
+
const cacheKey = `workout-activity-v1-${createHash('sha256')
|
|
583
|
+
.update(`${userId}\0${canonicalActivityId}`)
|
|
584
|
+
.digest('hex')}`;
|
|
585
|
+
if (useCache) {
|
|
586
|
+
try {
|
|
587
|
+
const cached = await this.cacheManager.get(cacheKey);
|
|
588
|
+
if (cached !== null) {
|
|
589
|
+
return cached;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// Cache reads are best-effort and must not prevent a fresh request.
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const activity = await this.httpClient.request(`/users/${userId}/workout-activities/${encodeURIComponent(canonicalActivityId)}`);
|
|
597
|
+
if (activity.completed === true) {
|
|
598
|
+
try {
|
|
599
|
+
await this.cacheManager.setPermanent(cacheKey, activity);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// Cache writes are best-effort and must not hide a successful API response.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return activity;
|
|
606
|
+
}
|
|
527
607
|
async getActivitySummaries(userId) {
|
|
528
608
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
529
609
|
}
|
|
@@ -562,7 +642,7 @@ class TonalClient {
|
|
|
562
642
|
this.httpClient = new HttpClient(this.authManager);
|
|
563
643
|
this.workoutService = new WorkoutService(this.httpClient);
|
|
564
644
|
this.movementService = new MovementService(this.httpClient, cacheDir);
|
|
565
|
-
this.userService = new UserService(this.httpClient);
|
|
645
|
+
this.userService = new UserService(this.httpClient, cacheDir);
|
|
566
646
|
}
|
|
567
647
|
static async create(credentials) {
|
|
568
648
|
const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
|
|
@@ -657,6 +737,10 @@ class TonalClient {
|
|
|
657
737
|
}
|
|
658
738
|
return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
|
|
659
739
|
}
|
|
740
|
+
async getWorkoutActivityById(activityId, useCache = true) {
|
|
741
|
+
const userInfo = await this.getUserInfo();
|
|
742
|
+
return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
|
|
743
|
+
}
|
|
660
744
|
async getActivitySummaries() {
|
|
661
745
|
const userInfo = await this.getUserInfo();
|
|
662
746
|
return this.userService.getActivitySummaries(userInfo.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dlwiest/ts-tonal-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "TypeScript client for Tonal API",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"example:muscle-readiness": "tsx examples/get-muscle-readiness.ts",
|
|
52
52
|
"example:program-by-id": "tsx examples/get-program-by-id.ts",
|
|
53
53
|
"example:target-scores": "tsx examples/get-target-scores.ts",
|
|
54
|
-
"example:metric-scores": "tsx examples/get-metric-scores.ts"
|
|
54
|
+
"example:metric-scores": "tsx examples/get-metric-scores.ts",
|
|
55
|
+
"example:workout-activity": "tsx examples/get-workout-activity.ts"
|
|
55
56
|
},
|
|
56
57
|
"repository": {
|
|
57
58
|
"type": "git",
|