@dlwiest/ts-tonal-client 0.3.1 → 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`)
@@ -441,6 +515,48 @@ if (volumeScores && volumeScores.length >= 2) {
441
515
  }
442
516
  ```
443
517
 
518
+ ### Strength Scores
519
+
520
+ Tonal's headline Strength Score, per body region. This is a different metric from the weekly
521
+ `Functional Strength Score` returned by `getGoalMetrics()` — that one measures goal progress
522
+ for a week, this one is the score the Tonal app shows you.
523
+
524
+ ```typescript
525
+ // Current score for each region
526
+ const scores = await client.getCurrentStrengthScores()
527
+ for (const score of scores) {
528
+ // The Overall row is synthesized: bodyRegionDisplay is empty, familyActivity is absent,
529
+ // workoutActivityId is an all-zero uuid, and updatedAt is a zero date. Fall back to
530
+ // strengthBodyRegion for a label, and do not render its updatedAt.
531
+ const label = score.bodyRegionDisplay || score.strengthBodyRegion
532
+ console.log(`${label}: ${score.score}`)
533
+ }
534
+ // One row per region: Upper Body, Core, Lower Body, Overall
535
+
536
+ // Per-workout history. Defaults to the whole account.
537
+ const history = await client.getStrengthScoreHistory()
538
+ console.log(`${history.length} scored activities`)
539
+ console.log(history[0]) // { upper, lower, core, overall, activityTime, workoutActivityId, ... }
540
+ ```
541
+
542
+ **`days` is a calendar-day lookback, not a row count.** The underlying API parameter is named
543
+ `limit`, but it selects a time window: a value smaller than the gap since your last workout
544
+ returns an **empty array**, not "no results found". Passing `30` on an account last used
545
+ 90 days ago yields nothing.
546
+
547
+ ```typescript
548
+ await client.getStrengthScoreHistory(365) // activities in the last 365 days
549
+ await client.getStrengthScoreHistory('all') // explicit; same as the default
550
+ ```
551
+
552
+ The default `'all'` derives the window from your account creation date, so it stays correct as
553
+ the account ages rather than relying on a large magic number. If `createdAt` is missing,
554
+ unparseable, or in the future it throws `TonalClientError` rather than guessing — pass an
555
+ explicit `days` in that case.
556
+
557
+ Each history entry carries `workoutActivityId`, which makes this the only complete way to
558
+ enumerate an account's activities: the list endpoints are capped at 50 records each.
559
+
444
560
  ### Movements
445
561
 
