hevy-shared 1.0.962 → 1.0.963

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +17 -2
  2. package/built/API/APIClient.d.ts +157 -0
  3. package/built/API/APIClient.js +381 -0
  4. package/built/API/index.d.ts +2 -0
  5. package/built/API/index.js +18 -0
  6. package/built/API/types.d.ts +38 -0
  7. package/built/API/types.js +18 -0
  8. package/built/adjustEventTokens.d.ts +16 -0
  9. package/built/adjustEventTokens.js +18 -0
  10. package/built/adminPermissions.d.ts +4 -0
  11. package/built/adminPermissions.js +22 -0
  12. package/built/async.d.ts +50 -0
  13. package/built/async.js +170 -0
  14. package/built/chat.d.ts +25 -23
  15. package/built/coachPlans.d.ts +2 -1
  16. package/built/coachPlans.js +2 -2
  17. package/built/cue.d.ts +12 -0
  18. package/built/cue.js +22 -0
  19. package/built/exerciseLocaleUtils.d.ts +17 -0
  20. package/built/exerciseLocaleUtils.js +62 -0
  21. package/built/filterExercises.d.ts +19 -3
  22. package/built/filterExercises.js +72 -60
  23. package/built/hevyTrainer.d.ts +250 -0
  24. package/built/hevyTrainer.js +676 -0
  25. package/built/index.d.ts +1140 -304
  26. package/built/index.js +269 -75
  27. package/built/muscleHeatmaps.d.ts +31 -0
  28. package/built/muscleHeatmaps.js +68 -0
  29. package/built/muscleSplits.d.ts +36 -0
  30. package/built/muscleSplits.js +100 -0
  31. package/built/normalizedWorkoutUtils.d.ts +88 -0
  32. package/built/normalizedWorkoutUtils.js +112 -0
  33. package/built/notifications.d.ts +215 -0
  34. package/built/notifications.js +9 -0
  35. package/built/routineUtils.d.ts +14 -0
  36. package/built/routineUtils.js +186 -0
  37. package/built/schemas.d.ts +14 -0
  38. package/built/schemas.js +9 -0
  39. package/built/setIndicatorUtils.d.ts +4 -3
  40. package/built/setIndicatorUtils.js +15 -1
  41. package/built/tests/async.test.d.ts +1 -0
  42. package/built/tests/async.test.js +49 -0
  43. package/built/tests/hevyTrainer.test.d.ts +1 -0
  44. package/built/tests/hevyTrainer.test.js +1199 -0
  45. package/built/tests/muscleSplit.test.d.ts +1 -0
  46. package/built/tests/muscleSplit.test.js +153 -0
  47. package/built/tests/routineUtils.test.d.ts +1 -0
  48. package/built/tests/routineUtils.test.js +745 -0
  49. package/built/tests/testUtils.d.ts +85 -0
  50. package/built/tests/testUtils.js +319 -0
  51. package/built/tests/utils.test.js +748 -0
  52. package/built/tests/workoutVolume.test.js +165 -49
  53. package/built/translations/index.d.ts +2 -0
  54. package/built/translations/index.js +18 -0
  55. package/built/translations/translationUtils.d.ts +2 -0
  56. package/built/translations/translationUtils.js +61 -0
  57. package/built/translations/types.d.ts +8 -0
  58. package/built/translations/types.js +20 -0
  59. package/built/typeUtils.d.ts +70 -0
  60. package/built/typeUtils.js +55 -0
  61. package/built/units.d.ts +14 -7
  62. package/built/units.js +24 -14
  63. package/built/utils.d.ts +192 -5
  64. package/built/utils.js +598 -85
  65. package/built/websocket.d.ts +14 -2
  66. package/built/workoutVolume.d.ts +24 -5
  67. package/built/workoutVolume.js +25 -34
  68. package/package.json +32 -10
  69. package/.eslintignore +0 -2
  70. package/.eslintrc +0 -21
  71. package/.github/workflows/ci.yml +0 -15
  72. package/.github/workflows/npm-publish.yml +0 -59
  73. package/.github/workflows/pr-auto-assign.yml +0 -15
  74. package/.prettierrc.js +0 -5
  75. package/jest.config.js +0 -4
  76. package/src/chat.ts +0 -130
  77. package/src/coachPlans.ts +0 -57
  78. package/src/constants.ts +0 -14
  79. package/src/filterExercises.ts +0 -222
  80. package/src/index.ts +0 -1576
  81. package/src/setIndicatorUtils.ts +0 -137
  82. package/src/tests/utils.test.ts +0 -156
  83. package/src/tests/workoutVolume.test.ts +0 -93
  84. package/src/units.ts +0 -41
  85. package/src/utils.ts +0 -516
  86. package/src/websocket.ts +0 -36
  87. package/src/workoutVolume.ts +0 -175
  88. package/tsconfig.json +0 -70
