diff options
| author | Marius Peter <dev@marius-peter.com> | 2026-07-18 23:23:35 +0200 |
|---|---|---|
| committer | Marius Peter <dev@marius-peter.com> | 2026-07-18 23:23:35 +0200 |
| commit | 753e25e99ddf57be78ff27ad89cb7e1bbc99d041 (patch) | |
| tree | 43db69ec341520f2923f2f13d0cd8846a9acac35 /src/recovery.lisp | |
Initial commit: HITO MVP
Heavy Duty I prescriptive training system encoding Mentzer's methodology.
- 4-day split program (Chest & Back, Legs, Shoulders, Arms)
- Prescription engine with pre-exhaust superset structure
- Strict progression logic (+5kg large / +2.5kg small muscles)
- Recovery gating with stall-based extension and override
- Onboarding via experience level (Novice/Intermediate/Advanced)
- Session logging with full progression analysis
- Exercise substitution (33 alternatives for 17 canonical movements)
- Objectivist aesthetic: black/gold, serif, rectilinear
Stack: Hunchentoot, Mito/SQLite, Djula, SBCL
Diffstat (limited to 'src/recovery.lisp')
| -rw-r--r-- | src/recovery.lisp | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/src/recovery.lisp b/src/recovery.lisp new file mode 100644 index 0000000..c4edabd --- /dev/null +++ b/src/recovery.lisp @@ -0,0 +1,180 @@ +(in-package #:hito) + +;;; --- Recovery Gating System --- +;;; +;;; "The muscles DO NOT grow during training. They grow during REST. +;;; If you are not making progress, you are not overtraining — +;;; you are under-recovering." +;;; +;;; Recovery is not optional. It is where growth occurs. The system enforces +;;; minimum rest periods and extends them when stalls indicate incomplete recovery. + +;;; --- Recovery Configuration --- + +(defparameter *base-recovery-days* 3 + "Minimum rest days between training sessions (Mentzer's HD I starting point).") + +(defparameter *stall-extension-days* 2 + "Additional rest days added when a stall is detected in recent sessions.") + +(defparameter *consecutive-stall-extension-days* 3 + "Additional rest days for consecutive stalls across sessions of the same day.") + +(defparameter *max-recovery-days* 14 + "Maximum recovery period before the system suggests program reassessment.") + +;;; --- Recovery Calculation --- + +(defun count-recent-stalls (workout-day &optional (lookback 2)) + "Count sessions of WORKOUT-DAY in the last LOOKBACK completed sessions +that had at least one stall." + (let* ((sessions (mito:select-dao 'training-session + (sxql:where (:and (:= :workout-day workout-day) + (:= :completed-p 1))) + (sxql:order-by (:desc :session-date)) + (sxql:limit lookback))) + (stall-count 0)) + (dolist (session sessions) + (let ((records (mito:select-dao 'set-record + (sxql:where (:= :session-id (mito:object-id session)))))) + (when (some (lambda (rec) + (let ((actual (set-record-actual-reps rec)) + (low (set-record-prescribed-rep-low rec))) + (and actual (< actual low)))) + records) + (incf stall-count)))) + stall-count)) + +(defun calculate-recovery-days (&optional session) + "Calculate the required recovery days based on recent performance. +If SESSION is provided, uses its workout-day for stall analysis. +Otherwise uses the user's current workout day. + +Returns (values days reason) where reason explains the calculation." + (let* ((workout-day (if session + (training-session-workout-day session) + (let ((profile (first (mito:select-dao 'user-profile)))) + (if profile + (user-profile-current-workout-day profile) + 1)))) + (stalls (count-recent-stalls workout-day)) + (base *base-recovery-days*) + (extension (cond + ((>= stalls 2) + *consecutive-stall-extension-days*) + ((>= stalls 1) + *stall-extension-days*) + (t 0))) + (total (min (+ base extension) *max-recovery-days*)) + (reason (cond + ((>= stalls 2) + (format nil "Consecutive stalls detected across recent sessions of Day ~D. ~ + Extended recovery is not merely suggested — it is required. ~ + Growth occurs during rest." workout-day)) + ((>= stalls 1) + (format nil "A stall was detected in your last Day ~D session. ~ + Additional recovery time has been prescribed." workout-day)) + (t + (format nil "Standard recovery period between sessions."))))) + (values total reason))) + +;;; --- Recovery Status --- + +(defstruct recovery-status + "Current recovery state for the trainee." + ready-p ; t if they can train + days-remaining ; days until ready (0 if ready) + next-date ; recommended next training date (string) + reason ; explanation text + last-session-date ; when they last trained + recovery-days) ; total prescribed recovery + +(defun parse-date-string (date-str) + "Parse an ISO date string (YYYY-MM-DD) to a local-time timestamp." + (when (and date-str (plusp (length date-str))) + (local-time:parse-timestring date-str))) + +(defun format-date (timestamp) + "Format a local-time timestamp as YYYY-MM-DD." + (local-time:format-timestring nil timestamp :format '(:year "-" (:month 2) "-" (:day 2)))) + +(defun days-since (date-string) + "Return the number of days elapsed since DATE-STRING (YYYY-MM-DD format)." + (let ((then (parse-date-string date-string)) + (now (local-time:now))) + (when then + (let ((diff (local-time:timestamp-difference now then))) + (floor diff 86400))))) ; seconds -> days + +(defun add-days-to-date (date-string days) + "Add DAYS to a date string, return new date string." + (let ((timestamp (parse-date-string date-string))) + (when timestamp + (format-date (local-time:timestamp+ timestamp days :day))))) + +(defun recovery-status () + "Compute the current recovery status for the trainee. +Returns a recovery-status struct." + (let* ((last-session (first (mito:select-dao 'training-session + (sxql:where (:= :completed-p 1)) + (sxql:order-by (:desc :session-date)) + (sxql:limit 1))))) + (if (null last-session) + ;; No sessions yet — ready to train + (make-recovery-status + :ready-p t + :days-remaining 0 + :next-date (format-date (local-time:now)) + :reason "No previous sessions recorded. You are ready to begin." + :last-session-date nil + :recovery-days 0) + ;; Calculate based on last session + (let* ((last-date (training-session-session-date last-session)) + (elapsed (days-since last-date))) + (multiple-value-bind (required-days reason) + (calculate-recovery-days last-session) + (let* ((remaining (max 0 (- required-days elapsed))) + (ready (zerop remaining)) + (next-date (if ready + (format-date (local-time:now)) + (add-days-to-date last-date required-days)))) + (make-recovery-status + :ready-p ready + :days-remaining remaining + :next-date next-date + :reason reason + :last-session-date last-date + :recovery-days required-days))))))) + +;;; --- Override Mechanism --- + +(defparameter *override-warning* + "You are overriding the prescribed recovery period. Understand: growth occurs ~ +during rest, not during training. The muscles require time to compensate and ~ +supercompensate. If you train before recovery is complete, you are not merely ~ +wasting effort — you are actively undermining your progress. Proceed only if ~ +you are certain your recovery is complete." + "Warning text displayed when user overrides recovery gate. Mentzer's voice.") + +(defun override-recovery () + "Log that the user has overridden the recovery gate. +Returns the warning text. The session created after this should be marked +with override-p = t." + (format t "~&[OVERRIDE] Recovery gate overridden at ~A~%" + (format-date (local-time:now))) + *override-warning*) + +;;; --- Recovery Formatting --- + +(defun format-recovery-status (status) + "Format recovery status for display." + (with-output-to-string (out) + (if (recovery-status-ready-p status) + (format out "READY. You may train today.") + (format out "RECOVERY IN PROGRESS.~%~ + Days remaining: ~D~%~ + Next session: ~A~%~ + ~A" + (recovery-status-days-remaining status) + (recovery-status-next-date status) + (recovery-status-reason status))))) |