@dgpholdings/greatoak-shared 1.2.55 → 1.2.57
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/utils/index.d.ts +2 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +16 -10
- package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +11 -8
- package/dist/utils/scoringWorkout/calculateTotalVolume.js +68 -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 +6 -1
- package/dist/utils/scoringWorkout/index.js +4 -1
- package/dist/utils/scoringWorkout/types.d.ts +19 -0
- package/package.json +2 -2
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;
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -7,7 +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 } from "./scoringWorkout";
|
|
10
|
+
export { calculateExerciseScoreV2, calculateTotalVolume, deriveTrainingAgeBracket, computeMuscleFatigueMap } from "./scoringWorkout";
|
|
11
|
+
export type { IHistoricalContext, TTrainingAgeBracket, TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult } from "./scoringWorkout";
|
|
11
12
|
export { scaleProPlan, calculateBMI, calculateDayPlanDuration, calculateExerciseDurationSecs } from "./adoptionEngine/scaleProPlan.util";
|
|
12
13
|
export * from "./exerciseRecord/workoutMath";
|
|
13
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.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");
|
|
@@ -41,6 +41,8 @@ Object.defineProperty(exports, "NOOP", { enumerable: true, get: function () { re
|
|
|
41
41
|
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
|
+
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; } });
|
|
44
46
|
var scaleProPlan_util_1 = require("./adoptionEngine/scaleProPlan.util");
|
|
45
47
|
Object.defineProperty(exports, "scaleProPlan", { enumerable: true, get: function () { return scaleProPlan_util_1.scaleProPlan; } });
|
|
46
48
|
Object.defineProperty(exports, "calculateBMI", { enumerable: true, get: function () { return scaleProPlan_util_1.calculateBMI; } });
|
|
@@ -68,7 +68,7 @@ function calculateMuscleFatigue(sets, exercise, user, timingGuardrails, historic
|
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -247,7 +247,7 @@ function distributeFatigueToMuscles(cumulativeFatigue, primaryMuscles, secondary
|
|
|
247
247
|
* should both be able to score 80–100 if performed well. Without scaling,
|
|
248
248
|
* a squat would always dominate because it uses more weight absolutely.
|
|
249
249
|
*/
|
|
250
|
-
function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFactor, historicalContext) {
|
|
250
|
+
function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFactor, historicalContext, scoringSpecialHandling) {
|
|
251
251
|
let singleSetMax;
|
|
252
252
|
switch (exerciseType) {
|
|
253
253
|
case "weight-reps": {
|
|
@@ -265,18 +265,24 @@ function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFac
|
|
|
265
265
|
break;
|
|
266
266
|
}
|
|
267
267
|
case "duration": {
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
268
|
+
// P3-4: continuous-duration exercises use the cardio dampened path in computeVolumeLoad.
|
|
269
|
+
// The reference max must mirror that dampener — otherwise the denominator is ~6.7× too
|
|
270
|
+
// large, causing systematic under-scoring for battle ropes, jump rope, high knees, etc.
|
|
271
|
+
if (scoringSpecialHandling === "continuous-duration") {
|
|
272
|
+
const maxDurationSecs = 60 + difficultyLevel * 30;
|
|
273
|
+
singleSetMax = maxDurationSecs * constants_1.CONTINUOUS_DURATION_INTENSITY_FACTOR * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Static hold: max time scales with difficulty
|
|
277
|
+
// difficulty 0 → 60s, difficulty 2 → 120s, difficulty 4 → 180s
|
|
278
|
+
const maxHoldSecs = 60 + difficultyLevel * 30;
|
|
279
|
+
singleSetMax = maxHoldSecs * (difficultyLevel + 1);
|
|
280
|
+
}
|
|
274
281
|
break;
|
|
275
282
|
}
|
|
276
283
|
case "cardio-machine":
|
|
277
284
|
case "cardio-free": {
|
|
278
|
-
// 30 minutes at 75% max speed
|
|
279
|
-
// Apply same dampener as volume calculation for consistent scaling
|
|
285
|
+
// 30 minutes at 75% max speed — same dampener as volume calculation
|
|
280
286
|
singleSetMax = 1800 * 0.75 * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
|
|
281
287
|
break;
|
|
282
288
|
}
|
|
@@ -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) => number;
|
|
@@ -1,73 +1,84 @@
|
|
|
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) => {
|
|
20
23
|
if (scoringSpecialHandling === "stretch-mobility")
|
|
21
24
|
return 0;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
const weight = parseFloat(set.kg) || 0;
|
|
32
|
+
return total + reps * weight;
|
|
47
33
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
34
|
+
case "reps-only": {
|
|
35
|
+
const reps = parseFloat(set.reps) || 0;
|
|
36
|
+
const auxWeight = parseFloat(set.auxWeightKg) || 0;
|
|
37
|
+
const bodyweight = user.weightKg || 70;
|
|
38
|
+
// Bug C fix: difficulty-aware bodyweight fraction (matches fatigue pillar)
|
|
39
|
+
const bodyweightLoad = (difficultyLevel / 4) * constants_1.BW_FRACTION_SCALE * bodyweight;
|
|
40
|
+
const effectiveWeight = auxWeight > 0 ? auxWeight : bodyweightLoad;
|
|
41
|
+
const baseVolume = reps * effectiveWeight;
|
|
42
|
+
return total + (scoringSpecialHandling === "plyometric"
|
|
43
|
+
? baseVolume * constants_1.PLYOMETRIC_LOAD_MULTIPLIER
|
|
44
|
+
: baseVolume);
|
|
45
|
+
}
|
|
46
|
+
case "duration": {
|
|
47
|
+
const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
|
|
48
|
+
if (scoringSpecialHandling === "continuous-duration")
|
|
49
|
+
return total + duration;
|
|
50
|
+
const auxWeight = parseFloat(set.auxWeightKg) || 0;
|
|
51
|
+
const weightFactor = auxWeight > 0 ? 1 + auxWeight / 100 : 1;
|
|
52
|
+
return total + duration * 10 * weightFactor;
|
|
53
|
+
}
|
|
54
|
+
case "cardio-machine": {
|
|
55
|
+
const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
|
|
56
|
+
const speed = parseFloat(set.speed || "10");
|
|
57
|
+
const inclineBoost = set.inclinePercentage
|
|
58
|
+
? 1 + parseFloat(set.inclinePercentage) * 0.02
|
|
59
|
+
: 1;
|
|
60
|
+
const resistanceBoost = set.resistanceLevel
|
|
61
|
+
? 1 + parseFloat(set.resistanceLevel) * 0.05
|
|
62
|
+
: 1;
|
|
63
|
+
const distance = parseFloat(set.distance || "0") || (speed * duration) / 3600;
|
|
64
|
+
return total + distance * 1000 * (1 + speed / 20) * inclineBoost * resistanceBoost;
|
|
65
|
+
}
|
|
66
|
+
case "cardio-free": {
|
|
67
|
+
const distance = parseFloat(set.distance) || 0;
|
|
68
|
+
const duration = (0, helpers_1.parseDurationMmSs)(set.durationMmSs);
|
|
69
|
+
// Bug B fix: guard against zero duration → Infinity speed
|
|
70
|
+
if (distance === 0 || duration === 0)
|
|
71
|
+
return total;
|
|
72
|
+
const speed = distance / (duration / 3600);
|
|
73
|
+
const auxWeight = parseFloat(set.auxWeightKg || "0") || 0;
|
|
74
|
+
const carryBoost = scoringSpecialHandling === "loaded-carry" && auxWeight > 0
|
|
75
|
+
? 1 + (auxWeight / (user.weightKg || 70)) * 0.5
|
|
76
|
+
: 1;
|
|
77
|
+
return total + distance * 1000 * (1 + speed / 20) * carryBoost;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return total;
|
|
69
81
|
}
|
|
70
|
-
return total;
|
|
71
82
|
}, 0);
|
|
72
83
|
};
|
|
73
84
|
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
|
+
}
|
|
@@ -17,8 +17,13 @@
|
|
|
17
17
|
* wired into the save flow — scheduled for Phase 3.
|
|
18
18
|
*/
|
|
19
19
|
import { TExercise, TRecord, TUserMetric } from "../../types";
|
|
20
|
-
import
|
|
20
|
+
import { deriveTrainingAgeBracket } from "./helpers";
|
|
21
|
+
import type { IScoreResult, IHistoricalContext, TTrainingAgeBracket } from "./types";
|
|
21
22
|
export { calculateTotalVolume } from "./calculateTotalVolume";
|
|
23
|
+
export { computeMuscleFatigueMap } from "./computeMuscleFatigueMap";
|
|
24
|
+
export { deriveTrainingAgeBracket };
|
|
25
|
+
export type { IHistoricalContext, TTrainingAgeBracket };
|
|
26
|
+
export type { TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult } from "./types";
|
|
22
27
|
export declare const calculateExerciseScoreV2: (param: {
|
|
23
28
|
exercise: TExercise;
|
|
24
29
|
record: TRecord[];
|
|
@@ -18,13 +18,16 @@
|
|
|
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.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");
|
|
25
|
+
Object.defineProperty(exports, "deriveTrainingAgeBracket", { enumerable: true, get: function () { return helpers_1.deriveTrainingAgeBracket; } });
|
|
25
26
|
const parseRecords_1 = require("./parseRecords");
|
|
26
27
|
var calculateTotalVolume_1 = require("./calculateTotalVolume");
|
|
27
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; } });
|
|
28
31
|
// ---------------------------------------------------------------------------
|
|
29
32
|
// Main Function — existing signature
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
@@ -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.
|
|
3
|
+
"version": "1.2.57",
|
|
4
4
|
"description": "Shared TypeScript types and utilities for @dgpholdings projects",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
|
-
"pub": "npm run build && npm version patch && npm publish",
|
|
12
|
+
"pub": "npm run test && npm run build && npm version patch && npm publish",
|
|
13
13
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
14
14
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
15
15
|
"test": "vitest run src",
|