@dgpholdings/greatoak-shared 1.2.57 → 1.2.59

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.
@@ -135,6 +135,9 @@ export type TExercise = {
135
135
  female: number;
136
136
  default: number;
137
137
  };
138
+ /** True for single-arm / single-leg exercises. The user enters weight per side,
139
+ * so the scoring engine doubles it to get total mechanical load. */
140
+ isUnilateral?: boolean;
138
141
  };
139
142
  export type TBodyPartExercises = Record<TBodyPart, TExercise[]>;
140
143
  export type TApiCreateOrUpdateExerciseReq = {
@@ -49,6 +49,8 @@ interface IFatigueExerciseData {
49
49
  muscleGroupFactor: number;
50
50
  };
51
51
  scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry";
52
+ /** P3-9: Single-arm/leg exercises. User enters weight per side; double it for total load. */
53
+ isUnilateral?: boolean;
52
54
  }
53
55
  /**
54
56
  * Calculate per-muscle fatigue scores for an exercise.
@@ -64,7 +64,7 @@ function calculateMuscleFatigue(sets, exercise, user, timingGuardrails, historic
64
64
  return {};
65
65
  const fatigueMultiplier = (_a = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.fatigueMultiplier) !== null && _a !== void 0 ? _a : constants_1.FALLBACK_FATIGUE_MULTIPLIER;
66
66
  // --- Step 1–3: Compute cumulative fatigue stimulus ---
67
- const cumulativeFatigue = computeCumulativeFatigue(sets, exercise.difficultyLevel, user, fatigueMultiplier, exercise.scoringSpecialHandling);
67
+ const cumulativeFatigue = computeCumulativeFatigue(sets, exercise.difficultyLevel, user, fatigueMultiplier, exercise.scoringSpecialHandling, exercise.isUnilateral);
68
68
  // --- Step 4: Distribute to muscles ---
69
69
  const rawMuscleFatigue = distributeFatigueToMuscles(cumulativeFatigue, exercise.primaryMuscles, exercise.secondaryMuscles);
70
70
  // --- Step 5: Normalize to 0–100 ---
@@ -88,11 +88,12 @@ function calculateMuscleFatigue(sets, exercise, user, timingGuardrails, historic
88
88
  * cardio: Duration × speed factor (represents sustained effort)
89
89
  * loaded-carry: Speed factor boosted by carried weight relative to bodyweight
90
90
  */
