@dgpholdings/greatoak-shared 1.2.86 → 1.2.88
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/README.md +148 -148
- package/dist/__mocks__/catalog.fixture.d.ts +2 -0
- package/dist/__mocks__/catalog.fixture.js +208 -0
- package/dist/__mocks__/exercises.mock.d.ts +4 -11
- package/dist/__mocks__/exercises.mock.js +82 -41
- package/dist/__mocks__/sessions.mock.d.ts +28 -0
- package/dist/__mocks__/sessions.mock.js +394 -0
- package/dist/__mocks__/testIds.d.ts +9 -0
- package/dist/__mocks__/testIds.js +13 -0
- package/dist/__mocks__/user.mock.js +3 -1
- package/dist/constants/goalJourney.d.ts +108 -0
- package/dist/constants/goalJourney.js +443 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/types/TApiAiExerciseAnalysis.d.ts +2 -1
- package/dist/types/TApiClientConstellation.d.ts +33 -0
- package/dist/types/TApiClientConstellation.js +13 -0
- package/dist/types/TApiExercise.d.ts +5 -3
- package/dist/types/TApiUser.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/adoptionEngine/scaleProPlan.util.js +9 -4
- package/dist/utils/constellation/computeNormalisedLoad.d.ts +48 -0
- package/dist/utils/constellation/computeNormalisedLoad.js +150 -0
- package/dist/utils/constellation/computeNormalisedLoad.test.d.ts +1 -0
- package/dist/utils/constellation/computeNormalisedLoad.test.js +218 -0
- package/dist/utils/constellation/evaluateConstellation.d.ts +27 -0
- package/dist/utils/constellation/evaluateConstellation.js +135 -0
- package/dist/utils/constellation/evaluateConstellation.test.d.ts +1 -0
- package/dist/utils/constellation/evaluateConstellation.test.js +93 -0
- package/dist/utils/constellation/index.d.ts +18 -0
- package/dist/utils/constellation/index.js +29 -0
- package/dist/utils/constellation/levelThresholds.d.ts +99 -0
- package/dist/utils/constellation/levelThresholds.js +123 -0
- package/dist/utils/constellation/starFoundation.d.ts +25 -0
- package/dist/utils/constellation/starFoundation.js +54 -0
- package/dist/utils/constellation/starFoundation.test.d.ts +1 -0
- package/dist/utils/constellation/starFoundation.test.js +75 -0
- package/dist/utils/constellation/stars/consistency.d.ts +29 -0
- package/dist/utils/constellation/stars/consistency.js +142 -0
- package/dist/utils/constellation/stars/consistency.test.d.ts +1 -0
- package/dist/utils/constellation/stars/consistency.test.js +94 -0
- package/dist/utils/constellation/stars/lowerBody.d.ts +17 -0
- package/dist/utils/constellation/stars/lowerBody.js +30 -0
- package/dist/utils/constellation/stars/pull.d.ts +11 -0
- package/dist/utils/constellation/stars/pull.js +24 -0
- package/dist/utils/constellation/stars/push.d.ts +11 -0
- package/dist/utils/constellation/stars/push.js +24 -0
- package/dist/utils/constellation/stars/quality.d.ts +19 -0
- package/dist/utils/constellation/stars/quality.js +98 -0
- package/dist/utils/constellation/stars/quality.test.d.ts +1 -0
- package/dist/utils/constellation/stars/quality.test.js +113 -0
- package/dist/utils/constellation/stars/recovery.d.ts +29 -0
- package/dist/utils/constellation/stars/recovery.js +169 -0
- package/dist/utils/constellation/stars/recovery.test.d.ts +1 -0
- package/dist/utils/constellation/stars/recovery.test.js +131 -0
- package/dist/utils/constellation/strengthStar.test.d.ts +1 -0
- package/dist/utils/constellation/strengthStar.test.js +190 -0
- package/dist/utils/constellation/strengthStarHelpers.d.ts +41 -0
- package/dist/utils/constellation/strengthStarHelpers.js +104 -0
- package/dist/utils/constellation/types.d.ts +124 -0
- package/dist/utils/constellation/types.js +18 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.js +1 -1
- package/dist/utils/exerciseRecord/recordValidator.js +1 -1
- package/dist/utils/exerciseRecord/recordValidator.test.js +8 -8
- package/dist/utils/index.d.ts +5 -3
- package/dist/utils/index.js +1 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +59 -36
- package/dist/utils/scoringWorkout/calculateQualityScore.js +234 -233
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +8 -5
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +72 -88
- package/dist/utils/scoringWorkout/constants.d.ts +20 -6
- package/dist/utils/scoringWorkout/constants.js +23 -9
- package/dist/utils/scoringWorkout/helpers.d.ts +7 -0
- package/dist/utils/scoringWorkout/helpers.js +24 -18
- package/dist/utils/scoringWorkout/index.d.ts +12 -8
- package/dist/utils/scoringWorkout/index.js +23 -15
- package/dist/utils/scoringWorkout/parseRecords.js +4 -3
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +223 -185
- package/dist/utils/scoringWorkout/types.d.ts +34 -14
- package/package.json +31 -31
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +0 -30
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +0 -138
- package/dist/utils/scaleProPlan.util.d.ts +0 -9
- package/dist/utils/scaleProPlan.util.js +0 -139
- package/dist/utils/scoring/calculateCalories.d.ts +0 -67
- package/dist/utils/scoring/calculateCalories.js +0 -345
- package/dist/utils/scoring/calculateMuscleFatiue.d.ts +0 -67
- package/dist/utils/scoring/calculateMuscleFatiue.js +0 -310
- package/dist/utils/scoring/calculateQualityScore.d.ts +0 -71
- package/dist/utils/scoring/calculateQualityScore.js +0 -334
- package/dist/utils/scoring/calculateTotalVolume.d.ts +0 -15
- package/dist/utils/scoring/calculateTotalVolume.js +0 -73
- package/dist/utils/scoring/constants.d.ts +0 -211
- package/dist/utils/scoring/constants.js +0 -247
- package/dist/utils/scoring/helpers.d.ts +0 -119
- package/dist/utils/scoring/helpers.js +0 -229
- package/dist/utils/scoring/index.d.ts +0 -28
- package/dist/utils/scoring/index.js +0 -47
- package/dist/utils/scoring/parseRecords.d.ts +0 -98
- package/dist/utils/scoring/parseRecords.js +0 -284
- package/dist/utils/scoring/types.d.ts +0 -86
- package/dist/utils/scoring/types.js +0 -11
- package/dist/utils/scoring.utils.d.ts +0 -14
- package/dist/utils/scoring.utils.js +0 -243
- /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.d.ts → calculateMuscleFatigue.d.ts} +0 -0
- /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.js → calculateMuscleFatigue.js} +0 -0
|
@@ -5,156 +5,181 @@ exports.calculateQualityScore = calculateQualityScore;
|
|
|
5
5
|
const constants_1 = require("./constants");
|
|
6
6
|
const helpers_1 = require("./helpers");
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
|
-
//
|
|
8
|
+
// Effort scoring tuning (kept local — these describe the score CURVE, not the
|
|
9
|
+
// physiological "optimal zone" the old [min,max] ranges encoded).
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
12
|
+
* Hard floor for any logged effort. Below the old EFFORT_MIN_SCORE (50) on
|
|
13
|
+
* purpose: now that every set logs RPE/RIR, a genuinely easy working set must
|
|
14
|
+
* be allowed to score lower than a no-data session ever could. 20 keeps the
|
|
15
|
+
* very-easy end from being a literal zero (a logged easy set is still better
|
|
16
|
+
* data than nothing) while letting it sink the session score.
|
|
17
|
+
*/
|
|
18
|
+
const EFFORT_FLOOR_SCORE = 20;
|
|
19
|
+
/**
|
|
20
|
+
* RPE → score. Linear, monotonic, peaking at RPE 9 = 100.
|
|
21
|
+
* score = 25 + (rpe - 1) * 9.375 (clamped 20–100)
|
|
22
|
+
* RPE 10 (grinding past failure every set) is capped slightly below 100 (95)
|
|
23
|
+
* so the model does not reward chronic to-failure training as "perfect".
|
|
24
|
+
*/
|
|
25
|
+
const EFFORT_RPE_BASE = 25;
|
|
26
|
+
const EFFORT_RPE_SLOPE = 9.375; // (100 - 25) / (9 - 1)
|
|
27
|
+
/**
|
|
28
|
+
* RIR → score. Inverse of RPE (RIR = reps in reserve, lower = harder).
|
|
29
|
+
* score = 100 - rir * 8 (clamped 20–100)
|
|
30
|
+
* RIR 0–1 ≈ failure → ~100; RIR 10 → 20.
|
|
31
|
+
*/
|
|
32
|
+
const EFFORT_RIR_SLOPE = 8;
|
|
33
|
+
/**
|
|
34
|
+
* Final-set emphasis for effort averaging. The last set's effort score is
|
|
35
|
+
* weighted this many times a normal set. 2.0 = the finish counts double.
|
|
36
|
+
*/
|
|
37
|
+
const EFFORT_FINAL_SET_WEIGHT = 2.0;
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Main entry point
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Calculate the overall quality score and its component breakdown.
|
|
20
43
|
*
|
|
21
|
-
* @
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
44
|
+
* @param parsedSets Cleaned completed sets (output of parseRecords)
|
|
45
|
+
* @param rawRecords Original TRecord[] — needed for completion count and RPE/RIR
|
|
46
|
+
* @param timingGuardrails Exercise DB guardrails — for rest period validation
|
|
47
|
+
* @param historicalContext Optional cross-session context for overload detection
|
|
25
48
|
*/
|
|
26
|
-
function calculateQualityScore(parsedSets, rawRecords, timingGuardrails,
|
|
27
|
-
|
|
28
|
-
// Edge case: no records at all or NONE were completed
|
|
49
|
+
function calculateQualityScore(parsedSets, rawRecords, timingGuardrails, historicalContext) {
|
|
50
|
+
// Nothing completed at all
|
|
29
51
|
if (rawRecords.length === 0 || parsedSets.length === 0) {
|
|
30
|
-
const emptyScoresByGoal = {};
|
|
31
|
-
for (const goal of userContext.fitnessGoals) {
|
|
32
|
-
emptyScoresByGoal[goal] = {
|
|
33
|
-
score: 0,
|
|
34
|
-
qualityBreakdown: {
|
|
35
|
-
completion: 0,
|
|
36
|
-
consistency: 0,
|
|
37
|
-
effortAdequacy: 0,
|
|
38
|
-
restDiscipline: 0,
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
52
|
return {
|
|
43
|
-
|
|
53
|
+
score: 0,
|
|
54
|
+
qualityBreakdown: {
|
|
55
|
+
completion: 0,
|
|
56
|
+
consistency: 0,
|
|
57
|
+
effortAdequacy: 0,
|
|
58
|
+
restDiscipline: 0,
|
|
59
|
+
},
|
|
60
|
+
restDisciplineActive: false,
|
|
44
61
|
};
|
|
45
62
|
}
|
|
63
|
+
// ── Three always-active components ──────────────────────────────────────
|
|
46
64
|
const completion = calculateCompletion(parsedSets, rawRecords);
|
|
47
65
|
const consistency = calculateConsistency(parsedSets, historicalContext);
|
|
48
66
|
const effortAdequacy = calculateEffortAdequacy(rawRecords);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
// ── Conditional: rest discipline ─────────────────────────────────────────
|
|
68
|
+
// null = no rest data logged → excluded from scoring entirely.
|
|
69
|
+
// The 15% weight is redistributed to the three active components below.
|
|
70
|
+
// Two-gate check for rest discipline:
|
|
71
|
+
//
|
|
72
|
+
// Gate 1 — raw data: did the user actually log rest timing?
|
|
73
|
+
// Check rawRecords (not parsedSets). parseRecords synthesises rest values
|
|
74
|
+
// from timingGuardrails.typical for non-last sets even when the user never
|
|
75
|
+
// logged rest, making parsedSets an unreliable signal.
|
|
76
|
+
//
|
|
77
|
+
// Gate 2 — evaluable sets: does calculateRestDiscipline have non-last sets to score?
|
|
78
|
+
// Even if raw data exists, a single-set exercise has its only set marked as
|
|
79
|
+
// last-set → parsedSet.restDurationSecs = null → no evaluable sets.
|
|
80
|
+
// calculateRestDiscipline returns null in this case.
|
|
81
|
+
//
|
|
82
|
+
// restDisciplineActive = true only when BOTH gates pass — real data existed
|
|
83
|
+
// AND produced an evaluable score.
|
|
84
|
+
const hasRawRestData = rawRecords.some((r) => r.restDurationSecs !== undefined && r.restDurationSecs !== null);
|
|
85
|
+
const restDiscipline = hasRawRestData
|
|
86
|
+
? calculateRestDiscipline(parsedSets, timingGuardrails) // may return null for single-set
|
|
87
|
+
: null;
|
|
88
|
+
// ── Dynamic weight normalisation ─────────────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// With rest: activeWeightTotal = 1.00 → standard weights apply
|
|
91
|
+
// Without rest: activeWeightTotal = 0.85 → remaining three components
|
|
92
|
+
// are normalised to fill 100% of the score space
|
|
93
|
+
//
|
|
94
|
+
// Using the sum of active weights rather than a hardcoded 0.85 means any
|
|
95
|
+
// future adjustments to QUALITY_WEIGHTS in constants.ts propagate here
|
|
96
|
+
// automatically without touching this logic.
|
|
97
|
+
const activeWeightTotal = restDiscipline !== null
|
|
98
|
+
? 1.0
|
|
99
|
+
: constants_1.QUALITY_WEIGHTS.completion +
|
|
100
|
+
constants_1.QUALITY_WEIGHTS.consistency +
|
|
101
|
+
constants_1.QUALITY_WEIGHTS.effortAdequacy;
|
|
102
|
+
const weightedSum = completion * constants_1.QUALITY_WEIGHTS.completion +
|
|
103
|
+
consistency * constants_1.QUALITY_WEIGHTS.consistency +
|
|
104
|
+
effortAdequacy * constants_1.QUALITY_WEIGHTS.effortAdequacy +
|
|
105
|
+
(restDiscipline !== null
|
|
106
|
+
? restDiscipline * constants_1.QUALITY_WEIGHTS.restDiscipline
|
|
107
|
+
: 0);
|
|
108
|
+
const score = (0, helpers_1.clamp)(Math.round(weightedSum / activeWeightTotal), 0, 100);
|
|
80
109
|
return {
|
|
81
|
-
|
|
110
|
+
score,
|
|
111
|
+
qualityBreakdown: {
|
|
112
|
+
completion: Math.round(completion),
|
|
113
|
+
consistency: Math.round(consistency),
|
|
114
|
+
effortAdequacy: Math.round(effortAdequacy),
|
|
115
|
+
restDiscipline: restDiscipline !== null ? Math.round(restDiscipline) : 0,
|
|
116
|
+
},
|
|
117
|
+
restDisciplineActive: restDiscipline !== null,
|
|
82
118
|
};
|
|
83
119
|
}
|
|
84
120
|
// ---------------------------------------------------------------------------
|
|
85
|
-
// Component A
|
|
121
|
+
// Component A — Completion (0–100)
|
|
86
122
|
// ---------------------------------------------------------------------------
|
|
87
123
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* Simple ratio: completedSets / totalSets × 100
|
|
91
|
-
*
|
|
92
|
-
* This rewards finishing what you started. Skipping sets tanks the score.
|
|
93
|
-
*
|
|
94
|
-
* Uses rawRecords.length as total (includes isDone:false) and
|
|
95
|
-
* parsedSets.length as completed (only isDone:true after filtering).
|
|
124
|
+
* Ratio of completed sets to planned sets.
|
|
125
|
+
* Skipping sets directly reduces this score. Completing everything = 100.
|
|
96
126
|
*/
|
|
97
127
|
function calculateCompletion(parsedSets, rawRecords) {
|
|
98
128
|
const total = rawRecords.length;
|
|
99
129
|
if (total === 0)
|
|
100
130
|
return 0;
|
|
101
|
-
|
|
102
|
-
return (completed / total) * 100;
|
|
131
|
+
return (parsedSets.length / total) * 100;
|
|
103
132
|
}
|
|
104
133
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// Component B
|
|
134
|
+
// Component B — Consistency (0–100)
|
|
106
135
|
// ---------------------------------------------------------------------------
|
|
107
136
|
/**
|
|
108
137
|
* Were sets consistent in performance output?
|
|
109
138
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
139
|
+
* Uses coefficient of variation (CV) of a per-set output metric.
|
|
140
|
+
* CV = 0 → perfectly consistent → 100
|
|
141
|
+
* CV = 0.5 → very variable → clamped to CONSISTENCY_MIN_SCORE
|
|
113
142
|
*
|
|
114
|
-
*
|
|
115
|
-
* weight-reps
|
|
116
|
-
* reps-only
|
|
117
|
-
* duration
|
|
118
|
-
* cardio-machine
|
|
119
|
-
* cardio-free
|
|
143
|
+
* Output metric by record type:
|
|
144
|
+
* weight-reps → kg × reps (volume per set — handles pyramids and backoffs)
|
|
145
|
+
* reps-only → rep count
|
|
146
|
+
* duration → hold time in seconds
|
|
147
|
+
* cardio-machine → speed
|
|
148
|
+
* cardio-free → effective speed (distance / time)
|
|
120
149
|
*
|
|
121
|
-
*
|
|
122
|
-
* If values are monotonically increasing (warming up / pyramiding UP)
|
|
123
|
-
* or monotonically decreasing (drop sets / fatigue), this is INTENTIONAL.
|
|
124
|
-
* We don't penalize it — instead, we floor the score at 75.
|
|
125
|
-
* Requires ≥ 3 sets to detect (2 sets are always monotonic).
|
|
150
|
+
* Progressive overload protection — intentional patterns are NOT penalised:
|
|
126
151
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
152
|
+
* Intra-session (≥ 3 sets required — 2 sets are always trivially monotonic):
|
|
153
|
+
* Monotonically ascending [warm-up pyramid, RPT] → floor at PROGRESSIVE_OVERLOAD_FLOOR
|
|
154
|
+
* Monotonically descending [drop sets] → floor at PROGRESSIVE_OVERLOAD_FLOOR
|
|
155
|
+
* Top-set + stable backoffs [500, 640, 640] → non-strictly ascending → protected
|
|
130
156
|
*
|
|
131
|
-
*
|
|
157
|
+
* Cross-session (P2-6):
|
|
158
|
+
* Current total volume ≥ previous session volume → floor at PROGRESSIVE_OVERLOAD_FLOOR
|
|
159
|
+
* Rewards users who beat last session even when intra-set CV is high.
|
|
160
|
+
*
|
|
161
|
+
* Single set → 100 (nothing to compare against).
|
|
132
162
|
*/
|
|
133
163
|
function calculateConsistency(parsedSets, historicalContext) {
|
|
134
164
|
if (parsedSets.length <= 1)
|
|
135
165
|
return 100;
|
|
136
166
|
const values = parsedSets.map(extractConsistencyMetric);
|
|
137
|
-
// Filter out zero/invalid values
|
|
138
167
|
const validValues = values.filter((v) => v > 0);
|
|
139
168
|
if (validValues.length <= 1)
|
|
140
169
|
return 100;
|
|
141
|
-
// Calculate coefficient of variation
|
|
142
170
|
const cv = (0, helpers_1.coefficientOfVariation)(validValues);
|
|
143
|
-
// Base score: penalize variance
|
|
144
171
|
let score = (0, helpers_1.clamp)(100 - cv * constants_1.CONSISTENCY_CV_PENALTY, constants_1.CONSISTENCY_MIN_SCORE, 100);
|
|
145
172
|
let isProgressiveOverload = false;
|
|
146
|
-
//
|
|
147
|
-
const
|
|
148
|
-
if ((historicalContext === null || historicalContext === void 0 ? void 0 : historicalContext.previousSessionVolume)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
// Cross-session: beat last session's total volume?
|
|
174
|
+
const currentVolume = validValues.reduce((sum, v) => sum + v, 0);
|
|
175
|
+
if ((historicalContext === null || historicalContext === void 0 ? void 0 : historicalContext.previousSessionVolume) !== undefined &&
|
|
176
|
+
historicalContext.previousSessionVolume > 0 &&
|
|
177
|
+
currentVolume >= historicalContext.previousSessionVolume) {
|
|
178
|
+
isProgressiveOverload = true;
|
|
152
179
|
}
|
|
153
|
-
// Intra-session
|
|
154
|
-
if (validValues.length >= 3) {
|
|
155
|
-
|
|
156
|
-
const isMonotonicallyDecreasing = isMonotonic(validValues, "desc");
|
|
157
|
-
if (isMonotonicallyIncreasing || isMonotonicallyDecreasing) {
|
|
180
|
+
// Intra-session: monotonic pattern (≥ 3 sets)
|
|
181
|
+
if (!isProgressiveOverload && validValues.length >= 3) {
|
|
182
|
+
if (isMonotonic(validValues, "asc") || isMonotonic(validValues, "desc")) {
|
|
158
183
|
isProgressiveOverload = true;
|
|
159
184
|
}
|
|
160
185
|
}
|
|
@@ -164,46 +189,39 @@ function calculateConsistency(parsedSets, historicalContext) {
|
|
|
164
189
|
return score;
|
|
165
190
|
}
|
|
166
191
|
/**
|
|
167
|
-
* Extract the consistency metric for
|
|
192
|
+
* Extract the consistency metric for one set based on its record type.
|
|
193
|
+
*
|
|
194
|
+
* weight-reps uses kg × reps (integrated volume) rather than kg alone:
|
|
195
|
+
* Straight sets: [80×8, 80×8, 80×8] → [640, 640, 640] CV = 0 → 100
|
|
196
|
+
* Top set + backoffs: [100×5, 80×8, 80×8] → [500, 640, 640] ascending → floor
|
|
197
|
+
* Pyramid up: [60×12, 80×8, 100×5] → [720, 640, 500] descending → floor
|
|
198
|
+
* Drop sets: [100×8, 80×10, 60×12] → [800, 800, 720] descending → floor
|
|
168
199
|
*/
|
|
169
200
|
function extractConsistencyMetric(set) {
|
|
170
201
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
171
202
|
switch (set.type) {
|
|
172
|
-
case "weight-reps":
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const reps = (_b = set.reps) !== null && _b !== void 0 ? _b : 0;
|
|
176
|
-
return kg * reps;
|
|
177
|
-
}
|
|
178
|
-
case "reps-only": {
|
|
179
|
-
// Just reps (aux weight changes are intentional, not inconsistency)
|
|
203
|
+
case "weight-reps":
|
|
204
|
+
return ((_a = set.kg) !== null && _a !== void 0 ? _a : 0) * ((_b = set.reps) !== null && _b !== void 0 ? _b : 0);
|
|
205
|
+
case "reps-only":
|
|
180
206
|
return (_c = set.reps) !== null && _c !== void 0 ? _c : 0;
|
|
181
|
-
|
|
182
|
-
case "duration": {
|
|
183
|
-
// Hold duration
|
|
207
|
+
case "duration":
|
|
184
208
|
return (_d = set.durationSecs) !== null && _d !== void 0 ? _d : set.activeDurationSecs;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// Average speed
|
|
188
|
-
const speed = (_e = set.speed) !== null && _e !== void 0 ? _e : 0;
|
|
189
|
-
return speed;
|
|
190
|
-
}
|
|
209
|
+
case "cardio-machine":
|
|
210
|
+
return (_e = set.speed) !== null && _e !== void 0 ? _e : 0;
|
|
191
211
|
case "cardio-free": {
|
|
192
|
-
// Effective speed (km/h)
|
|
193
212
|
const distance = (_f = set.distance) !== null && _f !== void 0 ? _f : 0;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return 0;
|
|
213
|
+
const durationSec = (_g = set.cardioDurationSecs) !== null && _g !== void 0 ? _g : set.activeDurationSecs;
|
|
214
|
+
return durationSec > 0 && distance > 0
|
|
215
|
+
? distance / (durationSec / 3600)
|
|
216
|
+
: 0;
|
|
199
217
|
}
|
|
200
218
|
default:
|
|
201
219
|
return 0;
|
|
202
220
|
}
|
|
203
221
|
}
|
|
204
222
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
223
|
+
* True when values are non-strictly monotonic in the given direction.
|
|
224
|
+
* Equal consecutive values are allowed: [500, 640, 640] is ascending.
|
|
207
225
|
*/
|
|
208
226
|
function isMonotonic(values, direction) {
|
|
209
227
|
for (let i = 1; i < values.length; i++) {
|
|
@@ -215,143 +233,126 @@ function isMonotonic(values, direction) {
|
|
|
215
233
|
return true;
|
|
216
234
|
}
|
|
217
235
|
// ---------------------------------------------------------------------------
|
|
218
|
-
// Component C
|
|
236
|
+
// Component C — Effort Adequacy (0–100)
|
|
219
237
|
// ---------------------------------------------------------------------------
|
|
220
238
|
/**
|
|
221
|
-
* Was the user working
|
|
222
|
-
*
|
|
223
|
-
* The "productive zone" for most training goals:
|
|
224
|
-
* RIR 1–4: close enough to failure to stimulate adaptation, not so close
|
|
225
|
-
* that recovery is impaired
|
|
226
|
-
* RPE 6–9: same concept from the subjective side
|
|
239
|
+
* Was the user working hard enough to drive adaptation?
|
|
227
240
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* - Outside by 3+ units → 40/20
|
|
241
|
+
* Effort is now logged on EVERY set (post-set one-tap modal), so scoring is
|
|
242
|
+
* graded and monotonic rather than a flat optimal-band. Each set is scored,
|
|
243
|
+
* then averaged with the FINAL set weighted EFFORT_FINAL_SET_WEIGHT× because
|
|
244
|
+
* the last working set is where adequacy is truly decided.
|
|
233
245
|
*
|
|
234
|
-
*
|
|
246
|
+
* RPE: 9 → 100, 7 → ~81, 4 → ~53, 1 → 25 (floored at 20)
|
|
247
|
+
* RIR: 0 → 100, 3 → 76, 6 → 52, 10 → 20
|
|
235
248
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
249
|
+
* RIR is preferred over RPE when both exist (more objective — countable reps).
|
|
250
|
+
* A set with NO effort data (legacy/pre-update records only) scores the neutral
|
|
251
|
+
* EFFORT_NO_DATA_SCORE so historical sessions in the Gate 2 window don't break.
|
|
239
252
|
*/
|
|
240
253
|
function calculateEffortAdequacy(rawRecords) {
|
|
241
254
|
const completedRecords = rawRecords.filter((r) => r.isDone);
|
|
242
255
|
if (completedRecords.length === 0)
|
|
243
256
|
return 0;
|
|
244
|
-
const setScores =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
const setScores = completedRecords.map(scoreSetEffort);
|
|
258
|
+
// Weighted mean — final set counts EFFORT_FINAL_SET_WEIGHT×.
|
|
259
|
+
const lastIndex = setScores.length - 1;
|
|
260
|
+
let weightedSum = 0;
|
|
261
|
+
let weightTotal = 0;
|
|
262
|
+
setScores.forEach((s, i) => {
|
|
263
|
+
const w = i === lastIndex ? EFFORT_FINAL_SET_WEIGHT : 1;
|
|
264
|
+
weightedSum += s * w;
|
|
265
|
+
weightTotal += w;
|
|
266
|
+
});
|
|
267
|
+
return weightTotal > 0 ? weightedSum / weightTotal : constants_1.EFFORT_NO_DATA_SCORE;
|
|
250
268
|
}
|
|
251
|
-
/**
|
|
252
|
-
* Score a single set's effort level.
|
|
253
|
-
*
|
|
254
|
-
* Priority: RIR > RPE > no data.
|
|
255
|
-
* For RIR: optimal range [1, 4]
|
|
256
|
-
* For RPE: optimal range [6, 9]
|
|
257
|
-
*/
|
|
258
269
|
function scoreSetEffort(record) {
|
|
259
|
-
//
|
|
270
|
+
// RIR preferred (more objective — countable remaining reps)
|
|
260
271
|
if (record.rir !== undefined && record.rir !== "") {
|
|
261
|
-
const rir = (
|
|
262
|
-
if (rir >= 0) {
|
|
263
|
-
return
|
|
272
|
+
const rir = parseFloat(record.rir);
|
|
273
|
+
if (!isNaN(rir) && rir >= 0) {
|
|
274
|
+
return scoreEffortRir(rir);
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
|
-
//
|
|
277
|
+
// RPE fallback
|
|
267
278
|
if (record.rpe !== undefined && record.rpe !== "") {
|
|
268
|
-
const rpe = (
|
|
269
|
-
if (rpe >= 1) {
|
|
270
|
-
return
|
|
279
|
+
const rpe = parseFloat(record.rpe);
|
|
280
|
+
if (!isNaN(rpe) && rpe >= 1) {
|
|
281
|
+
return scoreEffortRpe(rpe);
|
|
271
282
|
}
|
|
272
283
|
}
|
|
273
|
-
// No effort data —
|
|
284
|
+
// No effort data — legacy/pre-update sessions only. Neutral, never a penalty.
|
|
274
285
|
return constants_1.EFFORT_NO_DATA_SCORE;
|
|
275
286
|
}
|
|
276
287
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* Distance 1 outside → 100 - penalty
|
|
281
|
-
* Distance 2 outside → 100 - 2×penalty
|
|
282
|
-
* etc., floored at EFFORT_MIN_SCORE
|
|
288
|
+
* Graded RPE → effort score. Monotonic: harder always scores higher.
|
|
289
|
+
* score = EFFORT_RPE_BASE + (rpe - 1) * EFFORT_RPE_SLOPE (clamped 20–100)
|
|
290
|
+
* RPE 10 capped at 95 — chronic grinding past failure is not "perfect".
|
|
283
291
|
*/
|
|
284
|
-
function
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
return
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
function scoreEffortRpe(rpe) {
|
|
293
|
+
const clamped = (0, helpers_1.clamp)(rpe, 1, 10);
|
|
294
|
+
if (clamped > 9)
|
|
295
|
+
return 95;
|
|
296
|
+
const score = EFFORT_RPE_BASE + (clamped - 1) * EFFORT_RPE_SLOPE;
|
|
297
|
+
return (0, helpers_1.clamp)(score, EFFORT_FLOOR_SCORE, 100);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Graded RIR → effort score. Inverse of RPE (RIR lower = harder).
|
|
301
|
+
* score = 100 - rir * EFFORT_RIR_SLOPE (clamped 20–100)
|
|
302
|
+
*/
|
|
303
|
+
function scoreEffortRir(rir) {
|
|
304
|
+
const clamped = (0, helpers_1.clamp)(rir, 0, 10);
|
|
305
|
+
const score = 100 - clamped * EFFORT_RIR_SLOPE;
|
|
306
|
+
return (0, helpers_1.clamp)(score, EFFORT_FLOOR_SCORE, 100);
|
|
291
307
|
}
|
|
292
308
|
// ---------------------------------------------------------------------------
|
|
293
|
-
// Component D
|
|
309
|
+
// Component D — Rest Discipline (0–100, conditional)
|
|
294
310
|
// ---------------------------------------------------------------------------
|
|
295
311
|
/**
|
|
296
312
|
* Were rest periods within the exercise's optimal windows?
|
|
297
313
|
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* outside [minimum, maximum] → 50 points (Reduced penalty so it doesn't tank the score too much)
|
|
314
|
+
* Only called when parsedSets has at least one non-null restDurationSecs.
|
|
315
|
+
* The caller (calculateQualityScore) handles conditional inclusion and
|
|
316
|
+
* weight redistribution — this function only scores what it receives.
|
|
302
317
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
318
|
+
* Scoring bands (using timingGuardrails.restPeriods from exercise DB):
|
|
319
|
+
* Within optimalRange → 100
|
|
320
|
+
* Within [minimum, maximum] → 70–99 proportional to proximity to optimal
|
|
321
|
+
* Outside [minimum, maximum] → 50 (soft penalty — doesn't crater the score)
|
|
305
322
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
323
|
+
* stressRestBonus (from exercise timingGuardrails):
|
|
324
|
+
* Heavy compounds benefit more from proper rest. A small bonus is applied
|
|
325
|
+
* when avgScore > 60 and the exercise carries a stressRestBonus value.
|
|
309
326
|
*/
|
|
310
327
|
function calculateRestDiscipline(parsedSets, timingGuardrails) {
|
|
311
|
-
var _a, _b, _c, _d, _e, _f, _g, _h
|
|
312
|
-
|
|
313
|
-
|
|
328
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
329
|
+
const setsWithRest = parsedSets.filter((s) => s.restDurationSecs !== null && s.restDurationSecs !== undefined);
|
|
330
|
+
// No evaluable sets (e.g. single-set exercise where the only set is the last set).
|
|
331
|
+
// Return null so the caller knows there is genuinely no data to score,
|
|
332
|
+
// distinct from a score of 0 (which would penalise the user unfairly).
|
|
314
333
|
if (setsWithRest.length === 0)
|
|
315
|
-
return
|
|
316
|
-
// Extract rest period bounds from guardrails
|
|
334
|
+
return null;
|
|
317
335
|
const restConfig = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.restPeriods;
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
const rest = set.restDurationSecs;
|
|
325
|
-
if (rest >= optimalMin && rest <= optimalMax)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
let ratio;
|
|
334
|
-
if (rest < optimalMin) {
|
|
335
|
-
// Between acceptable min and optimal min
|
|
336
|
-
ratio = (rest - acceptableMin) / (optimalMin - acceptableMin);
|
|
337
|
-
}
|
|
338
|
-
else {
|
|
339
|
-
// Between optimal max and acceptable max
|
|
340
|
-
ratio = (acceptableMax - rest) / (acceptableMax - optimalMax);
|
|
341
|
-
}
|
|
342
|
-
setScores.push(baseAcceptableScore +
|
|
343
|
-
ratio * (100 - baseAcceptableScore));
|
|
336
|
+
const typicalRest = (_a = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _a !== void 0 ? _a : constants_1.FALLBACK_REST_SECS;
|
|
337
|
+
const optimalMin = (_c = (_b = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : typicalRest * 0.75;
|
|
338
|
+
const optimalMax = (_e = (_d = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _d === void 0 ? void 0 : _d[1]) !== null && _e !== void 0 ? _e : typicalRest * 1.25;
|
|
339
|
+
const acceptMin = (_f = restConfig === null || restConfig === void 0 ? void 0 : restConfig.minimum) !== null && _f !== void 0 ? _f : optimalMin * 0.5;
|
|
340
|
+
const acceptMax = (_g = restConfig === null || restConfig === void 0 ? void 0 : restConfig.maximum) !== null && _g !== void 0 ? _g : optimalMax * 2;
|
|
341
|
+
const setScores = setsWithRest.map((set) => {
|
|
342
|
+
const rest = set.restDurationSecs;
|
|
343
|
+
if (rest >= optimalMin && rest <= optimalMax)
|
|
344
|
+
return 100;
|
|
345
|
+
if (rest >= acceptMin && rest <= acceptMax) {
|
|
346
|
+
const base = 70;
|
|
347
|
+
const ratio = rest < optimalMin
|
|
348
|
+
? (rest - acceptMin) / (optimalMin - acceptMin)
|
|
349
|
+
: (acceptMax - rest) / (acceptMax - optimalMax);
|
|
350
|
+
return base + ratio * (100 - base);
|
|
344
351
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
setScores.push(50);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
// Average rest scores
|
|
352
|
+
return 50;
|
|
353
|
+
});
|
|
351
354
|
let avgScore = setScores.reduce((sum, s) => sum + s, 0) / setScores.length;
|
|
352
|
-
|
|
353
|
-
// (e.g., heavy compounds) get a point bonus if they performed well.
|
|
354
|
-
const stressRestBonus = (_j = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.stressRestBonus) !== null && _j !== void 0 ? _j : 0;
|
|
355
|
+
const stressRestBonus = (_h = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.stressRestBonus) !== null && _h !== void 0 ? _h : 0;
|
|
355
356
|
if (avgScore > 60 && stressRestBonus > 0) {
|
|
356
357
|
avgScore = Math.min(100, avgScore + stressRestBonus);
|
|
357
358
|
}
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Recovery model accounts for:
|
|
12
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
13
|
* P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
|
|
15
14
|
* P3-7 Weekly volume — muscles trained multiple times this week recover slower
|
|
16
15
|
* P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
|
|
17
16
|
*
|
|
18
17
|
* Accumulation model:
|
|
19
|
-
* P3-3 Real muscleScores → additive with diminishing returns (P3-5)
|
|
20
|
-
*
|
|
18
|
+
* P3-3 Real per-session muscleScores → additive with diminishing returns (P3-5)
|
|
19
|
+
*
|
|
20
|
+
* Note: the legacy quality-score proxy path (pre-P3-1 sessions with empty
|
|
21
|
+
* muscleScores) has been removed — every session now saves real muscleScores.
|
|
22
|
+
* The P2-8 gender recovery factor has also been removed: the 1.05× female
|
|
23
|
+
* bump was within the noise of the model and not well-evidenced enough to
|
|
24
|
+
* justify a silent default-male assumption for unmentioned-gender users.
|
|
21
25
|
*/
|
|
22
26
|
import type { TBodyPartKeys, TUserMetric } from "../../types";
|
|
23
27
|
import type { TEnrichedExerciseRecord, TMuscleFatigueResult } from "./types";
|
|
24
|
-
interface IExerciseMuscleData {
|
|
28
|
+
export interface IExerciseMuscleData {
|
|
25
29
|
primaryMuscles: TBodyPartKeys;
|
|
26
30
|
secondaryMuscles: TBodyPartKeys;
|
|
27
31
|
}
|
|
@@ -35,4 +39,3 @@ interface IExerciseMuscleData {
|
|
|
35
39
|
* @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
|
|
36
40
|
*/
|
|
37
41
|
export declare function computeMuscleFatigueMap(exercises: Record<string, IExerciseMuscleData>, scoreHistory: TEnrichedExerciseRecord[], user?: Partial<TUserMetric>, currentDate?: Date): TMuscleFatigueResult;
|
|
38
|
-
export {};
|