@dgpholdings/greatoak-shared 1.2.56 → 1.2.58

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.
@@ -1,13 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isRecordCardioMachineTypeGuard = exports.isRecordCardioFreeTypeGuard = exports.isRecordBodyWeightTypeGuard = exports.isRecordDurationTypeGuard = exports.isRecordWeightTypeGuard = void 0;
4
- const isRecordWeightTypeGuard = (param) => { var _a; return ((_a = param === null || param === void 0 ? void 0 : param[0]) === null || _a === void 0 ? void 0 : _a.type) === "weight-reps"; };
4
+ // ---------------------------------------------------------------------------
5
+ // Array type guards
6
+ //
7
+ // Each guard checks EVERY element, not just [0].
8
+ // Checking only the first element and asserting the full array type is unsafe:
9
+ // a mixed array passes the guard incorrectly if the first record happens to
10
+ // match. Every element must agree.
11
+ //
12
+ // An empty array returns false — a zero-length record set has no meaningful type.
13
+ // ---------------------------------------------------------------------------
14
+ const isRecordWeightTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "weight-reps");
5
15
  exports.isRecordWeightTypeGuard = isRecordWeightTypeGuard;
6
- const isRecordDurationTypeGuard = (param) => { var _a; return ((_a = param === null || param === void 0 ? void 0 : param[0]) === null || _a === void 0 ? void 0 : _a.type) === "duration"; };
16
+ const isRecordDurationTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "duration");
7
17
  exports.isRecordDurationTypeGuard = isRecordDurationTypeGuard;
8
- const isRecordBodyWeightTypeGuard = (param) => { var _a; return ((_a = param === null || param === void 0 ? void 0 : param[0]) === null || _a === void 0 ? void 0 : _a.type) === "reps-only"; };
18
+ const isRecordBodyWeightTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "reps-only");
9
19
  exports.isRecordBodyWeightTypeGuard = isRecordBodyWeightTypeGuard;
10
- const isRecordCardioFreeTypeGuard = (param) => param[0].type === "cardio-free";
20
+ const isRecordCardioFreeTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "cardio-free");
11
21
  exports.isRecordCardioFreeTypeGuard = isRecordCardioFreeTypeGuard;
12
- const isRecordCardioMachineTypeGuard = (param) => param[0].type === "cardio-machine";
22
+ const isRecordCardioMachineTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "cardio-machine");
13
23
  exports.isRecordCardioMachineTypeGuard = isRecordCardioMachineTypeGuard;