package/built/utils.js CHANGED
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isExerciseTemplate = exports.isCompareExerciseSupported = exports.comparableExerciseTemplateIds = exports.numberWithCommas = exports.setVolume = exports.oneRepMax = exports.oneRepMaxPercentageMap = exports.workoutSetCount = exports.workoutDistanceMeters = exports.workoutReps = exports.workoutDurationSeconds = exports.removeAccents = exports.getClosestDataPointBeforeTargetDate = exports.stringToNumber = exports.forceStringToNumber = exports.formatDurationInput = exports.isValidFormattedTime = exports.customExerciseTemplateToExerciseTemplate = exports.isValidPhoneNumber = exports.isValidWebUrl = exports.isValidEmail = exports.secondsToWordFormatMinutes = exports.secondsToWordFormat = exports.secondsToClockFormat = exports.isValidUsername = exports.divide = exports.num = void 0;
6
+ exports.calculateCurrentWeekStreak = exports.getYoutubeVideoId = exports.validateYoutubeUrl = exports.splitAtUsernamesAndLinks = exports.isVersionAGreaterOrEqualToVersionB = exports.generateUserGroupValue = exports.generateUserGroup = exports.isBaseExerciseTemplate = exports.getStrengthLevelFromPercentile = exports.numberToLocaleString = exports.numberWithCommas = exports.setVolume = exports.oneRepMax = exports.oneRepMaxPercentageMap = exports.getEstimatedExercisesDurationSeconds = exports.ESTIMATED_REST_TIMER_DURATION = exports.ESTIMATED_SET_DURATION = exports.UserFacingIndicatorToSetIndicator = exports.workoutSetCount = exports.userExerciseSetWeight = exports.workoutDistanceMeters = exports.workoutReps = exports.workoutDurationSeconds = exports.removeAccents = exports.getClosestDataPointAroundTargetDate = exports.getClosestDataPointBeforeTargetDate = exports.toFragmentedJSON = exports.findMapped = exports.stringToNumber = exports.forceStringToNumber = exports.formatDurationInput = exports.isValidFormattedTime = exports.isWholeNumber = exports.isNumber = exports.isValidUuid = exports.isValidPhoneNumber = exports.isValidWebUrl = exports.URL_REGEX = exports.isValidEmail = exports.secondsToWordFormatMinutes = exports.secondsToWordFormat = exports.secondsToClockFormat = exports.secondsToClockParts = exports.isValidUsername = exports.roundToWholeNumber = exports.roundToOneDecimal = exports.roundToTwoDecimal = exports.divide = exports.clampNumber = exports.num = void 0;
7
+ exports.indexByNearestValue = exports.roundToKnownValue = exports.rawInstructionsToIndexedSteps = exports.formatSetValue = exports.exerciseWeight = exports.distance = exports.weekdayNumberMap = exports.startOfWeek = void 0;
8
+ const dayjs_1 = __importDefault(require("dayjs"));
9
+ const _1 = require(".");
4
10
  /**
5
11
  * Doesn't matter what you throw in the function it'll
6
12
  * always return a number. Non number values will return
@@ -12,26 +18,48 @@ const num = (value) => {
12
18
  return value !== null && value !== void 0 ? value : 0;
13
19
  };
14
20
  exports.num = num;
21
+ const clampNumber = (value, limits) => {
22
+ var _a, _b;
23
+ const min = (_a = limits.min) !== null && _a !== void 0 ? _a : -Infinity;
24
+ const max = (_b = limits.max) !== null && _b !== void 0 ? _b : Infinity;
25
+ if (min > max) {
26
+ return NaN;
27
+ }
28
+ return Math.max(min, Math.min(max, value));
29
+ };
30
+ exports.clampNumber = clampNumber;
15
31
  const divide = (numerator, denominator) => {
16
32
  if (denominator === 0)
17
33
  return 0;
18
34
  return numerator / denominator;
19
35
  };
20
36
  exports.divide = divide;
37
+ const roundToTwoDecimal = (value) => Math.round(value * 100) / 100;
38
+ exports.roundToTwoDecimal = roundToTwoDecimal;
39
+ const roundToOneDecimal = (value) => Math.round(value * 10) / 10;
40
+ exports.roundToOneDecimal = roundToOneDecimal;
41
+ const roundToWholeNumber = (value) => Math.round(value);
42
+ exports.roundToWholeNumber = roundToWholeNumber;
21
43
  const isValidUsername = (username) => {
22
44
  if (username.length > 20) {
23
45
  return false;
24
46
  }
25
- const lowercase = username.toLowerCase();
26
- if (lowercase.length < 3 ||
27
- lowercase.startsWith('_') ||
28
- lowercase.endsWith('_')) {
47
+ if (username.length < 3 ||
48
+ username.startsWith('_') ||
49
+ username.endsWith('_')) {
29
50
  return false;
30
51
  }
31
52
  const re = /^[a-z0-9_]+$/;
32
- return re.test(lowercase);
53
+ return re.test(username);
33
54
  };
34
55
  exports.isValidUsername = isValidUsername;
56
+ const secondsToClockParts = (totalSeconds) => {
57
+ const hours = Math.floor(totalSeconds / 3600);
58
+ const minutes = Math.floor((totalSeconds - hours * 3600) / 60);
59
+ const seconds = totalSeconds - hours * 3600 - minutes * 60;
60
+ return { hours, minutes, seconds };
61
+ };
62
+ exports.secondsToClockParts = secondsToClockParts;
35
63
  /**
36
64
  * 01:25
37
65
  * 02:25:36
@@ -79,10 +107,12 @@ exports.secondsToWordFormat = secondsToWordFormat;
79
107
  /**
80
108
  * 14min
81
109
  * 2h 4min
110
+ * 2h 0min
82
111
  */
