@dlwiest/ts-tonal-client 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/{index.js → index.cjs} +141 -73
- package/dist/index.d.ts +13 -7
- package/dist/index.esm.js +141 -73
- package/package.json +5 -5
- package/dist/examples/create-workout.d.ts +0 -1
- package/dist/examples/debug-daily-lifts.d.ts +0 -1
- package/dist/examples/delete-workout.d.ts +0 -1
- package/dist/examples/edit-workout.d.ts +0 -1
- package/dist/examples/estimate-workout.d.ts +0 -1
- package/dist/examples/get-achievement-stats.d.ts +0 -2
- package/dist/examples/get-achievements.d.ts +0 -2
- package/dist/examples/get-activity-summaries.d.ts +0 -2
- package/dist/examples/get-current-streak.d.ts +0 -1
- package/dist/examples/get-daily-lifts.d.ts +0 -1
- package/dist/examples/get-daily-metrics.d.ts +0 -1
- package/dist/examples/get-goal-metrics.d.ts +0 -1
- package/dist/examples/get-goals.d.ts +0 -1
- package/dist/examples/get-home-calendar.d.ts +0 -2
- package/dist/examples/get-metric-scores.d.ts +0 -1
- package/dist/examples/get-movements.d.ts +0 -1
- package/dist/examples/get-muscle-readiness.d.ts +0 -1
- package/dist/examples/get-program-by-id.d.ts +0 -1
- package/dist/examples/get-target-scores.d.ts +0 -1
- package/dist/examples/get-training-effect-goals.d.ts +0 -1
- package/dist/examples/get-training-types.d.ts +0 -1
- package/dist/examples/get-user-info.d.ts +0 -1
- package/dist/examples/get-user-settings.d.ts +0 -1
- package/dist/examples/get-user-statistics.d.ts +0 -2
- package/dist/examples/get-user-workouts.d.ts +0 -1
- package/dist/examples/get-workout-by-id.d.ts +0 -1
- package/dist/examples/get-workout-by-name.d.ts +0 -1
- package/dist/examples/get-workout-by-share-url.d.ts +0 -1
- package/dist/examples/raw-daily-lifts-test.d.ts +0 -1
- package/dist/src/auth/auth-manager.d.ts +0 -15
- package/dist/src/client.d.ts +0 -40
- package/dist/src/http/http-client.d.ts +0 -12
- package/dist/src/index.d.ts +0 -4
- package/dist/src/services/movement-service.d.ts +0 -9
- package/dist/src/services/user-service.d.ts +0 -51
- package/dist/src/services/workout-service.d.ts +0 -14
- package/dist/src/types/auth.d.ts +0 -18
- package/dist/src/types/common.d.ts +0 -1
- package/dist/src/types/index.d.ts +0 -6
- package/dist/src/types/movements.d.ts +0 -30
- package/dist/src/types/programs.d.ts +0 -46
- package/dist/src/types/users.d.ts +0 -418
- package/dist/src/types/workouts.d.ts +0 -120
- package/dist/src/types.d.ts +0 -125
- package/dist/src/utils/cache-manager.d.ts +0 -11
package/README.md
CHANGED
|
@@ -52,6 +52,34 @@ const workouts = await client.getUserWorkouts()
|
|
|
52
52
|
console.log(`You have ${workouts.length} workouts`)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Client setup
|
|
56
|
+
|
|
57
|
+
`TonalClient.create()` accepts credentials and an optional movement cache directory:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
TonalClient.create(credentials: {
|
|
61
|
+
username: string,
|
|
62
|
+
password: string,
|
|
63
|
+
cacheDir?: string,
|
|
64
|
+
}): Promise<TonalClient>
|
|
65
|
+
```
|
|
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.
|
|
68
|
+
|
|
69
|
+
Pass `cacheDir` to store the movement cache somewhere else:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const client = await TonalClient.create({
|
|
73
|
+
username: 'your_email@example.com',
|
|
74
|
+
password: 'your_password',
|
|
75
|
+
cacheDir: '/path/to/cache',
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Cache invalidation
|
|
80
|
+
|
|
81
|
+
`TonalClient.invalidateUserInfo(): void` clears the cached user profile. Call `client.invalidateUserInfo()` before the next `getUserInfo()` request to fetch fresh data.
|
|
82
|
+
|
|
55
83
|
## Examples
|
|
56
84
|
|
|
57
85
|
To run the example scripts, first create a `.env` file:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
5
|
var fs = require('fs');
|
|
6
|
+
var os = require('os');
|
|
6
7
|
var path = require('path');
|
|
7
8
|
|
|
8
9
|
class TonalClientError extends Error {
|
|
@@ -19,9 +20,11 @@ class AuthManager {
|
|
|
19
20
|
this.idToken = '';
|
|
20
21
|
this.refreshToken = '';
|
|
21
22
|
this.tokenExpiresAt = 0;
|
|
22
|
-
this.
|
|
23
|
+
this.authPromise = null;
|
|
24
|
+
this.refreshPromise = null;
|
|
23
25
|
this.authUrl = 'https://tonal.auth0.com/oauth/token';
|
|
24
26
|
this.clientId = 'ERCyexW-xoVG_Yy3RDe-eV4xsOnRHP6L';
|
|
27
|
+
this.authTimeout = 30000;
|
|
25
28
|
this.username = username;
|
|
26
29
|
this.password = password;
|
|
27
30
|
}
|
|
@@ -29,6 +32,18 @@ class AuthManager {
|
|
|
29
32
|
if (this.isTokenValid()) {
|
|
30
33
|
return this.idToken;
|
|
31
34
|
}
|
|
35
|
+
const activeAuth = this.authPromise ?? this.authenticateWithPassword();
|
|
36
|
+
this.authPromise = activeAuth;
|
|
37
|
+
try {
|
|
38
|
+
return await activeAuth;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (this.authPromise === activeAuth) {
|
|
42
|
+
this.authPromise = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async authenticateWithPassword() {
|
|
32
47
|
const response = await fetch(this.authUrl, {
|
|
33
48
|
method: 'POST',
|
|
34
49
|
headers: {
|
|
@@ -41,6 +56,7 @@ class AuthManager {
|
|
|
41
56
|
grant_type: 'password',
|
|
42
57
|
scope: 'offline_access',
|
|
43
58
|
}),
|
|
59
|
+
signal: AbortSignal.timeout(this.authTimeout),
|
|
44
60
|
});
|
|
45
61
|
if (!response.ok) {
|
|
46
62
|
const errorText = await response.text();
|
|
@@ -55,69 +71,69 @@ class AuthManager {
|
|
|
55
71
|
}
|
|
56
72
|
const tokenData = await response.json();
|
|
57
73
|
this.idToken = tokenData.id_token;
|
|
58
|
-
this.refreshToken = tokenData.refresh_token;
|
|
74
|
+
this.refreshToken = tokenData.refresh_token ?? '';
|
|
59
75
|
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
60
76
|
return this.idToken;
|
|
61
77
|
}
|
|
78
|
+
invalidateToken() {
|
|
79
|
+
this.tokenExpiresAt = 0;
|
|
80
|
+
}
|
|
62
81
|
async getValidToken() {
|
|
63
82
|
if (this.isTokenValid()) {
|
|
64
83
|
return this.idToken;
|
|
65
84
|
}
|
|
66
|
-
if (this.refreshToken) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
if (!this.refreshToken) {
|
|
86
|
+
return this.authenticate();
|
|
87
|
+
}
|
|
88
|
+
const activeRefresh = this.refreshPromise ?? this.refreshTokens();
|
|
89
|
+
this.refreshPromise = activeRefresh;
|
|
90
|
+
try {
|
|
91
|
+
await activeRefresh;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return this.authenticate();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (this.refreshPromise === activeRefresh) {
|
|
98
|
+
this.refreshPromise = null;
|
|
78
99
|
}
|
|
79
100
|
}
|
|
80
|
-
|
|
101
|
+
if (this.isTokenValid()) {
|
|
102
|
+
return this.idToken;
|
|
103
|
+
}
|
|
104
|
+
return this.authenticate();
|
|
81
105
|
}
|
|
82
106
|
isTokenValid() {
|
|
83
107
|
return !!this.idToken && Date.now() < this.tokenExpiresAt - 60000; // 1 minute buffer
|
|
84
108
|
}
|
|
85
109
|
async refreshTokens() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const errorText = await response.text();
|
|
104
|
-
let errorData;
|
|
105
|
-
try {
|
|
106
|
-
errorData = JSON.parse(errorText);
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
errorData = { error: errorText };
|
|
110
|
-
}
|
|
111
|
-
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
110
|
+
const response = await fetch(this.authUrl, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
client_id: this.clientId,
|
|
117
|
+
grant_type: 'refresh_token',
|
|
118
|
+
refresh_token: this.refreshToken,
|
|
119
|
+
}),
|
|
120
|
+
signal: AbortSignal.timeout(this.authTimeout),
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const errorText = await response.text();
|
|
124
|
+
let errorData;
|
|
125
|
+
try {
|
|
126
|
+
errorData = JSON.parse(errorText);
|
|
112
127
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
finally {
|
|
119
|
-
this.isRefreshing = false;
|
|
128
|
+
catch {
|
|
129
|
+
errorData = { error: errorText };
|
|
130
|
+
}
|
|
131
|
+
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
120
132
|
}
|
|
133
|
+
const tokenData = await response.json();
|
|
134
|
+
this.idToken = tokenData.id_token;
|
|
135
|
+
this.refreshToken = tokenData.refresh_token ?? this.refreshToken;
|
|
136
|
+
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
|
|
@@ -133,10 +149,11 @@ class HttpClient {
|
|
|
133
149
|
return this.makeRequestWithRetry(url, options, expectsBody);
|
|
134
150
|
}
|
|
135
151
|
async makeRequest(url, options = {}, expectsBody = true) {
|
|
136
|
-
|
|
137
|
-
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
152
|
+
let timeoutId;
|
|
138
153
|
try {
|
|
139
154
|
const token = await this.authManager.getValidToken();
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
140
157
|
const response = await fetch(url, {
|
|
141
158
|
...options,
|
|
142
159
|
headers: {
|
|
@@ -147,6 +164,7 @@ class HttpClient {
|
|
|
147
164
|
signal: controller.signal,
|
|
148
165
|
});
|
|
149
166
|
clearTimeout(timeoutId);
|
|
167
|
+
timeoutId = undefined;
|
|
150
168
|
if (!response.ok) {
|
|
151
169
|
const errorText = await response.text();
|
|
152
170
|
let errorData;
|
|
@@ -173,28 +191,35 @@ class HttpClient {
|
|
|
173
191
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
174
192
|
throw new TonalClientError('Request timeout', undefined, error);
|
|
175
193
|
}
|
|
176
|
-
throw new TonalClientError('Request failed', undefined, error);
|
|
194
|
+
throw new TonalClientError(error instanceof Error ? error.message : 'Request failed', undefined, error);
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
197
|
async makeRequestWithRetry(url, options = {}, expectsBody = true) {
|
|
180
198
|
let lastError;
|
|
199
|
+
const canRetry = (options.method ?? 'GET').toUpperCase() === 'GET';
|
|
181
200
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
182
201
|
try {
|
|
183
202
|
return await this.makeRequest(url, options, expectsBody);
|
|
184
203
|
}
|
|
185
204
|
catch (error) {
|
|
186
205
|
lastError = error instanceof TonalClientError ? error : new TonalClientError('Unknown error', undefined, error);
|
|
187
|
-
// If it's an auth error on first attempt,
|
|
188
|
-
if (attempt === 1 &&
|
|
206
|
+
// If it's an auth error on first attempt, invalidate the token and retry once
|
|
207
|
+
if (attempt === 1 && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
|
|
189
208
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
209
|
+
this.authManager.invalidateToken();
|
|
210
|
+
await this.authManager.getValidToken();
|
|
211
|
+
continue;
|
|
192
212
|
}
|
|
193
|
-
catch
|
|
213
|
+
catch {
|
|
194
214
|
// If refresh fails, continue with normal retry logic
|
|
195
215
|
}
|
|
196
216
|
}
|
|
197
|
-
|
|
217
|
+
const isAbort = lastError.originalError instanceof Error &&
|
|
218
|
+
lastError.originalError.name === 'AbortError';
|
|
219
|
+
if (attempt === this.maxRetries ||
|
|
220
|
+
!canRetry ||
|
|
221
|
+
(lastError.statusCode !== undefined && lastError.statusCode < 500) ||
|
|
222
|
+
(lastError.statusCode === undefined && !isAbort)) {
|
|
198
223
|
throw lastError;
|
|
199
224
|
}
|
|
200
225
|
const delay = Math.pow(2, attempt - 1) * 1000;
|
|
@@ -223,6 +248,9 @@ class WorkoutService {
|
|
|
223
248
|
}
|
|
224
249
|
async getDailyLifts(userInfo, timeZone) {
|
|
225
250
|
const device = userInfo.recentMobileDevice;
|
|
251
|
+
if (!device) {
|
|
252
|
+
throw new TonalClientError('Recent mobile device information is unavailable; daily lifts require a mobile device');
|
|
253
|
+
}
|
|
226
254
|
const userAgent = device.platform === 'ios'
|
|
227
255
|
? `Tonal/3004226 CFNetwork/3860.100.1 Darwin/${device.osVersion}`
|
|
228
256
|
: `Tonal/${device.appVersion}`;
|
|
@@ -259,8 +287,8 @@ class WorkoutService {
|
|
|
259
287
|
if (!shareUrl?.trim()) {
|
|
260
288
|
throw new TonalClientError('Share URL is required');
|
|
261
289
|
}
|
|
262
|
-
const urlPattern =
|
|
263
|
-
const match = shareUrl.match(urlPattern);
|
|
290
|
+
const urlPattern = /^https:\/\/share\.tonal\.com\/workout\/([a-fA-F0-9-]+)(?:[?#]\S*)?$/;
|
|
291
|
+
const match = shareUrl.trim().match(urlPattern);
|
|
264
292
|
if (!match) {
|
|
265
293
|
throw new TonalClientError('Invalid share URL format. Expected: https://share.tonal.com/workout/{id}');
|
|
266
294
|
}
|
|
@@ -273,7 +301,7 @@ class WorkoutService {
|
|
|
273
301
|
}
|
|
274
302
|
return this.httpClient.request('/user-workouts/estimate', {
|
|
275
303
|
method: 'POST',
|
|
276
|
-
body: JSON.stringify(
|
|
304
|
+
body: JSON.stringify(sets),
|
|
277
305
|
});
|
|
278
306
|
}
|
|
279
307
|
async createWorkout(workoutData) {
|
|
@@ -305,9 +333,16 @@ class WorkoutService {
|
|
|
305
333
|
if (!workoutData.sets?.length) {
|
|
306
334
|
throw new TonalClientError('At least one set is required');
|
|
307
335
|
}
|
|
336
|
+
// Tonal's PUT behavior is unverified. Omitting an absent shortDescription is equivalent
|
|
337
|
+
// under full-replace semantics and avoids an unintended clear under merge semantics;
|
|
338
|
+
// an explicit empty string intentionally clears it. `!= null` also covers null, which
|
|
339
|
+
// sibling response fields are known to return despite their non-optional types.
|
|
308
340
|
const requestBody = {
|
|
309
341
|
id: workoutData.id,
|
|
310
342
|
title: workoutData.title,
|
|
343
|
+
...(workoutData.shortDescription != null
|
|
344
|
+
? { shortDescription: workoutData.shortDescription }
|
|
345
|
+
: {}),
|
|
311
346
|
description: workoutData.description || '',
|
|
312
347
|
coachId: workoutData.coachId || '00000000-0000-0000-0000-000000000000',
|
|
313
348
|
sets: workoutData.sets,
|
|
@@ -331,10 +366,11 @@ class WorkoutService {
|
|
|
331
366
|
}
|
|
332
367
|
|
|
333
368
|
class CacheManager {
|
|
334
|
-
constructor(cacheDir =
|
|
369
|
+
constructor(cacheDir = process.env.XDG_CACHE_HOME
|
|
370
|
+
? path.join(process.env.XDG_CACHE_HOME, 'ts-tonal-client')
|
|
371
|
+
: path.join(os.homedir(), '.cache', 'ts-tonal-client'), defaultTTL = 24 * 60 * 60 * 1000) {
|
|
335
372
|
this.cacheDir = cacheDir;
|
|
336
373
|
this.defaultTTL = defaultTTL;
|
|
337
|
-
this.ensureCacheDir();
|
|
338
374
|
}
|
|
339
375
|
ensureCacheDir() {
|
|
340
376
|
if (!fs.existsSync(this.cacheDir)) {
|
|
@@ -355,8 +391,7 @@ class CacheManager {
|
|
|
355
391
|
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
356
392
|
const now = Date.now();
|
|
357
393
|
const age = now - cachedAt;
|
|
358
|
-
if (age > entry.ttl) {
|
|
359
|
-
// Cache expired
|
|
394
|
+
if (!Number.isFinite(cachedAt) || typeof entry.ttl !== 'number' || age > entry.ttl) {
|
|
360
395
|
return null;
|
|
361
396
|
}
|
|
362
397
|
return entry.data;
|
|
@@ -368,12 +403,15 @@ class CacheManager {
|
|
|
368
403
|
}
|
|
369
404
|
async set(key, data, ttl) {
|
|
370
405
|
const cachePath = this.getCachePath(key);
|
|
406
|
+
this.ensureCacheDir();
|
|
371
407
|
const entry = {
|
|
372
408
|
cachedAt: new Date().toISOString(),
|
|
373
409
|
ttl: ttl || this.defaultTTL,
|
|
374
410
|
data,
|
|
375
411
|
};
|
|
376
|
-
|
|
412
|
+
const tempPath = `${cachePath}.tmp`;
|
|
413
|
+
fs.writeFileSync(tempPath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
414
|
+
fs.renameSync(tempPath, cachePath);
|
|
377
415
|
}
|
|
378
416
|
async invalidate(key) {
|
|
379
417
|
const cachePath = this.getCachePath(key);
|
|
@@ -383,7 +421,7 @@ class CacheManager {
|
|
|
383
421
|
}
|
|
384
422
|
async clear() {
|
|
385
423
|
if (fs.existsSync(this.cacheDir)) {
|
|
386
|
-
const files = fs.readdirSync(this.cacheDir);
|
|
424
|
+
const files = fs.readdirSync(this.cacheDir).filter(file => file.endsWith('.json') || file.endsWith('.json.tmp'));
|
|
387
425
|
for (const file of files) {
|
|
388
426
|
fs.unlinkSync(path.join(this.cacheDir, file));
|
|
389
427
|
}
|
|
@@ -392,9 +430,9 @@ class CacheManager {
|
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
class MovementService {
|
|
395
|
-
constructor(httpClient) {
|
|
433
|
+
constructor(httpClient, cacheDir) {
|
|
396
434
|
this.httpClient = httpClient;
|
|
397
|
-
this.cacheManager = new CacheManager();
|
|
435
|
+
this.cacheManager = new CacheManager(cacheDir);
|
|
398
436
|
}
|
|
399
437
|
async getMovements(useCache = true) {
|
|
400
438
|
if (useCache) {
|
|
@@ -404,7 +442,12 @@ class MovementService {
|
|
|
404
442
|
}
|
|
405
443
|
}
|
|
406
444
|
const movements = await this.httpClient.request('/movements');
|
|
407
|
-
|
|
445
|
+
try {
|
|
446
|
+
await this.cacheManager.set('movements', movements);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Cache writes are best-effort and must not hide a successful API response.
|
|
450
|
+
}
|
|
408
451
|
return movements;
|
|
409
452
|
}
|
|
410
453
|
async invalidateMovementsCache() {
|
|
@@ -508,15 +551,15 @@ class UserService {
|
|
|
508
551
|
}
|
|
509
552
|
|
|
510
553
|
class TonalClient {
|
|
511
|
-
constructor(username, password) {
|
|
554
|
+
constructor(username, password, cacheDir) {
|
|
512
555
|
this.authManager = new AuthManager(username, password);
|
|
513
556
|
this.httpClient = new HttpClient(this.authManager);
|
|
514
557
|
this.workoutService = new WorkoutService(this.httpClient);
|
|
515
|
-
this.movementService = new MovementService(this.httpClient);
|
|
558
|
+
this.movementService = new MovementService(this.httpClient, cacheDir);
|
|
516
559
|
this.userService = new UserService(this.httpClient);
|
|
517
560
|
}
|
|
518
561
|
static async create(credentials) {
|
|
519
|
-
const client = new TonalClient(credentials.username, credentials.password);
|
|
562
|
+
const client = new TonalClient(credentials.username, credentials.password, credentials.cacheDir);
|
|
520
563
|
await client.authManager.authenticate();
|
|
521
564
|
return client;
|
|
522
565
|
}
|
|
@@ -529,7 +572,32 @@ class TonalClient {
|
|
|
529
572
|
}
|
|
530
573
|
// User operations
|
|
531
574
|
async getUserInfo() {
|
|
532
|
-
|
|
575
|
+
if (this.userInfo) {
|
|
576
|
+
return this.userInfo;
|
|
577
|
+
}
|
|
578
|
+
let request = this.userInfoPromise;
|
|
579
|
+
if (!request) {
|
|
580
|
+
request = this.userService.getUserInfo();
|
|
581
|
+
this.userInfoPromise = request;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const userInfo = await request;
|
|
585
|
+
if (this.userInfoPromise === request) {
|
|
586
|
+
this.userInfo = userInfo;
|
|
587
|
+
this.userInfoPromise = undefined;
|
|
588
|
+
}
|
|
589
|
+
return userInfo;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (this.userInfoPromise === request) {
|
|
593
|
+
this.userInfoPromise = undefined;
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
invalidateUserInfo() {
|
|
599
|
+
this.userInfo = undefined;
|
|
600
|
+
this.userInfoPromise = undefined;
|
|
533
601
|
}
|
|
534
602
|
async getGoals() {
|
|
535
603
|
return this.userService.getGoals();
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ declare class TonalClientError extends Error {
|
|
|
11
11
|
interface OAuthTokenResponse {
|
|
12
12
|
access_token: string;
|
|
13
13
|
id_token: string;
|
|
14
|
-
refresh_token
|
|
14
|
+
refresh_token?: string;
|
|
15
15
|
scope: string;
|
|
16
16
|
token_type: string;
|
|
17
17
|
expires_in: number;
|
|
@@ -55,7 +55,8 @@ interface WorkoutSet {
|
|
|
55
55
|
workoutId: string;
|
|
56
56
|
blockStart: boolean;
|
|
57
57
|
movementId: string;
|
|
58
|
-
prescribedReps
|
|
58
|
+
prescribedReps?: number;
|
|
59
|
+
prescribedDuration?: number;
|
|
59
60
|
repetition: number;
|
|
60
61
|
repetitionTotal: number;
|
|
61
62
|
blockNumber: number;
|
|
@@ -88,7 +89,7 @@ interface TonalWorkout {
|
|
|
88
89
|
productionCode: string;
|
|
89
90
|
assetId: string;
|
|
90
91
|
coachId: string;
|
|
91
|
-
sets: WorkoutSet[];
|
|
92
|
+
sets: WorkoutSet[] | null;
|
|
92
93
|
duration: number;
|
|
93
94
|
publishState: WorkoutPublishState;
|
|
94
95
|
programId: string | null;
|
|
@@ -96,7 +97,7 @@ interface TonalWorkout {
|
|
|
96
97
|
groupIds: string[];
|
|
97
98
|
targetArea: string;
|
|
98
99
|
tags: string[] | null;
|
|
99
|
-
bodyRegions: MuscleGroup[];
|
|
100
|
+
bodyRegions: MuscleGroup[] | null;
|
|
100
101
|
goalIds: string[] | null;
|
|
101
102
|
trainingEffectGoals: string[];
|
|
102
103
|
disableModification: boolean;
|
|
@@ -112,7 +113,7 @@ interface TonalWorkout {
|
|
|
112
113
|
recoveryWeight: boolean;
|
|
113
114
|
supportedDevices: string[] | null;
|
|
114
115
|
featureGroupIds: string[] | null;
|
|
115
|
-
movementIds: string[];
|
|
116
|
+
movementIds: string[] | null;
|
|
116
117
|
accessories?: string[];
|
|
117
118
|
muscleGroupsForExclusion: MuscleGroup[] | null;
|
|
118
119
|
playbackType: string;
|
|
@@ -161,6 +162,7 @@ interface TonalWorkoutCreateRequest {
|
|
|
161
162
|
interface TonalWorkoutUpdateRequest {
|
|
162
163
|
id: string;
|
|
163
164
|
title: string;
|
|
165
|
+
shortDescription?: string;
|
|
164
166
|
description?: string;
|
|
165
167
|
coachId: string;
|
|
166
168
|
sets: TonalWorkoutEstimateSet[];
|
|
@@ -209,7 +211,7 @@ interface TonalProgram {
|
|
|
209
211
|
programWorkouts: TonalProgramWorkout[];
|
|
210
212
|
nonComparableWorkouts: boolean;
|
|
211
213
|
mobileFriendly: boolean;
|
|
212
|
-
supportedDevices: string[];
|
|
214
|
+
supportedDevices: string[] | null;
|
|
213
215
|
featureGroupIds: string[] | null;
|
|
214
216
|
excludeFromCoachHighlights: boolean;
|
|
215
217
|
trainingEffectGoals: TonalProgramTrainingEffectGoal[];
|
|
@@ -614,7 +616,7 @@ interface TonalUserInfo {
|
|
|
614
616
|
isGuestAccount: boolean;
|
|
615
617
|
isDemoAccount: boolean;
|
|
616
618
|
watchedSafetyVideo: boolean;
|
|
617
|
-
recentMobileDevice: TonalUserDevice;
|
|
619
|
+
recentMobileDevice: TonalUserDevice | null;
|
|
618
620
|
emailVerified: boolean;
|
|
619
621
|
username: string;
|
|
620
622
|
workoutsPerWeek: number;
|
|
@@ -640,14 +642,18 @@ declare class TonalClient {
|
|
|
640
642
|
private workoutService;
|
|
641
643
|
private movementService;
|
|
642
644
|
private userService;
|
|
645
|
+
private userInfo?;
|
|
646
|
+
private userInfoPromise?;
|
|
643
647
|
private constructor();
|
|
644
648
|
static create(credentials: {
|
|
645
649
|
username: string;
|
|
646
650
|
password: string;
|
|
651
|
+
cacheDir?: string;
|
|
647
652
|
}): Promise<TonalClient>;
|
|
648
653
|
getMovements(useCache?: boolean): Promise<TonalMovement[]>;
|
|
649
654
|
invalidateMovementsCache(): Promise<void>;
|
|
650
655
|
getUserInfo(): Promise<TonalUserInfo>;
|
|
656
|
+
invalidateUserInfo(): void;
|
|
651
657
|
getGoals(): Promise<TonalGoal[]>;
|
|
652
658
|
getTrainingEffectGoals(): Promise<TonalTrainingEffectGoalsResponse>;
|
|
653
659
|
getTrainingTypes(): Promise<TonalTrainingType[]>;
|