446
562
  ```typescript
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');
@@ -518,6 +568,46 @@ class UserService {
518
568
  async getCurrentStreak(userId) {
519
569
  return this.httpClient.request(`/users/${userId}/streaks/current`);
520
570
  }
571
+ async getCurrentStrengthScores(userId) {
572
+ return this.httpClient.request(`/users/${userId}/strength-scores/current`);
573
+ }
574
+ async getStrengthScoreHistory(userId, days) {
575
+ if (!Number.isSafeInteger(days) || days <= 0) {
576
+ throw new TonalClientError('Strength score history days must be a positive safe integer');
577
+ }
578
+ // Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
579
+ return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
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
+ }
521
611
  async getActivitySummaries(userId) {
522
612
  return this.httpClient.request(`/users/${userId}/activity-summaries`);
523
613
  }
@@ -556,7 +646,7 @@ class TonalClient {
556
646
  this.httpClient = new HttpClient(this.authManager);
557
647
  this.workoutService = new WorkoutService(this.httpClient);
558
648
  this.movementService = new MovementService(this.httpClient, cacheDir);
559
- this.userService = new UserService(this.httpClient);
649
+ this.userService = new UserService(this.httpClient, cacheDir);
560
650
  }
561
651
  static async create(credentials) {
562
652
  const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
@@ -623,6 +713,38 @@ class TonalClient {
623
713
  const userInfo = await this.getUserInfo();
624
714
  return this.userService.getCurrentStreak(userInfo.id);
625
715
  }
716
+ async getCurrentStrengthScores() {
717
+ const userInfo = await this.getUserInfo();
718
+ return this.userService.getCurrentStrengthScores(userInfo.id);
719
+ }
720
+ async getStrengthScoreHistory(days = 'all') {
721
+ if (days !== 'all' && (!Number.isSafeInteger(days) || days <= 0)) {
722
+ throw new TonalClientError('Strength score history days must be a positive safe integer');
723
+ }
724
+ const userInfo = await this.getUserInfo();
725
+ let lookbackDays;
726
+ if (days === 'all') {
727
+ const createdAt = userInfo.createdAt;
728
+ const createdAtMs = typeof createdAt === 'string' ? Date.parse(createdAt) : Number.NaN;
729
+ const now = Date.now();
730
+ if (!Number.isFinite(createdAtMs) || createdAtMs > now) {
731
+ throw new TonalClientError('Cannot derive all strength score history from user createdAt; pass explicit days');
732
+ }
733
+ // +2 covers the account-creation calendar day and the server's midnight-boundary
734
+ // ambiguity; over-fetching before account creation is harmless. The Math.max floor is
735
+ // belt-and-braces only -- the future-createdAt rejection above already guarantees a
736
+ // non-negative difference, so this expression is always >= 2.
737
+ lookbackDays = Math.max(1, Math.ceil((now - createdAtMs) / 86400000) + 2);
738
+ }
739
+ else {
740
+ lookbackDays = days;
741
+ }
742
+ return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
743
+ }
744
+ async getWorkoutActivityById(activityId, useCache = true) {
745
+ const userInfo = await this.getUserInfo();
746
+ return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
747
+ }
626
748
  async getActivitySummaries() {
627
749
  const userInfo = await this.getUserInfo();
628
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;
@@ -373,6 +411,30 @@ interface TonalCurrentStreak {
373
411
  maxStreakStartDate: string;
374
412
  updatedByActivityId: string;
375
413
  }
414
+ type TonalStrengthBodyRegion = 'Upper Body' | 'Core' | 'Lower Body' | 'Overall' | string;
415
+ interface TonalStrengthScore {
416
+ id: string;
417
+ createdAt: string;
418
+ updatedAt: string;
419
+ userId: string;
420
+ workoutActivityId: string;
421
+ strengthBodyRegion: TonalStrengthBodyRegion;
422
+ bodyRegionDisplay: string;
423
+ score: number;
424
+ current: boolean;
425
+ familyActivity?: unknown[];
426
+ }
427
+ interface TonalStrengthScoreHistoryEntry {
428
+ id: string;
429
+ userId: string;
430
+ workoutActivityId: string;
431
+ upper: number;
432
+ lower: number;
433
+ core: number;
434
+ overall: number;
435
+ activityTime: string;
436
+ }
437
+ type TonalStrengthScoreHistoryLookback = number | 'all';
376
438
  interface TonalActivitySummary {
377
439
  id: string;
378
440
  deletedAt: string | null;
@@ -661,6 +723,9 @@ declare class TonalClient {
661
723
  getUserSettings(): Promise<TonalUserSettings>;
662
724
  getDailyMetrics(days?: number): Promise<TonalDailyMetrics[]>;
663
725
  getCurrentStreak(): Promise<TonalCurrentStreak>;
726
+ getCurrentStrengthScores(): Promise<TonalStrengthScore[]>;
727
+ getStrengthScoreHistory(days?: TonalStrengthScoreHistoryLookback): Promise<TonalStrengthScoreHistoryEntry[]>;
728
+ getWorkoutActivityById(activityId: string, useCache?: boolean): Promise<TonalWorkoutActivity>;
664
729
  getActivitySummaries(): Promise<TonalActivitySummary[]>;
665
730
  getUserStatistics(): Promise<TonalUserStatistics>;
666
731
  getAchievementStats(): Promise<TonalAchievementStats>;
@@ -680,4 +745,4 @@ declare class TonalClient {
680
745
  deleteWorkout(workoutId: string): Promise<void>;
681
746
  }
682
747
 
683
- 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, 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');
@@ -514,6 +564,46 @@ class UserService {
514
564
  async getCurrentStreak(userId) {
515
565
  return this.httpClient.request(`/users/${userId}/streaks/current`);
516
566
  }
567
+ async getCurrentStrengthScores(userId) {
568
+ return this.httpClient.request(`/users/${userId}/strength-scores/current`);
569
+ }
570
+ async getStrengthScoreHistory(userId, days) {
571
+ if (!Number.isSafeInteger(days) || days <= 0) {
572
+ throw new TonalClientError('Strength score history days must be a positive safe integer');
573
+ }
574
+ // Tonal's "limit" query parameter is a calendar-day window; small values return an empty array.
575
+ return this.httpClient.request(`/users/${userId}/strength-scores/history?limit=${days}`);
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
+ }
517
607
  async getActivitySummaries(userId) {
518
608
  return this.httpClient.request(`/users/${userId}/activity-summaries`);
519
609
  }
@@ -552,7 +642,7 @@ class TonalClient {
552
642
  this.httpClient = new HttpClient(this.authManager);
553
643
  this.workoutService = new WorkoutService(this.httpClient);
554
644
  this.movementService = new MovementService(this.httpClient, cacheDir);
555
- this.userService = new UserService(this.httpClient);
645
+ this.userService = new UserService(this.httpClient, cacheDir);
556
646
  }
557
647
  static async create(credentials) {
558
648
  const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
@@ -619,6 +709,38 @@ class TonalClient {
619
709
  const userInfo = await this.getUserInfo();
620
710
  return this.userService.getCurrentStreak(userInfo.id);
621
711
  }
712
+ async getCurrentStrengthScores() {
713
+ const userInfo = await this.getUserInfo();
714
+ return this.userService.getCurrentStrengthScores(userInfo.id);
715
+ }
716
+ async getStrengthScoreHistory(days = 'all') {
717
+ if (days !== 'all' && (!Number.isSafeInteger(days) || days <= 0)) {
718
+ throw new TonalClientError('Strength score history days must be a positive safe integer');
719
+ }
720
+ const userInfo = await this.getUserInfo();
721
+ let lookbackDays;
722
+ if (days === 'all') {
723
+ const createdAt = userInfo.createdAt;
724
+ const createdAtMs = typeof createdAt === 'string' ? Date.parse(createdAt) : Number.NaN;
725
+ const now = Date.now();
726
+ if (!Number.isFinite(createdAtMs) || createdAtMs > now) {
727
+ throw new TonalClientError('Cannot derive all strength score history from user createdAt; pass explicit days');
728
+ }
729
+ // +2 covers the account-creation calendar day and the server's midnight-boundary
730
+ // ambiguity; over-fetching before account creation is harmless. The Math.max floor is
731
+ // belt-and-braces only -- the future-createdAt rejection above already guarantees a
732
+ // non-negative difference, so this expression is always >= 2.
733
+ lookbackDays = Math.max(1, Math.ceil((now - createdAtMs) / 86400000) + 2);
734
+ }
735
+ else {
736
+ lookbackDays = days;
737
+ }
738
+ return this.userService.getStrengthScoreHistory(userInfo.id, lookbackDays);
739
+ }
740
+ async getWorkoutActivityById(activityId, useCache = true) {
741
+ const userInfo = await this.getUserInfo();
742
+ return this.userService.getWorkoutActivityById(userInfo.id, activityId, useCache);
743
+ }
622
744
  async getActivitySummaries() {
623
745
  const userInfo = await this.getUserInfo();
624
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.1",
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",