hevy-shared 1.0.982 → 1.0.984
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/built/hevyTrainer.d.ts +27 -3
- package/built/hevyTrainer.js +79 -3
- package/built/index.d.ts +25 -6
- package/built/tests/hevyTrainer.test.js +237 -13
- package/package.json +1 -1
package/built/hevyTrainer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WeeklyTrainingFrequency, TrainingGoal, TrainingLevel, SimplifiedMuscleGroup, MuscleGroup, LibraryExercise, ExerciseCategory, GranularEquipment, HevyTrainerProgramEquipment, RestTimerLength } from '.';
|
|
1
|
+
import { WeeklyTrainingFrequency, TrainingGoal, TrainingLevel, SimplifiedMuscleGroup, MuscleGroup, LibraryExercise, ExerciseCategory, GranularEquipment, HevyTrainerProgramEquipment, RestTimerLength, CardioPreference } from '.';
|
|
2
2
|
export type HevyTrainerExerciseCategory = typeof hevyTrainerExerciseCategories[number];
|
|
3
3
|
export type HevyTrainerRoutineName = typeof routineNames[number];
|
|
4
4
|
export declare const workoutDurationOptions: readonly [40, 60, 80];
|
|
@@ -59,6 +59,7 @@ export interface ProgramGenerationParams<T extends HevyTrainerLibraryExercise> {
|
|
|
59
59
|
exerciseStore: T[];
|
|
60
60
|
focusMuscle?: SimplifiedMuscleGroup;
|
|
61
61
|
excludedExerciseIds?: Set<string>;
|
|
62
|
+
cardioPreference: CardioPreference;
|
|
62
63
|
/**
|
|
63
64
|
* When enabled, includes exercise selection trace breadcrumbs in the result
|
|
64
65
|
* for debugging. Intended for troubleshooting on backoffice; keep off in
|
|
@@ -66,7 +67,8 @@ export interface ProgramGenerationParams<T extends HevyTrainerLibraryExercise> {
|
|
|
66
67
|
*/
|
|
67
68
|
debugExerciseSelectionTrace?: boolean;
|
|
68
69
|
}
|
|
69
|
-
export interface
|
|
70
|
+
export interface TemplateExerciseSelectionTraceRecord {
|
|
71
|
+
traceKind: 'template';
|
|
70
72
|
routine: HevyTrainerRoutineName;
|
|
71
73
|
prescriptionIndex: number;
|
|
72
74
|
prescription: ExercisePrescription;
|
|
@@ -75,6 +77,16 @@ export interface ProgramExerciseSelectionTraceRecord {
|
|
|
75
77
|
selectedExerciseId?: string;
|
|
76
78
|
trace: ExerciseSelectionTrace;
|
|
77
79
|
}
|
|
80
|
+
export interface CardioExerciseSelectionTraceRecord {
|
|
81
|
+
traceKind: 'cardio';
|
|
82
|
+
routine: HevyTrainerRoutineName;
|
|
83
|
+
prescriptionIndex: number;
|
|
84
|
+
selectedExerciseId?: string;
|
|
85
|
+
equipments: GranularEquipment[];
|
|
86
|
+
level: TrainingLevel;
|
|
87
|
+
trace: ExerciseSelectionTrace;
|
|
88
|
+
}
|
|
89
|
+
export type ProgramExerciseSelectionTraceRecord = TemplateExerciseSelectionTraceRecord | CardioExerciseSelectionTraceRecord;
|
|
78
90
|
export type TrainerProgramAttemptWithTraces = TrainerProgramAttempt & {
|
|
79
91
|
exerciseSelectionTraces: ProgramExerciseSelectionTraceRecord[];
|
|
80
92
|
};
|
|
@@ -145,7 +157,9 @@ export interface TrainerAlgorithmSettings {
|
|
|
145
157
|
exercise_notes: ExerciseNotes;
|
|
146
158
|
exercise_replacements: ExerciseReplacements;
|
|
147
159
|
}
|
|
148
|
-
|
|
160
|
+
/** Resistance prescriptions from templates (sets, reps, rest, notes). */
|
|
161
|
+
export interface TrainerProgramResistanceExercise {
|
|
162
|
+
kind: 'resistance';
|
|
149
163
|
exerciseTemplate: HevyTrainerLibraryExercise;
|
|
150
164
|
muscleGroup: MuscleGroup | 'focus_muscle';
|
|
151
165
|
category: HevyTrainerExerciseCategory;
|
|
@@ -156,6 +170,15 @@ export interface TrainerProgramExercise {
|
|
|
156
170
|
restTimerSeconds: number;
|
|
157
171
|
notes?: string;
|
|
158
172
|
}
|
|
173
|
+
/** Optional cardio attachment — no prescription semantics beyond exercise choice. */
|
|
174
|
+
export interface TrainerProgramCardioExercise {
|
|
175
|
+
kind: 'cardio';
|
|
176
|
+
exerciseTemplate: HevyTrainerLibraryExercise;
|
|
177
|
+
durationSeconds: number;
|
|
178
|
+
}
|
|
179
|
+
export type TrainerProgramExercise = TrainerProgramResistanceExercise | TrainerProgramCardioExercise;
|
|
180
|
+
export declare const isTrainerProgramResistanceExercise: (exercise: TrainerProgramExercise) => exercise is TrainerProgramResistanceExercise;
|
|
181
|
+
export declare const isTrainerProgramCardioExercise: (exercise: TrainerProgramExercise) => exercise is TrainerProgramCardioExercise;
|
|
159
182
|
export interface TrainerProgramRoutine {
|
|
160
183
|
name: HevyTrainerRoutineName;
|
|
161
184
|
exercises: TrainerProgramExercise[];
|
|
@@ -208,6 +231,7 @@ export declare function pickExerciseForPrescription<T extends HevyTrainerLibrary
|
|
|
208
231
|
}): PickExerciseResult<T>;
|
|
209
232
|
export declare function pickExerciseForPrescription<T extends HevyTrainerLibraryExercise>(params: ExerciseSelectionParams<T>): T | undefined;
|
|
210
233
|
export type HevyTrainerLibraryExercise = Pick<LibraryExercise, 'id' | 'title' | 'priority' | 'muscle_group' | 'other_muscles' | 'exercise_type' | 'equipment_category' | 'category' | 'level' | 'goal' | 'granular_equipments'>;
|
|
234
|
+
export declare const DEFAULT_TRAINER_CARDIO_ATTACHMENT_DURATION_SECONDS = 600;
|
|
211
235
|
export interface ExercisePrescriptionError {
|
|
212
236
|
type: 'exercise_not_found';
|
|
213
237
|
prescription: ExercisePrescription;
|
package/built/hevyTrainer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getPrioritySortedExercises = exports.isEquipmentCompatible = exports.normalizeExerciseCategory = exports.getTrainerRestTimerSeconds = exports.getTrainerRepRange = exports.getTrainerSetCount = exports.programSplits = exports.frequencyMap = exports.routineNames = exports.defaultDurationPerFrequency = exports.hevyTrainerExerciseCategories = exports.granularEquipmentsToTrainerEquipments = exports.trainerEquipmentToGranularEquipments = exports.granularEquipmentDefaults = exports.trainerGymTypes = exports.workoutDurationOptions = void 0;
|
|
3
|
+
exports.DEFAULT_TRAINER_CARDIO_ATTACHMENT_DURATION_SECONDS = exports.getPrioritySortedExercises = exports.isEquipmentCompatible = exports.normalizeExerciseCategory = exports.getTrainerRestTimerSeconds = exports.getTrainerRepRange = exports.getTrainerSetCount = exports.isTrainerProgramCardioExercise = exports.isTrainerProgramResistanceExercise = exports.programSplits = exports.frequencyMap = exports.routineNames = exports.defaultDurationPerFrequency = exports.hevyTrainerExerciseCategories = exports.granularEquipmentsToTrainerEquipments = exports.trainerEquipmentToGranularEquipments = exports.granularEquipmentDefaults = exports.trainerGymTypes = exports.workoutDurationOptions = void 0;
|
|
4
4
|
exports.pickExerciseForPrescription = pickExerciseForPrescription;
|
|
5
5
|
exports.generateProgram = generateProgram;
|
|
6
6
|
const _1 = require(".");
|
|
@@ -197,6 +197,10 @@ exports.programSplits = {
|
|
|
197
197
|
5: ['push_1', 'pull_1', 'legs_1', 'upper_2', 'lower_2'],
|
|
198
198
|
6: ['push_2_a', 'pull_2_a', 'legs_2_a', 'push_2_b', 'pull_2_b', 'legs_2_b'],
|
|
199
199
|
};
|
|
200
|
+
const isTrainerProgramResistanceExercise = (exercise) => exercise.kind === 'resistance';
|
|
201
|
+
exports.isTrainerProgramResistanceExercise = isTrainerProgramResistanceExercise;
|
|
202
|
+
const isTrainerProgramCardioExercise = (exercise) => exercise.kind === 'cardio';
|
|
203
|
+
exports.isTrainerProgramCardioExercise = isTrainerProgramCardioExercise;
|
|
200
204
|
const getTrainerSetCount = (trainerAlgorithmSettings, goal, frequency) => {
|
|
201
205
|
return trainerAlgorithmSettings.sets[exports.frequencyMap[frequency]][goal];
|
|
202
206
|
};
|
|
@@ -433,7 +437,8 @@ const isAlternativeIsolationExerciseMatch = (exercise, criteria) => {
|
|
|
433
437
|
/**
|
|
434
438
|
* Finds an exercise that matches the criteria and is not already used
|
|
435
439
|
*/
|
|
436
|
-
const findMatchingExercise = (exercises,
|
|
440
|
+
const findMatchingExercise = (exercises, //TODO: muscle group exercises
|
|
441
|
+
criteria, context) => {
|
|
437
442
|
return exercises.find((exercise) => {
|
|
438
443
|
const matchesCriteria = isExerciseMatch(exercise, criteria);
|
|
439
444
|
const isNotUsed = !isExerciseUsed(exercise, context);
|
|
@@ -535,15 +540,74 @@ function pickExerciseForPrescription(params) {
|
|
|
535
540
|
}
|
|
536
541
|
return pickExerciseForPrescriptionWithTrace(params).exercise;
|
|
537
542
|
}
|
|
543
|
+
const CARDIO_TRACE_INDEX_BEFORE_TEMPLATE = -1;
|
|
544
|
+
exports.DEFAULT_TRAINER_CARDIO_ATTACHMENT_DURATION_SECONDS = 600;
|
|
545
|
+
const appendCardioExerciseSelectionTrace = (traces, params) => {
|
|
546
|
+
traces.push({
|
|
547
|
+
traceKind: 'cardio',
|
|
548
|
+
routine: params.routine,
|
|
549
|
+
prescriptionIndex: params.prescriptionIndex,
|
|
550
|
+
selectedExerciseId: params.selectedExerciseId,
|
|
551
|
+
equipments: params.equipments,
|
|
552
|
+
level: params.level,
|
|
553
|
+
trace: {
|
|
554
|
+
entries: [
|
|
555
|
+
{
|
|
556
|
+
pass: 1,
|
|
557
|
+
label: 'cardio: first priority exercise matching equipment + level; globally excluded IDs skipped',
|
|
558
|
+
candidatePoolSize: params.candidatePoolSize,
|
|
559
|
+
selectedExerciseId: params.selectedExerciseId,
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
selectedPass: 1,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
};
|
|
566
|
+
/** Inserts optional cardio before or after template exercises for one routine. */
|
|
567
|
+
const attachOptionalCardioToRoutine = ({ cardioPreference, cardioExercises, criteria, debugExerciseSelectionTrace, exerciseSelectionTraces, routine, templatePrescriptionCount, routineExercises, }) => {
|
|
568
|
+
if (cardioPreference === 'no-cardio')
|
|
569
|
+
return;
|
|
570
|
+
const cardioExercise = cardioExercises.find((ex) => {
|
|
571
|
+
var _a, _b, _c;
|
|
572
|
+
const equipmentCompatible = (0, exports.isEquipmentCompatible)(ex, criteria.equipments);
|
|
573
|
+
const levelCompatible = (_a = ex.level) === null || _a === void 0 ? void 0 : _a.includes(criteria.level);
|
|
574
|
+
const excluded = (_c = (_b = criteria.excludedExerciseIds) === null || _b === void 0 ? void 0 : _b.has(ex.id)) !== null && _c !== void 0 ? _c : false;
|
|
575
|
+
return equipmentCompatible && levelCompatible && !excluded;
|
|
576
|
+
});
|
|
577
|
+
if (!cardioExercise)
|
|
578
|
+
return;
|
|
579
|
+
const slot = {
|
|
580
|
+
kind: 'cardio',
|
|
581
|
+
exerciseTemplate: cardioExercise,
|
|
582
|
+
durationSeconds: exports.DEFAULT_TRAINER_CARDIO_ATTACHMENT_DURATION_SECONDS,
|
|
583
|
+
};
|
|
584
|
+
if (cardioPreference === 'workout-start')
|
|
585
|
+
routineExercises.unshift(slot);
|
|
586
|
+
else
|
|
587
|
+
routineExercises.push(slot);
|
|
588
|
+
if (debugExerciseSelectionTrace) {
|
|
589
|
+
appendCardioExerciseSelectionTrace(exerciseSelectionTraces, {
|
|
590
|
+
routine,
|
|
591
|
+
prescriptionIndex: cardioPreference === 'workout-start'
|
|
592
|
+
? CARDIO_TRACE_INDEX_BEFORE_TEMPLATE
|
|
593
|
+
: templatePrescriptionCount,
|
|
594
|
+
selectedExerciseId: cardioExercise.id,
|
|
595
|
+
equipments: criteria.equipments,
|
|
596
|
+
level: criteria.level,
|
|
597
|
+
candidatePoolSize: cardioExercises.length,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
};
|
|
538
601
|
function generateProgram(params) {
|
|
539
602
|
var _a, _b;
|
|
540
|
-
const { trainerAlgorithmSettings, frequency, goal, level, equipments, workoutDurationMinutes, restTimerLength, exerciseStore, focusMuscle, excludedExerciseIds, debugExerciseSelectionTrace, } = params;
|
|
603
|
+
const { trainerAlgorithmSettings, frequency, goal, level, equipments, workoutDurationMinutes, restTimerLength, exerciseStore, focusMuscle, excludedExerciseIds, debugExerciseSelectionTrace, cardioPreference, } = params;
|
|
541
604
|
const exerciseSelectionTraces = [];
|
|
542
605
|
const routines = exports.programSplits[frequency];
|
|
543
606
|
const program = {
|
|
544
607
|
name: frequency,
|
|
545
608
|
routines: [],
|
|
546
609
|
};
|
|
610
|
+
// TODO: Rename sortedExercises to sortedExercisesByMuscleGroup or something similar
|
|
547
611
|
const sortedExercises = (0, exports.getPrioritySortedExercises)(trainerAlgorithmSettings.exercise_priorities, exerciseStore);
|
|
548
612
|
const programUsedExerciseIds = new Set();
|
|
549
613
|
let programFocusMuscleExerciseCount = 0;
|
|
@@ -596,6 +660,7 @@ function generateProgram(params) {
|
|
|
596
660
|
});
|
|
597
661
|
exercise = selection.exercise;
|
|
598
662
|
exerciseSelectionTraces.push({
|
|
663
|
+
traceKind: 'template',
|
|
599
664
|
routine,
|
|
600
665
|
prescriptionIndex,
|
|
601
666
|
prescription: exercisePrescription,
|
|
@@ -625,6 +690,7 @@ function generateProgram(params) {
|
|
|
625
690
|
programFocusMuscleExerciseCount++;
|
|
626
691
|
const repRange = (0, exports.getTrainerRepRange)(trainerAlgorithmSettings, goal, exercisePrescription.category);
|
|
627
692
|
routineExercises.push({
|
|
693
|
+
kind: 'resistance',
|
|
628
694
|
exerciseTemplate: exercise,
|
|
629
695
|
muscleGroup: exercisePrescription.muscle_group,
|
|
630
696
|
category: exercisePrescription.category,
|
|
@@ -651,6 +717,16 @@ function generateProgram(params) {
|
|
|
651
717
|
});
|
|
652
718
|
}
|
|
653
719
|
}
|
|
720
|
+
attachOptionalCardioToRoutine({
|
|
721
|
+
cardioPreference,
|
|
722
|
+
cardioExercises: sortedExercises.cardio,
|
|
723
|
+
criteria: { equipments, level, excludedExerciseIds },
|
|
724
|
+
debugExerciseSelectionTrace,
|
|
725
|
+
exerciseSelectionTraces,
|
|
726
|
+
routine,
|
|
727
|
+
templatePrescriptionCount: routineTemplate.exercises.length,
|
|
728
|
+
routineExercises,
|
|
729
|
+
});
|
|
654
730
|
program.routines.push({
|
|
655
731
|
name: routine,
|
|
656
732
|
notes: routineTemplate.notes,
|
package/built/index.d.ts
CHANGED
|
@@ -651,6 +651,7 @@ export type TrainingGoal = Lookup<typeof trainingGoals>;
|
|
|
651
651
|
export type TrainingLevel = Lookup<typeof trainingLevels>;
|
|
652
652
|
export type ExerciseCategory = Lookup<typeof exerciseCategories>;
|
|
653
653
|
export type RestTimerLength = typeof restTimerLengths[number];
|
|
654
|
+
export type CardioPreference = 'no-cardio' | 'workout-start' | 'workout-end';
|
|
654
655
|
export type HevyTrainerProgramEquipment = Extract<Equipment, 'barbell' | 'dumbbell' | 'machine'>;
|
|
655
656
|
export declare const hevyTrainerProgramEquipments: readonly ["barbell", "dumbbell", "machine"];
|
|
656
657
|
export declare const granularEquipments: readonly ["barbell", "dumbbell", "kettlebell", "plate", "medicine_ball", "ez_bar", "landmine", "trap_bar", "pullup_bar", "dip_bar", "squat_rack", "flat_bench", "adjustable_bench", "dual_cable_machine", "single_cable_machine", "lat_pulldown_cable", "leg_press_machine", "smith_machine", "t_bar", "plate_machines", "stack_machines", "treadmill", "elliptical_trainer", "rowing_machine", "spinning", "stair_machine", "air_bike", "suspension_band", "resistance_band", "battle_rope", "rings", "jump_rope"];
|
|
@@ -965,6 +966,19 @@ export interface PostWorkoutRequestWorkout {
|
|
|
965
966
|
is_biometrics_public: boolean;
|
|
966
967
|
trainer_program_id: string | undefined;
|
|
967
968
|
}
|
|
969
|
+
export type WorkoutDataImporterReport = {
|
|
970
|
+
state: 'idle';
|
|
971
|
+
} | {
|
|
972
|
+
state: 'waiting';
|
|
973
|
+
} | {
|
|
974
|
+
state: 'import-exercises';
|
|
975
|
+
} | {
|
|
976
|
+
state: 'import-workouts';
|
|
977
|
+
current: number;
|
|
978
|
+
total: number;
|
|
979
|
+
} | {
|
|
980
|
+
state: 'rollback';
|
|
981
|
+
} | never;
|
|
968
982
|
export declare const isHeartRateSamples: (x: any) => x is HeartRateSample[];
|
|
969
983
|
export declare const isWorkoutBiometrics: (x: any) => x is WorkoutBiometrics;
|
|
970
984
|
export interface WorkoutBiometrics {
|
|
@@ -1264,11 +1278,13 @@ export interface HevyTrainerProgram {
|
|
|
1264
1278
|
routines: HevyTrainerRoutine[];
|
|
1265
1279
|
focus_muscle?: SimplifiedMuscleGroup;
|
|
1266
1280
|
next_workout_index: number;
|
|
1267
|
-
workout_duration_minutes
|
|
1281
|
+
workout_duration_minutes: WorkoutDurationMinutes;
|
|
1268
1282
|
rest_timer_length: RestTimerLength;
|
|
1283
|
+
cardio_preference: CardioPreference;
|
|
1269
1284
|
}
|
|
1270
1285
|
export interface PostHevyTrainerProgramRequestBody {
|
|
1271
1286
|
program: {
|
|
1287
|
+
version: 1;
|
|
1272
1288
|
title: string;
|
|
1273
1289
|
level: TrainingLevel;
|
|
1274
1290
|
goal: TrainingGoal;
|
|
@@ -1276,13 +1292,15 @@ export interface PostHevyTrainerProgramRequestBody {
|
|
|
1276
1292
|
weekly_frequency: WeeklyTrainingFrequency;
|
|
1277
1293
|
focus_muscle?: SimplifiedMuscleGroup;
|
|
1278
1294
|
routines: RoutineUpdate[];
|
|
1279
|
-
next_workout_index
|
|
1280
|
-
workout_duration_minutes
|
|
1295
|
+
next_workout_index: number;
|
|
1296
|
+
workout_duration_minutes: WorkoutDurationMinutes;
|
|
1281
1297
|
rest_timer_length: RestTimerLength;
|
|
1298
|
+
cardio_preference: CardioPreference;
|
|
1282
1299
|
};
|
|
1283
1300
|
}
|
|
1284
1301
|
export interface UpdateHevyTrainerProgramRequestBody {
|
|
1285
1302
|
program: {
|
|
1303
|
+
version: 1;
|
|
1286
1304
|
programId: string;
|
|
1287
1305
|
title: string;
|
|
1288
1306
|
level: TrainingLevel;
|
|
@@ -1290,9 +1308,10 @@ export interface UpdateHevyTrainerProgramRequestBody {
|
|
|
1290
1308
|
equipments: GranularEquipment[];
|
|
1291
1309
|
weekly_frequency: WeeklyTrainingFrequency;
|
|
1292
1310
|
focus_muscle?: SimplifiedMuscleGroup;
|
|
1293
|
-
next_workout_index
|
|
1294
|
-
workout_duration_minutes
|
|
1295
|
-
rest_timer_length
|
|
1311
|
+
next_workout_index: number;
|
|
1312
|
+
workout_duration_minutes: WorkoutDurationMinutes;
|
|
1313
|
+
rest_timer_length: RestTimerLength;
|
|
1314
|
+
cardio_preference: CardioPreference;
|
|
1296
1315
|
routines: {
|
|
1297
1316
|
id: string;
|
|
1298
1317
|
title: string;
|
|
@@ -608,6 +608,7 @@ describe('generateProgram', () => {
|
|
|
608
608
|
workoutDurationMinutes: 60,
|
|
609
609
|
restTimerLength: 'medium',
|
|
610
610
|
exerciseStore: [chestExercise],
|
|
611
|
+
cardioPreference: 'no-cardio',
|
|
611
612
|
});
|
|
612
613
|
expect(result.success).toBe(true);
|
|
613
614
|
if (!result.success)
|
|
@@ -619,6 +620,9 @@ describe('generateProgram', () => {
|
|
|
619
620
|
expect(routine.notes).toBe('Hit the chest hard.');
|
|
620
621
|
expect(routine.exercises).toHaveLength(1);
|
|
621
622
|
const [exercise] = routine.exercises;
|
|
623
|
+
expect(exercise.kind).toBe('resistance');
|
|
624
|
+
if (exercise.kind !== 'resistance')
|
|
625
|
+
return;
|
|
622
626
|
expect(exercise.exerciseTemplate.id).toBe('bench');
|
|
623
627
|
expect(exercise.category).toBe('compound');
|
|
624
628
|
expect(exercise.muscleGroup).toBe('chest');
|
|
@@ -642,6 +646,7 @@ describe('generateProgram', () => {
|
|
|
642
646
|
workoutDurationMinutes: 60,
|
|
643
647
|
restTimerLength: 'medium',
|
|
644
648
|
exerciseStore: [],
|
|
649
|
+
cardioPreference: 'no-cardio',
|
|
645
650
|
});
|
|
646
651
|
expect(result.success).toBe(false);
|
|
647
652
|
if (result.success)
|
|
@@ -683,6 +688,7 @@ describe('generateProgram', () => {
|
|
|
683
688
|
workoutDurationMinutes,
|
|
684
689
|
restTimerLength: 'medium',
|
|
685
690
|
exerciseStore: [makeExercise({ id: 'bench' })],
|
|
691
|
+
cardioPreference: 'no-cardio',
|
|
686
692
|
});
|
|
687
693
|
const routine = (result.success ? result.program : result.partialProgram)
|
|
688
694
|
.routines[0];
|
|
@@ -751,6 +757,7 @@ describe('generateProgram', () => {
|
|
|
751
757
|
makeExercise({ id: 'bench-2', priority: 2 }),
|
|
752
758
|
makeExercise({ id: 'bench-3', priority: 1 }),
|
|
753
759
|
],
|
|
760
|
+
cardioPreference: 'no-cardio',
|
|
754
761
|
});
|
|
755
762
|
expect(result.success).toBe(true);
|
|
756
763
|
if (!result.success)
|
|
@@ -758,7 +765,10 @@ describe('generateProgram', () => {
|
|
|
758
765
|
// Only the 40- and 60-minute prescriptions survive; the 80-minute one
|
|
759
766
|
// is filtered out. Order is preserved.
|
|
760
767
|
const placed = result.program.routines[0].exercises;
|
|
761
|
-
expect(placed.
|
|
768
|
+
expect(placed.every((e) => e.kind === 'resistance')).toBe(true);
|
|
769
|
+
expect(placed
|
|
770
|
+
.filter(hevyTrainer_1.isTrainerProgramResistanceExercise)
|
|
771
|
+
.map((e) => e.warmupSetCount)).toEqual([1, 2]);
|
|
762
772
|
});
|
|
763
773
|
});
|
|
764
774
|
it('skips focus_muscle prescriptions when no focusMuscle is provided', () => {
|
|
@@ -777,6 +787,7 @@ describe('generateProgram', () => {
|
|
|
777
787
|
exerciseStore: [
|
|
778
788
|
makeExercise({ id: 'biceps-curl', muscle_group: 'biceps' }),
|
|
779
789
|
],
|
|
790
|
+
cardioPreference: 'no-cardio',
|
|
780
791
|
});
|
|
781
792
|
expect(result.success).toBe(true);
|
|
782
793
|
if (!result.success)
|
|
@@ -803,11 +814,15 @@ describe('generateProgram', () => {
|
|
|
803
814
|
restTimerLength: 'medium',
|
|
804
815
|
exerciseStore: [bicepsCurl],
|
|
805
816
|
focusMuscle: 'arms',
|
|
817
|
+
cardioPreference: 'no-cardio',
|
|
806
818
|
});
|
|
807
819
|
expect(result.success).toBe(true);
|
|
808
820
|
if (!result.success)
|
|
809
821
|
return;
|
|
810
822
|
const [exercise] = result.program.routines[0].exercises;
|
|
823
|
+
expect(exercise.kind).toBe('resistance');
|
|
824
|
+
if (exercise.kind !== 'resistance')
|
|
825
|
+
return;
|
|
811
826
|
expect(exercise.exerciseTemplate.id).toBe('biceps-curl');
|
|
812
827
|
expect(exercise.muscleGroup).toBe('focus_muscle');
|
|
813
828
|
});
|
|
@@ -825,6 +840,7 @@ describe('generateProgram', () => {
|
|
|
825
840
|
restTimerLength: 'medium',
|
|
826
841
|
exerciseStore: [excluded, alt],
|
|
827
842
|
excludedExerciseIds: new Set(['excluded']),
|
|
843
|
+
cardioPreference: 'no-cardio',
|
|
828
844
|
});
|
|
829
845
|
expect(result.success).toBe(true);
|
|
830
846
|
if (!result.success)
|
|
@@ -844,10 +860,12 @@ describe('generateProgram', () => {
|
|
|
844
860
|
restTimerLength: 'medium',
|
|
845
861
|
exerciseStore: [chest],
|
|
846
862
|
debugExerciseSelectionTrace: true,
|
|
863
|
+
cardioPreference: 'no-cardio',
|
|
847
864
|
});
|
|
848
865
|
expect(result.success).toBe(true);
|
|
849
866
|
expect(result.exerciseSelectionTraces).toHaveLength(1);
|
|
850
867
|
const [trace] = result.exerciseSelectionTraces;
|
|
868
|
+
expect(trace.traceKind).toBe('template');
|
|
851
869
|
expect(trace.routine).toBe('full_body_1');
|
|
852
870
|
expect(trace.selectedExerciseId).toBe('bench');
|
|
853
871
|
expect(trace.trace.selectedPass).toBe(1);
|
|
@@ -876,10 +894,213 @@ describe('generateProgram', () => {
|
|
|
876
894
|
makeExercise({ id: 'bench-2' }),
|
|
877
895
|
],
|
|
878
896
|
debugExerciseSelectionTrace: true,
|
|
897
|
+
cardioPreference: 'no-cardio',
|
|
879
898
|
});
|
|
880
899
|
expect(result.success).toBe(true);
|
|
881
900
|
expect(result.exerciseSelectionTraces.map((t) => t.prescriptionIndex)).toEqual([0, 1]);
|
|
882
901
|
});
|
|
902
|
+
it('records a single cardio trace entry (no multi-pass picker)', () => {
|
|
903
|
+
const settings = makeSettings();
|
|
904
|
+
settings.templates.full_body_1 = {
|
|
905
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
906
|
+
};
|
|
907
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
908
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
909
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
910
|
+
trainerAlgorithmSettings: settings,
|
|
911
|
+
frequency: 1,
|
|
912
|
+
goal: 'strength',
|
|
913
|
+
level: 'beginner',
|
|
914
|
+
equipments: [],
|
|
915
|
+
workoutDurationMinutes: 60,
|
|
916
|
+
restTimerLength: 'medium',
|
|
917
|
+
exerciseStore: [
|
|
918
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
919
|
+
makeExercise({
|
|
920
|
+
id: 'run',
|
|
921
|
+
muscle_group: 'cardio',
|
|
922
|
+
category: 'compound',
|
|
923
|
+
equipment_category: 'other',
|
|
924
|
+
}),
|
|
925
|
+
],
|
|
926
|
+
debugExerciseSelectionTrace: true,
|
|
927
|
+
cardioPreference: 'workout-start',
|
|
928
|
+
});
|
|
929
|
+
expect(result.success).toBe(true);
|
|
930
|
+
if (!result.success)
|
|
931
|
+
return;
|
|
932
|
+
const cardioTrace = result.exerciseSelectionTraces.find((t) => t.traceKind === 'cardio');
|
|
933
|
+
expect(cardioTrace).toBeDefined();
|
|
934
|
+
expect(cardioTrace.prescriptionIndex).toBe(-1);
|
|
935
|
+
expect(cardioTrace.trace.entries).toHaveLength(1);
|
|
936
|
+
expect(cardioTrace.trace.selectedPass).toBe(1);
|
|
937
|
+
expect(cardioTrace.trace.entries[0].selectedExerciseId).toBe('run');
|
|
938
|
+
expect(cardioTrace.equipments).toEqual([]);
|
|
939
|
+
expect(cardioTrace.level).toBe('beginner');
|
|
940
|
+
});
|
|
941
|
+
it('uses the same prioritized cardio exercise on every routine (not blocked by program reuse)', () => {
|
|
942
|
+
const settings = makeSettings();
|
|
943
|
+
settings.templates.full_body_2_a = {
|
|
944
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
945
|
+
};
|
|
946
|
+
settings.templates.full_body_2_b = {
|
|
947
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
948
|
+
};
|
|
949
|
+
settings.exercise_priorities.chest = ['bench-a', 'bench-b'];
|
|
950
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
951
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
952
|
+
trainerAlgorithmSettings: settings,
|
|
953
|
+
frequency: 2,
|
|
954
|
+
goal: 'strength',
|
|
955
|
+
level: 'beginner',
|
|
956
|
+
equipments: [],
|
|
957
|
+
workoutDurationMinutes: 60,
|
|
958
|
+
restTimerLength: 'medium',
|
|
959
|
+
exerciseStore: [
|
|
960
|
+
makeExercise({ id: 'bench-a', muscle_group: 'chest' }),
|
|
961
|
+
makeExercise({ id: 'bench-b', muscle_group: 'chest' }),
|
|
962
|
+
makeExercise({
|
|
963
|
+
id: 'run',
|
|
964
|
+
muscle_group: 'cardio',
|
|
965
|
+
category: 'compound',
|
|
966
|
+
equipment_category: 'other',
|
|
967
|
+
}),
|
|
968
|
+
],
|
|
969
|
+
cardioPreference: 'workout-start',
|
|
970
|
+
});
|
|
971
|
+
expect(result.success).toBe(true);
|
|
972
|
+
if (!result.success)
|
|
973
|
+
return;
|
|
974
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
975
|
+
kind: 'cardio',
|
|
976
|
+
exerciseTemplate: { id: 'run' },
|
|
977
|
+
});
|
|
978
|
+
expect(result.program.routines[1].exercises[0]).toMatchObject({
|
|
979
|
+
kind: 'cardio',
|
|
980
|
+
exerciseTemplate: { id: 'run' },
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
it('skips cardio exercises that need equipment the user does not have', () => {
|
|
984
|
+
const settings = makeSettings();
|
|
985
|
+
settings.templates.full_body_1 = {
|
|
986
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
987
|
+
};
|
|
988
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
989
|
+
settings.exercise_priorities.cardio = ['treadmill-run', 'body-run'];
|
|
990
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
991
|
+
trainerAlgorithmSettings: settings,
|
|
992
|
+
frequency: 1,
|
|
993
|
+
goal: 'strength',
|
|
994
|
+
level: 'beginner',
|
|
995
|
+
equipments: [],
|
|
996
|
+
workoutDurationMinutes: 60,
|
|
997
|
+
restTimerLength: 'medium',
|
|
998
|
+
exerciseStore: [
|
|
999
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
1000
|
+
makeExercise({
|
|
1001
|
+
id: 'treadmill-run',
|
|
1002
|
+
muscle_group: 'cardio',
|
|
1003
|
+
category: 'compound',
|
|
1004
|
+
equipment_category: 'other',
|
|
1005
|
+
granular_equipments: ['treadmill'],
|
|
1006
|
+
}),
|
|
1007
|
+
makeExercise({
|
|
1008
|
+
id: 'body-run',
|
|
1009
|
+
muscle_group: 'cardio',
|
|
1010
|
+
category: 'compound',
|
|
1011
|
+
equipment_category: 'other',
|
|
1012
|
+
}),
|
|
1013
|
+
],
|
|
1014
|
+
cardioPreference: 'workout-start',
|
|
1015
|
+
});
|
|
1016
|
+
expect(result.success).toBe(true);
|
|
1017
|
+
if (!result.success)
|
|
1018
|
+
return;
|
|
1019
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
1020
|
+
kind: 'cardio',
|
|
1021
|
+
exerciseTemplate: { id: 'body-run' },
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
it('skips globally excluded cardio and uses the next priority exercise', () => {
|
|
1025
|
+
const settings = makeSettings();
|
|
1026
|
+
settings.templates.full_body_1 = {
|
|
1027
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1028
|
+
};
|
|
1029
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
1030
|
+
settings.exercise_priorities.cardio = ['run-a', 'run-b'];
|
|
1031
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1032
|
+
trainerAlgorithmSettings: settings,
|
|
1033
|
+
frequency: 1,
|
|
1034
|
+
goal: 'strength',
|
|
1035
|
+
level: 'beginner',
|
|
1036
|
+
equipments: [],
|
|
1037
|
+
workoutDurationMinutes: 60,
|
|
1038
|
+
restTimerLength: 'medium',
|
|
1039
|
+
exerciseStore: [
|
|
1040
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
1041
|
+
makeExercise({
|
|
1042
|
+
id: 'run-a',
|
|
1043
|
+
muscle_group: 'cardio',
|
|
1044
|
+
category: 'compound',
|
|
1045
|
+
equipment_category: 'other',
|
|
1046
|
+
}),
|
|
1047
|
+
makeExercise({
|
|
1048
|
+
id: 'run-b',
|
|
1049
|
+
muscle_group: 'cardio',
|
|
1050
|
+
category: 'compound',
|
|
1051
|
+
equipment_category: 'other',
|
|
1052
|
+
}),
|
|
1053
|
+
],
|
|
1054
|
+
excludedExerciseIds: new Set(['run-a']),
|
|
1055
|
+
cardioPreference: 'workout-start',
|
|
1056
|
+
});
|
|
1057
|
+
expect(result.success).toBe(true);
|
|
1058
|
+
if (!result.success)
|
|
1059
|
+
return;
|
|
1060
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
1061
|
+
kind: 'cardio',
|
|
1062
|
+
exerciseTemplate: { id: 'run-b' },
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
it('keeps global trace order aligned with routine generation (no cross-routine unshift)', () => {
|
|
1066
|
+
const settings = makeSettings();
|
|
1067
|
+
settings.templates.full_body_2_a = {
|
|
1068
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1069
|
+
};
|
|
1070
|
+
settings.templates.full_body_2_b = {
|
|
1071
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1072
|
+
};
|
|
1073
|
+
settings.exercise_priorities.chest = ['bench-a', 'bench-b'];
|
|
1074
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
1075
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1076
|
+
trainerAlgorithmSettings: settings,
|
|
1077
|
+
frequency: 2,
|
|
1078
|
+
goal: 'strength',
|
|
1079
|
+
level: 'beginner',
|
|
1080
|
+
equipments: [],
|
|
1081
|
+
workoutDurationMinutes: 60,
|
|
1082
|
+
restTimerLength: 'medium',
|
|
1083
|
+
exerciseStore: [
|
|
1084
|
+
makeExercise({ id: 'bench-a', muscle_group: 'chest' }),
|
|
1085
|
+
makeExercise({ id: 'bench-b', muscle_group: 'chest' }),
|
|
1086
|
+
makeExercise({
|
|
1087
|
+
id: 'run',
|
|
1088
|
+
muscle_group: 'cardio',
|
|
1089
|
+
category: 'compound',
|
|
1090
|
+
equipment_category: 'other',
|
|
1091
|
+
}),
|
|
1092
|
+
],
|
|
1093
|
+
debugExerciseSelectionTrace: true,
|
|
1094
|
+
cardioPreference: 'workout-start',
|
|
1095
|
+
});
|
|
1096
|
+
expect(result.success).toBe(true);
|
|
1097
|
+
if (!result.success)
|
|
1098
|
+
return;
|
|
1099
|
+
const idxA = result.exerciseSelectionTraces.findIndex((t) => t.routine === 'full_body_2_a');
|
|
1100
|
+
const idxB = result.exerciseSelectionTraces.findIndex((t) => t.routine === 'full_body_2_b');
|
|
1101
|
+
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
1102
|
+
expect(idxB).toBeGreaterThan(idxA);
|
|
1103
|
+
});
|
|
883
1104
|
describe('barbell cap at frequency === 1 (end-to-end)', () => {
|
|
884
1105
|
it('drops the 4th+ barbell prescription when the user has a substitute', () => {
|
|
885
1106
|
const settings = makeSettings();
|
|
@@ -907,6 +1128,7 @@ describe('generateProgram', () => {
|
|
|
907
1128
|
workoutDurationMinutes: 60,
|
|
908
1129
|
restTimerLength: 'medium',
|
|
909
1130
|
exerciseStore: exercises,
|
|
1131
|
+
cardioPreference: 'no-cardio',
|
|
910
1132
|
});
|
|
911
1133
|
expect(result.success).toBe(false);
|
|
912
1134
|
if (result.success)
|
|
@@ -937,6 +1159,7 @@ describe('generateProgram', () => {
|
|
|
937
1159
|
workoutDurationMinutes: 60,
|
|
938
1160
|
restTimerLength: 'medium',
|
|
939
1161
|
exerciseStore: exercises,
|
|
1162
|
+
cardioPreference: 'no-cardio',
|
|
940
1163
|
});
|
|
941
1164
|
expect(result.success).toBe(true);
|
|
942
1165
|
if (!result.success)
|
|
@@ -990,24 +1213,21 @@ describe('generateProgram', () => {
|
|
|
990
1213
|
exerciseStore: exercises,
|
|
991
1214
|
focusMuscle: 'arms',
|
|
992
1215
|
debugExerciseSelectionTrace: true,
|
|
1216
|
+
cardioPreference: 'no-cardio',
|
|
993
1217
|
});
|
|
994
1218
|
const traces = result.exerciseSelectionTraces;
|
|
995
1219
|
// Counter increments once per placed focus_muscle prescription, so the
|
|
996
1220
|
// 4th prescription wraps back to the first entry of
|
|
997
1221
|
// simplifiedMuscleGroupToMuscleGroups.arms ('biceps').
|
|
998
|
-
expect(traces
|
|
999
|
-
'
|
|
1000
|
-
'triceps',
|
|
1001
|
-
'forearms',
|
|
1002
|
-
'biceps',
|
|
1003
|
-
]);
|
|
1222
|
+
expect(traces
|
|
1223
|
+
.filter((t) => t.traceKind === 'template')
|
|
1224
|
+
.map((t) => t.resolvedMuscleGroup)).toEqual(['biceps', 'triceps', 'forearms', 'biceps']);
|
|
1004
1225
|
// The first three passes should land on the priority-matching exercise
|
|
1005
1226
|
// (each muscle's only exercise is picked by pass 1).
|
|
1006
|
-
expect(traces
|
|
1007
|
-
'
|
|
1008
|
-
|
|
1009
|
-
'forearms-ex'
|
|
1010
|
-
]);
|
|
1227
|
+
expect(traces
|
|
1228
|
+
.filter((t) => t.traceKind === 'template')
|
|
1229
|
+
.slice(0, 3)
|
|
1230
|
+
.map((t) => t.selectedExerciseId)).toEqual(['biceps-ex', 'triceps-ex', 'forearms-ex']);
|
|
1011
1231
|
});
|
|
1012
1232
|
it('does not advance the focus_muscle counter when a prescription is skipped for template mismatch', () => {
|
|
1013
1233
|
// 5-day split: push_1, pull_1, legs_1, upper_2, lower_2.
|
|
@@ -1051,6 +1271,7 @@ describe('generateProgram', () => {
|
|
|
1051
1271
|
exerciseStore: exercises,
|
|
1052
1272
|
focusMuscle: 'legs',
|
|
1053
1273
|
debugExerciseSelectionTrace: true,
|
|
1274
|
+
cardioPreference: 'no-cardio',
|
|
1054
1275
|
});
|
|
1055
1276
|
expect(result.success).toBe(true);
|
|
1056
1277
|
if (!result.success)
|
|
@@ -1062,7 +1283,9 @@ describe('generateProgram', () => {
|
|
|
1062
1283
|
'legs_1',
|
|
1063
1284
|
'lower_2',
|
|
1064
1285
|
]);
|
|
1065
|
-
expect(result.exerciseSelectionTraces
|
|
1286
|
+
expect(result.exerciseSelectionTraces
|
|
1287
|
+
.filter((t) => t.traceKind === 'template')
|
|
1288
|
+
.map((t) => t.resolvedMuscleGroup)).toEqual(['quadriceps', 'hamstrings']);
|
|
1066
1289
|
});
|
|
1067
1290
|
});
|
|
1068
1291
|
describe('isFocusMuscleExtraExerciseAllowed template compatibility', () => {
|
|
@@ -1088,6 +1311,7 @@ describe('generateProgram', () => {
|
|
|
1088
1311
|
exerciseStore: [candidateExercise],
|
|
1089
1312
|
focusMuscle,
|
|
1090
1313
|
debugExerciseSelectionTrace: true,
|
|
1314
|
+
cardioPreference: 'no-cardio',
|
|
1091
1315
|
});
|
|
1092
1316
|
// Find the routine we targeted (it may not be first in the split).
|
|
1093
1317
|
const routine = (result.success ? result.program : result.partialProgram).routines.find((r) => r.name === routineName);
|