@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.
Files changed (50) hide show
  1. package/README.md +28 -0
  2. package/dist/{index.js → index.cjs} +141 -73
  3. package/dist/index.d.ts +13 -7
  4. package/dist/index.esm.js +141 -73
  5. package/package.json +5 -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-name.d.ts +0 -1
  33. package/dist/examples/get-workout-by-share-url.d.ts +0 -1
  34. package/dist/examples/raw-daily-lifts-test.d.ts +0 -1
  35. package/dist/src/auth/auth-manager.d.ts +0 -15
  36. package/dist/src/client.d.ts +0 -40
  37. package/dist/src/http/http-client.d.ts +0 -12
  38. package/dist/src/index.d.ts +0 -4
  39. package/dist/src/services/movement-service.d.ts +0 -9
  40. package/dist/src/services/user-service.d.ts +0 -51
  41. package/dist/src/services/workout-service.d.ts +0 -14
  42. package/dist/src/types/auth.d.ts +0 -18
  43. package/dist/src/types/common.d.ts +0 -1
  44. package/dist/src/types/index.d.ts +0 -6
  45. package/dist/src/types/movements.d.ts +0 -30
  46. package/dist/src/types/programs.d.ts +0 -46
  47. package/dist/src/types/users.d.ts +0 -418
  48. package/dist/src/types/workouts.d.ts +0 -120
  49. package/dist/src/types.d.ts +0 -125
  50. 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.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
  }
@@ -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({ sets }),
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 = '.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[]>;