@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.
- package/dist/typeGuards/index.js +15 -5
- package/dist/types/TApiExercise.d.ts +3 -0
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +2 -1
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.d.ts +2 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +22 -15
- package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +11 -8
- package/dist/utils/scoringWorkout/calculateTotalVolume.js +69 -57
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +37 -0
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +196 -0
- package/dist/utils/scoringWorkout/index.d.ts +2 -0
- package/dist/utils/scoringWorkout/index.js +4 -1
- package/dist/utils/scoringWorkout/types.d.ts +19 -0
- package/package.json +1 -1
package/dist/typeGuards/index.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
16
|
+
const isRecordDurationTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "duration");
|
|
7
17
|
exports.isRecordDurationTypeGuard = isRecordDurationTypeGuard;
|
|
8
|
-
const isRecordBodyWeightTypeGuard = (param) =>
|
|
18
|
+
const isRecordBodyWeightTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "reps-only");
|
|
9
19
|
exports.isRecordBodyWeightTypeGuard = isRecordBodyWeightTypeGuard;
|
|
10
|
-
const isRecordCardioFreeTypeGuard = (param) => param
|
|
20
|
+
const isRecordCardioFreeTypeGuard = (param) => param.length > 0 && param.every((r) => r.type === "cardio-free");
|
|
11
21
|
exports.isRecordCardioFreeTypeGuard = isRecordCardioFreeTypeGuard;
|
|
12
|
-
const isRecordCardioMachineTypeGuard = (param) => param
|
|
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 = {
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/utils/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
|
6
|
-
* - reps-only: reps
|
|
7
|
-
* plyometric: ×
|
|
8
|
-
* - duration: durationSecs
|
|
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)
|
|
12
|
-
* - cardio-free: distance (m)
|
|
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
|
|
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
|
|
9
|
-
* - reps-only: reps
|
|
10
|
-
* plyometric: ×
|
|
11
|
-
* - duration: durationSecs
|
|
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)
|
|
15
|
-
* - cardio-free: distance (m)
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|