@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/dist/index.esm.js
CHANGED
|
@@ -73,43 +73,54 @@ class AuthManager {
|
|
|
73
73
|
this.tokenExpiresAt = this.getTokenExpiresAt(tokenData.id_token, expiresInDeadline);
|
|
74
74
|
return this.idToken;
|
|
75
75
|
}
|
|
76
|
+
// `expiresInDeadline` may be NaN: OAuthTokenResponse declares `expires_in` as required,
|
|
77
|
+
// but that same interface declared `refresh_token` required until Auth0 was observed
|
|
78
|
+
// omitting it, so the field is not trustworthy. A NaN deadline must never reach
|
|
79
|
+
// `tokenExpiresAt` -- `isTokenValid()` compares against it and would return false
|
|
80
|
+
// forever, refreshing on every single call.
|
|
76
81
|
getTokenExpiresAt(idToken, expiresInDeadline) {
|
|
82
|
+
const expiresInUsable = Number.isFinite(expiresInDeadline);
|
|
83
|
+
// Neither source usable: a short finite window beats NaN, which would spin the
|
|
84
|
+
// refresh grant on every request and invite Auth0 rate limiting.
|
|
85
|
+
const noInfoFallback = expiresInUsable ? expiresInDeadline : Date.now() + 10 * 60 * 1000;
|
|
77
86
|
try {
|
|
78
87
|
if (!idToken) {
|
|
79
|
-
return
|
|
88
|
+
return noInfoFallback;
|
|
80
89
|
}
|
|
81
90
|
const segments = idToken.split('.');
|
|
82
91
|
if (segments.length !== 3) {
|
|
83
|
-
return
|
|
92
|
+
return noInfoFallback;
|
|
84
93
|
}
|
|
85
94
|
const payloadSegment = segments[1];
|
|
86
95
|
if (!/^[A-Za-z0-9_-]+$/.test(payloadSegment)) {
|
|
87
|
-
return
|
|
96
|
+
return noInfoFallback;
|
|
88
97
|
}
|
|
89
98
|
const payloadBuffer = Buffer.from(payloadSegment, 'base64url');
|
|
90
99
|
if (payloadBuffer.toString('base64url') !== payloadSegment) {
|
|
91
|
-
return
|
|
100
|
+
return noInfoFallback;
|
|
92
101
|
}
|
|
93
102
|
const payload = JSON.parse(payloadBuffer.toString('utf8'));
|
|
94
103
|
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
95
|
-
return
|
|
104
|
+
return noInfoFallback;
|
|
96
105
|
}
|
|
97
106
|
const exp = 'exp' in payload ? payload.exp : undefined;
|
|
98
107
|
if (typeof exp !== 'number' || !Number.isFinite(exp) || exp <= 0) {
|
|
99
|
-
return
|
|
108
|
+
return noInfoFallback;
|
|
100
109
|
}
|
|
101
110
|
const expDeadline = exp * 1000;
|
|
102
111
|
// ID tokens are hour-lived; ten years rejects nonsensical dates without affecting real tokens.
|
|
103
112
|
const latestReasonableExpiry = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
|
104
113
|
if (!Number.isFinite(expDeadline) || expDeadline > latestReasonableExpiry) {
|
|
105
|
-
return
|
|
114
|
+
return noInfoFallback;
|
|
106
115
|
}
|
|
107
116
|
// Auth0's expires_in (measured at 24h) describes the access token, while
|
|
108
117
|
// we send the ID token (measured at 10h), so its own exp must cap the deadline.
|
|
109
|
-
|
|
118
|
+
// When expires_in is unusable the exp claim stands alone rather than being
|
|
119
|
+
// discarded by Math.min(valid, NaN) === NaN.
|
|
120
|
+
return expiresInUsable ? Math.min(expDeadline, expiresInDeadline) : expDeadline;
|
|
110
121
|
}
|
|
111
122
|
catch {
|
|
112
|
-
return
|
|
123
|
+
return noInfoFallback;
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
126
|
invalidateToken() {
|
|
@@ -607,6 +618,29 @@ class UserService {
|
|
|
607
618
|
async getActivitySummaries(userId) {
|
|
608
619
|
return this.httpClient.request(`/users/${userId}/activity-summaries`);
|
|
609
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
|
+
}
|
|
610
644
|
async getUserStatistics(userId) {
|
|
611
645
|
return this.httpClient.request(`/users/${userId}/statistics`);
|
|
612
646
|
}
|
|
@@ -636,6 +670,271 @@ class UserService {
|
|
|
636
670
|
}
|
|
637
671
|
}
|
|
638
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;
|
|
639
938
|
class TonalClient {
|
|
640
939
|
constructor(username, password, cacheDir) {
|
|
641
940
|
this.authManager = new AuthManager(username, password);
|
|
@@ -745,6 +1044,40 @@ class TonalClient {
|
|
|
745
1044
|
const userInfo = await this.getUserInfo();
|
|
746
1045
|
return this.userService.getActivitySummaries(userInfo.id);
|
|
747
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
|
+
}
|
|
748
1081
|
async getUserStatistics() {
|
|
749
1082
|
const userInfo = await this.getUserInfo();
|
|
750
1083
|
return this.userService.getUserStatistics(userInfo.id);
|
|
@@ -776,6 +1109,89 @@ class TonalClient {
|
|
|
776
1109
|
const userInfo = await this.getUserInfo();
|
|
777
1110
|
return this.userService.getMetricScores(userInfo.id, startWeek);
|
|
778
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
|
+
}
|
|
779
1195
|
// Workout operations
|
|
780
1196
|
async getUserWorkouts(offset = 0, limit = 50) {
|
|
781
1197
|
return this.workoutService.getUserWorkouts(offset, limit);
|
|
@@ -805,4 +1221,4 @@ class TonalClient {
|
|
|
805
1221
|
}
|
|
806
1222
|
}
|
|
807
1223
|
|
|
808
|
-
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.
|
|
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",
|