83
112
  const secondsToWordFormatMinutes = (seconds) => {
84
- const hours = Math.floor(seconds / 3600);
85
- const minutes = Math.floor((seconds - hours * 3600) / 60);
113
+ const totalMinutes = Math.round(seconds / 60);
114
+ const minutes = totalMinutes % 60;
115
+ const hours = Math.floor(totalMinutes / 60);
86
116
  if (hours) {
87
117
  return `${hours}h ${minutes}min`;
88
118
  }
@@ -94,9 +124,32 @@ const isValidEmail = (email) => {
94
124
  return re.test(String(email).toLowerCase());
95
125
  };
96
126
  exports.isValidEmail = isValidEmail;
127
+ /**
128
+ * Matches strings with a base format of `domain.tld`.
129
+ * - `domain` may contain letters, digits and dashes, but it can't begin or
130
+ * end with a dash
131
+ * - `domain` may be prefixed once or multiple times with another `domain.`
132
+ * - `tld` may be followed by `/path-to-page`
133
+ * - the entire string may be prefixed by `https://` or `http://`
134
+ */
135
+ exports.URL_REGEX = RegExp([
136
+ // optionally begin with http(s)://
137
+ /(https?:\/\/)?/,
138
+ // followed by the domain:
139
+ // - any combination of letters, numbers and dashes
140
+ // - but without a dash at the start or end
141
+ // - maximum 255 characters per domain
142
+ // - each domain ends with a `.`
143
+ /((?!-)[-a-z0-9]{1,255}(?<!-)\.)+/,
144
+ // followed by the top-level domain
145
+ /[a-z]{2,10}/,
146
+ // optionally followed by a path
147
+ /(\/[-a-z0-9@:%_+.~#?!&/=]*)?/,
148
+ ]
149
+ .map((r) => r.source)
150
+ .join(''));
97
151
  const isValidWebUrl = (url) => {
98
- const re = /^(https?:\/\/)?(www\.)?[-a-z0-9.]{2,256}\.[a-z]{2,10}\b([-a-z0-9@:%_+.~#?&//=]*)$/;
99
- return re.test(String(url).toLowerCase());
152
+ return RegExp(`^${exports.URL_REGEX.source}$`).test(String(url).toLowerCase());
100
153
  };
101
154
  exports.isValidWebUrl = isValidWebUrl;
102
155
  /**
@@ -118,21 +171,23 @@ const isValidPhoneNumber = (phoneNumber) => {
118
171
  return typeof phoneNumber === 'string' && /^\+\d{9,15}$/.test(phoneNumber);
119
172
  };
120
173
  exports.isValidPhoneNumber = isValidPhoneNumber;
121
- const customExerciseTemplateToExerciseTemplate = (cet) => ({
122
- id: cet.id,
123
- title: cet.title,
124
- priority: 10,
125
- muscle_group: cet.muscle_group,
126
- other_muscles: cet.other_muscles || [],
127
- exercise_type: cet.exercise_type,
128
- equipment_category: cet.equipment_category,
129
- is_custom: true,
130
- is_archived: cet.is_archived,
131
- custom_exercise_image_url: cet.custom_exercise_image_url,
132
- thumbnail_url: cet.thumbnail_url,
133
- parent_exercise_template_id: cet.parent_exercise_template_id,
134
- });
135
- exports.customExerciseTemplateToExerciseTemplate = customExerciseTemplateToExerciseTemplate;
174
+ /**
175
+ * Matches all UUID types, case-insensitive (matches both uppercase and
176
+ * lowercase hexadecimal digits).
177
+ */
178
+ const isValidUuid = (uuid) => {
179
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
180
+ return typeof uuid === 'string' && uuidRegex.test(uuid);
181
+ };
182
+ exports.isValidUuid = isValidUuid;
183
+ const isNumber = (x) => {
184
+ return typeof x === 'number';
185
+ };
186
+ exports.isNumber = isNumber;
187
+ const isWholeNumber = (value) => {
188
+ return value % 1 === 0;
189
+ };
190
+ exports.isWholeNumber = isWholeNumber;
136
191
  /**
137
192
  * Return true is value is of format: NN:NN or NN:NN:NN
138
193
  */
@@ -229,6 +284,77 @@ const stringToNumber = (value) => {
229
284
  return isNaN(numOrNaN) ? undefined : numOrNaN;
230
285
  };
231
286
  exports.stringToNumber = stringToNumber;
287
+ /**
288
+ * Returns the first non-undefined value produced by transform function being
289
+ * applied to elements of the array in iteration order, or `undefined` if no
290
+ * such value was produced. Equivalent to `firstNotNullOfOrNull` in Kotlin.
291
+ */
292
+ const findMapped = (array, transform) => {
293
+ for (const element of array) {
294
+ const result = transform(element);
295
+ if (result !== undefined) {
296
+ return result;
297
+ }
298
+ }
299
+ };
300
+ exports.findMapped = findMapped;
301
+ /**
302
+ * converts any array into an array of JSON chunks with a maximum given length
303
+ * @param data an array of objects or primitives to be split into chunks
304
+ * @param maxLength maximum length of any single returned JSON string
305
+ * @returns an array of JSON strings, each one no longer than `maxLength`
306
+ * @throws an error if fragmentation is impossible due to input data structure
307
+ */
308
+ const toFragmentedJSON = (data, maxLength, options = { lengthIn: 'utf8bytes' }) => {
309
+ const outputArray = [];
310
+ const recurse = (left, right) => {
311
+ if (left === right)
312
+ return;
313
+ // slice the array and convert it to JSON
314
+ const sliceJson = JSON.stringify(data.slice(left, right));
315
+ const length = options.lengthIn === 'characters'
316
+ ? sliceJson.length
317
+ : Buffer.byteLength(sliceJson, 'utf8');
318
+ if (length <= maxLength) {
319
+ // the size is good, push the chunk to the output array
320
+ outputArray.push(sliceJson);
321
+ }
322
+ else if (right > left + 1) {
323
+ // there are at least 2 elements in the current slice
324
+ const middle = Math.ceil((left + right) / 2);
325
+ recurse(left, middle);
326
+ recurse(middle, right);
327
+ }
328
+ else {
329
+ // we can't subdivide any further but the string is still too long
330
+ throw new Error(`couldn't subdivide element`);
331
+ }
332
+ };
333
+ outputArray.length = 0;
334
+ recurse(0, data.length);
335
+ return outputArray;
336
+ };
337
+ exports.toFragmentedJSON = toFragmentedJSON;
338
+ /**
339
+ * Finds the closest data point that occurs before or at the target date using binary search.
340
+ * Assumes input array is sorted by date in ascending order.
341
+ *
342
+ * @param data - Array of items containing dates in ascending order
343
+ * @param dateExtractor - Function to extract Date from each item
344
+ * @param targetDate - Target date to search for
345
+ * @returns The closest item before or at the target date, or undefined if not found
346
+ *
347
+ * @example
348
+ * const data = [
349
+ * { id: 1, date: dayjs('2023-01-01').toDate() },
350
+ * { id: 2, date: dayjs('2023-04-01').toDate() }
351
+ * ];
352
+ * const result = getClosestDataPointBeforeTargetDate(
353
+ * data,
354
+ * item => item.date,
355
+ * dayjs('2023-03-15').toDate(),
356
+ * ); // Returns { id: 1, date: Date('2023-01-01') }
357
+ */
232
358
  const getClosestDataPointBeforeTargetDate = (data, dateExtractor, targetDate) => {
233
359
  var _a;
234
360
  if (!data.length)
@@ -262,6 +388,37 @@ const getClosestDataPointBeforeTargetDate = (data, dateExtractor, targetDate) =>
262
388
  }
263
389
  };
264
390
  exports.getClosestDataPointBeforeTargetDate = getClosestDataPointBeforeTargetDate;
391
+ /**
392
+ * Finds the closest data point to the target date.
393
+ *
394
+ * @param data - Array of items containing dates
395
+ * @param dateExtractor - Function to extract Date from each item
396
+ * @param targetDate - Target date to search for
397
+ * @returns The closest item to the target date, or undefined if array is empty
398
+ *
399
+ * @example
400
+ * const data = [
401
+ * { id: 1, date: dayjs('2023-01-01').toDate() },
402
+ * { id: 2, date: dayjs('2023-04-01').toDate() }
403
+ * ];
404
+ * const result = getClosestDataPoint(
405
+ * data,
406
+ * item => item.date,
407
+ * dayjs('2023-03-15').toDate(),
408
+ * ); // Returns { id: 2, date: Date('2023-04-01') }
409
+ */
410
+ const getClosestDataPointAroundTargetDate = (data, dateExtractor, targetDate) => {
411
+ if (!data.length)
412
+ return undefined;
413
+ return data.reduce((closest, current) => {
414
+ if (!closest)
415
+ return current;
416
+ const closestDiff = Math.abs(dateExtractor(closest).getTime() - targetDate.getTime());
417
+ const currentDiff = Math.abs(dateExtractor(current).getTime() - targetDate.getTime());
418
+ return currentDiff < closestDiff ? current : closest;
419
+ }, undefined);
420
+ };
421
+ exports.getClosestDataPointAroundTargetDate = getClosestDataPointAroundTargetDate;
265
422
  const removeAccents = (str) => {
266
423
  return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
267
424
  };
@@ -295,12 +452,84 @@ const workoutDistanceMeters = (workout) => {
295
452
  }, 0);
296
453
  };
297
454
  exports.workoutDistanceMeters = workoutDistanceMeters;
455
+ /**
456
+ * Calculate the set weight for a given user exercise set
457
+ * to be used in the exercise stats calculations on the web and coach app
458
+ */
459
+ const userExerciseSetWeight = (set, exerciseStore, hundredPercentBodyweightExercise) => {
460
+ if (!set)
461
+ return 0;
462
+ const exercise = exerciseStore.find((e) => e.id === set.exercise_template_id);
463
+ if (!exercise) {
464
+ return (0, exports.num)(set.weight_kg);
465
+ }
466
+ if (exercise.exercise_type === 'bodyweight_reps') {
467
+ return hundredPercentBodyweightExercise
468
+ ? (0, exports.num)(set.user_bodyweight_kg) + (0, exports.num)(set.weight_kg)
469
+ : (0, exports.num)(set.weight_kg);
470
+ }
471
+ else if (exercise.exercise_type === 'bodyweight_assisted_reps') {
472
+ return hundredPercentBodyweightExercise
473
+ ? Math.max((0, exports.num)(set.user_bodyweight_kg) - (0, exports.num)(set.weight_kg), 0)
474
+ : 0;
475
+ }
476
+ else if (exercise.exercise_type === 'reps_only') {
477
+ return hundredPercentBodyweightExercise ? (0, exports.num)(set.user_bodyweight_kg) : 0;
478
+ }
479
+ else {
480
+ return (0, exports.num)(set.weight_kg);
481
+ }
482
+ };
483
+ exports.userExerciseSetWeight = userExerciseSetWeight;
298
484
  const workoutSetCount = (w) => {
299
485
  return w.exercises.reduce((accu, exercise) => {
300
486
  return accu + exercise.sets.length;
301
487
  }, 0);
302
488
  };
303
489
  exports.workoutSetCount = workoutSetCount;
490
+ const UserFacingIndicatorToSetIndicator = (indicator) => {
491
+ switch (indicator) {
492
+ case 'dropset':
493
+ return 'dropset';
494
+ case 'warmup':
495
+ return 'warmup';
496
+ case 'failure':
497
+ return 'failure';
498
+ default:
499
+ return 'normal';
500
+ }
501
+ };
502
+ exports.UserFacingIndicatorToSetIndicator = UserFacingIndicatorToSetIndicator;
503
+ exports.ESTIMATED_SET_DURATION = 45;
504
+ exports.ESTIMATED_REST_TIMER_DURATION = 90;
505
+ const isDurationExercise = (type) => {
506
+ return (type === 'duration' ||
507
+ type === 'weight_duration' ||
508
+ type === 'distance_duration' ||
509
+ type === 'floors_duration' ||
510
+ type === 'steps_duration');
511
+ };
512
+ const getEstimatedExercisesDurationSeconds = ({ exercises, }) => {
513
+ const totalSeconds = exercises.reduce((exercisesTotal, exercise) => {
514
+ var _a;
515
+ const restPerSet = (_a = exercise.rest_seconds) !== null && _a !== void 0 ? _a : exports.ESTIMATED_REST_TIMER_DURATION;
516
+ const setsTotal = exercise.sets.reduce((setTotal, set) => {
517
+ var _a;
518
+ // Sometimes we get 0 values for duration on exercises that aren't duration.
519
+ // Added this check to prevent having a estimated duration of 0 for these.
520
+ const duration = isDurationExercise(exercise.exercise_type)
521
+ ? (_a = set.duration_seconds) !== null && _a !== void 0 ? _a : exports.ESTIMATED_SET_DURATION
522
+ : exports.ESTIMATED_SET_DURATION;
523
+ // if it is a dropset, we don't add the rest time
524
+ if (set.indicator === 'dropset')
525
+ return setTotal + duration;
526
+ return setTotal + duration + restPerSet;
527
+ }, 0);
528
+ return exercisesTotal + setsTotal;
529
+ }, 0);
530
+ return totalSeconds;
531
+ };
532
+ exports.getEstimatedExercisesDurationSeconds = getEstimatedExercisesDurationSeconds;
304
533
  exports.oneRepMaxPercentageMap = {
305
534
  1: 1.0,
306
535
  2: 0.97,
@@ -345,73 +574,357 @@ const setVolume = (weight, reps) => {
345
574
  return weight * reps;
346
575
  };
347
576
  exports.setVolume = setVolume;
577
+ /** @deprecated use `numberToLocaleString` */
348
578
  const numberWithCommas = (x) => {
349
579
  return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
350
580
  };
351
581
  exports.numberWithCommas = numberWithCommas;
352
- exports.comparableExerciseTemplateIds = new Set([
353
- '79D0BB3A' /* Bench Press (Barbell) **/,
354
- 'D04AC939' /* Squat (Barbell) **/,
355
- 'A5AC6449' /* Bicep Curl (Barbell) **/,
356
- '5F4E6DD3' /* Deadlift (Barbell) **/,
357
- 'D20D7BBE' /* Sumo Deadlift **/,
358
- '50DFDFAB' /* Incline Bench (Barbell) **/,
359
- '55E6546F' /* Penlay Row **/,
360
- 'C6272009' /* Deadlift (Barbell) **/,
361
- '7B8D84E8' /* Overhead Press (Barbell) **/,
362
- 'C7973E0E' /* Leg Press **/,
363
- 'DA0F0470' /* Decline Bench (Barbell) **/,
364
- '5046D0A9' /* Front Squat **/,
365
- '1283BBA6' /* Full Squat **/,
366
- '6622E5A0' /* Sumo Squat (Barbell) **/,
367
- 'FE389074' /* Rack Pull **/,
368
- '92B8C7E1' /* Hip Thrust **/,
369
- '1B2B1E7C' /* Pull Up **/,
370
- '392887AA' /* Push Up **/,
371
- '29083183' /* Chin Up **/,
372
- '7AB9A362' /* Upright Row (Barbell) **/,
373
- '90E506D5' /* Thruster (Barbell) **/,
374
- '2A48E443' /* Straight Leg Deadlift **/,
375
- '073032BB' /* Standing Military Press (Barbell) **/,
376
- '9694DA61' /* Squat (Bodyweight) **/,
377
- 'FB09C938' /* Snatch **/,
378
- '875F585F' /* Skullcrusher (Barbell) **/,
379
- '022DF610' /* Sit Up **/,
380
- '0B841777' /* Shrug (Barbell) **/,
381
- '91AF29E0' /* Seated Overhead Press (Barbell) **/,
382
- '2B4B7310' /* Romanian Deadlift (Barbell) **/,
383
- '818BA121' /* Reverse Lunge (Barbell) **/,
384
- '542F3CD5' /* Push Press **/,
385
- 'E22F9358' /* Power Snatch **/,
386
- 'C628D768' /* Power Clean **/,
387
- '3FF6A22E' /* Pistol Squat **/,
388
- '0EFE8162' /* Pike Pushup **/,
389
- '018ADC12' /* Pendlay Row (Barbell) **/,
390
- '6E6EE645' /* Lunge (Barbell) **/,
391
- '5E1A7777' /* Lunge **/,
392
- 'B74A95BB' /* Kneeling Push Up **/,
393
- '70D4EBBF' /* Jump Squat **/,
394
- 'D57C2EC7' /* Hip Thrust (Barbell) **/,
395
- '4180C405' /* Good Morning (Barbell) **/,
396
- '6575F52D' /* Diamond Push Up **/,
397
- 'DCF3B31B' /* Crunch **/,
398
- '652FEA39' /* Clean Pull **/,
399
- 'D3095577' /* Clean and Press **/,
400
- '9E09CEC3' /* Clean and Jerk **/,
401
- 'ABB00838' /* Clean **/,
402
- 'BB792A36' /* Burpee **/,
403
- 'E644F828' /* Bench Press - Wide Grip (Barbell) **/,
404
- '35B51B87' /* Bench Press - Close Grip (Barbell) **/,
405
- ]);
406
- const isCompareExerciseSupported = (temlpateId) => {
407
- return exports.comparableExerciseTemplateIds.has(temlpateId);
408
- };
409
- exports.isCompareExerciseSupported = isCompareExerciseSupported;
410
- const isExerciseTemplate = (x) => {
582
+ /**
583
+ * Formats a number into a string, accounting for the system locale.
584
+ *
585
+ * @example
586
+ * numberToLocaleString(1234.567) === '1,234.567' // English (UK / US)
587
+ * numberToLocaleString(1234.567) === '1.234,567' // German (Germany)
588
+ * numberToLocaleString(1234.567) === '1 234,567' // French (France)
589
+ */
590
+ const numberToLocaleString = (value, options) => value.toLocaleString(undefined, Object.assign({}, options));
591
+ exports.numberToLocaleString = numberToLocaleString;
592
+ const getStrengthLevelFromPercentile = (percentile) => {
593
+ if (percentile >= 90) {
594
+ return 'elite';
595
+ }
596
+ else if (percentile >= 70) {
597
+ return 'advanced';
598
+ }
599
+ else if (percentile >= 40) {
600
+ return 'intermediate';
601
+ }
602
+ else {
603
+ return 'beginner';
604
+ }
605
+ };
606
+ exports.getStrengthLevelFromPercentile = getStrengthLevelFromPercentile;
607
+ const isBaseExerciseTemplate = (x) => {
411
608
  return (x.id !== undefined &&
412
609
  x.title !== undefined &&
413
610
  x.muscle_group !== undefined &&
414
611
  x.exercise_type !== undefined &&
415
612
  x.equipment_category !== undefined);
416
613
  };
417
- exports.isExerciseTemplate = isExerciseTemplate;
614
+ exports.isBaseExerciseTemplate = isBaseExerciseTemplate;
615
+ const _v4uuidCheckRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-([0-9a-f])[0-9a-f]{3}-([0-9a-f]{12})/i;
616
+ /**
617
+ * Generates a subsample or test group given a user id. Technically, it just
618
+ * calculates a trivial checksum of the last (random) part of a v4 UUID.
619
+ * @param userId a v4 UUID; _must_ be v4 to ensure random distribution
620
+ * @param numGroups number of possible groups, from 2 to 2^32
621
+ * @returns a number in the `[0, numGroups)` range, always equal for a given
622
+ * `(userId, numGroups)` pair, or an error, inside a Result<T> object
623
+ */
624
+ const generateUserGroup = (userId, numGroups) => {
625
+ if (numGroups < 2 || numGroups > 2 ** 32) {
626
+ return { isSuccess: false, error: 'invalid-number-of-groups' };
627
+ }
628
+ const match = _v4uuidCheckRegex.exec(userId);
629
+ if (!match) {
630
+ return { isSuccess: false, error: 'invalid-uuid' };
631
+ }
632
+ const [, version, variant, tail] = match;
633
+ if (version !== '4') {
634
+ return { isSuccess: false, error: 'uuid-not-v4' };
635
+ }
636
+ if (!['8', '9', 'a', 'b', 'c', 'd'].includes(variant.toLowerCase())) {
637
+ return { isSuccess: false, error: 'invalid-variant' };
638
+ }
639
+ let value;
640
+ try {
641
+ const idHexString = `0x${tail}`;
642
+ const idBigInt = BigInt(idHexString);
643
+ const numGroupsBigInt = BigInt(numGroups);
644
+ const remainderBigInt = idBigInt % numGroupsBigInt;
645
+ value = Number(remainderBigInt);
646
+ }
647
+ catch (e) {
648
+ throw new Error(`generateUserGroup: tail="${tail}": ${e === null || e === void 0 ? void 0 : e.message}`);
649
+ }
650
+ return { isSuccess: true, value };
651
+ };
652
+ exports.generateUserGroup = generateUserGroup;
653
+ /**
654
+ * Get the user group value for a given user id and number of groups.
655
+ * @param userId a v4 UUID; _must_ be v4 to ensure random distribution
656
+ * @param numGroups number of possible groups, from 2 to 2^32
657
+ * @returns User group value (A, B, C, etc.), or undefined if the user id is not a v4 UUID
658
+ * or if an error occurs.
659
+ */
660
+ const generateUserGroupValue = (userId, numGroups) => {
661
+ const group = (0, exports.generateUserGroup)(userId, numGroups);
662
+ if (group.isSuccess) {
663
+ switch (group.value) {
664
+ case 0:
665
+ return 'A';
666
+ case 1:
667
+ return 'B';
668
+ case 2:
669
+ return 'C';
670
+ case 3:
671
+ return 'D';
672
+ case 4:
673
+ return 'E';
674
+ case 5:
675
+ return 'F';
676
+ case 6:
677
+ return 'G';
678
+ case 7:
679
+ return 'H';
680
+ case 8:
681
+ return 'I';
682
+ case 9:
683
+ return 'J';
684
+ default:
685
+ return undefined;
686
+ }
687
+ }
688
+ else {
689
+ return undefined;
690
+ }
691
+ };
692
+ exports.generateUserGroupValue = generateUserGroupValue;
693
+ /**
694
+ * @example
695
+ * isVersionAGreaterOrEqualToVersionB('1.2.3', '1.2') // true
696
+ * isVersionAGreaterOrEqualToVersionB('1.2.3', '1.2.2') // true
697
+ * isVersionAGreaterOrEqualToVersionB('1.2.3', '1.2.3') // true
698
+ *
699
+ * isVersionAGreaterOrEqualToVersionB('1.2.3', '1.2.4') // false
700
+ * isVersionAGreaterOrEqualToVersionB('1.2.3', '1.3') // false
701
+ */
702
+ const isVersionAGreaterOrEqualToVersionB = (versionA, versionB) => {
703
+ var _a, _b;
704
+ const versionAparts = `${versionA}`.split('.');
705
+ const versionBparts = `${versionB}`.split('.');
706
+ const longestVersionLength = Math.max(versionAparts.length, versionBparts.length);
707
+ for (let i = 0; i < longestVersionLength; i++) {
708
+ const versionApart = (_a = versionAparts[i]) !== null && _a !== void 0 ? _a : 0;
709
+ const versionBpart = (_b = versionBparts[i]) !== null && _b !== void 0 ? _b : 0;
710
+ if (parseInt(versionApart, 10) > parseInt(versionBpart, 10)) {
711
+ return true;
712
+ }
713
+ if (parseInt(versionApart, 10) < parseInt(versionBpart, 10)) {
714
+ return false;
715
+ }
716
+ // if equal, continue to next part
717
+ }
718
+ // the versions are entirely equal
719
+ return true;
720
+ };
721
+ exports.isVersionAGreaterOrEqualToVersionB = isVersionAGreaterOrEqualToVersionB;
722
+ const splitAtUsernamesAndLinks = (text) => {
723
+ const mentionsRegex = /\B@[a-z0-9_-]+/gi;
724
+ const linksRegex = exports.URL_REGEX;
725
+ const regex = new RegExp(`(${mentionsRegex.source}|${linksRegex.source})`, 'gi');
726
+ const matches = text.match(regex);
727
+ const format = [];
728
+ if (!matches) {
729
+ return [{ format: 'none', text }];
730
+ }
731
+ let lastIndex = 0;
732
+ matches.forEach((match) => {
733
+ const index = text.indexOf(match, lastIndex);
734
+ if (index > lastIndex) {
735
+ format.push({ format: 'none', text: text.substring(lastIndex, index) });
736
+ }
737
+ if (match.match(linksRegex)) {
738
+ format.push({ format: 'link', text: match });
739
+ }
740
+ else if (match.match(mentionsRegex)) {
741
+ format.push({ format: '@', text: match });
742
+ }
743
+ lastIndex = index + match.length;
744
+ });
745
+ if (lastIndex < text.length) {
746
+ format.push({ format: 'none', text: text.substring(lastIndex) });
747
+ }
748
+ return format;
749
+ };
750
+ exports.splitAtUsernamesAndLinks = splitAtUsernamesAndLinks;
751
+ // Single comprehensive YouTube URL regex for both validation and ID extraction
752
+ const YOUTUBE_URL_REGEX = /^(?:https?:\/\/)?(?:(?:www\.)?youtube\.com\/(?:watch\?v=|shorts\/|embed\/|v\/|user\/[^/]+#p\/|attribution_link\?a=|channel\/|c\/|playlist\?list=)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[/?#&].*)?$/;
753
+ const validateYoutubeUrl = (url) => {
754
+ return YOUTUBE_URL_REGEX.test(url);
755
+ };
756
+ exports.validateYoutubeUrl = validateYoutubeUrl;
757
+ const getYoutubeVideoId = (url) => {
758
+ const match = url.match(YOUTUBE_URL_REGEX);
759
+ return match && match[1] && match[1].length === 11 ? match[1] : undefined;
760
+ };
761
+ exports.getYoutubeVideoId = getYoutubeVideoId;
762
+ /**@param workouts must be sorted descending by start_time */
763
+ const calculateCurrentWeekStreak = (workouts, firstWeekday, untilUnix = (0, dayjs_1.default)().unix()) => {
764
+ let streakCount = 0;
765
+ let weekStart = (0, exports.startOfWeek)(dayjs_1.default.unix(untilUnix), firstWeekday).subtract(1, 'week');
766
+ let weekEnd = weekStart.add(1, 'week');
767
+ let i = 0;
768
+ /** Iterate through the workouts from most recent to least and keep track of the week-long
769
+ * sliding window in which there needs to be a workout present in order for the streak to count.
770
+ * If there is a workout present during this window, move the window into the past by a week
771
+ * and keep iterating through the workouts. If there are no workouts during this window,
772
+ * the streak count is not incremented.
773
+ */
774
+ while (i < workouts.length) {
775
+ const workoutStart = workouts[i].start_time;
776
+ if (workoutStart >= weekStart.unix() && workoutStart <= weekEnd.unix()) {
777
+ streakCount += 1;
778
+ weekStart = weekStart.subtract(1, 'week');
779
+ weekEnd = weekEnd.subtract(1, 'week');
780
+ }
781
+ else if (workoutStart < weekStart.unix()) {
782
+ /** This workout is before the sliding window. We don't get to this point unless
783
+ * the user didn't workout during the window. So that means the streak is over
784
+ */
785
+ break;
786
+ }
787
+ i += 1;
788
+ }
789
+ // check if the current week has any workouts
790
+ weekStart = (0, exports.startOfWeek)(dayjs_1.default.unix(untilUnix), firstWeekday);
791
+ weekEnd = weekStart.add(1, 'week');
792
+ if (workouts.find(({ start_time }) => start_time >= weekStart.unix() && start_time <= weekEnd.unix())) {
793
+ streakCount += 1;
794
+ }
795
+ return streakCount;
796
+ };
797
+ exports.calculateCurrentWeekStreak = calculateCurrentWeekStreak;
798
+ const startOfWeek = (d, firstDayOfWeek) => {
799
+ const firstDayOfWeekIndex = exports.weekdayNumberMap[firstDayOfWeek];
800
+ let testDay = d;
801
+ while (testDay.day() !== firstDayOfWeekIndex) {
802
+ testDay = testDay.subtract(1, 'day');
803
+ }
804
+ return testDay.startOf('day');
805
+ };
806
+ exports.startOfWeek = startOfWeek;
807
+ exports.weekdayNumberMap = {
808
+ sunday: 0,
809
+ monday: 1,
810
+ tuesday: 2,
811
+ wednesday: 3,
812
+ thursday: 4,
813
+ friday: 5,
814
+ saturday: 6,
815
+ };
816
+ const distance = (value, distanceUnit) => {
817
+ if (distanceUnit === 'miles') {
818
+ const unrounded = (0, _1.exactMetersToMiles)(value);
819
+ return (0, exports.roundToTwoDecimal)(unrounded);
820
+ }
821
+ return (0, exports.roundToTwoDecimal)(value / 1000);
822
+ };
823
+ exports.distance = distance;
824
+ const exerciseWeight = (value, weightUnit) => {
825
+ if (weightUnit === 'lbs') {
826
+ return (0, exports.roundToTwoDecimal)((0, _1.exactKgtoLbs)(value));
827
+ }
828
+ return (0, exports.roundToTwoDecimal)(value);
829
+ };
830
+ exports.exerciseWeight = exerciseWeight;
831
+ const formatSetValue = ({ exerciseType, set, units, lokalizedLabels, }) => {
832
+ var _a, _b, _c;
833
+ const unitToLabelMap = {
834
+ kg: lokalizedLabels.kg,
835
+ lbs: lokalizedLabels.lbs,
836
+ km: lokalizedLabels.km,
837
+ mi: lokalizedLabels.mi,
838
+ m: lokalizedLabels.m,
839
+ yd: lokalizedLabels.yd,
840
+ steps: lokalizedLabels.steps,
841
+ floors: lokalizedLabels.floors,
842
+ };
843
+ switch (exerciseType) {
844
+ case 'weight_reps':
845
+ case 'bodyweight_reps': {
846
+ const weight = `${(0, exports.exerciseWeight)(set.weight_kg || 0, units.weight)} ${unitToLabelMap[units.weight]}`;
847
+ const reps = `${(_a = set.reps) !== null && _a !== void 0 ? _a : 0}`;
848
+ return `${weight} x ${reps}`;
849
+ }
850
+ case 'bodyweight_assisted_reps': {
851
+ const weight = `-${(0, exports.exerciseWeight)(set.weight_kg || 0, units.weight)} ${unitToLabelMap[units.weight]}`;
852
+ const reps = `${(_b = set.reps) !== null && _b !== void 0 ? _b : 0}`;
853
+ return `${weight} x ${reps}`;
854
+ }
855
+ case 'reps_only':
856
+ return `${(_c = set.reps) !== null && _c !== void 0 ? _c : 0}`;
857
+ case 'distance_duration': {
858
+ const distanceUnitShort = units.distance === 'kilometers' ? 'km' : 'mi';
859
+ const distanceValue = `${(0, exports.distance)(set.distance_meters || 0, units.distance)} ${unitToLabelMap[distanceUnitShort]}`;
860
+ const duration = `${(0, exports.secondsToWordFormat)(Number(set.duration_seconds))}`;
861
+ return `${distanceValue} - ${duration}`;
862
+ }
863
+ case 'duration':
864
+ return (0, exports.secondsToWordFormat)(Number(set.duration_seconds));
865
+ case 'short_distance_weight': {
866
+ const weight = `${(0, exports.exerciseWeight)(set.weight_kg || 0, units.weight)} ${unitToLabelMap[units.weight]}`;
867
+ const shortDistance = (0, exports.roundToTwoDecimal)(set.distance_meters || 0);
868
+ const shortDistanceUnitShort = units.distance === 'kilometers' ? 'm' : 'yd';
869
+ const shortDistanceUnitShortLabel = unitToLabelMap[shortDistanceUnitShort];
870
+ return `${weight} - ${shortDistance} ${shortDistanceUnitShortLabel}`;
871
+ }
872
+ case 'weight_duration': {
873
+ const weight = `${(0, exports.exerciseWeight)(set.weight_kg || 0, units.weight)} ${unitToLabelMap[units.weight]}`;
874
+ const duration = `${(0, exports.secondsToWordFormat)(Number(set.duration_seconds))}`;
875
+ return `${weight} - ${duration}`;
876
+ }
877
+ case 'floors_duration':
878
+ return `${set.custom_metric ? (0, exports.roundToWholeNumber)(set.custom_metric) : 0} ${unitToLabelMap.floors} - ${(0, exports.secondsToWordFormat)(Number(set.duration_seconds))}`;
879
+ case 'steps_duration':
880
+ return `${set.custom_metric ? (0, exports.roundToWholeNumber)(set.custom_metric) : 0} ${unitToLabelMap.steps} - ${(0, exports.secondsToWordFormat)(Number(set.duration_seconds))}`;
881
+ default:
882
+ (0, _1.exhaustiveTypeCheck)(exerciseType);
883
+ return '';
884
+ }
885
+ };
886
+ exports.formatSetValue = formatSetValue;
887
+ const rawInstructionsToIndexedSteps = (rawInstructions) => {
888
+ const instructionsByLine = rawInstructions
889
+ .split('\n')
890
+ .map((l) => l.trim())
891
+ .filter((l) => l.length > 0);
892
+ const instructionsByStep = instructionsByLine.map((s) => {
893
+ var _a, _b;
894
+ const parsedStep = s.match(/^([0-9]*)\\?\.(.*)$/);
895
+ if (!parsedStep) {
896
+ // this line doesn't contain a number, so just return it unchanged
897
+ return { description: s };
898
+ }
899
+ const index = Number(parsedStep[1]) || undefined; // if it's NaN or 0, convert it to undefined
900
+ const description = (_b = (_a = parsedStep[2]) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : s;
901
+ return { index, description };
902
+ });
903
+ return instructionsByStep;
904
+ };
905
+ exports.rawInstructionsToIndexedSteps = rawInstructionsToIndexedSteps;
906
+ const roundToKnownValue = (value, knownValues) => {
907
+ if (knownValues.length === 0)
908
+ return undefined;
909
+ const boundingValues = knownValues.reduce(([lower, upper], v) => {
910
+ return [
911
+ v > lower && v <= value ? v : lower,
912
+ v < upper && v >= value ? v : upper,
913
+ ];
914
+ }, [-Infinity, Infinity]);
915
+ if (boundingValues[0] === -Infinity && boundingValues[1] === Infinity)
916
+ return undefined;
917
+ if (boundingValues[0] === -Infinity)
918
+ return boundingValues[1];
919
+ if (boundingValues[1] === Infinity)
920
+ return boundingValues[0];
921
+ return value - boundingValues[0] < boundingValues[1] - value
922
+ ? boundingValues[0]
923
+ : boundingValues[1];
924
+ };
925
+ exports.roundToKnownValue = roundToKnownValue;
926
+ const indexByNearestValue = (value, map) => {
927
+ const index = (0, exports.roundToKnownValue)(value, Object.keys(map).map((e) => Number(e)));
928
+ return index ? map[index] : undefined;
929
+ };
930
+ exports.indexByNearestValue = indexByNearestValue;