@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/dist/index.esm.js CHANGED
@@ -618,6 +618,29 @@ class UserService {
618
618
  async getActivitySummaries(userId) {
619
619
  return this.httpClient.request(`/users/${userId}/activity-summaries`);
620
620
  }
621
+ async getWorkoutActivities(userId, offset = 0, limit = 100) {
622
+ if (!Number.isInteger(offset) || offset < 0) {
623
+ throw new TonalClientError('Offset must be a non-negative integer');
624
+ }
625
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 100) {
626
+ throw new TonalClientError('Limit must be an integer between 1 and 100');
627
+ }
628
+ // Verified live: this endpoint uses pg-* headers, unlike /user-workouts.
629
+ return this.httpClient.request(`/users/${userId}/workout-activities`, {
630
+ method: 'GET',
631
+ headers: {
632
+ 'pg-offset': offset.toString(),
633
+ 'pg-limit': limit.toString(),
634
+ },
635
+ });
636
+ }
637
+ async getFormattedWorkoutSummary(userId, activityId) {
638
+ const canonicalActivityId = activityId.trim();
639
+ if (!canonicalActivityId) {
640
+ throw new TonalClientError('Workout activity ID is required');
641
+ }
642
+ return this.httpClient.request(`/users/${userId}/workout-summaries/${encodeURIComponent(canonicalActivityId)}`);
643
+ }
621
644
  async getUserStatistics(userId) {
622
645
  return this.httpClient.request(`/users/${userId}/statistics`);
623
646
  }
@@ -647,6 +670,271 @@ class UserService {
647
670
  }
648
671
  }
649
672
 
