@aneuhold/core-ts-db-lib 4.1.13 → 4.1.14

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.
@@ -7,13 +7,18 @@ import type {
7
7
  import type { WorkoutSessionExercise } from '../../../../documents/workout/WorkoutSessionExercise.js';
8
8
  import type WorkoutMesocyclePlanContext from '../../Mesocycle/WorkoutMesocyclePlanContext.js';
9
9
  import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionExerciseService.js';
10
- import WorkoutSFRService from '../SFR/WorkoutSFRService.js';
10
+
11
+ /** An exercise eligible to receive additional sets during SFR-based distribution. */
12
+ type SetAdditionCandidate = {
13
+ exerciseId: UUID;
14
+ sfr: number;
15
+ muscleGroupIndex: number;
16
+ previousSetCount: number;
17
+ };
11
18
 
12
19
  /**
13
20
  * A service for handling volume planning operations across microcycles.
14
21
  *
15
- * SCOPE: Microcycle-level volume distribution (calculating set counts per exercise)
16
- *
17
22
  * RESPONSIBILITIES:
18
23
  * - Calculate set counts for exercises across a microcycle
19
24
  * - Apply progressive overload rules (baseline + historical adjustments)
@@ -26,14 +31,32 @@ import WorkoutSFRService from '../SFR/WorkoutSFRService.js';
26
31
  * - {@link WorkoutSessionExerciseService} - Used to calculate SFR and recovery recommendations
27
32
  */
28
33
  export default class WorkoutVolumePlanningService {
34
+ /**
35
+ * Controls the volume *progression rate* across accumulation microcycles.
36
+ *
37
+ * Both modes start at estimated MEV when historical volume data exists
38
+ * (falling back to 2 sets per exercise when no history is available).
39
+ *
40
+ * When `false` (default): legacy progression — adds 1 set per muscle group
41
+ * every `progressionInterval` microcycles from the MEV starting point.
42
+ *
43
+ * When `true`: MEV-to-MRV interpolation — linearly distributes sets from
44
+ * estimated MEV to estimated MRV across all accumulation microcycles.
45
+ *
46
+ * This flag exists to allow toggling between the two progression algorithms
47
+ * while the MEV-to-MRV approach is validated in practice. Once confidence is
48
+ * established, the flag and legacy path should be removed.
49
+ */
50
+ static USE_VOLUME_LANDMARK_PROGRESSION = false;
51
+
29
52
  private static readonly MAX_SETS_PER_EXERCISE = 8;
30
53
  private static readonly MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION = 10;
31
54
 
32
55
  /** Minimum average RSM required for a mesocycle to count toward MEV estimation. */
33
56
  private static readonly MEV_RSM_THRESHOLD = 4;
34
57
 
35
- /** Default estimated MEV when no qualifying mesocycle history exists. */
36
- private static readonly DEFAULT_MEV = 2;
58
+ /** Default estimated MEV when no qualifying mesocycle history exists. Per-exercise value. */
59
+ private static readonly DEFAULT_MEV_PER_EXERCISE = 2;
37
60
 
38
61
  /** Minimum average performance score (or recovery presence) to count a mesocycle toward MRV estimation. */
39
62
  private static readonly MRV_PERFORMANCE_THRESHOLD = 2.5;
@@ -41,20 +64,8 @@ export default class WorkoutVolumePlanningService {
41
64
  /** Extra sets added above the historical peak when no stressed mesocycles exist, to estimate MRV. */
42
65
  private static readonly MRV_HEADROOM = 2;
43
66
 
44
- /** Default estimated MRV when no mesocycle history exists at all. */
45
- private static readonly DEFAULT_MRV = 8;
46
-
47
- /** RSM bracket upper bound for "below MEV" proximity (0 to this value inclusive). */
48
- private static readonly MEV_PROXIMITY_BELOW_THRESHOLD = 3;
49
-
50
- /** RSM bracket upper bound for "at MEV" proximity (above BELOW threshold up to this value inclusive). */
51
- private static readonly MEV_PROXIMITY_AT_THRESHOLD = 6;
52
-
53
- /** Recommended set adjustment when volume is below MEV. */
54
- private static readonly MEV_BELOW_SET_ADJUSTMENT = 3;
55
-
56
- /** Recommended set adjustment when volume is above MEV. */
57
- private static readonly MEV_ABOVE_SET_ADJUSTMENT = -2;
67
+ /** Default estimated MRV when no mesocycle history exists at all. Per-exercise value. */
68
+ private static readonly DEFAULT_MRV_PER_EXERCISE = 8;
58
69
 
59
70
  /** Maximum sets that can be added to a single exercise in one progression step. */
60
71
  private static readonly MAX_SET_ADDITION_PER_EXERCISE = 2;
@@ -94,17 +105,6 @@ export default class WorkoutVolumePlanningService {
94
105
  }
95
106
  });
96
107
 
97
- // Apply MEV proximity adjustment when generating the second microcycle (index 1)
98
- // after the first microcycle is complete with RSM data. Only applies when volume
99
- // data (volumeCTOs) was provided to the context.
100
- if (
101
- microcycleIndex === 1 &&
102
- !isDeloadMicrocycle &&
103
- context.muscleGroupToVolumeLandmarkMap.size > 0
104
- ) {
105
- this.applyMevProximityAdjustments(context, exerciseIdToSetCount);
106
- }
107
-
108
108
  return { exerciseIdToSetCount, recoveryExerciseIds };
109
109
  }
110
110
 
@@ -133,7 +133,7 @@ export default class WorkoutVolumePlanningService {
133
133
  } else if (mesocycleHistory.length > 0) {
134
134
  estimatedMev = Math.min(...mesocycleHistory.map((m) => m.startingSetCount));
135
135
  } else {
136
- estimatedMev = this.DEFAULT_MEV;
136
+ estimatedMev = this.DEFAULT_MEV_PER_EXERCISE;
137
137
  }
138
138
 
