@dlwiest/ts-tonal-client 0.5.0 โ 0.6.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 +150 -14
- package/dist/index.cjs +426 -9
- package/dist/index.d.ts +537 -250
- package/dist/index.esm.js +426 -10
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -3,20 +3,53 @@
|
|
|
3
3
|
[](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client)
|
|
4
4
|
[](https://www.npmjs.com/package/@dlwiest/ts-tonal-client)
|
|
5
5
|
|
|
6
|
-
A
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
A TypeScript client for working with data from a Tonal account. It wraps
|
|
7
|
+
authentication, workout and movement APIs, completed performance data, recovery
|
|
8
|
+
metrics, programs, goals, and personal health-data exports in a typed interface.
|
|
9
|
+
|
|
10
|
+
> [!IMPORTANT]
|
|
11
|
+
> This is an unofficial community project. It is not affiliated with or
|
|
12
|
+
> supported by Tonal, and the private APIs it uses may change without notice.
|
|
13
|
+
|
|
14
|
+
## Product Overview
|
|
15
|
+
|
|
16
|
+
The client is meant for personal tools, data exports, and applications that need
|
|
17
|
+
more Tonal detail than a general fitness integration may provide. In particular,
|
|
18
|
+
it can retrieve performed set dataโmovements, reps, weight, volume, training
|
|
19
|
+
modes, timing, and estimated one-rep maxโand combine it with workout summaries,
|
|
20
|
+
muscle readiness, and lifetime totals.
|
|
21
|
+
|
|
22
|
+
One intended workflow is to create a privacy-conscious JSON snapshot and upload
|
|
23
|
+
it to an AI assistant such as ChatGPT for personal health analysis. This is a
|
|
24
|
+
file-based bridge, not a live or automatic ChatGPT connection: generate a new
|
|
25
|
+
export whenever the assistant needs current Tonal data.
|
|
26
|
+
|
|
27
|
+
## Product Features
|
|
28
|
+
|
|
29
|
+
| Area | Current capabilities |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Authentication | Sign in with Tonal account credentials and automatically manage access-token refresh |
|
|
32
|
+
| Workout library | List, retrieve, create, update, delete, share, and estimate workouts; retrieve Daily Lifts |
|
|
33
|
+
| Completed performance | Retrieve activity summaries, paginated workout history, and individual workout details including performed sets, reps, weights, volume, timing, training modes, and estimated one-rep max |
|
|
34
|
+
| Movements and programs | Browse Tonal movements and muscle groups and retrieve detailed training programs |
|
|
35
|
+
| Training and recovery | Retrieve daily metrics, streaks, muscle readiness, weekly targets, and actual metric scores |
|
|
36
|
+
| History and achievements | Retrieve lifetime statistics, achievement progress, earned achievements, and home-calendar recommendations |
|
|
37
|
+
| Health data export | Produce date-filtered JSON with aggregate totals, optional recovery and lifetime data, and optional set-level workout details |
|
|
38
|
+
| Developer experience | TypeScript response types, request validation, retries, timeouts, movement caching, and runnable examples |
|
|
39
|
+
|
|
40
|
+
## Current Limitations
|
|
41
|
+
|
|
42
|
+
- Tonal does not publish or guarantee the private APIs used by this project.
|
|
43
|
+
- Authentication currently requires Tonal account credentials; never commit a
|
|
44
|
+
populated `.env` file or include credentials in an export.
|
|
45
|
+
- Health exports are point-in-time files. They do not continuously synchronize
|
|
46
|
+
Tonal with ChatGPT or another health service.
|
|
47
|
+
- Detailed exports fetch paginated workout activity data, so large account
|
|
48
|
+
histories may require additional requests and take longer.
|
|
49
|
+
- Tonal reports average resistance per cable. The export preserves that value
|
|
50
|
+
and derives effective average resistance from Tonal's total on-machine volume
|
|
51
|
+
divided by completed reps, correctly accounting for dual-cable movements such
|
|
52
|
+
as straight-bar lifts.
|
|
20
53
|
|
|
21
54
|
## Installation
|
|
22
55
|
|
|
@@ -165,8 +198,111 @@ npm run example:target-scores
|
|
|
165
198
|
|
|
166
199
|
# Get actual performance scores vs targets with comprehensive analytics
|
|
167
200
|
npm run example:metric-scores
|
|
201
|
+
|
|
202
|
+
# Export workout summaries for personal health analysis
|
|
203
|
+
npm run example:health-export
|
|
204
|
+
|
|
205
|
+
# Export complete Tonal history and all available health metrics
|
|
206
|
+
npm run example:complete-health-export
|
|
168
207
|
```
|
|
169
208
|
|
|
209
|
+
## Health Data Export
|
|
210
|
+
|
|
211
|
+
Create a JSON-ready export containing workout summaries, aggregate totals, current
|
|
212
|
+
muscle readiness, and lifetime statistics:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { writeFile } from 'node:fs/promises'
|
|
216
|
+
|
|
217
|
+
const exportData = await client.getHealthExport({
|
|
218
|
+
startDate: '2026-01-01',
|
|
219
|
+
limit: 100,
|
|
220
|
+
includeSetDetails: true,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await writeFile(
|
|
224
|
+
'tonal-health-export.json',
|
|
225
|
+
JSON.stringify(exportData, null, 2)
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Run `npm run example:health-export` to write a compact export of the 50 most
|
|
230
|
+
recent Tonal workouts to `tonal-health-export.json`. The example atomically
|
|
231
|
+
replaces the file with owner-only permissions and refuses symbolic-link
|
|
232
|
+
destinations.
|
|
233
|
+
|
|
234
|
+
The export intentionally excludes profile details, account identifiers, device
|
|
235
|
+
identifiers, application versions, and authentication data. It still contains
|
|
236
|
+
private health and workout information, so store and share it carefully.
|
|
237
|
+
|
|
238
|
+
Available options:
|
|
239
|
+
|
|
240
|
+
- `startDate` and `endDate`: include activities within an ISO-8601 range;
|
|
241
|
+
date-only values use each workout's local calendar day
|
|
242
|
+
- `limit`: include at most this many activities, newest first
|
|
243
|
+
- `includeMuscleReadiness`: include current readiness data (default: `true`)
|
|
244
|
+
- `includeLifetimeStatistics`: include lifetime aggregate data (default: `true`)
|
|
245
|
+
- `includeExternalActivities`: include workouts imported into Tonal from another
|
|
246
|
+
service (default: `false`, preventing duplication with Apple Health data)
|
|
247
|
+
- `includeSetDetails`: fetch performed sets, reps, weights, movement names, and
|
|
248
|
+
one-rep-max estimates from paginated workout activity data (default: `false`)
|
|
249
|
+
|
|
250
|
+
Set details keep Tonal's reported per-cable values in
|
|
251
|
+
`averageResistancePerCablePounds` and
|
|
252
|
+
`estimatedOneRepMaxPerCablePounds`. Arithmetic derived from total volume and
|
|
253
|
+
completed reps is separated under `derivedEstimates`, with
|
|
254
|
+
`averageResistancePounds` and `oneRepMaxPounds` explicitly documented as
|
|
255
|
+
estimates rather than measured values. `totalVolumePounds` uses Tonal's
|
|
256
|
+
`totalOnMachineVolume`, which reconciles with the completed workout's total
|
|
257
|
+
volume.
|
|
258
|
+
|
|
259
|
+
Detailed completed workouts are also available directly:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// Get a page of completed activities with performed set data
|
|
263
|
+
const activities = await client.getWorkoutActivities(0, 100)
|
|
264
|
+
|
|
265
|
+
// Get every activity, with duplicate-page and page-cap safety checks
|
|
266
|
+
const completeHistory = await client.getAllWorkoutActivities()
|
|
267
|
+
|
|
268
|
+
// Get one completed activity by its activity ID
|
|
269
|
+
const activity = await client.getWorkoutActivityById('activity-uuid')
|
|
270
|
+
console.log(activity.workoutSetActivity)
|
|
271
|
+
|
|
272
|
+
// Get the summary returned for one activity
|
|
273
|
+
const summary = await client.getFormattedWorkoutSummary('activity-uuid')
|
|
274
|
+
|
|
275
|
+
// Get summaries in bounded request batches
|
|
276
|
+
const summaries = await client.getFormattedWorkoutSummaries(
|
|
277
|
+
completeHistory.map(item => item.id),
|
|
278
|
+
5
|
|
279
|
+
)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Complete History Export
|
|
283
|
+
|
|
284
|
+
Run `npm run example:complete-health-export` to retrieve every paginated Tonal
|
|
285
|
+
workout from the beginning of the account history. The resulting
|
|
286
|
+
`tonal-complete-health-export.json` includes raw set-performance metrics,
|
|
287
|
+
formatted workout and movement summaries, full daily metrics for the covered
|
|
288
|
+
period, strength-score history, weekly targets and scores, current readiness,
|
|
289
|
+
streaks, lifetime statistics, achievements, and reference definitions.
|
|
290
|
+
|
|
291
|
+
The complete export is structured as JSON for analysis tools such as ChatGPT.
|
|
292
|
+
It includes a data dictionary explaining units and Tonal's per-cable resistance
|
|
293
|
+
convention. Account, device, application, subscription, and authentication
|
|
294
|
+
identifiers and user weight are removed recursively. The JSON is compacted to
|
|
295
|
+
reduce upload size and text-token usage without dropping allowed data. The file
|
|
296
|
+
still contains highly private health information and is atomically replaced
|
|
297
|
+
with owner-only permissions.
|
|
298
|
+
|
|
299
|
+
The script also atomically recreates a private `tonal-chatgpt-export` directory
|
|
300
|
+
containing `overview-and-metrics.json` and one `workouts-YYYY.json` file for
|
|
301
|
+
each year in the history, so files from an older export cannot survive a rerun.
|
|
302
|
+
Upload every file listed by `overview-and-metrics.json` together. The split
|
|
303
|
+
bundle contains the same information but keeps each text file smaller and
|
|
304
|
+
easier for ChatGPT to analyze completely.
|
|
305
|
+
|
|
170
306
|
## API Reference
|
|
171
307
|
|
|
172
308
|
### Workouts
|
package/dist/index.cjs
CHANGED
|
@@ -77,43 +77,54 @@ class AuthManager {
|
|
|
77
77
|
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
78
78
|
return this.idToken;
|
|
79
79
|
}
|
|
80
|
+
// `expiresInDeadline` may be NaN: OAuthTokenResponse declares `expires_in` as required,
|
|
81
|
+
// but that same interface declared `refresh_token` required until Auth0 was observed
|
|
82
|
+
// omitting it, so the field is not trustworthy. A NaN deadline must never reach
|
|
83
|
+
// `tokenExpiresAt` -- `isTokenValid()` compares against it and would return false
|
|
84
|
+
// forever, refreshing on every single call.
|
|
80
85
|
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
86
|
+
const expiresInUsable = Number.isFinite(expiresInDeadline);
|
|
87
|
+
// Neither source usable: a short finite window beats NaN, which would spin the
|
|
88
|
+
// refresh grant on every request and invite Auth0 rate limiting.
|
|
89
|
+
const noInfoFallback = expiresInUsable ? expiresInDeadline : Date.now() + 10 * 60 * 1000;
|
|
81
90
|
try {
|
|
82
91
|
if (!idToken) {
|
|
83
|
-
return
|
|
92
|
+
return noInfoFallback;
|
|
84
93
|
}
|
|
85
94
|
const segments = idToken.split('.');
|
|
86
95
|
if (segments.length !== 3) {
|
|
87
|
-
return
|
|
96
|
+
return noInfoFallback;
|
|
88
97
|
}
|
|
89
98
|
const payloadSegment = segments[1];
|
|
90
99
|
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
91
|
-
return
|
|
100
|
+
return noInfoFallback;
|
|
92
101
|
}
|
|
93
102
|
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
94
103
|
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
95
|
-
return
|
|
104
|
+
return noInfoFallback;
|
|
96
105
|
}
|
|
97
106
|
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
98
107
|
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
99
|
-
return
|
|
108
|
+
return noInfoFallback;
|
|
100
109
|
}
|
|
101
110
|
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
102
111
|
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
103
|
-
return
|
|
112
|
+
return noInfoFallback;
|
|
104
113
|
}
|
|
105
114
|
const expDeadline = exp * 1000;
|
|
106
115
|
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
107
116
|
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
108
117
|
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
109
|
-
return
|
|
118
|
+
return noInfoFallback;
|
|
110
119
|
}
|
|
111
120
|
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
112
121
|
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
113
|
-
|
|
122
|
+
// When expires_in is unusable the exp claim stands alone rather than being
|
|
123
|
+
// discarded by Math.min(valid, NaN) === NaN.
|
|
124
|
+
return expiresInUsable ? Math.min(expDeadline, expiresInDeadline) : expDeadline;
|
|
114
125
|
}
|
|
115
126
|
catch {
|
|
116
|
-
return
|
|
127
|
+
return noInfoFallback;
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
130
|
invalidateToken() {
|
|
@@ -611,6 +622,29 @@ class UserService {
|
|
|
611
622
|
async getActivitySummaries(userId) {
|
|
612
623
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
613
624
|
}
|
|
625
|
+
async getWorkoutActivities(userId, offset = 0, limit = 100) {
|
|
626
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
627
|
+
throw new TonalClientError('Offset must be a non-negative integer');
|
|
628
|
+
}
|
|
629
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 100) {
|
|
630
|
+
throw new TonalClientError('Limit must be an integer between 1 and 100');
|
|
631
|
+
}
|
|
632
|
+
// Verified live: this endpoint uses pg-* headers, unlike /user-workouts.
|
|
633
|
+
return this.httpClient.request(`/users/${userId}/workout-activities`, {
|
|
634
|
+
method: 'GET',
|
|
635
|
+
headers: {
|
|
636
|
+
'pg-offset': offset.toString(),
|
|
637
|
+
'pg-limit': limit.toString(),
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
async getFormattedWorkoutSummary(userId, activityId) {
|
|
642
|
+
const canonicalActivityId = activityId.trim();
|
|
643
|
+
if (!canonicalActivityId) {
|
|
644
|
+
throw new TonalClientError('Workout activity ID is required');
|
|
645
|
+
}
|
|
646
|
+
return this.httpClient.request(`/users/${userId}/workout-summaries/${encodeURIComponent(canonicalActivityId)}`);
|
|
647
|
+
}
|
|
614
648
|
async getUserStatistics(userId) {
|
|
615
649
|
return this.httpClient.request(`/users/${userId}/statistics`);
|
|
616
650
|
}
|
|
@@ -640,6 +674,271 @@ class UserService {
|
|
|
640
674
|
}
|
|
641
675
|
}
|
|
642
676
|
|
|
677
|
+
function parseDate(value, fieldName, endOfDay = false) {
|
|
678
|
+
if (value === undefined) {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
const calendarDateMatch = typeof value === 'string'
|
|
682
|
+
? /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/.exec(value)
|
|
683
|
+
: null;
|
|
684
|
+
if (calendarDateMatch !== null) {
|
|
685
|
+
const year = Number(calendarDateMatch[1]);
|
|
686
|
+
const month = Number(calendarDateMatch[2]);
|
|
687
|
+
const day = Number(calendarDateMatch[3]);
|
|
688
|
+
const roundTrip = new Date(0);
|
|
689
|
+
roundTrip.setUTCHours(0, 0, 0, 0);
|
|
690
|
+
roundTrip.setUTCFullYear(year, month - 1, day);
|
|
691
|
+
if (roundTrip.getUTCFullYear() !== year ||
|
|
692
|
+
roundTrip.getUTCMonth() !== month - 1 ||
|
|
693
|
+
roundTrip.getUTCDate() !== day) {
|
|
694
|
+
throw new TonalClientError(`${fieldName} must be a valid ISO-8601 date or timestamp`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
698
|
+
if (Number.isNaN(timestamp)) {
|
|
699
|
+
throw new TonalClientError(`${fieldName} must be a valid ISO-8601 date or timestamp`);
|
|
700
|
+
}
|
|
701
|
+
const isDateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
702
|
+
return {
|
|
703
|
+
timestamp: endOfDay && isDateOnly ? timestamp + 24 * 60 * 60 * 1000 - 1 : timestamp,
|
|
704
|
+
localDate: isDateOnly ? value : undefined,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function getActivityLocalDate(activity) {
|
|
708
|
+
const localTimestampDate = /^(\d{4}-\d{2}-\d{2})T/.exec(activity.localTimestamp)?.[1];
|
|
709
|
+
try {
|
|
710
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
711
|
+
timeZone: activity.timeZone,
|
|
712
|
+
year: 'numeric',
|
|
713
|
+
month: '2-digit',
|
|
714
|
+
day: '2-digit',
|
|
715
|
+
}).formatToParts(new Date(activity.timestamp));
|
|
716
|
+
const partValues = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
|
717
|
+
if (partValues.year && partValues.month && partValues.day) {
|
|
718
|
+
return `${partValues.year}-${partValues.month}-${partValues.day}`;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// Fall back to Tonal's local timestamp when a historical time zone is unavailable.
|
|
723
|
+
}
|
|
724
|
+
return localTimestampDate;
|
|
725
|
+
}
|
|
726
|
+
function mapSetActivity(set, movement) {
|
|
727
|
+
const totalVolume = set.totalOnMachineVolume ?? set.volume;
|
|
728
|
+
const estimatedAverageResistance = set.repCount !== undefined &&
|
|
729
|
+
set.repCount > 0 &&
|
|
730
|
+
totalVolume !== undefined &&
|
|
731
|
+
totalVolume > 0
|
|
732
|
+
? totalVolume / set.repCount
|
|
733
|
+
: undefined;
|
|
734
|
+
const estimatedOneRepMax = set.oneRepMax !== undefined &&
|
|
735
|
+
set.avgWeight !== undefined &&
|
|
736
|
+
set.avgWeight > 0 &&
|
|
737
|
+
estimatedAverageResistance !== undefined
|
|
738
|
+
? set.oneRepMax * (estimatedAverageResistance / set.avgWeight)
|
|
739
|
+
: undefined;
|
|
740
|
+
const derivedEstimates = estimatedAverageResistance !== undefined || estimatedOneRepMax !== undefined
|
|
741
|
+
? {
|
|
742
|
+
averageResistancePounds: estimatedAverageResistance,
|
|
743
|
+
oneRepMaxPounds: estimatedOneRepMax,
|
|
744
|
+
}
|
|
745
|
+
: undefined;
|
|
746
|
+
return {
|
|
747
|
+
setActivityId: set.id,
|
|
748
|
+
movementId: set.movementId,
|
|
749
|
+
movementName: movement?.name,
|
|
750
|
+
muscleGroups: movement?.muscleGroups,
|
|
751
|
+
accessory: movement?.onMachineInfo?.accessory,
|
|
752
|
+
bilateral: movement?.isBilateral,
|
|
753
|
+
twoSided: movement?.isTwoSided,
|
|
754
|
+
beginTime: set.beginTime,
|
|
755
|
+
endTime: set.endTime,
|
|
756
|
+
durationSeconds: set.duration,
|
|
757
|
+
prescribedReps: set.prescribedReps ?? undefined,
|
|
758
|
+
prescribedDurationSeconds: set.prescribedDuration ?? undefined,
|
|
759
|
+
completedReps: set.repCount,
|
|
760
|
+
repsInReserve: set.repsInReserve ?? undefined,
|
|
761
|
+
repetition: set.repetition ?? undefined,
|
|
762
|
+
repetitionTotal: set.repetitionTotal ?? undefined,
|
|
763
|
+
sideNumber: set.sideNumber ?? undefined,
|
|
764
|
+
movementSide: set.movementSide,
|
|
765
|
+
averageResistancePerCablePounds: set.avgWeight,
|
|
766
|
+
baseResistancePerCablePounds: set.baseWeight ?? undefined,
|
|
767
|
+
minimumResistancePerCablePounds: set.minWeight,
|
|
768
|
+
maximumResistancePerCablePounds: set.maxWeight,
|
|
769
|
+
totalVolumePounds: totalVolume,
|
|
770
|
+
estimatedOneRepMaxPerCablePounds: set.oneRepMax,
|
|
771
|
+
derivedEstimates,
|
|
772
|
+
rangeOfMotionInches: set.romLengthIn,
|
|
773
|
+
maxConcentricPowerWatts: set.maxConPower,
|
|
774
|
+
warmUp: set.warmUp,
|
|
775
|
+
spotter: set.spotter ?? undefined,
|
|
776
|
+
eccentric: set.eccentric,
|
|
777
|
+
chains: set.chains,
|
|
778
|
+
flex: set.flex,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
function mapActivity(activity, detail, movements = new Map()) {
|
|
782
|
+
const exported = {
|
|
783
|
+
activityId: activity.id,
|
|
784
|
+
workoutId: activity.workoutId,
|
|
785
|
+
source: activity.activityType === 'Internal' ? 'tonal' : 'external',
|
|
786
|
+
name: activity.name,
|
|
787
|
+
timestamp: activity.timestamp,
|
|
788
|
+
localTimestamp: activity.localTimestamp,
|
|
789
|
+
timeZone: activity.timeZone,
|
|
790
|
+
targetArea: activity.targetArea,
|
|
791
|
+
workoutType: activity.workoutType,
|
|
792
|
+
level: activity.level,
|
|
793
|
+
durationSeconds: activity.duration,
|
|
794
|
+
timeUnderTensionSeconds: activity.timeUnderTension,
|
|
795
|
+
totalReps: activity.totalReps,
|
|
796
|
+
totalVolumePounds: activity.totalVolume,
|
|
797
|
+
totalWorkKilojoules: activity.totalWork,
|
|
798
|
+
completed: activity.completed,
|
|
799
|
+
guided: activity.isGuidedWorkout,
|
|
800
|
+
inProgram: activity.isInProgram,
|
|
801
|
+
baselineWorkout: activity.isBaselineWorkout,
|
|
802
|
+
};
|
|
803
|
+
if (detail !== undefined) {
|
|
804
|
+
exported.totalSets = detail.totalSets;
|
|
805
|
+
exported.activeDurationSeconds = detail.activeDuration ?? undefined;
|
|
806
|
+
exported.restDurationSeconds = detail.restDuration ?? undefined;
|
|
807
|
+
exported.percentCompleted = detail.percentCompleted ?? undefined;
|
|
808
|
+
exported.sets = (detail.workoutSetActivity ?? []).map(set => mapSetActivity(set, movements.get(set.movementId)));
|
|
809
|
+
}
|
|
810
|
+
return exported;
|
|
811
|
+
}
|
|
812
|
+
function sanitizeMuscleReadiness(readiness) {
|
|
813
|
+
return {
|
|
814
|
+
Chest: readiness.Chest,
|
|
815
|
+
Shoulders: readiness.Shoulders,
|
|
816
|
+
Back: readiness.Back,
|
|
817
|
+
Triceps: readiness.Triceps,
|
|
818
|
+
Biceps: readiness.Biceps,
|
|
819
|
+
Abs: readiness.Abs,
|
|
820
|
+
Obliques: readiness.Obliques,
|
|
821
|
+
Quads: readiness.Quads,
|
|
822
|
+
Glutes: readiness.Glutes,
|
|
823
|
+
Hamstrings: readiness.Hamstrings,
|
|
824
|
+
Calves: readiness.Calves,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function sanitizeLifetimeStatistics(statistics) {
|
|
828
|
+
return {
|
|
829
|
+
volume: {
|
|
830
|
+
total: statistics.volume.total,
|
|
831
|
+
maxVolumeInWorkout: statistics.volume.maxVolumeInWorkout,
|
|
832
|
+
maxVolumeInAWeek: statistics.volume.maxVolumeInAWeek,
|
|
833
|
+
avgVolumePerWorkout: statistics.volume.avgVolumePerWorkout,
|
|
834
|
+
avgVolumePerWeek: statistics.volume.avgVolumePerWeek,
|
|
835
|
+
},
|
|
836
|
+
workouts: {
|
|
837
|
+
total: statistics.workouts.total,
|
|
838
|
+
maxWorkoutDuration: statistics.workouts.maxWorkoutDuration,
|
|
839
|
+
avgWorkoutDuration: statistics.workouts.avgWorkoutDuration,
|
|
840
|
+
totalDuration: statistics.workouts.totalDuration,
|
|
841
|
+
totalTimeUnderTension: statistics.workouts.totalTimeUnderTension,
|
|
842
|
+
maxWorkoutsPerWeek: statistics.workouts.maxWorkoutsPerWeek,
|
|
843
|
+
avgWorkoutsPerWeek: statistics.workouts.avgWorkoutsPerWeek,
|
|
844
|
+
totalFreeliftWorkouts: statistics.workouts.totalFreeliftWorkouts,
|
|
845
|
+
totalCustomWorkouts: statistics.workouts.totalCustomWorkouts,
|
|
846
|
+
},
|
|
847
|
+
movements: {
|
|
848
|
+
total: statistics.movements.total,
|
|
849
|
+
movementIds: statistics.movements.movementIds.filter(movementId => typeof movementId === 'string'),
|
|
850
|
+
},
|
|
851
|
+
programs: {
|
|
852
|
+
total: statistics.programs.total,
|
|
853
|
+
totalProgramVolume: statistics.programs.totalProgramVolume,
|
|
854
|
+
totalProgramWorkouts: statistics.programs.totalProgramWorkouts,
|
|
855
|
+
totalDuration: statistics.programs.totalDuration,
|
|
856
|
+
programSummaries: null,
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Build a compact, privacy-conscious health export from Tonal API data.
|
|
862
|
+
*
|
|
863
|
+
* The export intentionally excludes profile details, account identifiers,
|
|
864
|
+
* device identifiers, application versions, and authentication data.
|
|
865
|
+
*/
|
|
866
|
+
function buildHealthExport(source, options = {}, exportedAt = new Date()) {
|
|
867
|
+
const startTimestamp = parseDate(options.startDate, 'startDate');
|
|
868
|
+
const endTimestamp = parseDate(options.endDate, 'endDate', true);
|
|
869
|
+
if (startTimestamp !== undefined &&
|
|
870
|
+
endTimestamp !== undefined &&
|
|
871
|
+
(startTimestamp.localDate === undefined) ===
|
|
872
|
+
(endTimestamp.localDate === undefined) &&
|
|
873
|
+
startTimestamp.timestamp > endTimestamp.timestamp) {
|
|
874
|
+
throw new TonalClientError('startDate must be before or equal to endDate');
|
|
875
|
+
}
|
|
876
|
+
if (options.limit !== undefined &&
|
|
877
|
+
(!Number.isInteger(options.limit) || options.limit <= 0)) {
|
|
878
|
+
throw new TonalClientError('limit must be a positive integer');
|
|
879
|
+
}
|
|
880
|
+
const details = new Map((source.activityDetails ?? []).map(detail => [detail.id, detail]));
|
|
881
|
+
const movements = new Map((source.movements ?? []).map(movement => [movement.id, movement]));
|
|
882
|
+
const activities = source.activities
|
|
883
|
+
.filter(activity => {
|
|
884
|
+
const timestamp = Date.parse(activity.timestamp);
|
|
885
|
+
const localDate = startTimestamp?.localDate !== undefined || endTimestamp?.localDate !== undefined
|
|
886
|
+
? getActivityLocalDate(activity)
|
|
887
|
+
: undefined;
|
|
888
|
+
const afterStart = startTimestamp === undefined ||
|
|
889
|
+
(startTimestamp.localDate !== undefined
|
|
890
|
+
? localDate !== undefined && localDate >= startTimestamp.localDate
|
|
891
|
+
: timestamp >= startTimestamp.timestamp);
|
|
892
|
+
const beforeEnd = endTimestamp === undefined ||
|
|
893
|
+
(endTimestamp.localDate !== undefined
|
|
894
|
+
? localDate !== undefined && localDate <= endTimestamp.localDate
|
|
895
|
+
: timestamp <= endTimestamp.timestamp);
|
|
896
|
+
return ((options.includeExternalActivities === true ||
|
|
897
|
+
activity.activityType === 'Internal') &&
|
|
898
|
+
!Number.isNaN(timestamp) &&
|
|
899
|
+
afterStart &&
|
|
900
|
+
beforeEnd);
|
|
901
|
+
})
|
|
902
|
+
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
|
|
903
|
+
.slice(0, options.limit)
|
|
904
|
+
.map(activity => mapActivity(activity, details.get(activity.id), movements));
|
|
905
|
+
const summary = activities.reduce((totals, activity) => ({
|
|
906
|
+
workoutCount: totals.workoutCount + 1,
|
|
907
|
+
completedWorkoutCount: totals.completedWorkoutCount + (activity.completed ? 1 : 0),
|
|
908
|
+
totalDurationSeconds: totals.totalDurationSeconds + activity.durationSeconds,
|
|
909
|
+
totalTimeUnderTensionSeconds: totals.totalTimeUnderTensionSeconds + activity.timeUnderTensionSeconds,
|
|
910
|
+
totalReps: totals.totalReps + activity.totalReps,
|
|
911
|
+
totalVolumePounds: totals.totalVolumePounds + activity.totalVolumePounds,
|
|
912
|
+
totalWorkKilojoules: totals.totalWorkKilojoules + activity.totalWorkKilojoules,
|
|
913
|
+
}), {
|
|
914
|
+
workoutCount: 0,
|
|
915
|
+
completedWorkoutCount: 0,
|
|
916
|
+
totalDurationSeconds: 0,
|
|
917
|
+
totalTimeUnderTensionSeconds: 0,
|
|
918
|
+
totalReps: 0,
|
|
919
|
+
totalVolumePounds: 0,
|
|
920
|
+
totalWorkKilojoules: 0,
|
|
921
|
+
});
|
|
922
|
+
const exportData = {
|
|
923
|
+
schemaVersion: 1,
|
|
924
|
+
exportedAt: exportedAt.toISOString(),
|
|
925
|
+
period: {
|
|
926
|
+
start: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
|
|
927
|
+
end: activities.length > 0 ? activities[0].timestamp : null,
|
|
928
|
+
},
|
|
929
|
+
summary,
|
|
930
|
+
activities,
|
|
931
|
+
};
|
|
932
|
+
if (source.muscleReadiness !== undefined) {
|
|
933
|
+
exportData.muscleReadiness = sanitizeMuscleReadiness(source.muscleReadiness);
|
|
934
|
+
}
|
|
935
|
+
if (source.lifetimeStatistics !== undefined) {
|
|
936
|
+
exportData.lifetimeStatistics = sanitizeLifetimeStatistics(source.lifetimeStatistics);
|
|
937
|
+
}
|
|
938
|
+
return exportData;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const MAX_WORKOUT_ACTIVITY_PAGES = 1000;
|
|
643
942
|
class TonalClient {
|
|
644
943
|
constructor(username, password, cacheDir) {
|
|
645
944
|
this.authManager = new AuthManager(username, password);
|
|
@@ -749,6 +1048,40 @@ class TonalClient {
|
|
|
749
1048
|
const userInfo = await this.getUserInfo();
|
|
750
1049
|
return this.userService.getActivitySummaries(userInfo.id);
|
|
751
1050
|
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Get a page of completed workout activities including performed set data.
|
|
1053
|
+
*/
|
|
1054
|
+
async getWorkoutActivities(offset = 0, limit = 100) {
|
|
1055
|
+
const userInfo = await this.getUserInfo();
|
|
1056
|
+
return this.userService.getWorkoutActivities(userInfo.id, offset, limit);
|
|
1057
|
+
}
|
|
1058
|
+
/** Get every completed Tonal workout activity using paginated requests. */
|
|
1059
|
+
async getAllWorkoutActivities(pageSize = 100) {
|
|
1060
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0 || pageSize > 100) {
|
|
1061
|
+
throw new TonalClientError('Page size must be an integer between 1 and 100');
|
|
1062
|
+
}
|
|
1063
|
+
const activities = [];
|
|
1064
|
+
await this.paginateWorkoutActivities(pageSize, page => {
|
|
1065
|
+
activities.push(...page);
|
|
1066
|
+
return false;
|
|
1067
|
+
});
|
|
1068
|
+
return activities;
|
|
1069
|
+
}
|
|
1070
|
+
async getFormattedWorkoutSummary(activityId) {
|
|
1071
|
+
const userInfo = await this.getUserInfo();
|
|
1072
|
+
return this.userService.getFormattedWorkoutSummary(userInfo.id, activityId);
|
|
1073
|
+
}
|
|
1074
|
+
async getFormattedWorkoutSummaries(activityIds, batchSize = 5) {
|
|
1075
|
+
if (!Number.isInteger(batchSize) || batchSize <= 0) {
|
|
1076
|
+
throw new Error('Batch size must be a positive integer');
|
|
1077
|
+
}
|
|
1078
|
+
const userInfo = await this.getUserInfo();
|
|
1079
|
+
const summaries = [];
|
|
1080
|
+
for (let index = 0; index < activityIds.length; index += batchSize) {
|
|
1081
|
+
summaries.push(...(await Promise.all(activityIds.slice(index, index + batchSize).map(activityId => this.userService.getFormattedWorkoutSummary(userInfo.id, activityId)))));
|
|
1082
|
+
}
|
|
1083
|
+
return summaries;
|
|
1084
|
+
}
|
|
752
1085
|
async getUserStatistics() {
|
|
753
1086
|
const userInfo = await this.getUserInfo();
|
|
754
1087
|
return this.userService.getUserStatistics(userInfo.id);
|
|
@@ -780,6 +1113,89 @@ class TonalClient {
|
|
|
780
1113
|
const userInfo = await this.getUserInfo();
|
|
781
1114
|
return this.userService.getMetricScores(userInfo.id, startWeek);
|
|
782
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Create a compact export intended for health analysis and data portability.
|
|
1118
|
+
*
|
|
1119
|
+
* The export excludes profile, device, and authentication details. Activities
|
|
1120
|
+
* can be filtered by date and limited, and optional readiness and lifetime
|
|
1121
|
+
* statistics can be omitted when a smaller data set is preferred.
|
|
1122
|
+
*/
|
|
1123
|
+
async getHealthExport(options = {}) {
|
|
1124
|
+
const includeMuscleReadiness = options.includeMuscleReadiness ?? true;
|
|
1125
|
+
const includeLifetimeStatistics = options.includeLifetimeStatistics ?? true;
|
|
1126
|
+
const [activities, muscleReadiness, lifetimeStatistics] = await Promise.all([
|
|
1127
|
+
this.getActivitySummaries(),
|
|
1128
|
+
includeMuscleReadiness ? this.getMuscleReadiness() : Promise.resolve(undefined),
|
|
1129
|
+
includeLifetimeStatistics ? this.getUserStatistics() : Promise.resolve(undefined),
|
|
1130
|
+
]);
|
|
1131
|
+
const exportedAt = new Date();
|
|
1132
|
+
const initialExport = buildHealthExport({
|
|
1133
|
+
activities,
|
|
1134
|
+
muscleReadiness,
|
|
1135
|
+
lifetimeStatistics,
|
|
1136
|
+
}, options, exportedAt);
|
|
1137
|
+
if (!options.includeSetDetails || initialExport.activities.length === 0) {
|
|
1138
|
+
return initialExport;
|
|
1139
|
+
}
|
|
1140
|
+
const tonalActivityIds = initialExport.activities
|
|
1141
|
+
.filter(activity => activity.source === 'tonal')
|
|
1142
|
+
.map(activity => activity.activityId);
|
|
1143
|
+
if (tonalActivityIds.length === 0) {
|
|
1144
|
+
return initialExport;
|
|
1145
|
+
}
|
|
1146
|
+
const [activityDetails, movements] = await Promise.all([
|
|
1147
|
+
this.getWorkoutActivityDetails(tonalActivityIds),
|
|
1148
|
+
this.getMovements(),
|
|
1149
|
+
]);
|
|
1150
|
+
return buildHealthExport({
|
|
1151
|
+
activities,
|
|
1152
|
+
muscleReadiness,
|
|
1153
|
+
lifetimeStatistics,
|
|
1154
|
+
activityDetails,
|
|
1155
|
+
movements,
|
|
1156
|
+
}, options, exportedAt);
|
|
1157
|
+
}
|
|
1158
|
+
async paginateWorkoutActivities(pageSize, visitActivities) {
|
|
1159
|
+
const userInfo = await this.getUserInfo();
|
|
1160
|
+
const seenIds = new Set();
|
|
1161
|
+
for (let pageNumber = 0, offset = 0;; pageNumber += 1) {
|
|
1162
|
+
const page = await this.userService.getWorkoutActivities(userInfo.id, offset, pageSize);
|
|
1163
|
+
const newActivities = page.filter(activity => {
|
|
1164
|
+
if (seenIds.has(activity.id)) {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
seenIds.add(activity.id);
|
|
1168
|
+
return true;
|
|
1169
|
+
});
|
|
1170
|
+
if (visitActivities(newActivities) || page.length < pageSize) {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (newActivities.length === 0) {
|
|
1174
|
+
throw new TonalClientError(`Workout activity pagination did not advance at offset ${offset}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (pageNumber + 1 >= MAX_WORKOUT_ACTIVITY_PAGES) {
|
|
1177
|
+
throw new TonalClientError(`Workout activity pagination exceeded the ${MAX_WORKOUT_ACTIVITY_PAGES}-page safety limit`);
|
|
1178
|
+
}
|
|
1179
|
+
const nextOffset = offset + pageSize;
|
|
1180
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset <= offset) {
|
|
1181
|
+
throw new TonalClientError(`Workout activity pagination could not advance beyond offset ${offset}`);
|
|
1182
|
+
}
|
|
1183
|
+
offset = nextOffset;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async getWorkoutActivityDetails(activityIds) {
|
|
1187
|
+
const requestedIds = new Set(activityIds);
|
|
1188
|
+
const details = [];
|
|
1189
|
+
await this.paginateWorkoutActivities(100, activities => {
|
|
1190
|
+
for (const activity of activities) {
|
|
1191
|
+
if (requestedIds.delete(activity.id)) {
|
|
1192
|
+
details.push(activity);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return requestedIds.size === 0;
|
|
1196
|
+
});
|
|
1197
|
+
return details;
|
|
1198
|
+
}
|
|
783
1199
|
// Workout operations
|
|
784
1200
|
async getUserWorkouts(offset = 0, limit = 50) {
|
|
785
1201
|
return this.workoutService.getUserWorkouts(offset, limit);
|
|
@@ -811,4 +1227,5 @@ class TonalClient {
|
|
|
811
1227
|
|
|
812
1228
|
exports.TonalClient = TonalClient;
|
|
813
1229
|
exports.TonalClientError = TonalClientError;
|
|
1230
|
+
exports.buildHealthExport = buildHealthExport;
|
|
814
1231
|
exports.default = TonalClient;
|