import { DateService } from '@aneuhold/core-ts-lib'; import type { UUID } from 'crypto'; import type { WorkoutExerciseCTO } from '../../../ctos/workout/WorkoutExerciseCTO.js'; import type { WorkoutMuscleGroupVolumeCTO } from '../../../ctos/workout/WorkoutMuscleGroupVolumeCTO.js'; import type { WorkoutExercise } from '../../../documents/workout/WorkoutExercise.js'; import { CycleType, type WorkoutMesocycle } from '../../../documents/workout/WorkoutMesocycle.js'; import type { WorkoutMicrocycle } from '../../../documents/workout/WorkoutMicrocycle.js'; import { WorkoutMicrocycleSchema } from '../../../documents/workout/WorkoutMicrocycle.js'; import type { WorkoutSession } from '../../../documents/workout/WorkoutSession.js'; import type { WorkoutSessionExercise } from '../../../documents/workout/WorkoutSessionExercise.js'; import type { WorkoutSet } from '../../../documents/workout/WorkoutSet.js'; import type { DocumentOperations } from '../../Document.service.js'; import WorkoutMicrocycleService from '../Microcycle/WorkoutMicrocycle.service.js'; import WorkoutSessionExerciseService from '../SessionExercise/WorkoutSessionExercise.service.js'; import WorkoutMesocyclePlanContext from './WorkoutMesocyclePlanContext.js'; import type { WorkoutDeloadRecommendation } from './WorkoutMesocycleService.types.js'; import { WorkoutDeloadSeverity, WorkoutDeloadTriggerRule } from './WorkoutMesocycleService.types.js'; /** * A service for handling operations related to {@link WorkoutMesocycle}s. */ export default class WorkoutMesocycleService { /** Minimum microcycle index (0-based) before deload detection is active. */ static readonly #MIN_MICROCYCLE_INDEX_FOR_DELOAD = 2; /** Recovery ratio above which a deload is Recommended. */ static readonly #RECOVERY_RATIO_RECOMMENDED = 0.5; /** Recovery ratio at or above which a deload is Suggested. */ static readonly #RECOVERY_RATIO_SUGGESTED = 0.4; /** Set surplus at or below which a performance drop is counted. */ static readonly #PERFORMANCE_DROP_SURPLUS_THRESHOLD = -3; /** Number of consecutive performance drops needed to trigger the rule. */ static readonly #CONSECUTIVE_DROPS_REQUIRED = 2; /** Number of exercises with consecutive drops needed for Recommended severity. */ static readonly #EXERCISES_WITH_DROPS_FOR_RECOMMENDED = 2; /** * Generates or updates the workout plan for a mesocycle. * * This method supports incremental generation by only creating new microcycles that don't * yet exist. It expects that the mesocycle's core parameters (planned session count, * microcycle count, microcycle length) and exercise ordering have not changed since * initial creation. If these parameters have changed, it is the responsibility of a * higher-level service to convert the mesocycle to free-form mode before calling this method. * * The method will clean up any incomplete microcycles (where the last session is not * complete) and regenerate from that point, unless the microcycle has already started * (first session complete), in which case it will throw an error. * * @param mesocycle The mesocycle configuration. * @param exerciseCTOs The exercise CTOs containing exercise, calibration, equipment, and historical data. * @param volumeCTOs Optional muscle group volume CTOs for historical volume landmark estimation. * @param existingMicrocycles Existing microcycle documents for this mesocycle. * @param existingSessions Existing session documents. * @param existingSessionExercises Existing session exercise documents. * @param existingSets Existing set documents. * @param startDate Optional start date for the first microcycle. Defaults to the current date when not provided. */ static generateOrUpdateMesocycle( mesocycle: WorkoutMesocycle, exerciseCTOs: WorkoutExerciseCTO[], volumeCTOs: WorkoutMuscleGroupVolumeCTO[] = [], existingMicrocycles: WorkoutMicrocycle[] = [], existingSessions: WorkoutSession[] = [], existingSessionExercises: WorkoutSessionExercise[] = [], existingSets: WorkoutSet[] = [], startDate?: Date ): { mesocycleUpdate?: Partial; microcycles?: DocumentOperations; sessions?: DocumentOperations; sessionExercises?: DocumentOperations; sets?: DocumentOperations; } { // Free-form mesocycles are intentionally not auto-planned. The user can still log workouts, // but we avoid generating microcycles/sessions/sets because recommendations wouldn't be able // to be done / make any sense. if (mesocycle.cycleType === CycleType.FreeForm) { return {}; } // Validate all exercises have calibrations before doing any work for (const cto of exerciseCTOs) { if (!cto.bestCalibration) { throw new Error( `Exercise "${cto.exerciseName}" (${cto._id}) has no bestCalibration. All exercises must be calibrated before planning a mesocycle.` ); } } // Clean up incomplete microcycles before creating context const cleanupResult = this.#cleanUpIncompleteMicrocycles( mesocycle, existingMicrocycles, existingSessions, existingSessionExercises, existingSets ); // Filter out documents that will be deleted const cleanMicrocycles = existingMicrocycles.filter( (m) => !cleanupResult.microcyclesToDelete.includes(m._id) ); const cleanSessions = existingSessions.filter( (s) => !cleanupResult.sessionsToDelete.includes(s._id) ); const cleanSessionExercises = existingSessionExercises.filter( (se) => !cleanupResult.sessionExercisesToDelete.includes(se._id) ); const cleanSets = existingSets.filter((s) => !cleanupResult.setsToDelete.includes(s._id)); // Create planning context with clean data const context = new WorkoutMesocyclePlanContext( mesocycle, exerciseCTOs, volumeCTOs, cleanMicrocycles, cleanSessions, cleanSessionExercises, cleanSets ); // Distribute exercises across sessions once for the entire mesocycle plan. // This session layout is expected to be stable across microcycles. context.setPlannedSessionExerciseCTOs( WorkoutMicrocycleService.distributeExercisesAcrossSessions( mesocycle.plannedSessionCountPerMicrocycle, exerciseCTOs ) ); // Determine number of microcycles (default to 6 if not specified: 5 accumulation + 1 deload) const totalMicrocycles = mesocycle.plannedMicrocycleCount ?? 6; const deloadMicrocycleIndex = context.skipDeload ? -1 : totalMicrocycles - 1; // Determine starting point for generation const startMicrocycleIndex = context.microcyclesInOrder.length; let currentDate: Date; if (startMicrocycleIndex === 0) { currentDate = startDate ?? new Date(); } else { // Continue from where the last existing microcycle ended const lastExistingMicrocycle = context.microcyclesInOrder[startMicrocycleIndex - 1]; currentDate = lastExistingMicrocycle.endDate; } const { firstMicrocycleRir } = context; // Generate remaining microcycles for ( let microcycleIndex = startMicrocycleIndex; microcycleIndex < totalMicrocycles; microcycleIndex++ ) { const isDeloadMicrocycle = microcycleIndex === deloadMicrocycleIndex; // Calculate RIR for this microcycle. Uses the cycle-type-specific starting RIR // and tapers down by 1 per microcycle: e.g. MuscleGain 4->3->2->1->0, Cut 3->2->1->0->0. // If progression is still, keep RIR flat const rirForMicrocycle = context.progressionInterval === 0 ? firstMicrocycleRir : Math.max(firstMicrocycleRir - microcycleIndex, 0); const targetRir = isDeloadMicrocycle ? null : rirForMicrocycle; // Create microcycle const microcycle = WorkoutMicrocycleSchema.parse({ userId: mesocycle.userId, workoutMesocycleId: mesocycle._id, startDate: currentDate, endDate: DateService.addDays(currentDate, mesocycle.plannedMicrocycleLengthInDays) }); context.addMicrocycle(microcycle); WorkoutMicrocycleService.generateSessionsForMicrocycle({ context, microcycleIndex, targetRir, isDeloadMicrocycle }); // Move to next microcycle currentDate = microcycle.endDate; } return { mesocycleUpdate: undefined, microcycles: { create: context.microcyclesToCreate, update: [], delete: cleanupResult.microcyclesToDelete }, sessions: { create: context.sessionsToCreate, update: [], delete: cleanupResult.sessionsToDelete }, sessionExercises: { create: context.sessionExercisesToCreate, update: [], delete: cleanupResult.sessionExercisesToDelete }, sets: { create: context.setsToCreate, update: [], delete: cleanupResult.setsToDelete } }; } /** * Returns the projected start date for a mesocycle. Uses * `mesocycle.startDate` if set (active/completed), otherwise falls back to * the earliest microcycle's start date (future/planned mesocycles). * * @param mesocycle The mesocycle to get the projected start date for. * @param microcycles The microcycles belonging to this mesocycle (pre-filtered). */ static getProjectedStartDate( mesocycle: WorkoutMesocycle, microcycles: WorkoutMicrocycle[] ): Date | null { if (mesocycle.startDate != null) { return mesocycle.startDate; } const sorted = [...microcycles].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); return sorted.length > 0 ? sorted[0].startDate : null; } /** * Returns the projected end date of a mesocycle based on its microcycles. * If microcycles exist, uses the last microcycle's endDate. Otherwise, * calculates from the mesocycle's start date and planned parameters. * * @param mesocycle The mesocycle to calculate the end date for. * @param microcycles The microcycles belonging to this mesocycle (pre-filtered). */ static getProjectedEndDate( mesocycle: WorkoutMesocycle, microcycles: WorkoutMicrocycle[] ): Date | null { const sorted = [...microcycles].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); if (sorted.length > 0) { return sorted[sorted.length - 1].endDate; } const startDate = this.getProjectedStartDate(mesocycle, microcycles); if (startDate == null) { return null; } const totalMicrocycles = mesocycle.plannedMicrocycleCount ?? 6; const totalDays = totalMicrocycles * mesocycle.plannedMicrocycleLengthInDays; return DateService.addDays(startDate, totalDays); } /** * Shifts all dates in a mesocycle and its child documents by the given number of days. * Mutates the passed-in objects in place. * * @param mesocycle The mesocycle to shift. Its startDate is mutated directly. * @param microcycles The microcycles belonging to this mesocycle (pre-filtered). Mutated in place. * @param sessions The sessions belonging to these microcycles (pre-filtered). Mutated in place. * @param daysDelta The number of days to shift (positive = forward, negative = backward). */ static shiftMesocycleDates( mesocycle: WorkoutMesocycle, microcycles: WorkoutMicrocycle[], sessions: WorkoutSession[], daysDelta: number ): void { if (mesocycle.startDate != null) { mesocycle.startDate = DateService.addDays(mesocycle.startDate, daysDelta); } for (const microcycle of microcycles) { microcycle.startDate = DateService.addDays(microcycle.startDate, daysDelta); microcycle.endDate = DateService.addDays(microcycle.endDate, daysDelta); } for (const session of sessions) { session.startTime = DateService.addDays(session.startTime, daysDelta); } } /** * Detects whether any mesocycles in the provided list have overlapping date ranges. * Two mesocycles overlap if their projected date ranges intersect. * * @param mesocycles The mesocycles to check. * @param mesocycleToMicrocyclesMap A map from mesocycle ID to its microcycles. */ static detectMesocycleOverlap( mesocycles: WorkoutMesocycle[], mesocycleToMicrocyclesMap: Map ): { hasOverlap: boolean; overlappingPairs: Array<[UUID, UUID]> } { const overlappingPairs: Array<[UUID, UUID]> = []; const ranges: Array<{ id: UUID; start: Date; end: Date }> = []; for (const mesocycle of mesocycles) { const microcycles = mesocycleToMicrocyclesMap.get(mesocycle._id) ?? []; const start = this.getProjectedStartDate(mesocycle, microcycles); if (start == null) { continue; } const end = this.getProjectedEndDate(mesocycle, microcycles); if (end == null) { continue; } ranges.push({ id: mesocycle._id, start, end }); } ranges.sort((a, b) => a.start.getTime() - b.start.getTime()); for (let i = 0; i < ranges.length; i++) { for (let j = i + 1; j < ranges.length; j++) { if (ranges[j].start.getTime() < ranges[i].end.getTime()) { overlappingPairs.push([ranges[i].id, ranges[j].id]); } } } return { hasOverlap: overlappingPairs.length > 0, overlappingPairs }; } /** * Returns the earliest allowed start date for a new mesocycle. This is the later * of: the projected end of the last existing mesocycle, or today. * * @param existingMesocycles All existing mesocycles. * @param mesocycleToMicrocyclesMap A map from mesocycle ID to its microcycles. */ static getEarliestAllowedStartDate( existingMesocycles: WorkoutMesocycle[], mesocycleToMicrocyclesMap: Map ): Date { let latestEnd = new Date(); for (const mesocycle of existingMesocycles) { if (mesocycle.completedDate != null) { continue; } const microcycles = mesocycleToMicrocyclesMap.get(mesocycle._id) ?? []; const projectedEnd = this.getProjectedEndDate(mesocycle, microcycles); if (projectedEnd != null && projectedEnd.getTime() > latestEnd.getTime()) { latestEnd = projectedEnd; } } return latestEnd; } /** * Evaluates whether the mesocycle should trigger an early deload based on * fatigue indicators from recent session data. * * Should be called after each session completion. Accepts the same document * inputs as {@link generateOrUpdateMesocycle} plus a current microcycle ID, * and uses {@link WorkoutMesocyclePlanContext} internally to derive all * needed data. */ static shouldTriggerEarlyDeload( mesocycle: WorkoutMesocycle, exerciseCTOs: WorkoutExerciseCTO[], currentMicrocycleId: UUID, existingMicrocycles: WorkoutMicrocycle[], existingSessions: WorkoutSession[], existingSessionExercises: WorkoutSessionExercise[], existingSets: WorkoutSet[] ): WorkoutDeloadRecommendation { const noDeload: WorkoutDeloadRecommendation = { shouldDeload: false, severity: WorkoutDeloadSeverity.None, triggeredRules: [] }; const context = new WorkoutMesocyclePlanContext( mesocycle, exerciseCTOs, [], existingMicrocycles, existingSessions, existingSessionExercises, existingSets ); // Find current microcycle index const currentMicrocycleIndex = context.microcyclesInOrder.findIndex( (mc) => mc._id === currentMicrocycleId ); // Guard: don't trigger before enough microcycles have been completed if (currentMicrocycleIndex < this.#MIN_MICROCYCLE_INDEX_FOR_DELOAD) { return noDeload; } // Guard: don't suggest a deload if already on the deload microcycle const isLastMicrocycle = currentMicrocycleIndex === context.microcyclesInOrder.length - 1; if (isLastMicrocycle && !context.skipDeload) { return noDeload; } // Gather recent session exercises from the last 2 microcycles const startIndex = Math.max(0, currentMicrocycleIndex - 1); const recentMicrocycles = context.microcyclesInOrder.slice( startIndex, currentMicrocycleIndex + 1 ); const recentMicrocycleIds = new Set(recentMicrocycles.map((mc) => mc._id)); const recentSessionExercises = [...context.sessionExerciseMap.values()].filter((se) => { const session = context.sessionMap.get(se.workoutSessionId); return session?.workoutMicrocycleId && recentMicrocycleIds.has(session.workoutMicrocycleId); }); const triggeredRules: WorkoutDeloadTriggerRule[] = []; let recoverySeverity = WorkoutDeloadSeverity.None; let performanceSeverity = WorkoutDeloadSeverity.None; // --- Rule 1: Recovery Session Threshold --- const recoveryResult = this.#evaluateRecoverySessionThreshold( recentSessionExercises, context.exerciseMap ); if (recoveryResult !== WorkoutDeloadSeverity.None) { triggeredRules.push(WorkoutDeloadTriggerRule.RecoverySessionThreshold); recoverySeverity = recoveryResult; } // --- Rule 2: Consecutive Performance Drops --- const performanceResult = this.#evaluateConsecutivePerformanceDrops( recentSessionExercises, context.setMap ); if (performanceResult !== WorkoutDeloadSeverity.None) { triggeredRules.push(WorkoutDeloadTriggerRule.ConsecutivePerformanceDrop); performanceSeverity = performanceResult; } if (triggeredRules.length === 0) { return noDeload; } // Determine combined severity let severity: WorkoutDeloadSeverity; if (triggeredRules.length >= 2) { severity = WorkoutDeloadSeverity.Urgent; } else { severity = recoverySeverity !== WorkoutDeloadSeverity.None ? recoverySeverity : performanceSeverity; } return { shouldDeload: true, severity, triggeredRules }; } /** * Checks whether the ratio of muscle groups in recovery mode exceeds the * deload thresholds. Derives the trained muscle group list and per-exercise * muscle group mapping from the exercise map built by * {@link WorkoutMesocyclePlanContext}. * * @param recentSessionExercises Session exercises from the last 2 microcycles. * @param exerciseMap Map of exercise ID to exercise (from context). */ static #evaluateRecoverySessionThreshold( recentSessionExercises: WorkoutSessionExercise[], exerciseMap: ReadonlyMap ): WorkoutDeloadSeverity { // Derive unique trained muscle groups from all exercises in the plan const trainedMuscleGroupIds = new Set(); for (const exercise of exerciseMap.values()) { for (const mgId of exercise.primaryMuscleGroups) { trainedMuscleGroupIds.add(mgId); } } if (trainedMuscleGroupIds.size === 0) { return WorkoutDeloadSeverity.None; } const recoveryMuscleGroups = new Set(); for (const sessionExercise of recentSessionExercises) { if (sessionExercise.isRecoveryExercise) { const exercise = exerciseMap.get(sessionExercise.workoutExerciseId); if (exercise) { for (const mgId of exercise.primaryMuscleGroups) { recoveryMuscleGroups.add(mgId); } } } } const ratio = recoveryMuscleGroups.size / trainedMuscleGroupIds.size; if (ratio > this.#RECOVERY_RATIO_RECOMMENDED) { return WorkoutDeloadSeverity.Recommended; } if (ratio >= this.#RECOVERY_RATIO_SUGGESTED) { return WorkoutDeloadSeverity.Suggested; } return WorkoutDeloadSeverity.None; } /** * Checks whether exercises show consecutive performance drops (negative set * surplus) across recent session exercises. Uses the context's set map for * O(1) set lookups instead of building a local map. * * @param recentSessionExercises Session exercises from the last 2 microcycles. * @param setMap Map of set ID to set (from context). */ static #evaluateConsecutivePerformanceDrops( recentSessionExercises: WorkoutSessionExercise[], setMap: ReadonlyMap ): WorkoutDeloadSeverity { // Group session exercises by exercise ID const exerciseGroups = new Map(); for (const se of recentSessionExercises) { const existing = exerciseGroups.get(se.workoutExerciseId); if (existing) { existing.push(se); } else { exerciseGroups.set(se.workoutExerciseId, [se]); } } let exercisesWithDropsCount = 0; for (const [, sessionExercises] of exerciseGroups) { // Sort by session exercise creation date (ascending) const sorted = [...sessionExercises].sort( (a, b) => a.createdDate.getTime() - b.createdDate.getTime() ); let consecutiveDrops = 0; let hasConsecutiveDrops = false; for (const se of sorted) { if (se.setOrder.length === 0) { consecutiveDrops = 0; continue; } // Average surplus across all completed sets for a holistic performance signal. const sets = se.setOrder .map((setId) => setMap.get(setId)) .filter((s): s is WorkoutSet => s != null); const surplus = WorkoutSessionExerciseService.calculateAverageSurplus(sets); if (surplus == null) { consecutiveDrops = 0; continue; } if (surplus <= this.#PERFORMANCE_DROP_SURPLUS_THRESHOLD) { consecutiveDrops++; } else { consecutiveDrops = 0; } if (consecutiveDrops >= this.#CONSECUTIVE_DROPS_REQUIRED) { hasConsecutiveDrops = true; break; } } if (hasConsecutiveDrops) { exercisesWithDropsCount++; } } if (exercisesWithDropsCount === 0) { return WorkoutDeloadSeverity.None; } if (exercisesWithDropsCount >= this.#EXERCISES_WITH_DROPS_FOR_RECOMMENDED) { return WorkoutDeloadSeverity.Recommended; } return WorkoutDeloadSeverity.Suggested; } /** * Cleans up incomplete microcycles and their associated documents. * * Finds the first microcycle where the last session is not complete, validates that it * hasn't started (first session incomplete), and returns IDs of all documents that should * be deleted (microcycles from that point forward and all their associated data). */ static #cleanUpIncompleteMicrocycles( mesocycle: WorkoutMesocycle, existingMicrocycles: WorkoutMicrocycle[], existingSessions: WorkoutSession[], existingSessionExercises: WorkoutSessionExercise[], existingSets: WorkoutSet[] ): { microcyclesToDelete: UUID[]; sessionsToDelete: UUID[]; sessionExercisesToDelete: UUID[]; setsToDelete: UUID[]; } { const microcyclesToDelete: UUID[] = []; const sessionsToDelete: UUID[] = []; const sessionExercisesToDelete: UUID[] = []; const setsToDelete: UUID[] = []; // Sort microcycles for this mesocycle const microcyclesForMesocycle = existingMicrocycles.sort( (a, b) => a.startDate.getTime() - b.startDate.getTime() ); // Find first incomplete microcycle let firstIncompleteMicrocycleIndex = -1; for (let i = 0; i < microcyclesForMesocycle.length; i++) { const microcycle = microcyclesForMesocycle[i]; if (microcycle.completedDate) { continue; } if (microcycle.sessionOrder.length === 0) { // Microcycle has no sessions, it's incomplete firstIncompleteMicrocycleIndex = i; break; } // Check if last session is complete const lastSessionId = microcycle.sessionOrder[microcycle.sessionOrder.length - 1]; const lastSession = existingSessions.find((s) => s._id === lastSessionId); if (!lastSession?.complete) { firstIncompleteMicrocycleIndex = i; break; } } // If all microcycles are complete, nothing to clean up if (firstIncompleteMicrocycleIndex === -1) { return { microcyclesToDelete, sessionsToDelete, sessionExercisesToDelete, setsToDelete }; } const firstIncompleteMicrocycle = microcyclesForMesocycle[firstIncompleteMicrocycleIndex]; // Check if the incomplete microcycle has started (first session complete) if (firstIncompleteMicrocycle.sessionOrder.length > 0) { const firstSessionId = firstIncompleteMicrocycle.sessionOrder[0]; const firstSession = existingSessions.find((s) => s._id === firstSessionId); if (firstSession?.complete) { throw new Error( `Cannot generate new microcycles for mesocycle ${mesocycle._id}: Microcycle at index ${firstIncompleteMicrocycleIndex} has started but is not complete. All sessions in the current microcycle must be completed before generating new microcycles.` ); } } // Collect IDs of incomplete microcycles (from firstIncompleteMicrocycleIndex onward) const incompleteMicrocycles = microcyclesForMesocycle.slice(firstIncompleteMicrocycleIndex); microcyclesToDelete.push(...incompleteMicrocycles.map((m) => m._id)); // Collect all sessions, session exercises, and sets associated with incomplete microcycles const incompleteMicrocycleIds = new Set(incompleteMicrocycles.map((m) => m._id)); for (const session of existingSessions) { if (session.workoutMicrocycleId && incompleteMicrocycleIds.has(session.workoutMicrocycleId)) { sessionsToDelete.push(session._id); } } const sessionIdsToDelete = new Set(sessionsToDelete); for (const sessionExercise of existingSessionExercises) { if (sessionIdsToDelete.has(sessionExercise.workoutSessionId)) { sessionExercisesToDelete.push(sessionExercise._id); } } const sessionExerciseIdsToDelete = new Set(sessionExercisesToDelete); for (const set of existingSets) { if (sessionExerciseIdsToDelete.has(set.workoutSessionExerciseId)) { setsToDelete.push(set._id); } } return { microcyclesToDelete, sessionsToDelete, sessionExercisesToDelete, setsToDelete }; } }