673
+ function parseDate(value, fieldName, endOfDay = false) {
674
+ if (value === undefined) {
675
+ return undefined;
676
+ }
677
+ const calendarDateMatch = typeof value === 'string'
678
+ ? /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/.exec(value)
679
+ : null;
680
+ if (calendarDateMatch !== null) {
681
+ const year = Number(calendarDateMatch[1]);
682
+ const month = Number(calendarDateMatch[2]);
683
+ const day = Number(calendarDateMatch[3]);
684
+ const roundTrip = new Date(0);
685
+ roundTrip.setUTCHours(0, 0, 0, 0);
686
+ roundTrip.setUTCFullYear(year, month - 1, day);
687
+ if (roundTrip.getUTCFullYear() !== year ||
688
+ roundTrip.getUTCMonth() !== month - 1 ||
689
+ roundTrip.getUTCDate() !== day) {
690
+ throw new TonalClientError(`${fieldName} must be a valid ISO-8601 date or timestamp`);
691
+ }
692
+ }
693
+ const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);
694
+ if (Number.isNaN(timestamp)) {
695
+ throw new TonalClientError(`${fieldName} must be a valid ISO-8601 date or timestamp`);
696
+ }
697
+ const isDateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
698
+ return {
699
+ timestamp: endOfDay && isDateOnly ? timestamp + 24 * 60 * 60 * 1000 - 1 : timestamp,
700
+ localDate: isDateOnly ? value : undefined,
701
+ };
702
+ }
703
+ function getActivityLocalDate(activity) {
704
+ const localTimestampDate = /^(\d{4}-\d{2}-\d{2})T/.exec(activity.localTimestamp)?.[1];
705
+ try {
706
+ const parts = new Intl.DateTimeFormat('en-US', {
707
+ timeZone: activity.timeZone,
708
+ year: 'numeric',
709
+ month: '2-digit',
710
+ day: '2-digit',
711
+ }).formatToParts(new Date(activity.timestamp));
712
+ const partValues = Object.fromEntries(parts.map(part => [part.type, part.value]));
713
+ if (partValues.year && partValues.month && partValues.day) {
714
+ return `${partValues.year}-${partValues.month}-${partValues.day}`;
715
+ }
716
+ }
717
+ catch {
718
+ // Fall back to Tonal's local timestamp when a historical time zone is unavailable.
719
+ }
720
+ return localTimestampDate;
721
+ }
722
+ function mapSetActivity(set, movement) {
723
+ const totalVolume = set.totalOnMachineVolume ?? set.volume;
724
+ const estimatedAverageResistance = set.repCount !== undefined &&
725
+ set.repCount > 0 &&
726
+ totalVolume !== undefined &&
727
+ totalVolume > 0
728
+ ? totalVolume / set.repCount
729
+ : undefined;
730
+ const estimatedOneRepMax = set.oneRepMax !== undefined &&
731
+ set.avgWeight !== undefined &&
732
+ set.avgWeight > 0 &&
733
+ estimatedAverageResistance !== undefined
734
+ ? set.oneRepMax * (estimatedAverageResistance / set.avgWeight)
735
+ : undefined;
736
+ const derivedEstimates = estimatedAverageResistance !== undefined || estimatedOneRepMax !== undefined
737
+ ? {
738
+ averageResistancePounds: estimatedAverageResistance,
739
+ oneRepMaxPounds: estimatedOneRepMax,
740
+ }
741
+ : undefined;
742
+ return {
743
+ setActivityId: set.id,
744
+ movementId: set.movementId,
745
+ movementName: movement?.name,
746
+ muscleGroups: movement?.muscleGroups,
747
+ accessory: movement?.onMachineInfo?.accessory,
748
+ bilateral: movement?.isBilateral,
749
+ twoSided: movement?.isTwoSided,
750
+ beginTime: set.beginTime,
751
+ endTime: set.endTime,
752
+ durationSeconds: set.duration,
753
+ prescribedReps: set.prescribedReps ?? undefined,
754
+ prescribedDurationSeconds: set.prescribedDuration ?? undefined,
755
+ completedReps: set.repCount,
756
+ repsInReserve: set.repsInReserve ?? undefined,
757
+ repetition: set.repetition ?? undefined,
758
+ repetitionTotal: set.repetitionTotal ?? undefined,
759
+ sideNumber: set.sideNumber ?? undefined,
760
+ movementSide: set.movementSide,
761
+ averageResistancePerCablePounds: set.avgWeight,
762
+ baseResistancePerCablePounds: set.baseWeight ?? undefined,
763
+ minimumResistancePerCablePounds: set.minWeight,
764
+ maximumResistancePerCablePounds: set.maxWeight,
765
+ totalVolumePounds: totalVolume,
766
+ estimatedOneRepMaxPerCablePounds: set.oneRepMax,
767
+ derivedEstimates,
768
+ rangeOfMotionInches: set.romLengthIn,
769
+ maxConcentricPowerWatts: set.maxConPower,
770
+ warmUp: set.warmUp,
771
+ spotter: set.spotter ?? undefined,
772
+ eccentric: set.eccentric,
773
+ chains: set.chains,
774
+ flex: set.flex,
775
+ };
776
+ }
777
+ function mapActivity(activity, detail, movements = new Map()) {
778
+ const exported = {
779
+ activityId: activity.id,
780
+ workoutId: activity.workoutId,
781
+ source: activity.activityType === 'Internal' ? 'tonal' : 'external',
782
+ name: activity.name,
783
+ timestamp: activity.timestamp,
784
+ localTimestamp: activity.localTimestamp,
785
+ timeZone: activity.timeZone,
786
+ targetArea: activity.targetArea,
787
+ workoutType: activity.workoutType,
788
+ level: activity.level,
789
+ durationSeconds: activity.duration,
790
+ timeUnderTensionSeconds: activity.timeUnderTension,
791
+ totalReps: activity.totalReps,
792
+ totalVolumePounds: activity.totalVolume,
793
+ totalWorkKilojoules: activity.totalWork,
794
+ completed: activity.completed,
795
+ guided: activity.isGuidedWorkout,
796
+ inProgram: activity.isInProgram,
797
+ baselineWorkout: activity.isBaselineWorkout,
798
+ };
799
+ if (detail !== undefined) {
800
+ exported.totalSets = detail.totalSets;
801
+ exported.activeDurationSeconds = detail.activeDuration ?? undefined;
802
+ exported.restDurationSeconds = detail.restDuration ?? undefined;
803
+ exported.percentCompleted = detail.percentCompleted ?? undefined;
804
+ exported.sets = (detail.workoutSetActivity ?? []).map(set => mapSetActivity(set, movements.get(set.movementId)));
805
+ }
806
+ return exported;
807
+ }
808
+ function sanitizeMuscleReadiness(readiness) {
809
+ return {
810
+ Chest: readiness.Chest,
811
+ Shoulders: readiness.Shoulders,
812
+ Back: readiness.Back,
813
+ Triceps: readiness.Triceps,
814
+ Biceps: readiness.Biceps,
815
+ Abs: readiness.Abs,
816
+ Obliques: readiness.Obliques,
817
+ Quads: readiness.Quads,
818
+ Glutes: readiness.Glutes,
819
+ Hamstrings: readiness.Hamstrings,
820
+ Calves: readiness.Calves,
821
+ };
822
+ }
823
+ function sanitizeLifetimeStatistics(statistics) {
824
+ return {
825
+ volume: {
826
+ total: statistics.volume.total,
827
+ maxVolumeInWorkout: statistics.volume.maxVolumeInWorkout,
828
+ maxVolumeInAWeek: statistics.volume.maxVolumeInAWeek,
829
+ avgVolumePerWorkout: statistics.volume.avgVolumePerWorkout,
830
+ avgVolumePerWeek: statistics.volume.avgVolumePerWeek,
831
+ },
832
+ workouts: {
833
+ total: statistics.workouts.total,
834
+ maxWorkoutDuration: statistics.workouts.maxWorkoutDuration,
835
+ avgWorkoutDuration: statistics.workouts.avgWorkoutDuration,
836
+ totalDuration: statistics.workouts.totalDuration,
837
+ totalTimeUnderTension: statistics.workouts.totalTimeUnderTension,
838
+ maxWorkoutsPerWeek: statistics.workouts.maxWorkoutsPerWeek,
839
+ avgWorkoutsPerWeek: statistics.workouts.avgWorkoutsPerWeek,
840
+ totalFreeliftWorkouts: statistics.workouts.totalFreeliftWorkouts,
841
+ totalCustomWorkouts: statistics.workouts.totalCustomWorkouts,
842
+ },
843
+ movements: {
844
+ total: statistics.movements.total,
845
+ movementIds: statistics.movements.movementIds.filter(movementId => typeof movementId === 'string'),
846
+ },
847
+ programs: {
848
+ total: statistics.programs.total,
849
+ totalProgramVolume: statistics.programs.totalProgramVolume,
850
+ totalProgramWorkouts: statistics.programs.totalProgramWorkouts,
851
+ totalDuration: statistics.programs.totalDuration,
852
+ programSummaries: null,
853
+ },
854
+ };
855
+ }
856
+ /**
857
+ * Build a compact, privacy-conscious health export from Tonal API data.
858
+ *
859
+ * The export intentionally excludes profile details, account identifiers,
860
+ * device identifiers, application versions, and authentication data.
861
+ */
862
+ function buildHealthExport(source, options = {}, exportedAt = new Date()) {
863
+ const startTimestamp = parseDate(options.startDate, 'startDate');
864
+ const endTimestamp = parseDate(options.endDate, 'endDate', true);
865
+ if (startTimestamp !== undefined &&
866
+ endTimestamp !== undefined &&
867
+ (startTimestamp.localDate === undefined) ===
868
+ (endTimestamp.localDate === undefined) &&
869
+ startTimestamp.timestamp > endTimestamp.timestamp) {
870
+ throw new TonalClientError('startDate must be before or equal to endDate');
871
+ }
872
+ if (options.limit !== undefined &&
873
+ (!Number.isInteger(options.limit) || options.limit <= 0)) {
874
+ throw new TonalClientError('limit must be a positive integer');
875
+ }
876
+ const details = new Map((source.activityDetails ?? []).map(detail => [detail.id, detail]));
877
+ const movements = new Map((source.movements ?? []).map(movement => [movement.id, movement]));
878
+ const activities = source.activities
879
+ .filter(activity => {
880
+ const timestamp = Date.parse(activity.timestamp);
881
+ const localDate = startTimestamp?.localDate !== undefined || endTimestamp?.localDate !== undefined
882
+ ? getActivityLocalDate(activity)
883
+ : undefined;
884
+ const afterStart = startTimestamp === undefined ||
885
+ (startTimestamp.localDate !== undefined
886
+ ? localDate !== undefined && localDate >= startTimestamp.localDate
887
+ : timestamp >= startTimestamp.timestamp);
888
+ const beforeEnd = endTimestamp === undefined ||
889
+ (endTimestamp.localDate !== undefined
890
+ ? localDate !== undefined && localDate <= endTimestamp.localDate
891
+ : timestamp <= endTimestamp.timestamp);
892
+ return ((options.includeExternalActivities === true ||
893
+ activity.activityType === 'Internal') &&
894
+ !Number.isNaN(timestamp) &&
895
+ afterStart &&
896
+ beforeEnd);
897
+ })
898
+ .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
899
+ .slice(0, options.limit)
900
+ .map(activity => mapActivity(activity, details.get(activity.id), movements));
901
+ const summary = activities.reduce((totals, activity) => ({
902
+ workoutCount: totals.workoutCount + 1,
903
+ completedWorkoutCount: totals.completedWorkoutCount + (activity.completed ? 1 : 0),
904
+ totalDurationSeconds: totals.totalDurationSeconds + activity.durationSeconds,
905
+ totalTimeUnderTensionSeconds: totals.totalTimeUnderTensionSeconds + activity.timeUnderTensionSeconds,
906
+ totalReps: totals.totalReps + activity.totalReps,
907
+ totalVolumePounds: totals.totalVolumePounds + activity.totalVolumePounds,
908
+ totalWorkKilojoules: totals.totalWorkKilojoules + activity.totalWorkKilojoules,
909
+ }), {
910
+ workoutCount: 0,
911
+ completedWorkoutCount: 0,
912
+ totalDurationSeconds: 0,
913
+ totalTimeUnderTensionSeconds: 0,
914
+ totalReps: 0,
915
+ totalVolumePounds: 0,
916
+ totalWorkKilojoules: 0,
917
+ });
918
+ const exportData = {
919
+ schemaVersion: 1,
920
+ exportedAt: exportedAt.toISOString(),
921
+ period: {
922
+ start: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
923
+ end: activities.length > 0 ? activities[0].timestamp : null,
924
+ },
925
+ summary,
926
+ activities,
927
+ };
928
+ if (source.muscleReadiness !== undefined) {
929
+ exportData.muscleReadiness = sanitizeMuscleReadiness(source.muscleReadiness);
930
+ }
931
+ if (source.lifetimeStatistics !== undefined) {
932
+ exportData.lifetimeStatistics = sanitizeLifetimeStatistics(source.lifetimeStatistics);
933
+ }
934
+ return exportData;
935
+ }
936
+
937
+ const MAX_WORKOUT_ACTIVITY_PAGES = 1000;
650
938
  class TonalClient {
651
939
  constructor(username, password, cacheDir) {
652
940
  this.authManager = new AuthManager(username, password);
@@ -756,6 +1044,40 @@ class TonalClient {
756
1044
  const userInfo = await this.getUserInfo();
757
1045
  return this.userService.getActivitySummaries(userInfo.id);
758
1046
  }
1047
+ /**
1048
+ * Get a page of completed workout activities including performed set data.
1049
+ */
1050
+ async getWorkoutActivities(offset = 0, limit = 100) {
1051
+ const userInfo = await this.getUserInfo();
1052
+ return this.userService.getWorkoutActivities(userInfo.id, offset, limit);
1053
+ }
1054
+ /** Get every completed Tonal workout activity using paginated requests. */
1055
+ async getAllWorkoutActivities(pageSize = 100) {
1056
+ if (!Number.isInteger(pageSize) || pageSize <= 0 || pageSize > 100) {
1057
+ throw new TonalClientError('Page size must be an integer between 1 and 100');
1058
+ }
1059
+ const activities = [];
1060
+ await this.paginateWorkoutActivities(pageSize, page => {
1061
+ activities.push(...page);
1062
+ return false;
1063
+ });
1064
+ return activities;
1065
+ }
1066
+ async getFormattedWorkoutSummary(activityId) {
1067
+ const userInfo = await this.getUserInfo();
1068
+ return this.userService.getFormattedWorkoutSummary(userInfo.id, activityId);
1069
+ }
1070
+ async getFormattedWorkoutSummaries(activityIds, batchSize = 5) {
1071
+ if (!Number.isInteger(batchSize) || batchSize <= 0) {
1072
+ throw new Error('Batch size must be a positive integer');
1073
+ }
1074
+ const userInfo = await this.getUserInfo();
1075
+ const summaries = [];
1076
+ for (let index = 0; index < activityIds.length; index += batchSize) {
1077
+ summaries.push(...(await Promise.all(activityIds.slice(index, index + batchSize).map(activityId => this.userService.getFormattedWorkoutSummary(userInfo.id, activityId)))));
1078
+ }
1079
+ return summaries;
1080
+ }
759
1081
  async getUserStatistics() {
760
1082
  const userInfo = await this.getUserInfo();
761
1083
  return this.userService.getUserStatistics(userInfo.id);
@@ -787,6 +1109,89 @@ class TonalClient {
787
1109
  const userInfo = await this.getUserInfo();
788
1110
  return this.userService.getMetricScores(userInfo.id, startWeek);
789
1111
  }
1112
+ /**
1113
+ * Create a compact export intended for health analysis and data portability.
1114
+ *
1115
+ * The export excludes profile, device, and authentication details. Activities
1116
+ * can be filtered by date and limited, and optional readiness and lifetime
1117
+ * statistics can be omitted when a smaller data set is preferred.
1118
+ */
1119
+ async getHealthExport(options = {}) {
1120
+ const includeMuscleReadiness = options.includeMuscleReadiness ?? true;
1121
+ const includeLifetimeStatistics = options.includeLifetimeStatistics ?? true;
1122
+ const [activities, muscleReadiness, lifetimeStatistics] = await Promise.all([
1123
+ this.getActivitySummaries(),
1124
+ includeMuscleReadiness ? this.getMuscleReadiness() : Promise.resolve(undefined),
1125
+ includeLifetimeStatistics ? this.getUserStatistics() : Promise.resolve(undefined),
1126
+ ]);
1127
+ const exportedAt = new Date();
1128
+ const initialExport = buildHealthExport({
1129
+ activities,
1130
+ muscleReadiness,
1131
+ lifetimeStatistics,
1132
+ }, options, exportedAt);
1133
+ if (!options.includeSetDetails || initialExport.activities.length === 0) {
1134
+ return initialExport;
1135
+ }
1136
+ const tonalActivityIds = initialExport.activities
1137
+ .filter(activity => activity.source === 'tonal')
1138
+ .map(activity => activity.activityId);
1139
+ if (tonalActivityIds.length === 0) {
1140
+ return initialExport;
1141
+ }
1142
+ const [activityDetails, movements] = await Promise.all([
1143
+ this.getWorkoutActivityDetails(tonalActivityIds),
1144
+ this.getMovements(),
1145
+ ]);
1146
+ return buildHealthExport({
1147
+ activities,
1148
+ muscleReadiness,
1149
+ lifetimeStatistics,
1150
+ activityDetails,
1151
+ movements,
1152
+ }, options, exportedAt);
1153
+ }
1154
+ async paginateWorkoutActivities(pageSize, visitActivities) {
1155
+ const userInfo = await this.getUserInfo();
1156
+ const seenIds = new Set();
1157
+ for (let pageNumber = 0, offset = 0;; pageNumber += 1) {
1158
+ const page = await this.userService.getWorkoutActivities(userInfo.id, offset, pageSize);
1159
+ const newActivities = page.filter(activity => {
1160
+ if (seenIds.has(activity.id)) {
1161
+ return false;
1162
+ }
1163
+ seenIds.add(activity.id);
1164
+ return true;
1165
+ });
1166
+ if (visitActivities(newActivities) || page.length < pageSize) {
1167
+ return;
1168
+ }
1169
+ if (newActivities.length === 0) {
1170
+ throw new TonalClientError(`Workout activity pagination did not advance at offset ${offset}`);
1171
+ }
1172
+ if (pageNumber + 1 >= MAX_WORKOUT_ACTIVITY_PAGES) {
1173
+ throw new TonalClientError(`Workout activity pagination exceeded the ${MAX_WORKOUT_ACTIVITY_PAGES}-page safety limit`);
1174
+ }
1175
+ const nextOffset = offset + pageSize;
1176
+ if (!Number.isSafeInteger(nextOffset) || nextOffset <= offset) {
1177
+ throw new TonalClientError(`Workout activity pagination could not advance beyond offset ${offset}`);
1178
+ }
1179
+ offset = nextOffset;
1180
+ }
1181
+ }
1182
+ async getWorkoutActivityDetails(activityIds) {
1183
+ const requestedIds = new Set(activityIds);
1184
+ const details = [];
1185
+ await this.paginateWorkoutActivities(100, activities => {
1186
+ for (const activity of activities) {
1187
+ if (requestedIds.delete(activity.id)) {
1188
+ details.push(activity);
1189
+ }
1190
+ }
1191
+ return requestedIds.size === 0;
1192
+ });
1193
+ return details;
1194
+ }
790
1195
  // Workout operations
791
1196
  async getUserWorkouts(offset = 0, limit = 50) {
792
1197
  return this.workoutService.getUserWorkouts(offset, limit);
@@ -816,4 +1221,4 @@ class TonalClient {
816
1221
  }
817
1222
  }
818
1223
 
819
- export { TonalClient, TonalClientError, TonalClient as default };
1224
+ export { TonalClient, TonalClientError, buildHealthExport, TonalClient as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dlwiest/ts-tonal-client",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript client for Tonal API",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.esm.js",
@@ -52,7 +52,9 @@
52
52
  "example:program-by-id": "tsx examples/get-program-by-id.ts",
53
53
  "example:target-scores": "tsx examples/get-target-scores.ts",
54
54
  "example:metric-scores": "tsx examples/get-metric-scores.ts",
55
- "example:workout-activity": "tsx examples/get-workout-activity.ts"
55
+ "example:workout-activity": "tsx examples/get-workout-activity.ts",
56
+ "example:health-export": "tsx examples/export-health-data.ts",
57
+ "example:complete-health-export": "tsx examples/export-complete-health-data.ts"
56
58
  },
57
59
  "repository": {
58
60
  "type": "git",