@dlwiest/ts-tonal-client 0.2.0 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +40 -1
  2. package/dist/{index.js → index.cjs} +140 -72
  3. package/dist/index.d.ts +13 -7
  4. package/dist/index.esm.js +140 -72
  5. package/package.json +6 -5
  6. package/dist/examples/create-workout.d.ts +0 -1
  7. package/dist/examples/debug-daily-lifts.d.ts +0 -1
  8. package/dist/examples/delete-workout.d.ts +0 -1
  9. package/dist/examples/edit-workout.d.ts +0 -1
  10. package/dist/examples/estimate-workout.d.ts +0 -1
  11. package/dist/examples/get-achievement-stats.d.ts +0 -2
  12. package/dist/examples/get-achievements.d.ts +0 -2
  13. package/dist/examples/get-activity-summaries.d.ts +0 -2
  14. package/dist/examples/get-current-streak.d.ts +0 -1
  15. package/dist/examples/get-daily-lifts.d.ts +0 -1
  16. package/dist/examples/get-daily-metrics.d.ts +0 -1
  17. package/dist/examples/get-goal-metrics.d.ts +0 -1
  18. package/dist/examples/get-goals.d.ts +0 -1
  19. package/dist/examples/get-home-calendar.d.ts +0 -2
  20. package/dist/examples/get-metric-scores.d.ts +0 -1
  21. package/dist/examples/get-movements.d.ts +0 -1
  22. package/dist/examples/get-muscle-readiness.d.ts +0 -1
  23. package/dist/examples/get-program-by-id.d.ts +0 -1
  24. package/dist/examples/get-target-scores.d.ts +0 -1
  25. package/dist/examples/get-training-effect-goals.d.ts +0 -1
  26. package/dist/examples/get-training-types.d.ts +0 -1
  27. package/dist/examples/get-user-info.d.ts +0 -1
  28. package/dist/examples/get-user-settings.d.ts +0 -1
  29. package/dist/examples/get-user-statistics.d.ts +0 -2
  30. package/dist/examples/get-user-workouts.d.ts +0 -1
  31. package/dist/examples/get-workout-by-id.d.ts +0 -1
  32. package/dist/examples/get-workout-by-share-url.d.ts +0 -1
  33. package/dist/examples/raw-daily-lifts-test.d.ts +0 -1
  34. package/dist/src/auth/auth-manager.d.ts +0 -15
  35. package/dist/src/client.d.ts +0 -40
  36. package/dist/src/http/http-client.d.ts +0 -12
  37. package/dist/src/index.d.ts +0 -4
  38. package/dist/src/services/movement-service.d.ts +0 -9
  39. package/dist/src/services/user-service.d.ts +0 -51
  40. package/dist/src/services/workout-service.d.ts +0 -14
  41. package/dist/src/types/auth.d.ts +0 -18
  42. package/dist/src/types/common.d.ts +0 -1
  43. package/dist/src/types/index.d.ts +0 -6
  44. package/dist/src/types/movements.d.ts +0 -30
  45. package/dist/src/types/programs.d.ts +0 -46
  46. package/dist/src/types/users.d.ts +0 -418
  47. package/dist/src/types/workouts.d.ts +0 -120
  48. package/dist/src/types.d.ts +0 -125
  49. 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:
@@ -67,6 +95,9 @@ Then try the examples:
67
95
  # See all your workouts
68
96
  npm run example:user-workouts
69
97
 
98
+ # Search for workouts by name
99
+ npm run example:workout:name "Upper Body"
100
+
70
101
  # Get your user info
71
102
  npm run example:user
72
103
 
@@ -150,9 +181,16 @@ console.log(`You have ${dailyLifts.length} daily lifts`)
150
181
  // Get daily lifts with specific timezone
151
182
  const dailyLiftsEST = await client.getDailyLifts('America/New_York')
152
183
 
153
- // Get specific workout details
184
+ // Get specific workout details
154
185
  const workout = await client.getWorkoutById('workout-uuid')
155
186
 
187
+ // Search for workouts by name (case-insensitive partial match)
188
+ const workouts = await client.getUserWorkouts(0, 50)
189
+ const matches = workouts.filter(w =>
190
+ w.title.toLowerCase().includes('upper body')
191
+ )
192
+ console.log(`Found ${matches.length} matching workouts`)
193
+
156
194
  // Get shared workout
157
195
  const sharedWorkout = await client.getWorkoutByShareUrl('https://share.tonal.com/workout/...')
158
196
 
@@ -425,6 +463,7 @@ const chestMovements = movements.filter(m =>
425
463
  - `npm run example:goals` - Show available goals
426
464
  - `npm run example:user-workouts` - List your workouts
427
465
  - `npm run example:workout:id <id>` - Get specific workout
466
+ - `npm run example:workout:name <name>` - Search for workouts by name
428
467
  - `npm run example:workout:share <url>` - Get shared workout
429
468
  - `npm run example:estimate` - Estimate workout duration
430
469
  - `npm run example:create-workout` - Create a new workout
@@ -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.isRefreshing = false;
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
- if (!this.isRefreshing) {
68
- await this.refreshTokens();
69
- }
70
- else {
71
- // Wait for ongoing refresh to complete
72
- while (this.isRefreshing) {
73
- await new Promise(resolve => setTimeout(resolve, 100));
74
- }
75
- }
76
- if (this.isTokenValid()) {
77
- return this.idToken;
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
- throw new TonalClientError('Token expired and refresh failed. Call authenticate() first.');
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
- if (this.isRefreshing) {
87
- return; // Prevent concurrent refresh attempts
88
- }
89
- this.isRefreshing = true;
90
- try {
91
- const response = await fetch(this.authUrl, {
92
- method: 'POST',
93
- headers: {
94
- 'Content-Type': 'application/json',
95
- },
96
- body: JSON.stringify({
97
- client_id: this.clientId,
98
- grant_type: 'refresh_token',
99
- refresh_token: this.refreshToken,
100
- }),
101
- });
102
- if (!response.ok) {
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
- const tokenData = await response.json();
114
- this.idToken = tokenData.id_token;
115
- this.refreshToken = tokenData.refresh_token;
116
- this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
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
- const controller = new AbortController();
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, try to refresh token and retry once
188
- if (attempt === 1 && lastError.statusCode && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
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
- await this.authManager.getValidToken(); // This will refresh if needed
191
- continue; // Retry the request with the new token
209
+ this.authManager.invalidateToken();
210
+ await this.authManager.getValidToken();
211
+ continue;
192
212
  }
193
- catch (refreshError) {
213
+ catch {
194
214
  // If refresh fails, continue with normal retry logic
195
215
  }
196
216
  }
197
- if (attempt === this.maxRetries || (lastError.statusCode && lastError.statusCode < 500)) {
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 = /https:\/\/share\.tonal\.com\/workout\/([a-f0-9-]+)/;
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
  }
@@ -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 = '.cache', defaultTTL = 24 * 60 * 60 * 1000) {
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
- fs.writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
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
- await this.cacheManager.set('movements', movements);
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
- return this.userService.getUserInfo();
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: string;
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: number;
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[]>;