@dlwiest/ts-tonal-client 0.4.0 → 0.5.1
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 +101 -6
- package/dist/index.d.ts +40 -1
- package/dist/index.esm.js +101 -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,60 @@ 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
|
+
// `expiresInDeadline` may be NaN: OAuthTokenResponse declares `expires_in` as required,
|
|
81
|
+
// but that same interface declared `refresh_token` required until Auth0 was observed
|
|
82
|
+
// omitting it, so the field is not trustworthy. A NaN deadline must never reach
|
|
83
|
+
// `tokenExpiresAt` -- `isTokenValid()` compares against it and would return false
|
|
84
|
+
// forever, refreshing on every single call.
|
|
85
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
86
|
+
const expiresInUsable = Number.isFinite(expiresInDeadline);
|
|
87
|
+
// Neither source usable: a short finite window beats NaN, which would spin the
|
|
88
|
+
// refresh grant on every request and invite Auth0 rate limiting.
|
|
89
|
+
const noInfoFallback = expiresInUsable ? expiresInDeadline : Date.now() + 10 * 60 * 1000;
|
|
90
|
+
try {
|
|
91
|
+
if (!idToken) {
|
|
92
|
+
return noInfoFallback;
|
|
93
|
+
}
|
|
94
|
+
const segments = idToken.split('.');
|
|
95
|
+
if (segments.length !== 3) {
|
|
96
|
+
return noInfoFallback;
|
|
97
|
+
}
|
|
98
|
+
const payloadSegment = segments[1];
|
|
99
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
100
|
+
return noInfoFallback;
|
|
101
|
+
}
|
|
102
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
103
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
104
|
+
return noInfoFallback;
|
|
105
|
+
}
|
|
106
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
107
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
108
|
+
return noInfoFallback;
|
|
109
|
+
}
|
|
110
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
111
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
112
|
+
return noInfoFallback;
|
|
113
|
+
}
|
|
114
|
+
const expDeadline = exp * 1000;
|
|
115
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
116
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
117
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
118
|
+
return noInfoFallback;
|
|
119
|
+
}
|
|
120
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
121
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
122
|
+
// When expires_in is unusable the exp claim stands alone rather than being
|
|
123
|
+
// discarded by Math.min(valid, NaN) === NaN.
|
|
124
|
+
return expiresInUsable ? Math.min(expDeadline, expiresInDeadline) : expDeadline;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return noInfoFallback;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
78
130
|
invalidateToken() {
|
|
79
131
|
this.tokenExpiresAt = 0;
|
|
80
132
|
}
|
|
@@ -133,7 +185,8 @@ class AuthManager {
|
|
|
133
185
|
const tokenData = await response.json();
|
|
134
186
|
this.idToken = tokenData.id_token;
|
|
135
187
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
136
|
-
|
|
188
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
189
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
137
190
|
}
|
|
138
191
|
}
|
|
139
192
|
|
|
@@ -391,7 +444,8 @@ class CacheManager {
|
|
|
391
444
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
392
445
|
const now = Date.now();
|
|
393
446
|
const age = now - cachedAt;
|
|
394
|
-
if (!Number.isFinite(cachedAt) ||
|
|
447
|
+
if (!Number.isFinite(cachedAt) ||
|
|
448
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
395
449
|
return null;
|
|
396
450
|
}
|
|
397
451
|
return entry.data;
|
|
@@ -402,11 +456,17 @@ class CacheManager {
|
|
|
402
456
|
}
|
|
403
457
|
}
|
|
404
458
|
async set(key, data, ttl) {
|
|
459
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
460
|
+
}
|
|
461
|
+
async setPermanent(key, data) {
|
|
462
|
+
this.write(key, data, null);
|
|
463
|
+
}
|
|
464
|
+
write(key, data, ttl) {
|
|
405
465
|
const cachePath = this.getCachePath(key);
|
|
406
466
|
this.ensureCacheDir();
|
|
407
467
|
const entry = {
|
|
408
468
|
cachedAt: new Date().toISOString(),
|
|
409
|
-
ttl
|
|
469
|
+
ttl,
|
|
410
470
|
data,
|
|
411
471
|
};
|
|
412
472
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -456,8 +516,9 @@ class MovementService {
|
|
|
456
516
|
}
|
|
457
517
|
|
|
458
518
|
class UserService {
|
|
459
|
-
constructor(httpClient) {
|
|
519
|
+
constructor(httpClient, cacheDir) {
|
|
460
520
|
this.httpClient = httpClient;
|
|
521
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
461
522
|
}
|
|
462
523
|
async getUserInfo() {
|
|
463
524
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -528,6 +589,36 @@ class UserService {
|
|
|
528
589
|
// Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
|
|
529
590
|
return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
|
|
530
591
|
}
|
|
592
|
+
async getWorkoutActivityById(userId, activityId, useCache = true) {
|
|
593
|
+
const canonicalActivityId = activityId.trim();
|
|
594
|
+
if (!canonicalActivityId) {
|
|
595
|
+
throw new TonalClientError('Workout activity id must not be empty');
|
|
596
|
+
}
|
|
597
|
+
const cacheKey = `workout-activity-v1-${crypto.createHash('sha256')
|
|
598
|
+
.update(`${userId}\0${canonicalActivityId}`)
|
|
599
|
+
.digest('hex')}`;
|
|
600
|
+
if (useCache) {
|
|
601
|
+
try {
|
|
602
|
+
const cached = await this.cacheManager.get(cacheKey);
|
|
603
|
+
if (cached !== null) {
|
|
604
|
+
return cached;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// Cache reads are best-effort and must not prevent a fresh request.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const activity = await this.httpClient.request(`/users/${userId}/workout-activities/${encodeURIComponent(canonicalActivityId)}`);
|
|
612
|
+
if (activity.completed === true) {
|
|
613
|
+
try {
|
|
614
|
+
await this.cacheManager.setPermanent(cacheKey, activity);
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// Cache writes are best-effort and must not hide a successful API response.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return activity;
|
|
621
|
+
}
|
|
531
622
|
async getActivitySummaries(userId) {
|
|
532
623
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
533
624
|
}
|
|
@@ -566,7 +657,7 @@ class TonalClient {
|
|
|
566
657
|
this.httpClient = new HttpClient(this.authManager);
|
|
567
658
|
this.workoutService = new WorkoutService(this.httpClient);
|
|
568
659
|
this.movementService = new MovementService(this.httpClient, cacheDir);
|
|
569
|
-
this.userService = new UserService(this.httpClient);
|
|
660
|
+
this.userService = new UserService(this.httpClient, cacheDir);
|
|
570
661
|
}
|
|
571
662
|
static async create(credentials) {
|
|
572
663
|
const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
|
|
@@ -661,6 +752,10 @@ class TonalClient {
|
|
|
661
752
|
}
|
|
662
753
|
return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
|
|
663
754
|
}
|
|
755
|
+
async getWorkoutActivityById(activityId, useCache = true) {
|
|
756
|
+
const userInfo = await this.getUserInfo();
|
|
757
|
+
return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
|
|
758
|
+
}
|
|
664
759
|
async getActivitySummaries() {
|
|
665
760
|
const userInfo = await this.getUserInfo();
|
|
666
761
|
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,60 @@ 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
|
+
// `expiresInDeadline` may be NaN: OAuthTokenResponse declares `expires_in` as required,
|
|
77
|
+
// but that same interface declared `refresh_token` required until Auth0 was observed
|
|
78
|
+
// omitting it, so the field is not trustworthy. A NaN deadline must never reach
|
|
79
|
+
// `tokenExpiresAt` -- `isTokenValid()` compares against it and would return false
|
|
80
|
+
// forever, refreshing on every single call.
|
|
81
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
82
|
+
const expiresInUsable = Number.isFinite(expiresInDeadline);
|
|
83
|
+
// Neither source usable: a short finite window beats NaN, which would spin the
|
|
84
|
+
// refresh grant on every request and invite Auth0 rate limiting.
|
|
85
|
+
const noInfoFallback = expiresInUsable ? expiresInDeadline : Date.now() + 10 * 60 * 1000;
|
|
86
|
+
try {
|
|
87
|
+
if (!idToken) {
|
|
88
|
+
return noInfoFallback;
|
|
89
|
+
}
|
|
90
|
+
const segments = idToken.split('.');
|
|
91
|
+
if (segments.length !== 3) {
|
|
92
|
+
return noInfoFallback;
|
|
93
|
+
}
|
|
94
|
+
const payloadSegment = segments[1];
|
|
95
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
96
|
+
return noInfoFallback;
|
|
97
|
+
}
|
|
98
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
99
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
100
|
+
return noInfoFallback;
|
|
101
|
+
}
|
|
102
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
103
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
104
|
+
return noInfoFallback;
|
|
105
|
+
}
|
|
106
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
107
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
108
|
+
return noInfoFallback;
|
|
109
|
+
}
|
|
110
|
+
const expDeadline = exp * 1000;
|
|
111
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
112
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
113
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
114
|
+
return noInfoFallback;
|
|
115
|
+
}
|
|
116
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
117
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
118
|
+
// When expires_in is unusable the exp claim stands alone rather than being
|
|
119
|
+
// discarded by Math.min(valid, NaN) === NaN.
|
|
120
|
+
return expiresInUsable ? Math.min(expDeadline, expiresInDeadline) : expDeadline;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return noInfoFallback;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
74
126
|
invalidateToken() {
|
|
75
127
|
this.tokenExpiresAt = 0;
|
|
76
128
|
}
|
|
@@ -129,7 +181,8 @@ class AuthManager {
|
|
|
129
181
|
const tokenData = await response.json();
|
|
130
182
|
this.idToken = tokenData.id_token;
|
|
131
183
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
132
|
-
|
|
184
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
185
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
133
186
|
}
|
|
134
187
|
}
|
|
135
188
|
|
|
@@ -387,7 +440,8 @@ class CacheManager {
|
|
|
387
440
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
388
441
|
const now = Date.now();
|
|
389
442
|
const age = now - cachedAt;
|
|
390
|
-
if (!Number.isFinite(cachedAt) ||
|
|
443
|
+
if (!Number.isFinite(cachedAt) ||
|
|
444
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
391
445
|
return null;
|
|
392
446
|
}
|
|
393
447
|
return entry.data;
|
|
@@ -398,11 +452,17 @@ class CacheManager {
|
|
|
398
452
|
}
|
|
399
453
|
}
|
|
400
454
|
async set(key, data, ttl) {
|
|
455
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
456
|
+
}
|
|
457
|
+
async setPermanent(key, data) {
|
|
458
|
+
this.write(key, data, null);
|
|
459
|
+
}
|
|
460
|
+
write(key, data, ttl) {
|
|
401
461
|
const cachePath = this.getCachePath(key);
|
|
402
462
|
this.ensureCacheDir();
|
|
403
463
|
const entry = {
|
|
404
464
|
cachedAt: new Date().toISOString(),
|
|
405
|
-
ttl
|
|
465
|
+
ttl,
|
|
406
466
|
data,
|
|
407
467
|
};
|
|
408
468
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -452,8 +512,9 @@ class MovementService {
|
|
|
452
512
|
}
|
|
453
513
|
|
|
454
514
|
class UserService {
|
|
455
|
-
constructor(httpClient) {
|
|
515
|
+
constructor(httpClient, cacheDir) {
|
|
456
516
|
this.httpClient = httpClient;
|
|
517
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
457
518
|
}
|
|
458
519
|
async getUserInfo() {
|
|
459
520
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -524,6 +585,36 @@ class UserService {
|
|
|
524
585
|
// Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
|
|
525
586
|
return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
|
|
526
587
|
}
|
|
588
|
+
async getWorkoutActivityById(userId, activityId, useCache = true) {
|
|
589
|
+
const canonicalActivityId = activityId.trim();
|
|
590
|
+
if (!canonicalActivityId) {
|
|
591
|
+
throw new TonalClientError('Workout activity id must not be empty');
|
|
592
|
+
}
|
|
593
|
+
const cacheKey = `workout-activity-v1-${createHash('sha256')
|
|
594
|
+
.update(`${userId}\0${canonicalActivityId}`)
|
|
595
|
+
.digest('hex')}`;
|
|
596
|
+
if (useCache) {
|
|
597
|
+
try {
|
|
598
|
+
const cached = await this.cacheManager.get(cacheKey);
|
|
599
|
+
if (cached !== null) {
|
|
600
|
+
return cached;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Cache reads are best-effort and must not prevent a fresh request.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const activity = await this.httpClient.request(`/users/${userId}/workout-activities/${encodeURIComponent(canonicalActivityId)}`);
|
|
608
|
+
if (activity.completed === true) {
|
|
609
|
+
try {
|
|
610
|
+
await this.cacheManager.setPermanent(cacheKey, activity);
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// Cache writes are best-effort and must not hide a successful API response.
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return activity;
|
|
617
|
+
}
|
|
527
618
|
async getActivitySummaries(userId) {
|
|
528
619
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
529
620
|
}
|
|
@@ -562,7 +653,7 @@ class TonalClient {
|
|
|
562
653
|
this.httpClient = new HttpClient(this.authManager);
|
|
563
654
|
this.workoutService = new WorkoutService(this.httpClient);
|
|
564
655
|
this.movementService = new MovementService(this.httpClient, cacheDir);
|
|
565
|
-
this.userService = new UserService(this.httpClient);
|
|
656
|
+
this.userService = new UserService(this.httpClient, cacheDir);
|
|
566
657
|
}
|
|
567
658
|
static async create(credentials) {
|
|
568
659
|
const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
|
|
@@ -657,6 +748,10 @@ class TonalClient {
|
|
|
657
748
|
}
|
|
658
749
|
return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
|
|
659
750
|
}
|
|
751
|
+
async getWorkoutActivityById(activityId, useCache = true) {
|
|
752
|
+
const userInfo = await this.getUserInfo();
|
|
753
|
+
return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
|
|
754
|
+
}
|
|
660
755
|
async getActivitySummaries() {
|
|
661
756
|
const userInfo = await this.getUserInfo();
|
|
662
757
|
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.1",
|
|
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",
|