@dlwiest/ts-tonal-client 0.5.1 โ†’ 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 CHANGED
@@ -3,20 +3,53 @@
3
3
  [![npm version](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client.svg)](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@dlwiest/ts-tonal-client.svg)](https://www.npmjs.com/package/@dlwiest/ts-tonal-client)
5
5
 
6
- A comprehensive TypeScript client for accessing Tonal's API. This library provides a robust interface to retrieve workout data, user information, movements, and more from your Tonal account.
7
-
8
- ## Features
9
-
10
- - ๐Ÿ‹๏ธ **Complete Workout Management** - Get, create, estimate, and share workouts including daily lifts
11
- - ๐Ÿ‘ค **User Management** - Access user info, goals, and preferences
12
- - ๐Ÿ’ช **Movement Database** - Browse all available Tonal movements
13
- - ๐ŸŽฏ **Muscle Readiness Tracking** - Monitor recovery status for all muscle groups
14
- - ๐Ÿ“‹ **Program Details** - Get comprehensive information about training programs
15
- - ๐ŸŽฏ **Target Score Tracking** - Get weekly fitness targets and progress ranges for all metrics
16
- - ๐Ÿ“ˆ **Metric Score Analysis** - Track actual performance vs targets with comprehensive analytics
17
- - ๐Ÿ›ก๏ธ **Enterprise-Grade Reliability** - Built-in error handling, retries, and timeouts
18
- - ๐Ÿ“ **Full TypeScript Support** - Comprehensive types for all API responses
19
- - ๐Ÿ”„ **Smart Token Management** - Automatic authentication and token refresh
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
@@ -622,6 +622,29 @@ class UserService {
622
622
  async getActivitySummaries(userId) {
623
623
  return this.httpClient.request(`/users/${userId}/activity-summaries`);
624
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
+ }
625
648
  async getUserStatistics(userId) {
626
649
  return this.httpClient.request(`/users/${userId}/statistics`);
627
650
  }
@@ -651,6 +674,271 @@ class UserService {
651
674
  }
652
675
  }
653
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;
654
942
  class TonalClient {
655
943
  constructor(username, password, cacheDir) {
656
944
  this.authManager = new AuthManager(username, password);
@@ -760,6 +1048,40 @@ class TonalClient {
760
1048
  const userInfo = await this.getUserInfo();
761
1049
  return this.userService.getActivitySummaries(userInfo.id);
762
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
+ }
763
1085
  async getUserStatistics() {
764
1086
  const userInfo = await this.getUserInfo();
765
1087
  return this.userService.getUserStatistics(userInfo.id);
@@ -791,6 +1113,89 @@ class TonalClient {
791
1113
  const userInfo = await this.getUserInfo();
792
1114
  return this.userService.getMetricScores(userInfo.id, startWeek);
793
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
+ }
794
1199
  // Workout operations
795
1200
  async getUserWorkouts(offset = 0, limit = 50) {
796
1201
  return this.workoutService.getUserWorkouts(offset, limit);
@@ -822,4 +1227,5 @@ class TonalClient {
822
1227
 
823
1228
  exports.TonalClient = TonalClient;
824
1229
  exports.TonalClientError = TonalClientError;
1230
+ exports.buildHealthExport = buildHealthExport;
825
1231
  exports.default = TonalClient;