@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 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,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
- 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
+ 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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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) || typeof entry.ttl !== 'number' || age > entry.ttl) {
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: ttl || this.defaultTTL,
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
- 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
+ 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
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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) || typeof entry.ttl !== 'number' || age > entry.ttl) {
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: ttl || this.defaultTTL,
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.4.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",