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.
@@ -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 ProgramExerciseSelectionTraceRecord {
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
- export interface TrainerProgramExercise {
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;
@@ -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, criteria, context) => {
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?: WorkoutDurationMinutes;
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?: number;
1280
- workout_duration_minutes?: WorkoutDurationMinutes;
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?: number;
1294
- workout_duration_minutes?: WorkoutDurationMinutes;
1295
- rest_timer_length?: RestTimerLength;
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.map((e) => e.warmupSetCount)).toEqual([1, 2]);
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.map((t) => t.resolvedMuscleGroup)).toEqual([
999
- 'biceps',
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.slice(0, 3).map((t) => t.selectedExerciseId)).toEqual([
1007
- 'biceps-ex',
1008
- 'triceps-ex',
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.map((t) => t.resolvedMuscleGroup)).toEqual(['quadriceps', 'hamstrings']);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hevy-shared",
3
- "version": "1.0.982",
3
+ "version": "1.0.984",
4
4
  "description": "",
5
5
  "main": "built/index.js",
6
6
  "types": "built/index.d.ts",