@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 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 movement cache directory:
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`, movement data is cached 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.
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 the movement cache somewhere else:
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
- `TonalClient.invalidateUserInfo(): void` clears the cached user profile. Call `client.invalidateUserInfo()` before the next `getUserInfo()` request to fetch fresh data.
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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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) || typeof entry.ttl !== 'number' || age > entry.ttl) {
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: ttl || this.defaultTTL,
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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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) || typeof entry.ttl !== 'number' || age > entry.ttl) {
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: ttl || this.defaultTTL,
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.4.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",