@@ -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 = {
@@ -7,8 +7,8 @@ export { toError } from "./toError.util";
7
7
  export { generatePlanCode } from "./planCode.util";
8
8
  export { maskEmail, isAnonymousEmail, isEmail } from "./email.utils";
9
9
  export { NOOP } from "./noop.utils";
10
- export { calculateExerciseScoreV2, calculateTotalVolume, deriveTrainingAgeBracket } from "./scoringWorkout";
11
- export type { IHistoricalContext, TTrainingAgeBracket } from "./scoringWorkout";
10
+ export { calculateExerciseScoreV2, calculateTotalVolume, deriveTrainingAgeBracket, computeMuscleFatigueMap } from "./scoringWorkout";
11
+ export type { IHistoricalContext, TTrainingAgeBracket, TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult } from "./scoringWorkout";
12
12
  export { scaleProPlan, calculateBMI, calculateDayPlanDuration, calculateExerciseDurationSecs } from "./adoptionEngine/scaleProPlan.util";
13
13
  export * from "./exerciseRecord/workoutMath";
14
14
  export * from "./exerciseRecord/recordValidator";
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.calculateExerciseDurationSecs = exports.calculateDayPlanDuration = exports.calculateBMI = exports.scaleProPlan = exports.deriveTrainingAgeBracket = exports.calculateTotalVolume = exports.calculateExerciseScoreV2 = exports.NOOP = exports.isEmail = exports.isAnonymousEmail = exports.maskEmail = exports.generatePlanCode = exports.toError = exports.slugifyText = exports.isDefinedNumber = exports.isDefined = exports.countryToCurrencyCode = exports.getDaysAndHoursDifference = exports.isUserAllowedToUpdate = exports.mmssToSecs = exports.toNumber = void 0;
17
+ exports.calculateExerciseDurationSecs = exports.calculateDayPlanDuration = exports.calculateBMI = exports.scaleProPlan = exports.computeMuscleFatigueMap = exports.deriveTrainingAgeBracket = exports.calculateTotalVolume = exports.calculateExerciseScoreV2 = exports.NOOP = exports.isEmail = exports.isAnonymousEmail = exports.maskEmail = exports.generatePlanCode = exports.toError = exports.slugifyText = exports.isDefinedNumber = exports.isDefined = exports.countryToCurrencyCode = exports.getDaysAndHoursDifference = exports.isUserAllowedToUpdate = exports.mmssToSecs = exports.toNumber = void 0;
18
18
  var number_util_1 = require("./number.util");
19
19
  Object.defineProperty(exports, "toNumber", { enumerable: true, get: function () { return number_util_1.toNumber; } });
20
20
  var time_util_1 = require("./time.util");
@@ -42,6 +42,7 @@ var scoringWorkout_1 = require("./scoringWorkout");
42
42
  Object.defineProperty(exports, "calculateExerciseScoreV2", { enumerable: true, get: function () { return scoringWorkout_1.calculateExerciseScoreV2; } });
43
43
  Object.defineProperty(exports, "calculateTotalVolume", { enumerable: true, get: function () { return scoringWorkout_1.calculateTotalVolume; } });
44
44
  Object.defineProperty(exports, "deriveTrainingAgeBracket", { enumerable: true, get: function () { return scoringWorkout_1.deriveTrainingAgeBracket; } });
45
+ Object.defineProperty(exports, "computeMuscleFatigueMap", { enumerable: true, get: function () { return scoringWorkout_1.computeMuscleFatigueMap; } });
45
46
  var scaleProPlan_util_1 = require("./adoptionEngine/scaleProPlan.util");
46
47
  Object.defineProperty(exports, "scaleProPlan", { enumerable: true, get: function () { return scaleProPlan_util_1.scaleProPlan; } });
47
48
  Object.defineProperty(exports, "calculateBMI", { enumerable: true, get: function () { return scaleProPlan_util_1.calculateBMI; } });
@@ -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,11 +64,11 @@ 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 ---
71
- const referenceMax = computeReferenceMax(sets[0].type, exercise.difficultyLevel, user, exercise.metabolicData.muscleGroupFactor, historicalContext);
71
+ const referenceMax = computeReferenceMax(sets[0].type, exercise.difficultyLevel, user, exercise.metabolicData.muscleGroupFactor, historicalContext, exercise.scoringSpecialHandling);
72
72
  return normalizeScores(rawMuscleFatigue, referenceMax);
73
73
  }
74
74
  // ---------------------------------------------------------------------------