91
- function computeVolumeLoad(set, difficultyLevel, user, scoringSpecialHandling) {
91
+ function computeVolumeLoad(set, difficultyLevel, user, scoringSpecialHandling, isUnilateral) {
92
92
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
93
93
  switch (set.type) {
94
94
  case "weight-reps": {
95
- const kg = (_a = set.kg) !== null && _a !== void 0 ? _a : 0;
95
+ // P3-9: User enters weight per side for unilateral exercises double for total load
96
+ const kg = ((_a = set.kg) !== null && _a !== void 0 ? _a : 0) * (isUnilateral ? 2 : 1);
96
97
  const reps = (_b = set.reps) !== null && _b !== void 0 ? _b : 0;
97
98
  return kg * reps;
98
99
  }
@@ -175,12 +176,12 @@ function computeVolumeLoad(set, difficultyLevel, user, scoringSpecialHandling) {
175
176
  *
176
177
  * @returns Single number representing total fatigue stimulus
177
178
  */
178
- function computeCumulativeFatigue(sets, difficultyLevel, user, fatigueMultiplier, scoringSpecialHandling) {
179
+ function computeCumulativeFatigue(sets, difficultyLevel, user, fatigueMultiplier, scoringSpecialHandling, isUnilateral) {
179
180
  let cumulative = 0;
180
181
  for (let i = 0; i < sets.length; i++) {
181
182
  const set = sets[i];
182
183
  // Step 1: Raw volume for this set
183
- const volumeLoad = computeVolumeLoad(set, difficultyLevel, user, scoringSpecialHandling);
184
+ const volumeLoad = computeVolumeLoad(set, difficultyLevel, user, scoringSpecialHandling, isUnilateral);
184
185
  // Step 2: Scale by effort and exercise fatigue multiplier
185
186
  const stimulus = volumeLoad * set.effortFraction * fatigueMultiplier;
186
187
  // Step 3: Apply diminishing returns decay
@@ -15,4 +15,4 @@ import { TRecord, TUserMetric } from "../../types";
15
15
  * @param difficultyLevel Exercise difficulty (0–4). Used for bodyweight load
16
16
  * scaling on reps-only exercises (matches fatigue pillar).
17
17
  */
18
- export declare const calculateTotalVolume: (record: TRecord[], user: TUserMetric, scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry", difficultyLevel?: number) => number;
18
+ export declare const calculateTotalVolume: (record: TRecord[], user: TUserMetric, scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry", difficultyLevel?: number, isUnilateral?: boolean) => number;
@@ -19,7 +19,7 @@ const constants_1 = require("./constants");
19
19
  * @param difficultyLevel Exercise difficulty (0–4). Used for bodyweight load
20
20
  * scaling on reps-only exercises (matches fatigue pillar).
21
21
  */
22
- const calculateTotalVolume = (record, user, scoringSpecialHandling, difficultyLevel = 2) => {
22
+ const calculateTotalVolume = (record, user, scoringSpecialHandling, difficultyLevel = 2, isUnilateral = false) => {
23
23
  if (scoringSpecialHandling === "stretch-mobility")
24
24
  return 0;
25
25
  // Bug A fix: only completed sets contribute to volume.
@@ -28,7 +28,8 @@ const calculateTotalVolume = (record, user, scoringSpecialHandling, difficultyLe
28
28
  switch (set.type) {
29
29
  case "weight-reps": {
30
30
  const reps = parseFloat(set.reps) || 0;
31
- const weight = parseFloat(set.kg) || 0;
31
+ // P3-9: User enters weight per side for unilateral exercises — double for total load
32
+ const weight = (parseFloat(set.kg) || 0) * (isUnilateral ? 2 : 1);
32
33
  return total + reps * weight;
33
34
  }
34
35
  case "reps-only": {
@@ -12,6 +12,7 @@
12
12
  * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
13
13
  * P2-8 Gender — females recover slightly faster (1.05×)
14
14
  * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
15
+ * P3-7 Weekly volume — muscles trained multiple times this week recover slower
15
16
  * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
16
17
  *
17
18
  * Accumulation model:
@@ -13,6 +13,7 @@
13
13
  * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
14
14
  * P2-8 Gender — females recover slightly faster (1.05×)
15
15
  * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
16
+ * P3-7 Weekly volume — muscles trained multiple times this week recover slower
16
17
  * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
17
18
  *
18
19
  * Accumulation model:
@@ -79,6 +80,35 @@ function getMuscleRecoveryRate(muscle) {
79
80
  return (_a = MUSCLE_RECOVERY_RATE[muscle]) !== null && _a !== void 0 ? _a : 1.0;
80
81
  }
81
82
  // ---------------------------------------------------------------------------
83
+ // P3-7: Weekly volume recovery modifier
84
+ //
85
+ // A muscle trained multiple times this week is carrying accumulated fatigue
86
+ // beyond what any single session decay captures. High weekly volume slows
87
+ // the effective recovery rate for that muscle.
88
+ //
89
+ // Formula: each session beyond the first (~80 score units) reduces recovery
90
+ // rate by 5%, up to a 40% maximum reduction.
91
+ //
92
+ // 1 session (~80): 0% reduction → 1.00× multiplier
93
+ // 2 sessions (~160): 5% reduction → 0.95×
94
+ // 3 sessions (~240): 10% reduction → 0.90×
95
+ // 5 sessions (~400): 20% reduction → 0.80×
96
+ // 9+ sessions (cap): 40% reduction → 0.60×
97
+ // ---------------------------------------------------------------------------
98
+ const WEEKLY_VOLUME_BASELINE = 80; // ~1 normal session score
99
+ const WEEKLY_VOLUME_STEP = 80; // score units per step
100
+ const WEEKLY_VOLUME_REDUCTION_PER_STEP = 0.05; // 5% per additional session
101
+ const WEEKLY_VOLUME_MAX_REDUCTION = 0.40;
102
+ function getWeeklyVolumeMultiplier(muscle, weeklyVolume) {
103
+ var _a;
104
+ const total = (_a = weeklyVolume[muscle]) !== null && _a !== void 0 ? _a : 0;
105
+ if (total <= WEEKLY_VOLUME_BASELINE)
106
+ return 1.0;
107
+ const steps = (total - WEEKLY_VOLUME_BASELINE) / WEEKLY_VOLUME_STEP;
108
+ const reduction = Math.min(WEEKLY_VOLUME_MAX_REDUCTION, steps * WEEKLY_VOLUME_REDUCTION_PER_STEP);
109
+ return 1.0 - reduction;
110
+ }
111
+ // ---------------------------------------------------------------------------
82
112
  // Core recovery formula
83
113
  // ---------------------------------------------------------------------------
84
114
  function calculateRecoveryFatigue(originalFatigue, hoursSinceWorkout, recoveryMultiplier = 1.0) {
@@ -124,7 +154,7 @@ const RECOVERY_WINDOW_HOURS = 168; // 7 days
124
154
  * @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
125
155
  */
126
156
  function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = new Date()) {
127
- var _a, _b, _c, _d, _e, _f, _g, _h;
157
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
128
158
  // Build combined user recovery multiplier (P2-7, P2-8, P2-9)
129
159
  let userRecoveryMultiplier = 1.0;
130
160
  if (user) {
@@ -137,6 +167,27 @@ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = ne
137
167
  ((_c = FITNESS_RECOVERY_SCALE[(_b = user.fitnessLevel) !== null && _b !== void 0 ? _b : "moderately-active"]) !== null && _c !== void 0 ? _c : 1.0);
138
168
  }
139
169
  const cutoffTime = currentDate.getTime() - RECOVERY_WINDOW_HOURS * 3600000;
170
+ const weekCutoff = currentDate.getTime() - 7 * 24 * 3600000; // same as RECOVERY_WINDOW_HOURS
171
+ // P3-7: First pass — compute weekly accumulated score per muscle across ALL exercises.
172
+ // This captures "chest trained 3× this week" even when each session appears independent.
173
+ const weeklyVolume = {};
174
+ for (const { exerciseId, recordScore } of scoreHistory) {
175
+ const exercise = exercises[exerciseId];
176
+ if (!exercise)
177
+ continue;
178
+ const weeklyScore = recordScore
179
+ .filter((r) => r.recordDate >= weekCutoff)
180
+ .reduce((sum, r) => sum + r.score, 0);
181
+ if (weeklyScore === 0)
182
+ continue;
183
+ for (const muscle of (_d = exercise.primaryMuscles) !== null && _d !== void 0 ? _d : []) {
184
+ weeklyVolume[muscle] = ((_e = weeklyVolume[muscle]) !== null && _e !== void 0 ? _e : 0) + weeklyScore;
185
+ }
186
+ for (const muscle of (_f = exercise.secondaryMuscles) !== null && _f !== void 0 ? _f : []) {
187
+ // Secondary muscles receive 35% of the stimulus
188
+ weeklyVolume[muscle] = ((_g = weeklyVolume[muscle]) !== null && _g !== void 0 ? _g : 0) + weeklyScore * 0.35;
189
+ }
190
+ }
140
191
  const internalMap = new Map();
141
192
  for (const { exerciseId, recordScore } of scoreHistory) {
142
193
  const exercise = exercises[exerciseId];
@@ -150,14 +201,17 @@ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = ne
150
201
  const hasRealMuscleScores = record.muscleScores && Object.keys(record.muscleScores).length > 0;
151
202
  if (hasRealMuscleScores) {
152
203
  const allMuscles = new Set([
153
- ...((_d = exercise.primaryMuscles) !== null && _d !== void 0 ? _d : []),
154
- ...((_e = exercise.secondaryMuscles) !== null && _e !== void 0 ? _e : []),
204
+ ...((_h = exercise.primaryMuscles) !== null && _h !== void 0 ? _h : []),
205
+ ...((_j = exercise.secondaryMuscles) !== null && _j !== void 0 ? _j : []),
155
206
  ]);
156
207
  for (const muscle of allMuscles) {
157
- const baseFatigue = (_f = record.muscleScores[muscle]) !== null && _f !== void 0 ? _f : 0;
208
+ const baseFatigue = (_k = record.muscleScores[muscle]) !== null && _k !== void 0 ? _k : 0;
158
209
  if (baseFatigue <= 0)
159
210
  continue;
160
- const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
211
+ // P3-7: weekly volume slows recovery for heavily-trained muscles
212
+ const multiplier = userRecoveryMultiplier
213
+ * getMuscleRecoveryRate(muscle)
214
+ * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
161
215
  const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
162
216
  if (remaining > 0)
163
217
  updateEntry(internalMap, muscle, remaining, workoutDate, true);
@@ -170,16 +224,20 @@ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = ne
170
224
  const intensityFraction = record.score <= MIN_MEANINGFUL_SCORE
171
225
  ? (record.score / MIN_MEANINGFUL_SCORE) * 0.3
172
226
  : ((record.score - MIN_MEANINGFUL_SCORE) / (MAX_SCORE - MIN_MEANINGFUL_SCORE)) * 0.7 + 0.3;
173
- for (const muscle of (_g = exercise.primaryMuscles) !== null && _g !== void 0 ? _g : []) {
227
+ for (const muscle of (_l = exercise.primaryMuscles) !== null && _l !== void 0 ? _l : []) {
174
228
  const baseFatigue = record.score * 0.7 * intensityFraction;
175
- const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
229
+ const multiplier = userRecoveryMultiplier
230
+ * getMuscleRecoveryRate(muscle)
231
+ * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
176
232
  const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
177
233
  if (remaining > 0)
178
234
  updateEntry(internalMap, muscle, remaining, workoutDate, false);
179
235
  }
180
- for (const muscle of (_h = exercise.secondaryMuscles) !== null && _h !== void 0 ? _h : []) {
236
+ for (const muscle of (_m = exercise.secondaryMuscles) !== null && _m !== void 0 ? _m : []) {
181
237
  const baseFatigue = record.score * 0.3 * intensityFraction;
182
- const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
238
+ const multiplier = userRecoveryMultiplier
239
+ * getMuscleRecoveryRate(muscle)
240
+ * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
183
241
  const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
184
242
  if (remaining > 0)
185
243
  updateEntry(internalMap, muscle, remaining, workoutDate, false);
@@ -45,6 +45,7 @@ const calculateExerciseScoreV2 = (param) => {
45
45
  muscleGroupFactor: exercise.metabolicData.muscleGroupFactor,
46
46
  },
47
47
  scoringSpecialHandling: exercise.scoringSpecialHandling,
48
+ isUnilateral: exercise.isUnilateral,
48
49
  }, userContext, exercise.timingGuardrails, historicalContext);
49
50
  // Pillar 3: Quality Score → score
50
51
  const isStrictTimingModeScoring = record.some((r) => r.isStrictMode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.57",
3
+ "version": "1.2.59",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",