139
139
  // Estimated MRV
@@ -151,12 +151,9 @@ export default class WorkoutVolumePlanningService {
151
151
  } else if (mesocycleHistory.length > 0) {
152
152
  estimatedMrv = Math.max(...mesocycleHistory.map((m) => m.peakSetCount)) + this.MRV_HEADROOM;
153
153
  } else {
154
- estimatedMrv = this.DEFAULT_MRV;
154
+ estimatedMrv = this.DEFAULT_MRV_PER_EXERCISE;
155
155
  }
156
156
 
157
- // Hard cap MRV at 10 (MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION)
158
- estimatedMrv = Math.min(estimatedMrv, this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION);
159
-
160
157
  // Ensure MRV > MEV
161
158
  if (estimatedMrv <= estimatedMev) {
162
159
  estimatedMrv = estimatedMev + 1;
@@ -167,117 +164,18 @@ export default class WorkoutVolumePlanningService {
167
164
  return { estimatedMev, estimatedMrv, estimatedMav, mesocycleCount: mesocycleHistory.length };
168
165
  }
169
166
 
170
- /**
171
- * Evaluates MEV (Minimum Effective Volume) proximity for a muscle group based on
172
- * RSM scores from the first microcycle. Called when generating the second microcycle to adjust
173
- * the volume baseline.
174
- *
175
- * Returns `null` when the first microcycle is incomplete or has no RSM data for the muscle group.
176
- *
177
- * @param context The mesocycle planning context containing microcycle/session/exercise data.
178
- * @param muscleGroupId The muscle group to evaluate.
179
- */
180
- static evaluateMevProximity(
181
- context: WorkoutMesocyclePlanContext,
182
- muscleGroupId: UUID
183
- ): {
184
- /** 'below' = RSM 0-3, 'at' = RSM 4-6, 'above' = RSM 7-9 */
185
- proximity: 'below' | 'at' | 'above';
186
-
187
- /**
188
- * Recommended total set adjustment for this muscle group.
189
- * Positive = add sets, negative = remove sets, 0 = no change.
190
- * Range: -2 to +3
191
- */
192
- recommendedSetAdjustment: number;
193
-
194
- /** The average RSM across session exercises targeting this muscle group. */
195
- averageRsm: number;
196
- } | null {
197
- const firstMicrocycle = context.microcyclesInOrder[0];
198
- if (!firstMicrocycle) return null;
199
-
200
- // Check the first microcycle is complete
201
- const lastSessionId = firstMicrocycle.sessionOrder[firstMicrocycle.sessionOrder.length - 1];
202
- if (!context.sessionMap.get(lastSessionId)?.complete) return null;
203
-
204
- // Collect RSM totals from session exercises that target this muscle group
205
- const rsmTotals: number[] = [];
206
- for (const sessionId of firstMicrocycle.sessionOrder) {
207
- const session = context.sessionMap.get(sessionId);
208
- if (!session) continue;
209
- for (const seId of session.sessionExerciseOrder) {
210
- const se = context.sessionExerciseMap.get(seId);
211
- if (!se) continue;
212
- const exercise = context.exerciseMap.get(se.workoutExerciseId);
213
- if (!exercise?.primaryMuscleGroups.includes(muscleGroupId)) continue;
214
-
215
- const rsmTotal = WorkoutSFRService.getRsmTotal(se.rsm);
216
- if (rsmTotal !== null) {
217
- rsmTotals.push(rsmTotal);
218
- }
219
- }
220
- }
221
-
222
- if (rsmTotals.length === 0) return null;
223
-
224
- const averageRsm = rsmTotals.reduce((sum, val) => sum + val, 0) / rsmTotals.length;
225
- const bracket = Math.floor(averageRsm);
226
-
227
- if (bracket <= this.MEV_PROXIMITY_BELOW_THRESHOLD) {
228
- return {
229
- proximity: 'below',
230
- recommendedSetAdjustment: this.MEV_BELOW_SET_ADJUSTMENT,
231
- averageRsm
232
- };
233
- } else if (bracket <= this.MEV_PROXIMITY_AT_THRESHOLD) {
234
- return { proximity: 'at', recommendedSetAdjustment: 0, averageRsm };
235
- }
236
- return {
237
- proximity: 'above',
238
- recommendedSetAdjustment: this.MEV_ABOVE_SET_ADJUSTMENT,
239
- averageRsm
240
- };
241
- }
242
-
243
- /**
244
- * Applies MEV proximity adjustments based on RSM data from the first microcycle.
245
- * Adjusts set counts per muscle group when the first microcycle indicates volume
246
- * was below or above MEV.
247
- */
248
- private static applyMevProximityAdjustments(
249
- context: WorkoutMesocyclePlanContext,
250
- exerciseIdToSetCount: Map<UUID, number>
251
- ): void {
252
- if (!context.muscleGroupToExerciseCTOsMap) return;
253
-
254
- for (const [muscleGroupId, muscleGroupExerciseCTOs] of context.muscleGroupToExerciseCTOsMap) {
255
- const mevResult = this.evaluateMevProximity(context, muscleGroupId);
256
- if (!mevResult || mevResult.recommendedSetAdjustment === 0) continue;
257
-
258
- // Distribute the adjustment evenly across exercises in this muscle group
259
- const exerciseCount = muscleGroupExerciseCTOs.length;
260
- const adjustmentPerExercise = Math.floor(mevResult.recommendedSetAdjustment / exerciseCount);
261
- const adjustmentRemainder = mevResult.recommendedSetAdjustment % exerciseCount;
262
-
263
- muscleGroupExerciseCTOs.forEach((cto, index) => {
264
- const currentSets = exerciseIdToSetCount.get(cto._id) ?? 2;
265
- const extra =
266
- index < Math.abs(adjustmentRemainder) ? Math.sign(mevResult.recommendedSetAdjustment) : 0;
267
- const newSets = Math.max(
268
- 1,
269
- Math.min(currentSets + adjustmentPerExercise + extra, this.MAX_SETS_PER_EXERCISE)
270
- );
271
- exerciseIdToSetCount.set(cto._id, newSets);
272
- });
273
- }
274
- }
275
-
276
167
  /**
277
168
  * Calculates the set count for each exercise in a particular muscle group for this microcycle.
278
169
  *
279
- * If there is no previous microcycle data for the muscle group, this falls back to
280
- * the baseline progression rules.
170
+ * Pipeline:
171
+ * 1. **Volume targets** — Determine start/end volume from landmarks or defaults
172
+ * 2. **Baseline** — Calculate default set counts from progression rules
173
+ * 3. **Resolve history** — Find the most recent session exercise data for each exercise
174
+ * 4. **Apply history** — Override baselines with historical set counts (or MAV for recovery returns)
175
+ * 5. **Evaluate SFR** — Determine recovery exercises and candidates for set additions
176
+ * 6. **Distribute sets** — Allocate added sets to candidates by SFR quality
177
+ *
178
+ * Falls back to baseline when no previous microcycle data exists.
281
179
  */
282
180
  private static calculateSetCountForEachExerciseInMuscleGroup(
283
181
  context: WorkoutMesocyclePlanContext,
@@ -288,22 +186,38 @@ export default class WorkoutVolumePlanningService {
288
186
  const exerciseIdToSetCount = new Map<UUID, number>();
289
187
  const recoveryExerciseIds = new Set<UUID>();
290
188
  const sessionIndexToExerciseIds = new Map<number, UUID[]>();
291
-
292
189
  const { progressionInterval } = context;
293
190
 
294
- // 1. Calculate baselines for all exercises in muscle group
191
+ // 1. Look up volume landmarks and compute volume targets
192
+ const primaryMuscleGroupId = muscleGroupExerciseCTOs[0]?.primaryMuscleGroups[0];
193
+ const volumeLandmark = primaryMuscleGroupId
194
+ ? context.muscleGroupToVolumeLandmarkMap.get(primaryMuscleGroupId)
195
+ : undefined;
196
+
197
+ const { startVolume, endVolume } = this.getVolumeTargetsForMuscleGroup(
198
+ volumeLandmark,
199
+ muscleGroupExerciseCTOs.length,
200
+ progressionInterval
201
+ );
202
+
203
+ // 2. Calculate baseline set counts for all exercises in the muscle group
204
+ const baselineCounts = this.calculateBaselineSetCounts(
205
+ microcycleIndex,
206
+ context.accumulationMicrocycleCount,
207
+ muscleGroupExerciseCTOs.length,
208
+ startVolume,
209
+ endVolume,
210
+ isDeloadMicrocycle,
211
+ progressionInterval
212
+ );
213
+
214
+ // 3. Assign baselines and build session-to-exercise index
295
215
  muscleGroupExerciseCTOs.forEach((cto, index) => {
296
- const baseline = this.calculateBaselineSetCount(
297
- microcycleIndex,
298
- muscleGroupExerciseCTOs.length,
299
- index,
300
- isDeloadMicrocycle,
301
- progressionInterval
216
+ exerciseIdToSetCount.set(
217
+ cto._id,
218
+ Math.min(baselineCounts[index], this.MAX_SETS_PER_EXERCISE)
302
219
  );
303
- exerciseIdToSetCount.set(cto._id, Math.min(baseline, this.MAX_SETS_PER_EXERCISE));
304
220
 
305
- // Build out the map for session indices to the array of exercise IDs as it pertains to this
306
- // muscle group.
307
221
  if (!context.exerciseIdToSessionIndex) return;
308
222
  const exerciseSessionIndex = context.exerciseIdToSessionIndex.get(cto._id);
309
223
  if (exerciseSessionIndex === undefined) return;
@@ -313,17 +227,204 @@ export default class WorkoutVolumePlanningService {
313
227
  sessionIndexToExerciseIds.set(exerciseSessionIndex, existingExerciseIdsForSession);
314
228
  });
315
229
 
316
- // 2. Resolve historical performance data
317
- // Return if no previous microcycle
230
+ // 4. Resolve historical data — returns null if no usable history exists
231
+ const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
232
+ const historicalData = this.resolveHistoricalExerciseData(
233
+ context,
234
+ microcycleIndex,
235
+ exerciseIds
236
+ );
237
+ if (!historicalData) return { exerciseIdToSetCount, recoveryExerciseIds };
238
+
239
+ // 5. Apply historical set counts (overrides baselines)
240
+ this.applyHistoricalSetCounts(
241
+ muscleGroupExerciseCTOs,
242
+ historicalData.exerciseIdToPrevSessionExercise,
243
+ historicalData.exercisesThatWerePreviouslyInRecovery,
244
+ exerciseIdToSetCount,
245
+ isDeloadMicrocycle,
246
+ volumeLandmark
247
+ );
248
+
249
+ // Deload microcycles and zero-progression cycles (Resensitization) skip SFR-based set additions
250
+ if (isDeloadMicrocycle || progressionInterval === 0) {
251
+ return { exerciseIdToSetCount, recoveryExerciseIds };
252
+ }
253
+
254
+ // 6. Evaluate SFR recommendations (per-exercise SFR + recovery detection)
255
+ const { totalSetsToAdd, candidates } = this.evaluateSfrRecommendations(
256
+ context,
257
+ muscleGroupExerciseCTOs,
258
+ historicalData.exerciseIdToPrevSessionExercise,
259
+ historicalData.exercisesThatWerePreviouslyInRecovery,
260
+ exerciseIdToSetCount,
261
+ recoveryExerciseIds,
262
+ sessionIndexToExerciseIds
263
+ );
264
+
265
+ if (totalSetsToAdd === 0 || candidates.length === 0) {
266
+ return { exerciseIdToSetCount, recoveryExerciseIds };
267
+ }
268
+
269
+ // 7. Distribute added sets to candidates by SFR quality
270
+ this.distributeSetsToExercises(
271
+ candidates,
272
+ totalSetsToAdd,
273
+ exerciseIdToSetCount,
274
+ context.exerciseIdToSessionIndex,
275
+ sessionIndexToExerciseIds
276
+ );
277
+
278
+ return { exerciseIdToSetCount, recoveryExerciseIds };
279
+ }
280
+
281
+ /**
282
+ * Determines the start and end volume targets for a muscle group based on
283
+ * historical volume landmarks and cycle type.
284
+ *
285
+ * @param volumeLandmark Volume landmark estimate from historical data, if available.
286
+ * @param exerciseCount Number of exercises in the muscle group.
287
+ * @param progressionInterval Cycle-type progression interval (1=MuscleGain, 2=Cut, 0=Resensitization).
288
+ */
289
+ private static getVolumeTargetsForMuscleGroup(
290
+ volumeLandmark: WorkoutVolumeLandmarkEstimate | undefined,
291
+ exerciseCount: number,
292
+ progressionInterval: number
293
+ ): { startVolume: number; endVolume: number } {
294
+ // Resensitization (interval=0): flat at estimated MEV (or default when no history)
295
+ if (progressionInterval === 0) {
296
+ const flatVolume =
297
+ volumeLandmark && volumeLandmark.mesocycleCount > 0
298
+ ? volumeLandmark.estimatedMev
299
+ : this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
300
+ return { startVolume: flatVolume, endVolume: flatVolume };
301
+ }
302
+
303
+ // With historical volume landmarks
304
+ if (volumeLandmark && volumeLandmark.mesocycleCount > 0) {
305
+ const startVolume = volumeLandmark.estimatedMev;
306
+ // Cut: progress to MAV (midpoint), not MRV
307
+ let endVolume =
308
+ progressionInterval === 2 ? volumeLandmark.estimatedMav : volumeLandmark.estimatedMrv;
309
+
310
+ if (endVolume <= startVolume) {
311
+ endVolume = startVolume + 1;
312
+ }
313
+
314
+ return { startVolume, endVolume };
315
+ }
316
+
317
+ // No history: use per-exercise defaults
318
+ const startVolume = this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
319
+ const mrvTotal = this.DEFAULT_MRV_PER_EXERCISE * exerciseCount;
320
+
321
+ // Cut: target MAV (midpoint), not MRV
322
+ const endVolume =
323
+ progressionInterval === 2 ? Math.ceil((startVolume + mrvTotal) / 2) : mrvTotal;
324
+
325
+ return { startVolume, endVolume };
326
+ }
327
+
328
+ /**
329
+ * Calculates the baseline set counts for all exercises in a muscle group for a given microcycle.
330
+ *
331
+ * When {@link USE_VOLUME_LANDMARK_PROGRESSION} is `false` (default), uses the legacy progression
332
+ * rate: +1 set per muscle group every `progressionInterval` microcycles from the starting volume.
333
+ *
334
+ * When `true`, linearly interpolates from `startVolume` to `endVolume` across accumulation
335
+ * microcycles.
336
+ *
337
+ * @param microcycleIndex The index of the current microcycle.
338
+ * @param accumulationMicrocycleCount Number of accumulation (non-deload) microcycles.
339
+ * @param exerciseCount Number of exercises in the muscle group.
340
+ * @param startVolume Total muscle-group volume at microcycle 0.
341
+ * @param endVolume Total muscle-group volume target at the last accumulation microcycle.
342
+ * @param isDeloadMicrocycle Whether this is a deload microcycle.
343
+ * @param progressionInterval Microcycles between each set addition (0 = no progression).
344
+ */
345
+ private static calculateBaselineSetCounts(
346
+ microcycleIndex: number,
347
+ accumulationMicrocycleCount: number,
348
+ exerciseCount: number,
349
+ startVolume: number,
350
+ endVolume: number,
351
+ isDeloadMicrocycle: boolean,
352
+ progressionInterval: number
353
+ ): number[] {
354
+ if (isDeloadMicrocycle) {
355
+ const lastAccumulationCounts = this.calculateBaselineSetCounts(
356
+ microcycleIndex - 1,
357
+ accumulationMicrocycleCount,
358
+ exerciseCount,
359
+ startVolume,
360
+ endVolume,
361
+ false,
362
+ progressionInterval
363
+ );
364
+ return lastAccumulationCounts.map((count) => Math.max(1, Math.floor(count / 2)));
365
+ }
366
+
367
+ let totalSets: number;
368
+
369
+ if (this.USE_VOLUME_LANDMARK_PROGRESSION) {
370
+ // Linear interpolation from startVolume to endVolume
371
+ if (accumulationMicrocycleCount <= 1) {
372
+ totalSets = startVolume;
373
+ } else {
374
+ totalSets = Math.round(
375
+ startVolume +
376
+ ((endVolume - startVolume) * microcycleIndex) / (accumulationMicrocycleCount - 1)
377
+ );
378
+ }
379
+ } else {
380
+ // Legacy progression: +1 set per muscle group per progressionInterval microcycles
381
+ const progressionSets =
382
+ progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
383
+ totalSets = startVolume + progressionSets;
384
+ }
385
+
386
+ return this.distributeEvenly(totalSets, exerciseCount);
387
+ }
388
+
389
+ /**
390
+ * Distributes a total evenly across N slots, with remainder going to earlier slots.
391
+ *
392
+ * @param total The total to distribute.
393
+ * @param slots The number of slots to distribute across.
394
+ */
395
+ private static distributeEvenly(total: number, slots: number): number[] {
396
+ const base = Math.floor(total / slots);
397
+ const remainder = total % slots;
398
+ return Array.from({ length: slots }, (_, i) => (i < remainder ? base + 1 : base));
399
+ }
400
+
401
+ /**
402
+ * Walks backward through completed microcycles to find the most recent non-recovery
403
+ * session exercise for each exercise in the muscle group.
404
+ *
405
+ * Returns `null` when no usable historical data exists (no previous microcycle, or
406
+ * previous microcycles are incomplete/have no matching exercises).
407
+ *
408
+ * @param context The mesocycle planning context.
409
+ * @param microcycleIndex The current microcycle index.
410
+ * @param exerciseIds The exercise IDs to search for.
411
+ */
412
+ private static resolveHistoricalExerciseData(
413
+ context: WorkoutMesocyclePlanContext,
414
+ microcycleIndex: number,
415
+ exerciseIds: Set<UUID>
416
+ ): {
417
+ exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>;
418
+ exercisesThatWerePreviouslyInRecovery: Set<UUID>;
419
+ } | null {
318
420
  let previousMicrocycleIndex = microcycleIndex - 1;
319
421
  let previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
320
- if (!previousMicrocycle) return { exerciseIdToSetCount, recoveryExerciseIds };
422
+ if (!previousMicrocycle) return null;
321
423
 
322
- // Map previous session exercises
323
- const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
324
424
  const exerciseIdToPrevSessionExercise = new Map<UUID, WorkoutSessionExercise>();
325
425
  const foundExerciseIds = new Set<UUID>();
326
426
  const exercisesThatWerePreviouslyInRecovery = new Set<UUID>();
427
+
327
428
  // Loop through each previous microcycle until we find all exercises or run out of microcycles
328
429
  while (exerciseIdToPrevSessionExercise.size < exerciseIds.size && previousMicrocycle) {
329
430
  // Check if the previous microcycle is complete; if not, we cannot use its data
@@ -334,14 +435,12 @@ export default class WorkoutVolumePlanningService {
334
435
  break;
335
436
  }
336
437
 
337
- // Start with session order
438
+ // Scan all session exercises in this microcycle for matching non-recovery exercises
338
439
  for (const sessionId of previousMicrocycle.sessionOrder) {
339
440
  const session = context.sessionMap.get(sessionId);
340
441
  if (!session) continue;
341
- // Get the session exercises for this session
342
442
  for (const sessionExerciseId of session.sessionExerciseOrder) {
343
443
  const sessionExercise = context.sessionExerciseMap.get(sessionExerciseId);
344
- // Map if in our muscle group && it isn't a recovery exercise
345
444
  if (
346
445
  sessionExercise &&
347
446
  exerciseIds.has(sessionExercise.workoutExerciseId) &&
@@ -350,91 +449,104 @@ export default class WorkoutVolumePlanningService {
350
449
  ) {
351
450
  exerciseIdToPrevSessionExercise.set(sessionExercise.workoutExerciseId, sessionExercise);
352
451
  foundExerciseIds.add(sessionExercise.workoutExerciseId);
452
+ // If we had to go back more than one microcycle, the exercise was in recovery
353
453
  if (previousMicrocycleIndex < microcycleIndex - 1) {
354
454
  exercisesThatWerePreviouslyInRecovery.add(sessionExercise.workoutExerciseId);
355
455
  }
356
456
  }
357
457
  }
358
458
  }
359
- // Move to earlier microcycle
360
459
  previousMicrocycleIndex = previousMicrocycleIndex - 1;
361
460
  previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
362
461
  }
363
- if (exerciseIdToPrevSessionExercise.size === 0)
364
- return { exerciseIdToSetCount, recoveryExerciseIds };
365
462
 
366
- // Update baseline with historical set counts when available.
367
- // For deload microcycles, halve the historical count (minimum 1 set). We don't use baseline
368
- // because the user may have adjusted over the mesocycle in a way that the baseline is actually
369
- // a higher set count than what would be calculated by halving the previous microcycle's sets.
370
- //
371
- // For exercises returning from recovery, use the estimated MAV from volume landmarks
372
- // when available, instead of the pre-recovery historical count.
373
- const primaryMuscleGroupId = muscleGroupExerciseCTOs[0]?.primaryMuscleGroups[0];
374
- const volumeLandmark = primaryMuscleGroupId
375
- ? context.muscleGroupToVolumeLandmarkMap.get(primaryMuscleGroupId)
376
- : undefined;
463
+ if (exerciseIdToPrevSessionExercise.size === 0) return null;
464
+ return { exerciseIdToPrevSessionExercise, exercisesThatWerePreviouslyInRecovery };
465
+ }
466
+
467
+ /**
468
+ * Overrides baseline set counts with historical data from the previous microcycle.
469
+ *
470
+ * For exercises returning from recovery, distributes the estimated MAV from volume
471
+ * landmarks proportionally across exercises. For deload microcycles, halves the
472
+ * historical count (minimum 1 set).
473
+ *
474
+ * @param muscleGroupExerciseCTOs Exercises in this muscle group.
475
+ * @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
476
+ * @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
477
+ * @param exerciseIdToSetCount Current set count assignments (mutated).
478
+ * @param isDeloadMicrocycle Whether this is a deload microcycle.
479
+ * @param volumeLandmark Volume landmark estimate for the primary muscle group, if available.
480
+ */
481
+ private static applyHistoricalSetCounts(
482
+ muscleGroupExerciseCTOs: WorkoutExerciseCTO[],
483
+ exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>,
484
+ exercisesThatWerePreviouslyInRecovery: Set<UUID>,
485
+ exerciseIdToSetCount: Map<UUID, number>,
486
+ isDeloadMicrocycle: boolean,
487
+ volumeLandmark: WorkoutVolumeLandmarkEstimate | undefined
488
+ ): void {
489
+ // Pre-compute per-exercise MAV distribution for exercises returning from recovery.
490
+ // Uses floor-based even distribution (conservative) per the source material's guidance
491
+ // to err on the lighter side when returning from recovery.
492
+ const hasExerciseReturningFromRecovery = muscleGroupExerciseCTOs.some((cto) =>
493
+ exercisesThatWerePreviouslyInRecovery.has(cto._id)
494
+ );
495
+ const mavDistribution =
496
+ hasExerciseReturningFromRecovery && volumeLandmark
497
+ ? this.distributeEvenly(volumeLandmark.estimatedMav, muscleGroupExerciseCTOs.length)
498
+ : undefined;
377
499
 
378
- muscleGroupExerciseCTOs.forEach((cto) => {
500
+ muscleGroupExerciseCTOs.forEach((cto, index) => {
379
501
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
380
- if (previousSessionExercise) {
381
- let setCount: number;
382
-
383
- if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && volumeLandmark) {
384
- // Exercise is returning from recovery — resume at the estimated MAV
385
- setCount = Math.min(volumeLandmark.estimatedMav, this.MAX_SETS_PER_EXERCISE);
386
- } else {
387
- setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
388
- }
502
+ if (!previousSessionExercise) return;
389
503
 
390
- if (isDeloadMicrocycle) {
391
- setCount = Math.max(1, Math.floor(setCount / 2));
392
- }
393
- exerciseIdToSetCount.set(cto._id, setCount);
504
+ let setCount: number;
505
+ if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && mavDistribution) {
506
+ // Returning from recovery — resume at conservatively distributed MAV
507
+ setCount = Math.min(mavDistribution[index], this.MAX_SETS_PER_EXERCISE);
508
+ } else {
509
+ // Carry forward last microcycle's set count
510
+ setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
394
511
  }
395
- });
396
512
 
397
- // Deload microcycles and zero-progression cycles (Resensitization) skip SFR-based set additions
398
- if (isDeloadMicrocycle || progressionInterval === 0) {
399
- return { exerciseIdToSetCount, recoveryExerciseIds };
400
- }
401
-
402
- /**
403
- * Determines if the session for the given exercise is already capped for this muscle group.
404
- */
405
- function sessionIsCapped(exerciseId: UUID): boolean {
406
- if (!context.exerciseIdToSessionIndex) {
407
- throw new Error(
408
- 'WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.'
409
- );
513
+ if (isDeloadMicrocycle) {
514
+ // Deload: halve volume, minimum 1 set
515
+ setCount = Math.max(1, Math.floor(setCount / 2));
410
516
  }
411
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
412
- if (sessionIndex === undefined) return false;
413
-
414
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
415
- if (!exerciseIdsInSession) return false;
416
- let totalSetsInSession = 0;
417
- exerciseIdsInSession.forEach((id) => {
418
- // Use the sets from the previous microcycle's session exercise
419
- totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
420
- });
421
- return (
422
- totalSetsInSession >= WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION
423
- );
424
- }
517
+ exerciseIdToSetCount.set(cto._id, setCount);
518
+ });
519
+ }
425
520
 
426
- // 3. Determine sets to add and valid candidates
521
+ /**
522
+ * Evaluates each exercise's performance feedback to determine recovery exercises,
523
+ * set addition recommendations, and candidates for volume increases.
524
+ *
525
+ * @param context The mesocycle planning context.
526
+ * @param muscleGroupExerciseCTOs Exercises in this muscle group.
527
+ * @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
528
+ * @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
529
+ * @param exerciseIdToSetCount Current set count assignments (mutated for recovery exercises).
530
+ * @param recoveryExerciseIds Set of exercise IDs flagged for recovery (mutated).
531
+ * @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
532
+ */
533
+ private static evaluateSfrRecommendations(
534
+ context: WorkoutMesocyclePlanContext,
535
+ muscleGroupExerciseCTOs: WorkoutExerciseCTO[],
536
+ exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>,
537
+ exercisesThatWerePreviouslyInRecovery: Set<UUID>,
538
+ exerciseIdToSetCount: Map<UUID, number>,
539
+ recoveryExerciseIds: Set<UUID>,
540
+ sessionIndexToExerciseIds: Map<number, UUID[]>
541
+ ): { totalSetsToAdd: number; candidates: SetAdditionCandidate[] } {
427
542
  let totalSetsToAdd = 0;
428
- const candidates: {
429
- exerciseId: UUID;
430
- sfr: number;
431
- muscleGroupIndex: number;
432
- previousSetCount: number;
433
- }[] = [];
543
+
544
+ const candidates: SetAdditionCandidate[] = [];
434
545
  muscleGroupExerciseCTOs.forEach((cto, muscleGroupIndex) => {
435
546
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
436
547
  if (!previousSessionExercise) return;
437
548
 
549
+ // Get SFR-based recommendation: positive = add sets, 0 = maintain, -1 = recovery needed
438
550
  let recommendation: number | null;
439
551
  if (!exercisesThatWerePreviouslyInRecovery.has(cto._id)) {
440
552
  recommendation =
@@ -442,26 +554,29 @@ export default class WorkoutVolumePlanningService {
442
554
  previousSessionExercise
443
555
  );
444
556
  } else {
445
- // If previously in recovery, do not recommend adding sets this microcycle. Also, this is
446
- // the only thing we are overriding. We still want to use the historical data for
447
- // SFR calculations, even though that one was the one that triggered a recovery session.
448
- // This should make it so that it is less likely to have sets added to it.
557
+ // Returning from recovery don't add sets yet, but still use SFR for candidate ranking
449
558
  recommendation = 0;
450
559
  }
451
560
 
452
561
  if (recommendation === -1) {
562
+ // Recovery needed: halve sets and flag as recovery exercise
453
563
  recoveryExerciseIds.add(cto._id);
454
- // Cut sets in half (rounded down, minimum 1) for recovery
455
564
  const previousSetCount = previousSessionExercise.setOrder.length;
456
565
  const recoverySets = Math.max(1, Math.floor(previousSetCount / 2));
457
566
  exerciseIdToSetCount.set(cto._id, recoverySets);
458
567
  } else if (recommendation !== null && recommendation >= 0) {
568
+ // Accumulate SFR-recommended additions into shared total
459
569
  totalSetsToAdd += recommendation;
460
570
 
461
- // Consider as candidate if session is not already capped
571
+ // Only exercises below per-exercise cap and in uncapped sessions are candidates
462
572
  if (
463
573
  previousSessionExercise.setOrder.length < this.MAX_SETS_PER_EXERCISE &&
464
- !sessionIsCapped(cto._id)
574
+ !this.sessionIsCapped(
575
+ cto._id,
576
+ context.exerciseIdToSessionIndex,
577
+ sessionIndexToExerciseIds,
578
+ exerciseIdToPrevSessionExercise
579
+ )
465
580
  ) {
466
581
  candidates.push({
467
582
  exerciseId: cto._id,
@@ -476,126 +591,152 @@ export default class WorkoutVolumePlanningService {
476
591
  }
477
592
  });
478
593
 
479
- // Return if nothing to add or no candidates
480
- if (totalSetsToAdd === 0 || candidates.length === 0) {
481
- return { exerciseIdToSetCount, recoveryExerciseIds };
482
- }
594
+ return { totalSetsToAdd, candidates };
595
+ }
483
596
 
484
- // 4. Distribute added sets based on SFR quality
485
- // Sort by SFR descending, then by muscleGroupIndex ascending (as tie-breaker)
597
+ /**
598
+ * Distributes added sets across candidate exercises, prioritizing higher SFR scores.
599
+ * Respects per-exercise, per-session, and per-addition caps.
600
+ *
601
+ * @param candidates Exercises eligible for set additions, with SFR scores.
602
+ * @param totalSetsToAdd Total sets recommended for addition (before capping).
603
+ * @param exerciseIdToSetCount Current set count assignments (mutated).
604
+ * @param exerciseIdToSessionIndex Map from exercise ID to its session index.
605
+ * @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
606
+ */
607
+ private static distributeSetsToExercises(
608
+ candidates: SetAdditionCandidate[],
609
+ totalSetsToAdd: number,
610
+ exerciseIdToSetCount: Map<UUID, number>,
611
+ exerciseIdToSessionIndex: Map<UUID, number> | undefined,
612
+ sessionIndexToExerciseIds: Map<number, UUID[]>
613
+ ): void {
614
+ // Prioritize exercises with highest SFR (best stimulus-to-fatigue ratio)
486
615
  candidates.sort((candidateA, candidateB) =>
487
616
  candidateA.sfr !== candidateB.sfr
488
617
  ? candidateB.sfr - candidateA.sfr
489
618
  : candidateA.muscleGroupIndex - candidateB.muscleGroupIndex
490
619
  );
491
620
 
492
- /**
493
- * Gets the total sets currently planned for a session.
494
- */
495
- function getSessionTotal(exerciseId: UUID): number {
496
- if (!context.exerciseIdToSessionIndex) return 0;
497
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
498
- if (sessionIndex === undefined) return 0;
499
-
500
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
501
- if (!exerciseIdsInSession) return 0;
502
-
503
- let total = 0;
504
- exerciseIdsInSession.forEach((id) => {
505
- total += exerciseIdToSetCount.get(id) || 0;
506
- });
507
- return total;
508
- }
509
-
510
- /**
511
- * Attempts to add sets to an exercise, respecting all constraints.
512
- * Returns the number of sets actually added.
513
- */
514
- function addSetsToExercise(exerciseId: UUID, setsToAdd: number): number {
515
- const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
516
- const sessionTotal = getSessionTotal(exerciseId);
517
-
518
- const maxDueToExerciseLimit =
519
- WorkoutVolumePlanningService.MAX_SETS_PER_EXERCISE - currentSets;
520
- const maxDueToSessionLimit =
521
- WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
522
- const maxAddable = Math.min(
523
- setsToAdd,
524
- maxDueToExerciseLimit,
525
- maxDueToSessionLimit,
526
- WorkoutVolumePlanningService.MAX_SET_ADDITION_PER_EXERCISE
527
- );
528
-
529
- if (maxAddable > 0) {
530
- exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
531
- }
532
- return maxAddable;
533
- }
534
-
621
+ // Cap total additions at MAX_TOTAL_SET_ADDITIONS regardless of raw recommendation
535
622
  let setsRemaining =
536
- totalSetsToAdd >= WorkoutVolumePlanningService.MAX_TOTAL_SET_ADDITIONS
537
- ? WorkoutVolumePlanningService.MAX_TOTAL_SET_ADDITIONS
623
+ totalSetsToAdd >= this.MAX_TOTAL_SET_ADDITIONS
624
+ ? this.MAX_TOTAL_SET_ADDITIONS
538
625
  : totalSetsToAdd;
626
+
627
+ // Distribute sets one candidate at a time until budget is exhausted
539
628
  for (const candidate of candidates) {
540
- const added = addSetsToExercise(candidate.exerciseId, setsRemaining);
629
+ const added = this.addSetsToExercise(
630
+ candidate.exerciseId,
631
+ setsRemaining,
632
+ exerciseIdToSetCount,
633
+ exerciseIdToSessionIndex,
634
+ sessionIndexToExerciseIds
635
+ );
541
636
  setsRemaining -= added;
542
637
  if (setsRemaining === 0) break;
543
638
  }
544
-
545
- return { exerciseIdToSetCount, recoveryExerciseIds };
546
639
  }
547
640
 
548
641
  /**
549
- * Calculates the default number of sets for an exercise based on microcycle progression.
550
- *
551
- * Key rule: set progression is distributed across exercises that share the same primary muscle group
552
- * for the entire microcycle, regardless of which session those exercises are in.
642
+ * Determines if the session containing the given exercise is already at the
643
+ * per-muscle-group-per-session cap, based on previous microcycle set counts.
553
644
  *
554
- * Baseline: 2 sets per exercise in the muscle group.
555
- * Progression: add 1 set per muscle group every `progressionInterval` microcycles.
556
- * - MuscleGain (interval 1): every microcycle
557
- * - Cut (interval 2): every other microcycle
558
- * - Resensitization (interval 0): no progression (flat 2 sets per exercise)
559
- *
560
- * @param microcycleIndex The index of the current microcycle.
561
- * @param totalExercisesInMuscleGroupForMicrocycle Total exercises in the muscle group for
562
- * this microcycle.
563
- * @param exerciseIndexInMuscleGroupForMicrocycle Index of the current exercise within the
564
- * muscle group.
565
- * @param isDeloadMicrocycle Whether this is a deload microcycle.
566
- * @param progressionInterval Number of microcycles between each set addition. 0 means no
567
- * progression.
645
+ * @param exerciseId The exercise to check.
646
+ * @param exerciseIdToSessionIndex Map from exercise ID to its session index.
647
+ * @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
648
+ * @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
568
649
  */
569
- private static calculateBaselineSetCount(
570
- microcycleIndex: number,
571
- totalExercisesInMuscleGroupForMicrocycle: number,
572
- exerciseIndexInMuscleGroupForMicrocycle: number,
573
- isDeloadMicrocycle: boolean,
574
- progressionInterval: number = 1
575
- ): number {
576
- // Deload microcycle: half the sets from the previous microcycle, minimum 1 set.
577
- if (isDeloadMicrocycle) {
578
- const baselineSets = this.calculateBaselineSetCount(
579
- microcycleIndex - 1,
580
- totalExercisesInMuscleGroupForMicrocycle,
581
- exerciseIndexInMuscleGroupForMicrocycle,
582
- false,
583
- progressionInterval
650
+ private static sessionIsCapped(
651
+ exerciseId: UUID,
652
+ exerciseIdToSessionIndex: Map<UUID, number> | undefined,
653
+ sessionIndexToExerciseIds: Map<number, UUID[]>,
654
+ exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>
655
+ ): boolean {
656
+ if (!exerciseIdToSessionIndex) {
657
+ throw new Error(
658
+ 'WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.'
584
659
  );
585
- return Math.max(1, Math.floor(baselineSets / 2));
586
660
  }
661
+ const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
662
+ if (sessionIndex === undefined) return false;
663
+
664
+ const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
665
+ if (!exerciseIdsInSession) return false;
666
+ let totalSetsInSession = 0;
667
+ exerciseIdsInSession.forEach((id) => {
668
+ totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
669
+ });
670
+ return totalSetsInSession >= this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION;
671
+ }
672
+
673
+ /**
674
+ * Gets the total sets currently planned for a session containing the given exercise.
675
+ *
676
+ * @param exerciseId The exercise whose session total to compute.
677
+ * @param exerciseIdToSetCount Current set count assignments.
678
+ * @param exerciseIdToSessionIndex Map from exercise ID to its session index.
679
+ * @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
680
+ */
681
+ private static getSessionSetTotal(
682
+ exerciseId: UUID,
683
+ exerciseIdToSetCount: Map<UUID, number>,
684
+ exerciseIdToSessionIndex: Map<UUID, number> | undefined,
685
+ sessionIndexToExerciseIds: Map<number, UUID[]>
686
+ ): number {
687
+ if (!exerciseIdToSessionIndex) return 0;
688
+ const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
689
+ if (sessionIndex === undefined) return 0;
587
690
 
588
- // Total sets to distribute for this muscle group in this microcycle.
589
- const progressionSets =
590
- progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
591
- const totalSets = 2 * totalExercisesInMuscleGroupForMicrocycle + progressionSets;
691
+ const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
692
+ if (!exerciseIdsInSession) return 0;
592
693
 
593
- // Distribute sets evenly, with earlier exercises getting extra sets from the remainder.
594
- const baseSetsPerExercise = Math.floor(totalSets / totalExercisesInMuscleGroupForMicrocycle);
595
- const remainder = totalSets % totalExercisesInMuscleGroupForMicrocycle;
694
+ let total = 0;
695
+ exerciseIdsInSession.forEach((id) => {
696
+ total += exerciseIdToSetCount.get(id) || 0;
697
+ });
698
+ return total;
699
+ }
596
700
 
597
- return exerciseIndexInMuscleGroupForMicrocycle < remainder
598
- ? baseSetsPerExercise + 1
599
- : baseSetsPerExercise;
701
+ /**
702
+ * Attempts to add sets to an exercise, respecting per-exercise, per-session, and
703
+ * per-addition caps. Mutates `exerciseIdToSetCount` in place.
704
+ *
705
+ * @param exerciseId The exercise to add sets to.
706
+ * @param setsToAdd The desired number of sets to add.
707
+ * @param exerciseIdToSetCount Current set count assignments (mutated).
708
+ * @param exerciseIdToSessionIndex Map from exercise ID to its session index.
709
+ * @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
710
+ * @returns The number of sets actually added.
711
+ */
712
+ private static addSetsToExercise(
713
+ exerciseId: UUID,
714
+ setsToAdd: number,
715
+ exerciseIdToSetCount: Map<UUID, number>,
716
+ exerciseIdToSessionIndex: Map<UUID, number> | undefined,
717
+ sessionIndexToExerciseIds: Map<number, UUID[]>
718
+ ): number {
719
+ const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
720
+ const sessionTotal = this.getSessionSetTotal(
721
+ exerciseId,
722
+ exerciseIdToSetCount,
723
+ exerciseIdToSessionIndex,
724
+ sessionIndexToExerciseIds
725
+ );
726
+
727
+ // Respect all caps: per-exercise max, per-session max, and per-addition max
728
+ const maxDueToExerciseLimit = this.MAX_SETS_PER_EXERCISE - currentSets;
729
+ const maxDueToSessionLimit = this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
730
+ const maxAddable = Math.min(
731
+ setsToAdd,
732
+ maxDueToExerciseLimit,
733
+ maxDueToSessionLimit,
734
+ this.MAX_SET_ADDITION_PER_EXERCISE
735
+ );
736
+
737
+ if (maxAddable > 0) {
738
+ exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
739
+ }
740
+ return maxAddable;
600
741
  }
601
742
  }