@@ -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
@@ -247,7 +248,7 @@ function distributeFatigueToMuscles(cumulativeFatigue, primaryMuscles, secondary
247
248
  * should both be able to score 80–100 if performed well. Without scaling,
248
249
  * a squat would always dominate because it uses more weight absolutely.
249
250
  */
250
- function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFactor, historicalContext) {
251
+ function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFactor, historicalContext, scoringSpecialHandling) {
251
252
  let singleSetMax;
252
253
  switch (exerciseType) {
253
254
  case "weight-reps": {
@@ -265,18 +266,24 @@ function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFac
265
266
  break;
266
267
  }
267
268
  case "duration": {
268
- // Max hold time scales with difficulty:
269
- // difficulty 0 60s (easy stretch)
270
- // difficulty 2 120s (plank, side plank)
271
- // difficulty 4 → 180s (advanced isometric hold)
272
- const maxHoldSecs = 60 + difficultyLevel * 30;
273
- singleSetMax = maxHoldSecs * (difficultyLevel + 1);
269
+ // P3-4: continuous-duration exercises use the cardio dampened path in computeVolumeLoad.
270
+ // The reference max must mirror that dampener — otherwise the denominator is ~6.7× too
271
+ // large, causing systematic under-scoring for battle ropes, jump rope, high knees, etc.
272
+ if (scoringSpecialHandling === "continuous-duration") {
273
+ const maxDurationSecs = 60 + difficultyLevel * 30;
274
+ singleSetMax = maxDurationSecs * constants_1.CONTINUOUS_DURATION_INTENSITY_FACTOR * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
275
+ }
276
+ else {
277
+ // Static hold: max time scales with difficulty
278
+ // difficulty 0 → 60s, difficulty 2 → 120s, difficulty 4 → 180s
279
+ const maxHoldSecs = 60 + difficultyLevel * 30;
280
+ singleSetMax = maxHoldSecs * (difficultyLevel + 1);
281
+ }
274
282
  break;
275
283
  }
276
284
  case "cardio-machine":
277
285
  case "cardio-free": {
278
- // 30 minutes at 75% max speed is a strong cardio effort
279
- // Apply same dampener as volume calculation for consistent scaling
286
+ // 30 minutes at 75% max speed same dampener as volume calculation
280
287
  singleSetMax = 1800 * 0.75 * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
281
288
  break;
282
289
  }
@@ -1,15 +1,18 @@
1
1
  import { TRecord, TUserMetric } from "../../types";
2
2
  /**
3
- * Calculates total volume for a set of records.
3
+ * Calculates total normalised volume for a completed set of records.
4
4
  *
5
- * - weight-reps: reps * weight
6
- * - reps-only: reps * (auxWeight || 30% of bodyweight)
7
- * plyometric: × 1.5 multiplier for impact/explosive demand
8
- * - duration: durationSecs * 10 * (1 + auxWeight/100)
5
+ * - weight-reps: reps × weight
6
+ * - reps-only: reps × effectiveLoad (difficulty-scaled BW + aux)
7
+ * plyometric: × PLYOMETRIC_LOAD_MULTIPLIER for impact/explosive demand
8
+ * - duration: durationSecs × 10 × auxFactor
9
9
  * continuous-duration: durationSecs only (no static-hold multiplier)
10
10
  * stretch-mobility: 0 (no training volume)
11
- * - cardio-machine: distance (m) * (1 + speed/20) * inclineBoost * resistanceBoost
12
- * - cardio-free: distance (m) * (1 + speed/20)
11
+ * - cardio-machine: distance (m) × (1 + speed/20) × inclineBoost × resistanceBoost
12
+ * - cardio-free: distance (m) × (1 + speed/20)
13
13
  * loaded-carry: × carried weight boost
14
+ *
15
+ * @param difficultyLevel Exercise difficulty (0–4). Used for bodyweight load
16
+ * scaling on reps-only exercises (matches fatigue pillar).
14
17
  */
15
- export declare const calculateTotalVolume: (record: TRecord[], user: TUserMetric, scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry") => number;
18
+ export declare const calculateTotalVolume: (record: TRecord[], user: TUserMetric, scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry", difficultyLevel?: number, isUnilateral?: boolean) => number;
@@ -1,73 +1,85 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.calculateTotalVolume = void 0;
4
- const time_util_1 = require("../time.util");
4
+ const helpers_1 = require("./helpers");
5
+ const constants_1 = require("./constants");
5
6
  /**
6
- * Calculates total volume for a set of records.
7
+ * Calculates total normalised volume for a completed set of records.
7
8
  *
8
- * - weight-reps: reps * weight
9
- * - reps-only: reps * (auxWeight || 30% of bodyweight)
10
- * plyometric: × 1.5 multiplier for impact/explosive demand
11
- * - duration: durationSecs * 10 * (1 + auxWeight/100)
9
+ * - weight-reps: reps × weight
10
+ * - reps-only: reps × effectiveLoad (difficulty-scaled BW + aux)
11
+ * plyometric: × PLYOMETRIC_LOAD_MULTIPLIER for impact/explosive demand
12
+ * - duration: durationSecs × 10 × auxFactor
12
13
  * continuous-duration: durationSecs only (no static-hold multiplier)
13
14
  * stretch-mobility: 0 (no training volume)
14
- * - cardio-machine: distance (m) * (1 + speed/20) * inclineBoost * resistanceBoost
15
- * - cardio-free: distance (m) * (1 + speed/20)
15
+ * - cardio-machine: distance (m) × (1 + speed/20) × inclineBoost × resistanceBoost
16
+ * - cardio-free: distance (m) × (1 + speed/20)
16
17
  * loaded-carry: × carried weight boost
18
+ *
19
+ * @param difficultyLevel Exercise difficulty (0–4). Used for bodyweight load
20
+ * scaling on reps-only exercises (matches fatigue pillar).
17
21
  */
18
- const calculateTotalVolume = (record, user, scoringSpecialHandling) => {
19
- // Stretching/mobility produces no training volume
22
+ const calculateTotalVolume = (record, user, scoringSpecialHandling, difficultyLevel = 2, isUnilateral = false) => {
20
23
  if (scoringSpecialHandling === "stretch-mobility")
21
24
  return 0;
22
- return record.reduce((total, set) => {
23
- const weight = parseFloat(set.type === "weight-reps"
24
- ? set.kg
25
- : set.type === "duration" || set.type === "reps-only" || set.type === "cardio-free"
26
- ? set.auxWeightKg || "0"
27
- : "0") || 0;
28
- const reps = parseFloat(set.type === "weight-reps" || set.type === "reps-only" ? set.reps : "0") || 0;
29
- const duration = set.type === "duration" ||
30
- set.type === "cardio-machine" ||
31
- set.type === "cardio-free"
32
- ? (0, time_util_1.mmssToSecs)(set.durationMmSs)
33
- : 0;
34
- if (set.type === "weight-reps") {
35
- return total + reps * weight;
36
- }
37
- else if (set.type === "reps-only") {
38
- const bodyweight = user.weightKg || 70;
39
- const effectiveWeight = weight > 0 ? weight : bodyweight * 0.3;
40
- const baseVolume = reps * effectiveWeight;
41
- return total + (scoringSpecialHandling === "plyometric" ? baseVolume * 1.5 : baseVolume);
42
- }
43
- else if (set.type === "duration") {
44
- // Continuous-duration: no static-hold multiplier — just time elapsed
45
- if (scoringSpecialHandling === "continuous-duration") {
46
- return total + duration;
25
+ // Bug A fix: only completed sets contribute to volume.
26
+ // Each case narrows `set` to its exact union variant — no `as any` needed.
27
+ return record.filter((r) => r.isDone).reduce((total, set) => {
28
+ switch (set.type) {
29
+ case "weight-reps": {
30
+ const reps = parseFloat(set.reps) || 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);
33
+ return total + reps * weight;
47
34
  }
48
- const weightFactor = weight > 0 ? 1 + weight / 100 : 1;
49
- return total + duration * 10 * weightFactor;
50
- }
51
- else if (set.type === "cardio-machine") {
52
- const speed = parseFloat(set.speed || "10");
53
- const inclineBoost = set.inclinePercentage
54
- ? 1 + parseFloat(set.inclinePercentage) * 0.02
55
- : 1;
56
- const resistanceBoost = set.resistanceLevel
57
- ? 1 + parseFloat(set.resistanceLevel) * 0.05
58
- : 1;
59
- const distance = parseFloat(set.distance || "0") || (speed * duration) / 3600;
60
- return total + distance * 1000 * (1 + speed / 20) * inclineBoost * resistanceBoost;
61
- }
62
- else if (set.type === "cardio-free") {
63
- const distance = parseFloat(set.distance) || 0;
64
- const speed = distance / (duration / 3600);
65
- const carryBoost = scoringSpecialHandling === "loaded-carry" && weight > 0
66
- ? 1 + weight / (user.weightKg || 70) * 0.5
67
- : 1;
68
- return total + distance * 1000 * (1 + speed / 20) * carryBoost;
35
+ case "reps-only": {
36
+ const reps = parseFloat(set.reps) || 0;
37
+ const auxWeight = parseFloat(set.auxWeightKg) || 0;
38
+ const bodyweight = user.weightKg || 70;
39
+ // Bug C fix: difficulty-aware bodyweight fraction (matches fatigue pillar)
40
+ const bodyweightLoad = (difficultyLevel / 4) * constants_1.BW_FRACTION_SCALE * bodyweight;
41
+ const effectiveWeight = auxWeight > 0 ? auxWeight : bodyweightLoad;
42
+ const baseVolume = reps * effectiveWeight;
43
+ return total + (scoringSpecialHandling === "plyometric"
44
+ ? baseVolume * constants_1.PLYOMETRIC_LOAD_MULTIPLIER
45
+ : baseVolume);
46
+ }
47
+ case "duration": {
48
+ const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
49
+ if (scoringSpecialHandling === "continuous-duration")
50
+ return total + duration;
51
+ const auxWeight = parseFloat(set.auxWeightKg) || 0;
52
+ const weightFactor = auxWeight > 0 ? 1 + auxWeight / 100 : 1;
53
+ return total + duration * 10 * weightFactor;
54
+ }
55
+ case "cardio-machine": {
56
+ const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
57
+ const speed = parseFloat(set.speed || "10");
58
+ const inclineBoost = set.inclinePercentage
59
+ ? 1 + parseFloat(set.inclinePercentage) * 0.02
60
+ : 1;
61
+ const resistanceBoost = set.resistanceLevel
62
+ ? 1 + parseFloat(set.resistanceLevel) * 0.05
63
+ : 1;
64
+ const distance = parseFloat(set.distance || "0") || (speed * duration) / 3600;
65
+ return total + distance * 1000 * (1 + speed / 20) * inclineBoost * resistanceBoost;
66
+ }
67
+ case "cardio-free": {
68
+ const distance = parseFloat(set.distance) || 0;
69
+ const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
70
+ // Bug B fix: guard against zero duration → Infinity speed
71
+ if (distance === 0 || duration === 0)
72
+ return total;
73
+ const speed = distance / (duration / 3600);
74
+ const auxWeight = parseFloat(set.auxWeightKg || "0") || 0;
75
+ const carryBoost = scoringSpecialHandling === "loaded-carry" && auxWeight > 0
76
+ ? 1 + (auxWeight / (user.weightKg || 70)) * 0.5
77
+ : 1;
78
+ return total + distance * 1000 * (1 + speed / 20) * carryBoost;
79
+ }
80
+ default:
81
+ return total;
69
82
  }
70
- return total;
71
83
  }, 0);
72
84
  };
73
85
  exports.calculateTotalVolume = calculateTotalVolume;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX — Muscle Fatigue Aggregation
4
+ * ============================================================================
5
+ *
6
+ * Pure domain logic: takes exercise history (with per-session muscleScores)
7
+ * and produces a time-decayed per-muscle fatigue map (0–100 per muscle).
8
+ *
9
+ * NO UI concerns here. Colors, display names and chart types live in the app.
10
+ *
11
+ * Recovery model accounts for:
12
+ * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
13
+ * P2-8 Gender — females recover slightly faster (1.05×)
14
+ * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
15
+ * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
16
+ *
17
+ * Accumulation model:
18
+ * P3-3 Real muscleScores → additive with diminishing returns (P3-5)
19
+ * Legacy (muscleScores={}) → quality score × 0.7/0.3 proxy + MAX accumulation
20
+ */
21
+ import type { TBodyPartKeys, TUserMetric } from "../../types";
22
+ import type { TEnrichedExerciseRecord, TMuscleFatigueResult } from "./types";
23
+ interface IExerciseMuscleData {
24
+ primaryMuscles: TBodyPartKeys;
25
+ secondaryMuscles: TBodyPartKeys;
26
+ }
27
+ /**
28
+ * Compute time-decayed per-muscle fatigue from exercise history.
29
+ *
30
+ * @param exercises Map of exerciseId → { primaryMuscles, secondaryMuscles }
31
+ * @param scoreHistory Enriched history with per-session muscleScores
32
+ * @param user Optional user profile for recovery personalisation
33
+ * @param currentDate Override for testing; defaults to now
34
+ * @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
35
+ */
36
+ export declare function computeMuscleFatigueMap(exercises: Record<string, IExerciseMuscleData>, scoreHistory: TEnrichedExerciseRecord[], user?: Partial<TUserMetric>, currentDate?: Date): TMuscleFatigueResult;
37
+ export {};
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ /**
3
+ * ============================================================================
4
+ * FITFRIX — Muscle Fatigue Aggregation
5
+ * ============================================================================
6
+ *
7
+ * Pure domain logic: takes exercise history (with per-session muscleScores)
8
+ * and produces a time-decayed per-muscle fatigue map (0–100 per muscle).
9
+ *
10
+ * NO UI concerns here. Colors, display names and chart types live in the app.
11
+ *
12
+ * Recovery model accounts for:
13
+ * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
14
+ * P2-8 Gender — females recover slightly faster (1.05×)
15
+ * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
16
+ * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
17
+ *
18
+ * Accumulation model:
19
+ * P3-3 Real muscleScores → additive with diminishing returns (P3-5)
20
+ * Legacy (muscleScores={}) → quality score × 0.7/0.3 proxy + MAX accumulation
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.computeMuscleFatigueMap = computeMuscleFatigueMap;
24
+ // ---------------------------------------------------------------------------
25
+ // P2-7: Age-adjusted recovery multiplier
26
+ // ---------------------------------------------------------------------------
27
+ function getAgeRecoveryMultiplier(age) {
28
+ if (age < 25)
29
+ return 1.05;
30
+ if (age < 35)
31
+ return 1.00;
32
+ if (age < 45)
33
+ return 0.95;
34
+ if (age < 55)
35
+ return 0.90;
36
+ return 0.85;
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // P2-8: Gender-adjusted recovery multiplier
40
+ // ---------------------------------------------------------------------------
41
+ function getGenderRecoveryMultiplier(gender) {
42
+ return gender === "female" ? 1.05 : 1.00;
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // P2-9: Fitness-level recovery multiplier
46
+ // ---------------------------------------------------------------------------
47
+ const FITNESS_RECOVERY_SCALE = {
48
+ sedentary: 0.80,
49
+ "lightly-active": 0.90,
50
+ "moderately-active": 1.00,
51
+ "very-active": 1.15,
52
+ };
53
+ // ---------------------------------------------------------------------------
54
+ // P3-8: Muscle-group-specific recovery rates
55
+ // Values > 1.0 = faster; < 1.0 = slower
56
+ // ---------------------------------------------------------------------------
57
+ const MUSCLE_RECOVERY_RATE = {
58
+ // Core — very fast
59
+ "abs-upper": 1.8, "abs-lower": 1.8, "obliques": 1.8,
60
+ // Small arm muscles — fast
61
+ "bicep-short-inner": 1.4, "bicep-long-outer": 1.4,
62
+ "tricep-brachii-long": 1.4, "tricep-brachii-lateral": 1.4,
63
+ "fore-arm-inner": 1.5, "fore-arm-outer": 1.5,
64
+ // Calves — fast
65
+ "calf-inner": 1.3, "calf-outer": 1.3,
66
+ // Shoulders / upper back — moderate
67
+ "deltoids-anterior": 1.1, "deltoids-middle": 1.1,
68
+ "trapezius": 1.0, "rhomboids": 1.0,
69
+ // Chest — moderate
70
+ "pectoralis-major": 1.0, "pectoralis-minor": 1.0,
71
+ // Large compound — slow
72
+ "latissimus-dorsi": 0.85,
73
+ "quadriceps": 0.80, "hamstrings": 0.80, "adductors": 0.80,
74
+ "glutes-maximus": 0.80, "glutes-medius": 0.85,
75
+ "lower-back": 0.75,
76
+ };
77
+ function getMuscleRecoveryRate(muscle) {
78
+ var _a;
79
+ return (_a = MUSCLE_RECOVERY_RATE[muscle]) !== null && _a !== void 0 ? _a : 1.0;
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Core recovery formula
83
+ // ---------------------------------------------------------------------------
84
+ function calculateRecoveryFatigue(originalFatigue, hoursSinceWorkout, recoveryMultiplier = 1.0) {
85
+ if (hoursSinceWorkout <= 0)
86
+ return originalFatigue;
87
+ let recoveryRatePerHour;
88
+ if (originalFatigue >= 80)
89
+ recoveryRatePerHour = 1.25;
90
+ else if (originalFatigue >= 60)
91
+ recoveryRatePerHour = 1.67;
92
+ else if (originalFatigue >= 40)
93
+ recoveryRatePerHour = 2.08;
94
+ else
95
+ recoveryRatePerHour = 2.92;
96
+ recoveryRatePerHour *= recoveryMultiplier;
97
+ const recovered = originalFatigue * ((recoveryRatePerHour * hoursSinceWorkout) / 100);
98
+ return Math.max(0, originalFatigue - recovered);
99
+ }
100
+ function updateEntry(map, muscle, remainingFatigue, workoutDate, useAdditive) {
101
+ var _a;
102
+ const existing = (_a = map.get(muscle)) !== null && _a !== void 0 ? _a : { fatigue: 0, lastWorked: workoutDate };
103
+ // P3-5: additive with diminishing returns for real muscleScores
104
+ // Legacy sessions keep MAX to avoid overcorrection with the proxy
105
+ const newFatigue = useAdditive
106
+ ? existing.fatigue + remainingFatigue * (1 - existing.fatigue / 100)
107
+ : Math.max(existing.fatigue, remainingFatigue);
108
+ map.set(muscle, {
109
+ fatigue: Math.min(100, newFatigue),
110
+ lastWorked: existing.lastWorked > workoutDate ? existing.lastWorked : workoutDate,
111
+ });
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Public API
115
+ // ---------------------------------------------------------------------------
116
+ const RECOVERY_WINDOW_HOURS = 168; // 7 days
117
+ /**
118
+ * Compute time-decayed per-muscle fatigue from exercise history.
119
+ *
120
+ * @param exercises Map of exerciseId → { primaryMuscles, secondaryMuscles }
121
+ * @param scoreHistory Enriched history with per-session muscleScores
122
+ * @param user Optional user profile for recovery personalisation
123
+ * @param currentDate Override for testing; defaults to now
124
+ * @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
125
+ */
126
+ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = new Date()) {
127
+ var _a, _b, _c, _d, _e, _f, _g, _h;
128
+ // Build combined user recovery multiplier (P2-7, P2-8, P2-9)
129
+ let userRecoveryMultiplier = 1.0;
130
+ if (user) {
131
+ const age = user.dob
132
+ ? currentDate.getFullYear() - new Date(user.dob).getFullYear()
133
+ : 30;
134
+ userRecoveryMultiplier =
135
+ getAgeRecoveryMultiplier(age) *
136
+ getGenderRecoveryMultiplier((_a = user.gender) !== null && _a !== void 0 ? _a : "unmentioned") *
137
+ ((_c = FITNESS_RECOVERY_SCALE[(_b = user.fitnessLevel) !== null && _b !== void 0 ? _b : "moderately-active"]) !== null && _c !== void 0 ? _c : 1.0);
138
+ }
139
+ const cutoffTime = currentDate.getTime() - RECOVERY_WINDOW_HOURS * 3600000;
140
+ const internalMap = new Map();
141
+ for (const { exerciseId, recordScore } of scoreHistory) {
142
+ const exercise = exercises[exerciseId];
143
+ if (!exercise || !recordScore.length)
144
+ continue;
145
+ const relevantRecords = recordScore.filter((r) => r.recordDate >= cutoffTime);
146
+ for (const record of relevantRecords) {
147
+ const workoutDate = new Date(record.recordDate);
148
+ const hoursSince = (currentDate.getTime() - workoutDate.getTime()) / 3600000;
149
+ // P3-3: Real path when muscleScores is available (sessions saved after P3-1)
150
+ const hasRealMuscleScores = record.muscleScores && Object.keys(record.muscleScores).length > 0;
151
+ if (hasRealMuscleScores) {
152
+ const allMuscles = new Set([
153
+ ...((_d = exercise.primaryMuscles) !== null && _d !== void 0 ? _d : []),
154
+ ...((_e = exercise.secondaryMuscles) !== null && _e !== void 0 ? _e : []),
155
+ ]);
156
+ for (const muscle of allMuscles) {
157
+ const baseFatigue = (_f = record.muscleScores[muscle]) !== null && _f !== void 0 ? _f : 0;
158
+ if (baseFatigue <= 0)
159
+ continue;
160
+ const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
161
+ const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
162
+ if (remaining > 0)
163
+ updateEntry(internalMap, muscle, remaining, workoutDate, true);
164
+ }
165
+ }
166
+ else {
167
+ // Legacy path: quality score × 0.7/0.3 proxy (P1-7 band-aid)
168
+ const MIN_MEANINGFUL_SCORE = 88;
169
+ const MAX_SCORE = 100;
170
+ const intensityFraction = record.score <= MIN_MEANINGFUL_SCORE
171
+ ? (record.score / MIN_MEANINGFUL_SCORE) * 0.3
172
+ : ((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 : []) {
174
+ const baseFatigue = record.score * 0.7 * intensityFraction;
175
+ const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
176
+ const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
177
+ if (remaining > 0)
178
+ updateEntry(internalMap, muscle, remaining, workoutDate, false);
179
+ }
180
+ for (const muscle of (_h = exercise.secondaryMuscles) !== null && _h !== void 0 ? _h : []) {
181
+ const baseFatigue = record.score * 0.3 * intensityFraction;
182
+ const multiplier = userRecoveryMultiplier * getMuscleRecoveryRate(muscle);
183
+ const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
184
+ if (remaining > 0)
185
+ updateEntry(internalMap, muscle, remaining, workoutDate, false);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ // Convert internal Map → plain object for the public return type
191
+ const result = {};
192
+ internalMap.forEach((entry, muscle) => {
193
+ result[muscle] = { fatigue: Math.round(entry.fatigue), lastWorked: entry.lastWorked };
194
+ });
195
+ return result;
196
+ }
@@ -20,8 +20,10 @@ import { TExercise, TRecord, TUserMetric } from "../../types";
20
20
  import { deriveTrainingAgeBracket } from "./helpers";
21
21
  import type { IScoreResult, IHistoricalContext, TTrainingAgeBracket } from "./types";
22
22
  export { calculateTotalVolume } from "./calculateTotalVolume";
23
+ export { computeMuscleFatigueMap } from "./computeMuscleFatigueMap";
23
24
  export { deriveTrainingAgeBracket };
24
25
  export type { IHistoricalContext, TTrainingAgeBracket };
26
+ export type { TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult } from "./types";
25
27
  export declare const calculateExerciseScoreV2: (param: {
26
28
  exercise: TExercise;
27
29
  record: TRecord[];
@@ -18,7 +18,7 @@
18
18
  * wired into the save flow — scheduled for Phase 3.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.calculateExerciseScoreV2 = exports.deriveTrainingAgeBracket = exports.calculateTotalVolume = void 0;
21
+ exports.calculateExerciseScoreV2 = exports.deriveTrainingAgeBracket = exports.computeMuscleFatigueMap = exports.calculateTotalVolume = void 0;
22
22
  const calculateMuscleFatiue_1 = require("./calculateMuscleFatiue");
23
23
  const calculateQualityScore_1 = require("./calculateQualityScore");
24
24
  const helpers_1 = require("./helpers");
@@ -26,6 +26,8 @@ Object.defineProperty(exports, "deriveTrainingAgeBracket", { enumerable: true, g
26
26
  const parseRecords_1 = require("./parseRecords");
27
27
  var calculateTotalVolume_1 = require("./calculateTotalVolume");
28
28
  Object.defineProperty(exports, "calculateTotalVolume", { enumerable: true, get: function () { return calculateTotalVolume_1.calculateTotalVolume; } });
29
+ var computeMuscleFatigueMap_1 = require("./computeMuscleFatigueMap");
30
+ Object.defineProperty(exports, "computeMuscleFatigueMap", { enumerable: true, get: function () { return computeMuscleFatigueMap_1.computeMuscleFatigueMap; } });
29
31
  // ---------------------------------------------------------------------------
30
32
  // Main Function — existing signature
31
33
  // ---------------------------------------------------------------------------
@@ -43,6 +45,7 @@ const calculateExerciseScoreV2 = (param) => {
43
45
  muscleGroupFactor: exercise.metabolicData.muscleGroupFactor,
44
46
  },
45
47
  scoringSpecialHandling: exercise.scoringSpecialHandling,
48
+ isUnilateral: exercise.isUnilateral,
46
49
  }, userContext, exercise.timingGuardrails, historicalContext);
47
50
  // Pillar 3: Quality Score → score
48
51
  const isStrictTimingModeScoring = record.some((r) => r.isStrictMode);
@@ -8,6 +8,25 @@
8
8
  * scoring pillars (Calories, Muscle Fatigue, Quality).
9
9
  */
10
10
  import { TActivityLevel, TFitnessGoal, TGender, TRecord } from "../../types";
11
+ /**
12
+ * A single scored session with real per-muscle fatigue values.
13
+ * muscleScores = {} for pre-P3-1 sessions (backward compat fallback).
14
+ */
15
+ export interface TEnrichedSessionScore {
16
+ score: number;
17
+ recordDate: number;
18
+ muscleScores: Record<string, number>;
19
+ }
20
+ export interface TEnrichedExerciseRecord {
21
+ exerciseId: string;
22
+ recordScore: TEnrichedSessionScore[];
23
+ }
24
+ export interface TMuscleFatigueEntry {
25
+ fatigue: number;
26
+ lastWorked: Date;
27
+ }
28
+ /** Record<muscleKey, fatigue entry> — pure numbers, no UI concerns. */
29
+ export type TMuscleFatigueResult = Record<string, TMuscleFatigueEntry>;
11
30
  /**
12
31
  * Quality score breakdown — lets the UI show users WHY they got their score.
13
32
  * Each sub-score is 0–100.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.56",
3
+ "version": "1.2.58",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",