@aneuhold/core-ts-db-lib 4.1.12 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/lib/browser.d.ts +11 -8
  3. package/lib/browser.d.ts.map +1 -1
  4. package/lib/browser.js +6 -4
  5. package/lib/browser.js.map +1 -1
  6. package/lib/browser.ts +23 -9
  7. package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts +14 -0
  8. package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts.map +1 -1
  9. package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.ts +18 -0
  10. package/lib/documents/workout/README.md +43 -9
  11. package/lib/documents/workout/WorkoutSet.d.ts +8 -0
  12. package/lib/documents/workout/WorkoutSet.d.ts.map +1 -1
  13. package/lib/documents/workout/WorkoutSet.ts +9 -0
  14. package/lib/services/workout/Exercise/WorkoutExerciseService.d.ts +40 -0
  15. package/lib/services/workout/Exercise/WorkoutExerciseService.d.ts.map +1 -1
  16. package/lib/services/workout/Exercise/WorkoutExerciseService.js +134 -1
  17. package/lib/services/workout/Exercise/WorkoutExerciseService.js.map +1 -1
  18. package/lib/services/workout/Exercise/WorkoutExerciseService.ts +204 -1
  19. package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.d.ts +15 -0
  20. package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.d.ts.map +1 -1
  21. package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.js +18 -1
  22. package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.js.map +1 -1
  23. package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.ts +19 -1
  24. package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts +30 -4
  25. package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts.map +1 -1
  26. package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js +58 -4
  27. package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js.map +1 -1
  28. package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.ts +69 -3
  29. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.d.ts +45 -1
  30. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.d.ts.map +1 -1
  31. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.js +201 -11
  32. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.js.map +1 -1
  33. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.ts +285 -9
  34. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.d.ts +33 -0
  35. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.d.ts.map +1 -0
  36. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.js +24 -0
  37. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.js.map +1 -0
  38. package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.ts +36 -0
  39. package/lib/services/workout/Session/WorkoutSessionService.d.ts.map +1 -1
  40. package/lib/services/workout/Session/WorkoutSessionService.js +1 -11
  41. package/lib/services/workout/Session/WorkoutSessionService.js.map +1 -1
  42. package/lib/services/workout/Session/WorkoutSessionService.ts +1 -17
  43. package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.d.ts +14 -2
  44. package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.d.ts.map +1 -1
  45. package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.js +17 -3
  46. package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.js.map +1 -1
  47. package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.ts +28 -3
  48. package/lib/services/workout/Set/WorkoutSetService.d.ts +17 -5
  49. package/lib/services/workout/Set/WorkoutSetService.d.ts.map +1 -1
  50. package/lib/services/workout/Set/WorkoutSetService.js +83 -16
  51. package/lib/services/workout/Set/WorkoutSetService.js.map +1 -1
  52. package/lib/services/workout/Set/WorkoutSetService.ts +107 -24
  53. package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts +161 -11
  54. package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts.map +1 -1
  55. package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js +364 -127
  56. package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js.map +1 -1
  57. package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.ts +551 -160
  58. package/package.json +1 -1
@@ -1,14 +1,24 @@
1
1
  import type { UUID } from 'crypto';
2
2
  import type { WorkoutExerciseCTO } from '../../../../ctos/workout/WorkoutExerciseCTO.js';
3
+ import type {
4
+ WorkoutMuscleGroupVolumeCTO,
5
+ WorkoutVolumeLandmarkEstimate
6
+ } from '../../../../ctos/workout/WorkoutMuscleGroupVolumeCTO.js';
3
7
  import type { WorkoutSessionExercise } from '../../../../documents/workout/WorkoutSessionExercise.js';
4
8
  import type WorkoutMesocyclePlanContext from '../../Mesocycle/WorkoutMesocyclePlanContext.js';
5
9
  import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionExerciseService.js';
