@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 +128 -12
- package/dist/index.cjs +128 -6
- package/dist/index.d.ts +66 -1
- package/dist/index.esm.js +128 -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`)
|
|
@@ -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
|
-
|
|
76
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
77
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
76
78
|
return this.idToken;
|
|
77
79
|
}
|
|
80
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
81
|
+
try {
|
|
82
|
+
if (!idToken) {
|
|
83
|
+
return expiresInDeadline;
|
|
84
|
+
}
|
|
85
|
+
const segments = idToken.split('.');
|
|
86
|
+
if (segments.length !== 3) {
|
|
87
|
+
return expiresInDeadline;
|
|
88
|
+
}
|
|
89
|
+
const payloadSegment = segments[1];
|
|
90
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
91
|
+
return expiresInDeadline;
|
|
92
|
+
}
|
|
93
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
94
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
95
|
+
return expiresInDeadline;
|
|
96
|
+
}
|
|
97
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
98
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
99
|
+
return expiresInDeadline;
|
|
100
|
+
}
|
|
101
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
102
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
103
|
+
return expiresInDeadline;
|
|
104
|
+
}
|
|
105
|
+
const expDeadline = exp * 1000;
|
|
106
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
107
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
108
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
109
|
+
return expiresInDeadline;
|
|
110
|
+
}
|
|
111
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
112
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
113
|
+
return Math.min(expDeadline, expiresInDeadline);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return expiresInDeadline;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
78
119
|
invalidateToken() {
|
|
79
120
|
this.tokenExpiresAt = 0;
|
|
80
121
|
}
|
|
@@ -133,7 +174,8 @@ class AuthManager {
|
|
|
133
174
|
const tokenData = await response.json();
|
|
134
175
|
this.idToken = tokenData.id_token;
|
|
135
176
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
136
|
-
|
|
177
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
178
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
137
179
|
}
|
|
138
180
|
}
|
|
139
181
|
|
|
@@ -391,7 +433,8 @@ class CacheManager {
|
|
|
391
433
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
392
434
|
const now = Date.now();
|
|
393
435
|
const age = now - cachedAt;
|
|
394
|
-
if (!Number.isFinite(cachedAt) ||
|
|
436
|
+
if (!Number.isFinite(cachedAt) ||
|
|
437
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
395
438
|
return null;
|
|
396
439
|
}
|
|
397
440
|
return entry.data;
|
|
@@ -402,11 +445,17 @@ class CacheManager {
|
|
|
402
445
|
}
|
|
403
446
|
}
|
|
404
447
|
async set(key, data, ttl) {
|
|
448
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
449
|
+
}
|
|
450
|
+
async setPermanent(key, data) {
|
|
451
|
+
this.write(key, data, null);
|
|
452
|
+
}
|
|
453
|
+
write(key, data, ttl) {
|
|
405
454
|
const cachePath = this.getCachePath(key);
|
|
406
455
|
this.ensureCacheDir();
|
|
407
456
|
const entry = {
|
|
408
457
|
cachedAt: new Date().toISOString(),
|
|
409
|
-
ttl
|
|
458
|
+
ttl,
|
|
410
459
|
data,
|
|
411
460
|
};
|
|
412
461
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -456,8 +505,9 @@ class MovementService {
|
|
|
456
505
|
}
|
|
457
506
|
|
|
458
507
|
class UserService {
|
|
459
|
-
constructor(httpClient) {
|
|
508
|
+
constructor(httpClient, cacheDir) {
|
|
460
509
|
this.httpClient = httpClient;
|
|
510
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
461
511
|
}
|
|
462
512
|
async getUserInfo() {
|
|
463
513
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -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
|
-
|
|
72
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
73
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
72
74
|
return this.idToken;
|
|
73
75
|
}
|
|
76
|
+
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
77
|
+
try {
|
|
78
|
+
if (!idToken) {
|
|
79
|
+
return expiresInDeadline;
|
|
80
|
+
}
|
|
81
|
+
const segments = idToken.split('.');
|
|
82
|
+
if (segments.length !== 3) {
|
|
83
|
+
return expiresInDeadline;
|
|
84
|
+
}
|
|
85
|
+
const payloadSegment = segments[1];
|
|
86
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
87
|
+
return expiresInDeadline;
|
|
88
|
+
}
|
|
89
|
+
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
90
|
+
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
91
|
+
return expiresInDeadline;
|
|
92
|
+
}
|
|
93
|
+
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
94
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
95
|
+
return expiresInDeadline;
|
|
96
|
+
}
|
|
97
|
+
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
98
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
99
|
+
return expiresInDeadline;
|
|
100
|
+
}
|
|
101
|
+
const expDeadline = exp * 1000;
|
|
102
|
+
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
103
|
+
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
104
|
+
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
105
|
+
return expiresInDeadline;
|
|
106
|
+
}
|
|
107
|
+
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
108
|
+
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
109
|
+
return Math.min(expDeadline, expiresInDeadline);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return expiresInDeadline;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
74
115
|
invalidateToken() {
|
|
75
116
|
this.tokenExpiresAt = 0;
|
|
76
117
|
}
|
|
@@ -129,7 +170,8 @@ class AuthManager {
|
|
|
129
170
|
const tokenData = await response.json();
|
|
130
171
|
this.idToken = tokenData.id_token;
|
|
131
172
|
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
132
|
-
|
|
173
|
+
const expiresInDeadline = Date.now() + tokenData.expires_in * 1000;
|
|
174
|
+
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
133
175
|
}
|
|
134
176
|
}
|
|
135
177
|
|
|
@@ -387,7 +429,8 @@ class CacheManager {
|
|
|
387
429
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
388
430
|
const now = Date.now();
|
|
389
431
|
const age = now - cachedAt;
|
|
390
|
-
if (!Number.isFinite(cachedAt) ||
|
|
432
|
+
if (!Number.isFinite(cachedAt) ||
|
|
433
|
+
(entry.ttl !== null && (typeof entry.ttl !== 'number' || age > entry.ttl))) {
|
|
391
434
|
return null;
|
|
392
435
|
}
|
|
393
436
|
return entry.data;
|
|
@@ -398,11 +441,17 @@ class CacheManager {
|
|
|
398
441
|
}
|
|
399
442
|
}
|
|
400
443
|
async set(key, data, ttl) {
|
|
444
|
+
this.write(key, data, ttl || this.defaultTTL);
|
|
445
|
+
}
|
|
446
|
+
async setPermanent(key, data) {
|
|
447
|
+
this.write(key, data, null);
|
|
448
|
+
}
|
|
449
|
+
write(key, data, ttl) {
|
|
401
450
|
const cachePath = this.getCachePath(key);
|
|
402
451
|
this.ensureCacheDir();
|
|
403
452
|
const entry = {
|
|
404
453
|
cachedAt: new Date().toISOString(),
|
|
405
|
-
ttl
|
|
454
|
+
ttl,
|
|
406
455
|
data,
|
|
407
456
|
};
|
|
408
457
|
const tempPath = `${cachePath}.tmp`;
|
|
@@ -452,8 +501,9 @@ class MovementService {
|
|
|
452
501
|
}
|
|
453
502
|
|
|
454
503
|
class UserService {
|
|
455
|
-
constructor(httpClient) {
|
|
504
|
+
constructor(httpClient, cacheDir) {
|
|
456
505
|
this.httpClient = httpClient;
|
|
506
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
457
507
|
}
|
|
458
508
|
async getUserInfo() {
|
|
459
509
|
return this.httpClient.request('/users/userinfo');
|
|
@@ -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
|
+
"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",
|