6
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
+ };
18
+
7
19
  /**
8
20
  * A service for handling volume planning operations across microcycles.
9
21
  *
10
- * SCOPE: Microcycle-level volume distribution (calculating set counts per exercise)
11
- *
12
22
  * RESPONSIBILITIES:
13
23
  * - Calculate set counts for exercises across a microcycle
14
24
  * - Apply progressive overload rules (baseline + historical adjustments)
@@ -21,9 +31,48 @@ import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionE
21
31
  * - {@link WorkoutSessionExerciseService} - Used to calculate SFR and recovery recommendations
22
32
  */
23
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
+
24
52
  private static readonly MAX_SETS_PER_EXERCISE = 8;
25
53
  private static readonly MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION = 10;
26
54
 
55
+ /** Minimum average RSM required for a mesocycle to count toward MEV estimation. */
56
+ private static readonly MEV_RSM_THRESHOLD = 4;
57
+
58
+ /** Default estimated MEV when no qualifying mesocycle history exists. Per-exercise value. */
59
+ private static readonly DEFAULT_MEV_PER_EXERCISE = 2;
60
+
61
+ /** Minimum average performance score (or recovery presence) to count a mesocycle toward MRV estimation. */
62
+ private static readonly MRV_PERFORMANCE_THRESHOLD = 2.5;
63
+
64
+ /** Extra sets added above the historical peak when no stressed mesocycles exist, to estimate MRV. */
65
+ private static readonly MRV_HEADROOM = 2;
66
+
67
+ /** Default estimated MRV when no mesocycle history exists at all. Per-exercise value. */
68
+ private static readonly DEFAULT_MRV_PER_EXERCISE = 8;
69
+
70
+ /** Maximum sets that can be added to a single exercise in one progression step. */
71
+ private static readonly MAX_SET_ADDITION_PER_EXERCISE = 2;
72
+
73
+ /** Maximum total sets to distribute across a muscle group in one progression step. */
74
+ private static readonly MAX_TOTAL_SET_ADDITIONS = 3;
75
+
27
76
  /**
28
77
  * Calculates the set plan for an entire microcycle.
29
78
  */
@@ -59,11 +108,74 @@ export default class WorkoutVolumePlanningService {
59
108
  return { exerciseIdToSetCount, recoveryExerciseIds };
60
109
  }
61
110
 
111
+ /**
112
+ * Estimates MEV, MRV, and MAV for a muscle group based on historical data
113
+ * across completed mesocycles.
114
+ *
115
+ * @param volumeCTO The WorkoutMuscleGroupVolumeCTO containing mesocycle
116
+ * history for this muscle group.
117
+ */
118
+ static estimateVolumeLandmarks(
119
+ volumeCTO: WorkoutMuscleGroupVolumeCTO
120
+ ): WorkoutVolumeLandmarkEstimate {
121
+ const { mesocycleHistory } = volumeCTO;
122
+
123
+ // Estimated MEV
124
+ let estimatedMev: number;
125
+ const effectiveMesocycles = mesocycleHistory.filter(
126
+ (m) => m.avgRsm !== null && m.avgRsm >= this.MEV_RSM_THRESHOLD
127
+ );
128
+ if (effectiveMesocycles.length > 0) {
129
+ estimatedMev =
130
+ effectiveMesocycles.reduce((sum, m) => sum + m.startingSetCount, 0) /
131
+ effectiveMesocycles.length;
132
+ estimatedMev = Math.round(estimatedMev);
133
+ } else if (mesocycleHistory.length > 0) {
134
+ estimatedMev = Math.min(...mesocycleHistory.map((m) => m.startingSetCount));
135
+ } else {
136
+ estimatedMev = this.DEFAULT_MEV_PER_EXERCISE;
137
+ }
138
+
139
+ // Estimated MRV
140
+ let estimatedMrv: number;
141
+ const stressedMesocycles = mesocycleHistory.filter(
142
+ (m) =>
143
+ (m.avgPerformanceScore !== null &&
144
+ m.avgPerformanceScore >= this.MRV_PERFORMANCE_THRESHOLD) ||
145
+ m.recoverySessionCount > 0
146
+ );
147
+ if (stressedMesocycles.length > 0) {
148
+ estimatedMrv =
149
+ stressedMesocycles.reduce((sum, m) => sum + m.peakSetCount, 0) / stressedMesocycles.length;
150
+ estimatedMrv = Math.round(estimatedMrv);
151
+ } else if (mesocycleHistory.length > 0) {
152
+ estimatedMrv = Math.max(...mesocycleHistory.map((m) => m.peakSetCount)) + this.MRV_HEADROOM;
153
+ } else {
154
+ estimatedMrv = this.DEFAULT_MRV_PER_EXERCISE;
155
+ }
156
+
157
+ // Ensure MRV > MEV
158
+ if (estimatedMrv <= estimatedMev) {
159
+ estimatedMrv = estimatedMev + 1;
160
+ }
161
+
162
+ const estimatedMav = Math.ceil((estimatedMev + estimatedMrv) / 2);
163
+
164
+ return { estimatedMev, estimatedMrv, estimatedMav, mesocycleCount: mesocycleHistory.length };
165
+ }
166
+
62
167
  /**
63
168
  * Calculates the set count for each exercise in a particular muscle group for this microcycle.
64
169
  *
65
- * If there is no previous microcycle data for the muscle group, this falls back to
66
- * 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.
67
179
  */
68
180
  private static calculateSetCountForEachExerciseInMuscleGroup(
69
181
  context: WorkoutMesocyclePlanContext,
@@ -74,19 +186,38 @@ export default class WorkoutVolumePlanningService {
74
186
  const exerciseIdToSetCount = new Map<UUID, number>();
75
187
  const recoveryExerciseIds = new Set<UUID>();
76
188
  const sessionIndexToExerciseIds = new Map<number, UUID[]>();
189
+ const { progressionInterval } = context;
190
+
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
+ );
77
202
 
78
- // 1. Calculate baselines for all exercises in muscle group
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
79
215
  muscleGroupExerciseCTOs.forEach((cto, index) => {
80
- const baseline = this.calculateBaselineSetCount(
81
- microcycleIndex,
82
- muscleGroupExerciseCTOs.length,
83
- index,
84
- isDeloadMicrocycle
216
+ exerciseIdToSetCount.set(
217
+ cto._id,
218
+ Math.min(baselineCounts[index], this.MAX_SETS_PER_EXERCISE)
85
219
  );
86
- exerciseIdToSetCount.set(cto._id, Math.min(baseline, this.MAX_SETS_PER_EXERCISE));
87
220
 
88
- // Build out the map for session indices to the array of exercise IDs as it pertains to this
89
- // muscle group.
90
221
  if (!context.exerciseIdToSessionIndex) return;
91
222
  const exerciseSessionIndex = context.exerciseIdToSessionIndex.get(cto._id);
92
223
  if (exerciseSessionIndex === undefined) return;
@@ -96,17 +227,204 @@ export default class WorkoutVolumePlanningService {
96
227
  sessionIndexToExerciseIds.set(exerciseSessionIndex, existingExerciseIdsForSession);
97
228
  });
98
229
 
99
- // 2. Resolve historical performance data
100
- // 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 {
101
420
  let previousMicrocycleIndex = microcycleIndex - 1;
102
421
  let previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
103
- if (!previousMicrocycle) return { exerciseIdToSetCount, recoveryExerciseIds };
422
+ if (!previousMicrocycle) return null;
104
423
 
105
- // Map previous session exercises
106
- const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
107
424
  const exerciseIdToPrevSessionExercise = new Map<UUID, WorkoutSessionExercise>();
108
425
  const foundExerciseIds = new Set<UUID>();
109
426
  const exercisesThatWerePreviouslyInRecovery = new Set<UUID>();
427
+
110
428
  // Loop through each previous microcycle until we find all exercises or run out of microcycles
111
429
  while (exerciseIdToPrevSessionExercise.size < exerciseIds.size && previousMicrocycle) {
112
430
  // Check if the previous microcycle is complete; if not, we cannot use its data
@@ -117,14 +435,12 @@ export default class WorkoutVolumePlanningService {
117
435
  break;
118
436
  }
119
437
 
120
- // Start with session order
438
+ // Scan all session exercises in this microcycle for matching non-recovery exercises
121
439
  for (const sessionId of previousMicrocycle.sessionOrder) {
122
440
  const session = context.sessionMap.get(sessionId);
123
441
  if (!session) continue;
124
- // Get the session exercises for this session
125
442
  for (const sessionExerciseId of session.sessionExerciseOrder) {
126
443
  const sessionExercise = context.sessionExerciseMap.get(sessionExerciseId);
127
- // Map if in our muscle group && it isn't a recovery exercise
128
444
  if (
129
445
  sessionExercise &&
130
446
  exerciseIds.has(sessionExercise.workoutExerciseId) &&
@@ -133,78 +449,104 @@ export default class WorkoutVolumePlanningService {
133
449
  ) {
134
450
  exerciseIdToPrevSessionExercise.set(sessionExercise.workoutExerciseId, sessionExercise);
135
451
  foundExerciseIds.add(sessionExercise.workoutExerciseId);
452
+ // If we had to go back more than one microcycle, the exercise was in recovery
136
453
  if (previousMicrocycleIndex < microcycleIndex - 1) {
137
454
  exercisesThatWerePreviouslyInRecovery.add(sessionExercise.workoutExerciseId);
138
455
  }
139
456
  }
140
457
  }
141
458
  }
142
- // Move to earlier microcycle
143
459
  previousMicrocycleIndex = previousMicrocycleIndex - 1;
144
460
  previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
145
461
  }
146
- if (exerciseIdToPrevSessionExercise.size === 0)
147
- return { exerciseIdToSetCount, recoveryExerciseIds };
148
462
 
149
- // Update baseline with historical set counts when available.
150
- // For deload microcycles, halve the historical count (minimum 1 set). We don't use baseline
151
- // because the user may have adjusted over the mesocycle in a way that the baseline is actually
152
- // a higher set count than what would be calculated by halving the previous microcycle's sets.
153
- muscleGroupExerciseCTOs.forEach((cto) => {
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;
499
+
500
+ muscleGroupExerciseCTOs.forEach((cto, index) => {
154
501
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
155
- if (previousSessionExercise) {
156
- let setCount = Math.min(
157
- previousSessionExercise.setOrder.length,
158
- this.MAX_SETS_PER_EXERCISE
159
- );
160
- if (isDeloadMicrocycle) {
161
- setCount = Math.max(1, Math.floor(setCount / 2));
162
- }
163
- exerciseIdToSetCount.set(cto._id, setCount);
164
- }
165
- });
502
+ if (!previousSessionExercise) return;
166
503
 
167
- // Deload microcycles use reduced volume — skip SFR-based set additions
168
- if (isDeloadMicrocycle) {
169
- return { exerciseIdToSetCount, recoveryExerciseIds };
170
- }
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);
511
+ }
171
512
 
172
- /**
173
- * Determines if the session for the given exercise is already capped for this muscle group.
174
- */
175
- function sessionIsCapped(exerciseId: UUID): boolean {
176
- if (!context.exerciseIdToSessionIndex) {
177
- throw new Error(
178
- 'WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.'
179
- );
513
+ if (isDeloadMicrocycle) {
514
+ // Deload: halve volume, minimum 1 set
515
+ setCount = Math.max(1, Math.floor(setCount / 2));
180
516
  }
181
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
182
- if (sessionIndex === undefined) return false;
183
-
184
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
185
- if (!exerciseIdsInSession) return false;
186
- let totalSetsInSession = 0;
187
- exerciseIdsInSession.forEach((id) => {
188
- // Use the sets from the previous microcycle's session exercise
189
- totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
190
- });
191
- return (
192
- totalSetsInSession >= WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION
193
- );
194
- }
517
+ exerciseIdToSetCount.set(cto._id, setCount);
518
+ });
519
+ }
195
520
 
196
- // 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[] } {
197
542
  let totalSetsToAdd = 0;
198
- const candidates: {
199
- exerciseId: UUID;
200
- sfr: number;
201
- muscleGroupIndex: number;
202
- previousSetCount: number;
203
- }[] = [];
543
+
544
+ const candidates: SetAdditionCandidate[] = [];
204
545
  muscleGroupExerciseCTOs.forEach((cto, muscleGroupIndex) => {
205
546
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
206
547
  if (!previousSessionExercise) return;
207
548
 
549
+ // Get SFR-based recommendation: positive = add sets, 0 = maintain, -1 = recovery needed
208
550
  let recommendation: number | null;
209
551
  if (!exercisesThatWerePreviouslyInRecovery.has(cto._id)) {
210
552
  recommendation =
@@ -212,26 +554,29 @@ export default class WorkoutVolumePlanningService {
212
554
  previousSessionExercise
213
555
  );
214
556
  } else {
215
- // If previously in recovery, do not recommend adding sets this microcycle. Also, this is
216
- // the only thing we are overriding. We still want to use the historical data for
217
- // SFR calculations, even though that one was the one that triggered a recovery session.
218
- // 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
219
558
  recommendation = 0;
220
559
  }
221
560
 
222
561
  if (recommendation === -1) {
562
+ // Recovery needed: halve sets and flag as recovery exercise
223
563
  recoveryExerciseIds.add(cto._id);
224
- // Cut sets in half (rounded down, minimum 1) for recovery
225
564
  const previousSetCount = previousSessionExercise.setOrder.length;
226
565
  const recoverySets = Math.max(1, Math.floor(previousSetCount / 2));
227
566
  exerciseIdToSetCount.set(cto._id, recoverySets);
228
- } else if (recommendation != null && recommendation >= 0) {
567
+ } else if (recommendation !== null && recommendation >= 0) {
568
+ // Accumulate SFR-recommended additions into shared total
229
569
  totalSetsToAdd += recommendation;
230
570
 
231
- // Consider as candidate if session is not already capped
571
+ // Only exercises below per-exercise cap and in uncapped sessions are candidates
232
572
  if (
233
573
  previousSessionExercise.setOrder.length < this.MAX_SETS_PER_EXERCISE &&
234
- !sessionIsCapped(cto._id)
574
+ !this.sessionIsCapped(
575
+ cto._id,
576
+ context.exerciseIdToSessionIndex,
577
+ sessionIndexToExerciseIds,
578
+ exerciseIdToPrevSessionExercise
579
+ )
235
580
  ) {
236
581
  candidates.push({
237
582
  exerciseId: cto._id,
@@ -246,106 +591,152 @@ export default class WorkoutVolumePlanningService {
246
591
  }
247
592
  });
248
593
 
249
- // Return if nothing to add or no candidates
250
- if (totalSetsToAdd === 0 || candidates.length === 0) {
251
- return { exerciseIdToSetCount, recoveryExerciseIds };
252
- }
594
+ return { totalSetsToAdd, candidates };
595
+ }
253
596
 
254
- // 4. Distribute added sets based on SFR quality
255
- // 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)
256
615
  candidates.sort((candidateA, candidateB) =>
257
616
  candidateA.sfr !== candidateB.sfr
258
617
  ? candidateB.sfr - candidateA.sfr
259
618
  : candidateA.muscleGroupIndex - candidateB.muscleGroupIndex
260
619
  );
261
620
 
262
- /**
263
- * Gets the total sets currently planned for a session.
264
- */
265
- function getSessionTotal(exerciseId: UUID): number {
266
- if (!context.exerciseIdToSessionIndex) return 0;
267
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
268
- if (sessionIndex === undefined) return 0;
269
-
270
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
271
- if (!exerciseIdsInSession) return 0;
272
-
273
- let total = 0;
274
- exerciseIdsInSession.forEach((id) => {
275
- total += exerciseIdToSetCount.get(id) || 0;
276
- });
277
- return total;
278
- }
621
+ // Cap total additions at MAX_TOTAL_SET_ADDITIONS regardless of raw recommendation
622
+ let setsRemaining =
623
+ totalSetsToAdd >= this.MAX_TOTAL_SET_ADDITIONS
624
+ ? this.MAX_TOTAL_SET_ADDITIONS
625
+ : totalSetsToAdd;
279
626
 
280
- /**
281
- * Attempts to add sets to an exercise, respecting all constraints.
282
- * Returns the number of sets actually added.
283
- */
284
- function addSetsToExercise(exerciseId: UUID, setsToAdd: number): number {
285
- const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
286
- const sessionTotal = getSessionTotal(exerciseId);
287
-
288
- const maxDueToExerciseLimit =
289
- WorkoutVolumePlanningService.MAX_SETS_PER_EXERCISE - currentSets;
290
- const maxDueToSessionLimit =
291
- WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
292
- // Hard limit of 2 to add to a particular exercise at once
293
- const maxAddable = Math.min(setsToAdd, maxDueToExerciseLimit, maxDueToSessionLimit, 2);
294
-
295
- if (maxAddable > 0) {
296
- exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
297
- }
298
- return maxAddable;
299
- }
300
-
301
- // Cap the actual sets to add to 3 total
302
- let setsRemaining = totalSetsToAdd >= 3 ? 3 : totalSetsToAdd;
627
+ // Distribute sets one candidate at a time until budget is exhausted
303
628
  for (const candidate of candidates) {
304
- const added = addSetsToExercise(candidate.exerciseId, setsRemaining);
629
+ const added = this.addSetsToExercise(
630
+ candidate.exerciseId,
631
+ setsRemaining,
632
+ exerciseIdToSetCount,
633
+ exerciseIdToSessionIndex,
634
+ sessionIndexToExerciseIds
635
+ );
305
636
  setsRemaining -= added;
306
637
  if (setsRemaining === 0) break;
307
638
  }
308
-
309
- return { exerciseIdToSetCount, recoveryExerciseIds };
310
639
  }
311
640
 
312
641
  /**
313
- * Calculates the default number of sets for an exercise based on microcycle progression.
314
- *
315
- * Key rule: set progression is distributed across exercises that share the same primary muscle group
316
- * 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.
317
644
  *
318
- * Baseline: 2 sets per exercise in the muscle group.
319
- * Progression: add 1 total set per microcycle per muscle group (distributed to earlier exercises
320
- * in the muscle-group-wide ordering).
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.
321
649
  */
322
- private static calculateBaselineSetCount(
323
- microcycleIndex: number,
324
- totalExercisesInMuscleGroupForMicrocycle: number,
325
- exerciseIndexInMuscleGroupForMicrocycle: number,
326
- isDeloadMicrocycle: boolean
327
- ): number {
328
- // Deload microcycle: half the sets from the previous microcycle, minimum 1 set.
329
- if (isDeloadMicrocycle) {
330
- const baselineSets = this.calculateBaselineSetCount(
331
- microcycleIndex - 1,
332
- totalExercisesInMuscleGroupForMicrocycle,
333
- exerciseIndexInMuscleGroupForMicrocycle,
334
- false
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.'
335
659
  );
336
- return Math.max(1, Math.floor(baselineSets / 2));
337
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
+ }
338
672
 
339
- // Total sets to distribute for this muscle group in this microcycle.
340
- // For now, add exactly +1 total set per microcycle per muscle group.
341
- const totalSets = 2 * totalExercisesInMuscleGroupForMicrocycle + microcycleIndex;
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;
342
690
 
343
- // Distribute sets evenly, with earlier exercises getting extra sets from the remainder.
344
- const baseSetsPerExercise = Math.floor(totalSets / totalExercisesInMuscleGroupForMicrocycle);
345
- const remainder = totalSets % totalExercisesInMuscleGroupForMicrocycle;
691
+ const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
692
+ if (!exerciseIdsInSession) return 0;
346
693
 
347
- return exerciseIndexInMuscleGroupForMicrocycle < remainder
348
- ? baseSetsPerExercise + 1
349
- : baseSetsPerExercise;
694
+ let total = 0;
695
+ exerciseIdsInSession.forEach((id) => {
696
+ total += exerciseIdToSetCount.get(id) || 0;
697
+ });
698
+ return total;
699
+ }
700
+
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;
350
741
  }
351
742
  }