@dgpholdings/greatoak-shared 1.2.54 → 1.2.56
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/__mocks__/exercises.mock.d.ts +35 -0
- package/dist/__mocks__/exercises.mock.js +144 -0
- package/dist/__mocks__/templateExercises.mock.d.ts +90 -0
- package/dist/__mocks__/templateExercises.mock.js +258 -0
- package/dist/__mocks__/user.mock.d.ts +2 -0
- package/dist/__mocks__/user.mock.js +36 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +30 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +138 -0
- package/dist/utils/exerciseRecord/recordValidator.d.ts +12 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.js +51 -0
- package/dist/utils/exerciseRecord/recordValidator.js +85 -0
- package/dist/utils/exerciseRecord/recordValidator.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.test.js +165 -0
- package/dist/utils/exerciseRecord/workoutMath.d.ts +28 -0
- package/dist/utils/exerciseRecord/workoutMath.js +116 -0
- package/dist/utils/exerciseRecord/workoutMath.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/workoutMath.test.js +238 -0
- package/dist/utils/index.d.ts +4 -1
- package/dist/utils/index.js +7 -4
- package/dist/utils/scoringWorkout/calculateCalories.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateCalories.js +351 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +330 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +73 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.js +357 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +15 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.js +73 -0
- package/dist/utils/scoringWorkout/constants.d.ts +211 -0
- package/dist/utils/scoringWorkout/constants.js +247 -0
- package/dist/utils/scoringWorkout/helpers.d.ts +127 -0
- package/dist/utils/scoringWorkout/helpers.js +245 -0
- package/dist/utils/scoringWorkout/index.d.ts +30 -0
- package/dist/utils/scoringWorkout/index.js +57 -0
- package/dist/utils/scoringWorkout/parseRecords.d.ts +68 -0
- package/dist/utils/scoringWorkout/parseRecords.js +281 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.d.ts +1 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +439 -0
- package/dist/utils/scoringWorkout/types.d.ts +104 -0
- package/dist/utils/scoringWorkout/types.js +11 -0
- package/package.json +7 -4
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// calculateQualityScore.ts
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.calculateQualityScore = calculateQualityScore;
|
|
5
|
+
const constants_1 = require("./constants");
|
|
6
|
+
const helpers_1 = require("./helpers");
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Main Quality Calculation
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/**
|
|
11
|
+
* Calculate the overall quality score and its breakdown.
|
|
12
|
+
*
|
|
13
|
+
* @param parsedSets Cleaned sets (from parseRecords) — only completed sets
|
|
14
|
+
* @param rawRecords Original TRecord[] — needed for completion count (includes skipped)
|
|
15
|
+
* @param timingGuardrails Exercise's guardrails — for rest period validation
|
|
16
|
+
* @param isStrictTimingModeScoring If false, ignores the rest discipline penalty.
|
|
17
|
+
* @param userContext User context for dynamic weighting (P2-3)
|
|
18
|
+
* @param historicalContext Optional history for progressive overload detection (P2-6)
|
|
19
|
+
* @returns { score: 0–100, breakdown: { completion, consistency, effortAdequacy, restDiscipline } }
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const { score, breakdown } = calculateQualityScore(parsed, raw, guardrails, false, userCtx, historyCtx);
|
|
23
|
+
* // score: 81
|
|
24
|
+
* // breakdown: { completion: 100, consistency: 75, effortAdequacy: 80, restDiscipline: 65 }
|
|
25
|
+
*/
|
|
26
|
+
function calculateQualityScore(parsedSets, rawRecords, timingGuardrails, isStrictTimingModeScoring, userContext, historicalContext) {
|
|
27
|
+
var _a;
|
|
28
|
+
// Edge case: no records at all
|
|
29
|
+
if (rawRecords.length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
score: 0,
|
|
32
|
+
breakdown: {
|
|
33
|
+
completion: 0,
|
|
34
|
+
consistency: 0,
|
|
35
|
+
effortAdequacy: 0,
|
|
36
|
+
restDiscipline: 0,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Edge case: records exist but NONE were completed
|
|
41
|
+
// If you didn't do a single set, the score is 0 — no partial credit
|
|
42
|
+
// from consistency/rest defaults
|
|
43
|
+
if (parsedSets.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
score: 0,
|
|
46
|
+
breakdown: {
|
|
47
|
+
completion: 0,
|
|
48
|
+
consistency: 0,
|
|
49
|
+
effortAdequacy: 0,
|
|
50
|
+
restDiscipline: 0,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const completion = calculateCompletion(parsedSets, rawRecords);
|
|
55
|
+
const consistency = calculateConsistency(parsedSets, historicalContext);
|
|
56
|
+
const effortAdequacy = calculateEffortAdequacy(rawRecords);
|
|
57
|
+
const restDiscipline = isStrictTimingModeScoring ? calculateRestDiscipline(parsedSets, timingGuardrails) : constants_1.REST_NO_DATA_SCORE;
|
|
58
|
+
// P2-3: Dynamic Quality Weights based on fitnessGoal
|
|
59
|
+
const goalWeights = {
|
|
60
|
+
strength: { completion: 0.15, consistency: 0.25, effortAdequacy: 0.45, restDiscipline: 0.15 },
|
|
61
|
+
hypertrophy: { completion: 0.20, consistency: 0.35, effortAdequacy: 0.30, restDiscipline: 0.15 },
|
|
62
|
+
endurance: { completion: 0.25, consistency: 0.40, effortAdequacy: 0.20, restDiscipline: 0.15 },
|
|
63
|
+
fat_burn: { completion: 0.30, consistency: 0.30, effortAdequacy: 0.25, restDiscipline: 0.15 },
|
|
64
|
+
flexibility: { completion: 0.40, consistency: 0.35, effortAdequacy: 0.10, restDiscipline: 0.15 },
|
|
65
|
+
general: { completion: 0.20, consistency: 0.35, effortAdequacy: 0.30, restDiscipline: 0.15 },
|
|
66
|
+
};
|
|
67
|
+
const weights = (_a = goalWeights[userContext.fitnessGoal]) !== null && _a !== void 0 ? _a : constants_1.QUALITY_WEIGHTS;
|
|
68
|
+
const score = Math.round(completion * weights.completion +
|
|
69
|
+
consistency * weights.consistency +
|
|
70
|
+
effortAdequacy * weights.effortAdequacy +
|
|
71
|
+
restDiscipline * weights.restDiscipline);
|
|
72
|
+
return {
|
|
73
|
+
score: (0, helpers_1.clamp)(score, 0, 100),
|
|
74
|
+
breakdown: {
|
|
75
|
+
completion: Math.round(completion),
|
|
76
|
+
consistency: Math.round(consistency),
|
|
77
|
+
effortAdequacy: Math.round(effortAdequacy),
|
|
78
|
+
restDiscipline: Math.round(restDiscipline),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Component A: Completion (0–100)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* Did the user complete all their sets?
|
|
87
|
+
*
|
|
88
|
+
* Simple ratio: completedSets / totalSets × 100
|
|
89
|
+
*
|
|
90
|
+
* This rewards finishing what you started. Skipping sets tanks the score.
|
|
91
|
+
*
|
|
92
|
+
* Uses rawRecords.length as total (includes isDone:false) and
|
|
93
|
+
* parsedSets.length as completed (only isDone:true after filtering).
|
|
94
|
+
*/
|
|
95
|
+
function calculateCompletion(parsedSets, rawRecords) {
|
|
96
|
+
const total = rawRecords.length;
|
|
97
|
+
if (total === 0)
|
|
98
|
+
return 0;
|
|
99
|
+
const completed = parsedSets.length;
|
|
100
|
+
return (completed / total) * 100;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Component B: Consistency (0–100)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/**
|
|
106
|
+
* Were sets consistent in performance output?
|
|
107
|
+
*
|
|
108
|
+
* Measures the coefficient of variation (CV) of the per-set "output metric".
|
|
109
|
+
* CV = 0 → perfectly consistent → score 100
|
|
110
|
+
* CV = 0.50 → very variable → score ~0 (clamped to minimum)
|
|
111
|
+
*
|
|
112
|
+
* The "output metric" depends on exercise type:
|
|
113
|
+
* weight-reps: kg × reps (volume per set)
|
|
114
|
+
* reps-only: reps (aux weight change is intentional, so just reps)
|
|
115
|
+
* duration: durationSecs
|
|
116
|
+
* cardio-machine: avg speed (speedMin + speedMax) / 2
|
|
117
|
+
* cardio-free: effective speed (distance / time)
|
|
118
|
+
*
|
|
119
|
+
* SPECIAL CASE — Progressive overload detection:
|
|
120
|
+
* If values are monotonically increasing (warming up / pyramiding UP)
|
|
121
|
+
* or monotonically decreasing (drop sets / fatigue), this is INTENTIONAL.
|
|
122
|
+
* We don't penalize it — instead, we floor the score at 75.
|
|
123
|
+
* Requires ≥ 3 sets to detect (2 sets are always monotonic).
|
|
124
|
+
*
|
|
125
|
+
* P2-6: Progressive overload across sessions
|
|
126
|
+
* If the session volume matches or beats the previous session's volume,
|
|
127
|
+
* we also floor the score at 75 to reward progression.
|
|
128
|
+
*
|
|
129
|
+
* SINGLE SET: Returns 100 (nothing to compare against).
|
|
130
|
+
*/
|
|
131
|
+
function calculateConsistency(parsedSets, historicalContext) {
|
|
132
|
+
if (parsedSets.length <= 1)
|
|
133
|
+
return 100;
|
|
134
|
+
const values = parsedSets.map(extractConsistencyMetric);
|
|
135
|
+
// Filter out zero/invalid values
|
|
136
|
+
const validValues = values.filter((v) => v > 0);
|
|
137
|
+
if (validValues.length <= 1)
|
|
138
|
+
return 100;
|
|
139
|
+
// Calculate coefficient of variation
|
|
140
|
+
const cv = (0, helpers_1.coefficientOfVariation)(validValues);
|
|
141
|
+
// Base score: penalize variance
|
|
142
|
+
let score = (0, helpers_1.clamp)(100 - cv * constants_1.CONSISTENCY_CV_PENALTY, constants_1.CONSISTENCY_MIN_SCORE, 100);
|
|
143
|
+
let isProgressiveOverload = false;
|
|
144
|
+
// P2-6: Cross-session progressive overload
|
|
145
|
+
const currentSessionVolume = validValues.reduce((sum, v) => sum + v, 0);
|
|
146
|
+
if ((historicalContext === null || historicalContext === void 0 ? void 0 : historicalContext.previousSessionVolume) && currentSessionVolume > 0) {
|
|
147
|
+
if (currentSessionVolume >= historicalContext.previousSessionVolume) {
|
|
148
|
+
isProgressiveOverload = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Intra-session progressive overload detection
|
|
152
|
+
if (validValues.length >= 3) {
|
|
153
|
+
const isMonotonicallyIncreasing = isMonotonic(validValues, "asc");
|
|
154
|
+
const isMonotonicallyDecreasing = isMonotonic(validValues, "desc");
|
|
155
|
+
if (isMonotonicallyIncreasing || isMonotonicallyDecreasing) {
|
|
156
|
+
isProgressiveOverload = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (isProgressiveOverload) {
|
|
160
|
+
score = Math.max(score, constants_1.PROGRESSIVE_OVERLOAD_FLOOR);
|
|
161
|
+
}
|
|
162
|
+
return score;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Extract the consistency metric for a set based on its type.
|
|
166
|
+
*/
|
|
167
|
+
function extractConsistencyMetric(set) {
|
|
168
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
169
|
+
switch (set.type) {
|
|
170
|
+
case "weight-reps": {
|
|
171
|
+
// Volume per set: weight × reps
|
|
172
|
+
const kg = (_a = set.kg) !== null && _a !== void 0 ? _a : 0;
|
|
173
|
+
const reps = (_b = set.reps) !== null && _b !== void 0 ? _b : 0;
|
|
174
|
+
return kg * reps;
|
|
175
|
+
}
|
|
176
|
+
case "reps-only": {
|
|
177
|
+
// Just reps (aux weight changes are intentional, not inconsistency)
|
|
178
|
+
return (_c = set.reps) !== null && _c !== void 0 ? _c : 0;
|
|
179
|
+
}
|
|
180
|
+
case "duration": {
|
|
181
|
+
// Hold duration
|
|
182
|
+
return (_d = set.durationSecs) !== null && _d !== void 0 ? _d : set.activeDurationSecs;
|
|
183
|
+
}
|
|
184
|
+
case "cardio-machine": {
|
|
185
|
+
// Average speed
|
|
186
|
+
const speed = (_e = set.speed) !== null && _e !== void 0 ? _e : 0;
|
|
187
|
+
return speed;
|
|
188
|
+
}
|
|
189
|
+
case "cardio-free": {
|
|
190
|
+
// Effective speed (km/h)
|
|
191
|
+
const distance = (_f = set.distance) !== null && _f !== void 0 ? _f : 0;
|
|
192
|
+
const durationSecs = (_g = set.cardioDurationSecs) !== null && _g !== void 0 ? _g : set.activeDurationSecs;
|
|
193
|
+
if (durationSecs > 0 && distance > 0) {
|
|
194
|
+
return distance / (durationSecs / 3600);
|
|
195
|
+
}
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
default:
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Check if an array of numbers is monotonically increasing or decreasing.
|
|
204
|
+
* "Allowing equal" — [10, 10, 12] counts as ascending.
|
|
205
|
+
*/
|
|
206
|
+
function isMonotonic(values, direction) {
|
|
207
|
+
for (let i = 1; i < values.length; i++) {
|
|
208
|
+
if (direction === "asc" && values[i] < values[i - 1])
|
|
209
|
+
return false;
|
|
210
|
+
if (direction === "desc" && values[i] > values[i - 1])
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Component C: Effort Adequacy (0–100)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/**
|
|
219
|
+
* Was the user working in a productive effort zone?
|
|
220
|
+
*
|
|
221
|
+
* The "productive zone" for most training goals:
|
|
222
|
+
* RIR 1–4: close enough to failure to stimulate adaptation, not so close
|
|
223
|
+
* that recovery is impaired
|
|
224
|
+
* RPE 6–9: same concept from the subjective side
|
|
225
|
+
*
|
|
226
|
+
* Scoring per set:
|
|
227
|
+
* - Within optimal range → 100
|
|
228
|
+
* - Outside by 1 unit → 80
|
|
229
|
+
* - Outside by 2 units → 60
|
|
230
|
+
* - Outside by 3+ units → 40/20
|
|
231
|
+
*
|
|
232
|
+
* If neither RPE nor RIR is provided → 70 (neutral, can't confirm or deny).
|
|
233
|
+
*
|
|
234
|
+
* Uses RAW records (not parsed sets) because:
|
|
235
|
+
* 1. We need the original RPE/RIR strings
|
|
236
|
+
* 2. We only score completed sets (isDone === true)
|
|
237
|
+
*/
|
|
238
|
+
function calculateEffortAdequacy(rawRecords) {
|
|
239
|
+
const completedRecords = rawRecords.filter((r) => r.isDone);
|
|
240
|
+
if (completedRecords.length === 0)
|
|
241
|
+
return 0;
|
|
242
|
+
const setScores = [];
|
|
243
|
+
for (const record of completedRecords) {
|
|
244
|
+
setScores.push(scoreSetEffort(record));
|
|
245
|
+
}
|
|
246
|
+
// Average all set effort scores
|
|
247
|
+
return setScores.reduce((sum, s) => sum + s, 0) / setScores.length;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Score a single set's effort level.
|
|
251
|
+
*
|
|
252
|
+
* Priority: RIR > RPE > no data.
|
|
253
|
+
* For RIR: optimal range [1, 4]
|
|
254
|
+
* For RPE: optimal range [6, 9]
|
|
255
|
+
*/
|
|
256
|
+
function scoreSetEffort(record) {
|
|
257
|
+
// Try RIR first (more objective)
|
|
258
|
+
if (record.rir !== undefined && record.rir !== "") {
|
|
259
|
+
const rir = (0, helpers_1.safeParseFloat)(record.rir, -1);
|
|
260
|
+
if (rir >= 0) {
|
|
261
|
+
return scoreAgainstRange(rir, constants_1.OPTIMAL_RIR_RANGE);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Try RPE
|
|
265
|
+
if (record.rpe !== undefined && record.rpe !== "") {
|
|
266
|
+
const rpe = (0, helpers_1.safeParseFloat)(record.rpe, -1);
|
|
267
|
+
if (rpe >= 1) {
|
|
268
|
+
return scoreAgainstRange(rpe, constants_1.OPTIMAL_RPE_RANGE);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// No effort data — neutral score
|
|
272
|
+
return constants_1.EFFORT_NO_DATA_SCORE;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Score a value against an optimal range.
|
|
276
|
+
*
|
|
277
|
+
* Within [min, max] → 100
|
|
278
|
+
* Distance 1 outside → 100 - penalty
|
|
279
|
+
* Distance 2 outside → 100 - 2×penalty
|
|
280
|
+
* etc., floored at EFFORT_MIN_SCORE
|
|
281
|
+
*/
|
|
282
|
+
function scoreAgainstRange(value, range) {
|
|
283
|
+
const [min, max] = range;
|
|
284
|
+
if (value >= min && value <= max)
|
|
285
|
+
return 100;
|
|
286
|
+
// Distance from nearest edge of the range
|
|
287
|
+
const distance = value < min ? min - value : value - max;
|
|
288
|
+
return Math.max(100 - distance * constants_1.EFFORT_DISTANCE_PENALTY, constants_1.EFFORT_MIN_SCORE);
|
|
289
|
+
}
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Component D: Rest Discipline (0–100)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
/**
|
|
294
|
+
* Were rest periods within the exercise's optimal windows?
|
|
295
|
+
*
|
|
296
|
+
* Uses the exercise's timingGuardrails.restPeriods:
|
|
297
|
+
* optimalRange [min, max] → 100 points
|
|
298
|
+
* within [minimum, maximum] → 70–100 (proportional to how close to optimal)
|
|
299
|
+
* outside [minimum, maximum] → 50 points (Reduced penalty so it doesn't tank the score too much)
|
|
300
|
+
*
|
|
301
|
+
* Only scores sets that HAVE rest data (last set often doesn't).
|
|
302
|
+
* If no valid rest data exists at all → neutral 70.
|
|
303
|
+
*
|
|
304
|
+
* The stressRestBonus from timingGuardrails is applied as a final multiplier:
|
|
305
|
+
* exercises where proper rest is especially important (heavy compounds)
|
|
306
|
+
* get a boost when rest is done right.
|
|
307
|
+
*/
|
|
308
|
+
function calculateRestDiscipline(parsedSets, timingGuardrails) {
|
|
309
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
310
|
+
// Gather sets that have rest data (non-null)
|
|
311
|
+
const setsWithRest = parsedSets.filter((s) => s.restDurationSecs !== null);
|
|
312
|
+
if (setsWithRest.length === 0)
|
|
313
|
+
return constants_1.REST_NO_DATA_SCORE;
|
|
314
|
+
// Extract rest period bounds from guardrails
|
|
315
|
+
const restConfig = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.restPeriods;
|
|
316
|
+
const optimalMin = (_b = (_a = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : ((_c = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _c !== void 0 ? _c : constants_1.FALLBACK_REST_SECS) * 0.75;
|
|
317
|
+
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 : ((_f = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _f !== void 0 ? _f : constants_1.FALLBACK_REST_SECS) * 1.25;
|
|
318
|
+
const acceptableMin = (_g = restConfig === null || restConfig === void 0 ? void 0 : restConfig.minimum) !== null && _g !== void 0 ? _g : optimalMin * 0.5;
|
|
319
|
+
const acceptableMax = (_h = restConfig === null || restConfig === void 0 ? void 0 : restConfig.maximum) !== null && _h !== void 0 ? _h : optimalMax * 2;
|
|
320
|
+
const setScores = [];
|
|
321
|
+
for (const set of setsWithRest) {
|
|
322
|
+
const rest = set.restDurationSecs; // Guaranteed non-null by filter
|
|
323
|
+
if (rest >= optimalMin && rest <= optimalMax) {
|
|
324
|
+
// Within optimal range → perfect score
|
|
325
|
+
setScores.push(100); // REST_OPTIMAL_SCORE
|
|
326
|
+
}
|
|
327
|
+
else if (rest >= acceptableMin && rest <= acceptableMax) {
|
|
328
|
+
// Within acceptable range but not optimal
|
|
329
|
+
// Score proportionally based on how close to optimal, base is 70 now
|
|
330
|
+
const baseAcceptableScore = 70;
|
|
331
|
+
let ratio;
|
|
332
|
+
if (rest < optimalMin) {
|
|
333
|
+
// Between acceptable min and optimal min
|
|
334
|
+
ratio = (rest - acceptableMin) / (optimalMin - acceptableMin);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
// Between optimal max and acceptable max
|
|
338
|
+
ratio = (acceptableMax - rest) / (acceptableMax - optimalMax);
|
|
339
|
+
}
|
|
340
|
+
setScores.push(baseAcceptableScore +
|
|
341
|
+
ratio * (100 - baseAcceptableScore));
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// Outside even the acceptable range, reduced penalty to 50
|
|
345
|
+
setScores.push(50);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Average rest scores
|
|
349
|
+
let avgScore = setScores.reduce((sum, s) => sum + s, 0) / setScores.length;
|
|
350
|
+
// Apply stressRestBonus (P1-4): exercises where proper rest matters more
|
|
351
|
+
// (e.g., heavy compounds) get a point bonus if they performed well.
|
|
352
|
+
const stressRestBonus = (_j = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.stressRestBonus) !== null && _j !== void 0 ? _j : 0;
|
|
353
|
+
if (avgScore > 60 && stressRestBonus > 0) {
|
|
354
|
+
avgScore = Math.min(100, avgScore + stressRestBonus);
|
|
355
|
+
}
|
|
356
|
+
return (0, helpers_1.clamp)(avgScore, 0, 100);
|
|
357
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TRecord, TUserMetric } from "../../types";
|
|
2
|
+
/**
|
|
3
|
+
* Calculates total volume for a set of records.
|
|
4
|
+
*
|
|
5
|
+
* - weight-reps: reps * weight
|
|
6
|
+
* - reps-only: reps * (auxWeight || 30% of bodyweight)
|
|
7
|
+
* plyometric: × 1.5 multiplier for impact/explosive demand
|
|
8
|
+
* - duration: durationSecs * 10 * (1 + auxWeight/100)
|
|
9
|
+
* continuous-duration: durationSecs only (no static-hold multiplier)
|
|
10
|
+
* stretch-mobility: 0 (no training volume)
|
|
11
|
+
* - cardio-machine: distance (m) * (1 + speed/20) * inclineBoost * resistanceBoost
|
|
12
|
+
* - cardio-free: distance (m) * (1 + speed/20)
|
|
13
|
+
* loaded-carry: × carried weight boost
|
|
14
|
+
*/
|
|
15
|
+
export declare const calculateTotalVolume: (record: TRecord[], user: TUserMetric, scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry") => number;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.calculateTotalVolume = void 0;
|
|
4
|
+
const time_util_1 = require("../time.util");
|
|
5
|
+
/**
|
|
6
|
+
* Calculates total volume for a set of records.
|
|
7
|
+
*
|
|
8
|
+
* - weight-reps: reps * weight
|
|
9
|
+
* - reps-only: reps * (auxWeight || 30% of bodyweight)
|
|
10
|
+
* plyometric: × 1.5 multiplier for impact/explosive demand
|
|
11
|
+
* - duration: durationSecs * 10 * (1 + auxWeight/100)
|
|
12
|
+
* continuous-duration: durationSecs only (no static-hold multiplier)
|
|
13
|
+
* stretch-mobility: 0 (no training volume)
|
|
14
|
+
* - cardio-machine: distance (m) * (1 + speed/20) * inclineBoost * resistanceBoost
|
|
15
|
+
* - cardio-free: distance (m) * (1 + speed/20)
|
|
16
|
+
* loaded-carry: × carried weight boost
|
|
17
|
+
*/
|
|
18
|
+
const calculateTotalVolume = (record, user, scoringSpecialHandling) => {
|
|
19
|
+
// Stretching/mobility produces no training volume
|
|
20
|
+
if (scoringSpecialHandling === "stretch-mobility")
|
|
21
|
+
return 0;
|
|
22
|
+
return record.reduce((total, set) => {
|
|
23
|
+
const weight = parseFloat(set.type === "weight-reps"
|
|
24
|
+
? set.kg
|
|
25
|
+
: set.type === "duration" || set.type === "reps-only" || set.type === "cardio-free"
|
|
26
|
+
? set.auxWeightKg || "0"
|
|
27
|
+
: "0") || 0;
|
|
28
|
+
const reps = parseFloat(set.type === "weight-reps" || set.type === "reps-only" ? set.reps : "0") || 0;
|
|
29
|
+
const duration = set.type === "duration" ||
|
|
30
|
+
set.type === "cardio-machine" ||
|
|
31
|
+
set.type === "cardio-free"
|
|
32
|
+
? (0, time_util_1.mmssToSecs)(set.durationMmSs)
|
|
33
|
+
: 0;
|
|
34
|
+
if (set.type === "weight-reps") {
|
|
35
|
+
return total + reps * weight;
|
|
36
|
+
}
|
|
37
|
+
else if (set.type === "reps-only") {
|
|
38
|
+
const bodyweight = user.weightKg || 70;
|
|
39
|
+
const effectiveWeight = weight > 0 ? weight : bodyweight * 0.3;
|
|
40
|
+
const baseVolume = reps * effectiveWeight;
|
|
41
|
+
return total + (scoringSpecialHandling === "plyometric" ? baseVolume * 1.5 : baseVolume);
|
|
42
|
+
}
|
|
43
|
+
else if (set.type === "duration") {
|
|
44
|
+
// Continuous-duration: no static-hold multiplier — just time elapsed
|
|
45
|
+
if (scoringSpecialHandling === "continuous-duration") {
|
|
46
|
+
return total + duration;
|
|
47
|
+
}
|
|
48
|
+
const weightFactor = weight > 0 ? 1 + weight / 100 : 1;
|
|
49
|
+
return total + duration * 10 * weightFactor;
|
|
50
|
+
}
|
|
51
|
+
else if (set.type === "cardio-machine") {
|
|
52
|
+
const speed = parseFloat(set.speed || "10");
|
|
53
|
+
const inclineBoost = set.inclinePercentage
|
|
54
|
+
? 1 + parseFloat(set.inclinePercentage) * 0.02
|
|
55
|
+
: 1;
|
|
56
|
+
const resistanceBoost = set.resistanceLevel
|
|
57
|
+
? 1 + parseFloat(set.resistanceLevel) * 0.05
|
|
58
|
+
: 1;
|
|
59
|
+
const distance = parseFloat(set.distance || "0") || (speed * duration) / 3600;
|
|
60
|
+
return total + distance * 1000 * (1 + speed / 20) * inclineBoost * resistanceBoost;
|
|
61
|
+
}
|
|
62
|
+
else if (set.type === "cardio-free") {
|
|
63
|
+
const distance = parseFloat(set.distance) || 0;
|
|
64
|
+
const speed = distance / (duration / 3600);
|
|
65
|
+
const carryBoost = scoringSpecialHandling === "loaded-carry" && weight > 0
|
|
66
|
+
? 1 + weight / (user.weightKg || 70) * 0.5
|
|
67
|
+
: 1;
|
|
68
|
+
return total + distance * 1000 * (1 + speed / 20) * carryBoost;
|
|
69
|
+
}
|
|
70
|
+
return total;
|
|
71
|
+
}, 0);
|
|
72
|
+
};
|
|
73
|
+
exports.calculateTotalVolume = calculateTotalVolume;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX EXERCISE SCORING SYSTEM — Constants
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* All magic numbers, default values, and tuning parameters live here.
|
|
7
|
+
* When we need to adjust scoring behavior, this is the ONLY file to change.
|
|
8
|
+
*
|
|
9
|
+
* Naming convention:
|
|
10
|
+
* DEFAULT_* = user-related fallbacks (weight, height, age)
|
|
11
|
+
* FALLBACK_* = exercise timing fallbacks (only when timingGuardrails is missing)
|
|
12
|
+
* ABSOLUTE_* = hard safety bounds (catches truly broken data)
|
|
13
|
+
* QUALITY_* = quality score weights and thresholds
|
|
14
|
+
* FACTOR_* = multipliers used in formulas
|
|
15
|
+
*/
|
|
16
|
+
/** Average adult weight when user hasn't provided theirs */
|
|
17
|
+
export declare const DEFAULT_USER_WEIGHT_KG = 70;
|
|
18
|
+
/** Average adult height */
|
|
19
|
+
export declare const DEFAULT_USER_HEIGHT_CM = 170;
|
|
20
|
+
/** Assumed age when DOB is missing */
|
|
21
|
+
export declare const DEFAULT_USER_AGE = 30;
|
|
22
|
+
/** Seconds per rep when timingGuardrails.singleRep is missing entirely */
|
|
23
|
+
export declare const FALLBACK_SECS_PER_REP = 3;
|
|
24
|
+
/** Set duration when timingGuardrails.setDuration is missing entirely */
|
|
25
|
+
export declare const FALLBACK_SET_DURATION_SECS = 30;
|
|
26
|
+
/** Rest period when timingGuardrails.restPeriods is missing entirely */
|
|
27
|
+
export declare const FALLBACK_REST_SECS = 90;
|
|
28
|
+
/** Fatigue multiplier when timingGuardrails.fatigueMultiplier is missing */
|
|
29
|
+
export declare const FALLBACK_FATIGUE_MULTIPLIER = 1;
|
|
30
|
+
/** Stress rest bonus when timingGuardrails.stressRestBonus is missing */
|
|
31
|
+
export declare const FALLBACK_STRESS_REST_BONUS = 1;
|
|
32
|
+
/**
|
|
33
|
+
* Absolute sanity bounds — these catch truly absurd sensor/input errors
|
|
34
|
+
* that even the exercise's own guardrails wouldn't expect.
|
|
35
|
+
* These are NOT the exercise's min/max; they're a safety net.
|
|
36
|
+
*
|
|
37
|
+
* Example: a restDurationSecs of 86400 (24 hours) is clearly a bug,
|
|
38
|
+
* regardless of what the exercise's guardrails say.
|
|
39
|
+
*/
|
|
40
|
+
export declare const ABSOLUTE_REST_MIN = 0;
|
|
41
|
+
export declare const ABSOLUTE_REST_MAX = 3600;
|
|
42
|
+
export declare const ABSOLUTE_WORK_MIN = 1;
|
|
43
|
+
export declare const ABSOLUTE_WORK_MAX = 14400;
|
|
44
|
+
/**
|
|
45
|
+
* Effort fraction when neither RPE nor RIR is provided.
|
|
46
|
+
* 0.6 = moderate effort assumption.
|
|
47
|
+
*/
|
|
48
|
+
export declare const DEFAULT_EFFORT_FRACTION = 0.6;
|
|
49
|
+
/**
|
|
50
|
+
* Exponent for non-linear RPE/RIR → effort conversion.
|
|
51
|
+
* Values > 1.0 make the last reps near failure disproportionately harder,
|
|
52
|
+
* which matches real physiology (RPE 9→10 is a bigger jump than 5→6).
|
|
53
|
+
*/
|
|
54
|
+
export declare const EFFORT_CURVE_EXPONENT = 1.3;
|
|
55
|
+
/**
|
|
56
|
+
* Effort fraction output range.
|
|
57
|
+
* effort = BASE + RANGE × (normalized_input ^ EXPONENT)
|
|
58
|
+
*/
|
|
59
|
+
export declare const EFFORT_FRACTION_BASE = 0.5;
|
|
60
|
+
export declare const EFFORT_FRACTION_RANGE = 0.8;
|
|
61
|
+
/**
|
|
62
|
+
* MET value during rest periods.
|
|
63
|
+
* Not true resting (1.0) because the body is still elevated after a set.
|
|
64
|
+
* 1.5 accounts for elevated heart rate and recovery metabolism.
|
|
65
|
+
*/
|
|
66
|
+
export declare const REST_MET = 1.5;
|
|
67
|
+
/**
|
|
68
|
+
* Default weight intensity multipliers when metabolicData.weightFactors
|
|
69
|
+
* is missing. Keyed by intensity category.
|
|
70
|
+
*/
|
|
71
|
+
export declare const DEFAULT_WEIGHT_FACTORS: {
|
|
72
|
+
light: number;
|
|
73
|
+
moderate: number;
|
|
74
|
+
heavy: number;
|
|
75
|
+
};
|
|
76
|
+
/** Thresholds for weight category classification (as ratio of bodyweight) */
|
|
77
|
+
export declare const WEIGHT_CATEGORY_THRESHOLDS: {
|
|
78
|
+
lightMax: number;
|
|
79
|
+
moderateMax: number;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Default duration multipliers when metabolicData.durationFactors is missing.
|
|
83
|
+
*/
|
|
84
|
+
export declare const DEFAULT_DURATION_FACTORS: {
|
|
85
|
+
short: number;
|
|
86
|
+
medium: number;
|
|
87
|
+
long: number;
|
|
88
|
+
};
|
|
89
|
+
/** Thresholds for duration category classification (in seconds) */
|
|
90
|
+
export declare const DURATION_CATEGORY_THRESHOLDS: {
|
|
91
|
+
shortMax: number;
|
|
92
|
+
mediumMax: number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* For reps-only / bodyweight exercises: how much of bodyweight is "the load".
|
|
96
|
+
* Scaled by exercise difficultyLevel (0–4):
|
|
97
|
+
* load = (difficultyLevel / 4) × BW_FRACTION_SCALE × userWeightKg
|
|
98
|
+
*
|
|
99
|
+
* difficulty 0 → 0.0 × 0.65 = 0% (basically no BW load, e.g. finger exercise)
|
|
100
|
+
* difficulty 1 → 0.25 × 0.65 = 16% (light BW, e.g. easy crunches)
|
|
101
|
+
* difficulty 2 → 0.50 × 0.65 = 33% (moderate, e.g. push-ups)
|
|
102
|
+
* difficulty 4 → 1.00 × 0.65 = 65% (heavy, e.g. pistol squats)
|
|
103
|
+
*/
|
|
104
|
+
export declare const BW_FRACTION_SCALE = 0.65;
|
|
105
|
+
/**
|
|
106
|
+
* Expected min/max speed range for cardio (km/h) when paceFactors are missing.
|
|
107
|
+
* Used to normalize speed into a 0–1 fraction for MET interpolation.
|
|
108
|
+
*/
|
|
109
|
+
export declare const CARDIO_SPEED_RANGE: {
|
|
110
|
+
min: number;
|
|
111
|
+
max: number;
|
|
112
|
+
};
|
|
113
|
+
/** Fraction of total fatigue stimulus allocated to primary muscles */
|
|
114
|
+
export declare const PRIMARY_MUSCLE_ALLOCATION = 1;
|
|
115
|
+
/** Fraction of total fatigue stimulus allocated to secondary muscles */
|
|
116
|
+
export declare const SECONDARY_MUSCLE_ALLOCATION = 0.35;
|
|
117
|
+
/**
|
|
118
|
+
* Diminishing returns decay rate for consecutive sets.
|
|
119
|
+
* setDecay = 1 / (1 + DECAY_RATE × setIndex)
|
|
120
|
+
*
|
|
121
|
+
* With 0.15: set0=1.00, set1=0.87, set2=0.77, set3=0.69, set4=0.63
|
|
122
|
+
*/
|
|
123
|
+
export declare const SET_FATIGUE_DECAY_RATE = 0.15;
|
|
124
|
+
/**
|
|
125
|
+
* Cardio exercises create cardiovascular fatigue, not the same mechanical
|
|
126
|
+
* muscle fatigue as strength training. A 30-minute jog doesn't fatigue
|
|
127
|
+
* your quads the way 5 sets of heavy squats does.
|
|
128
|
+
*
|
|
129
|
+
* This dampener reduces the volume-load contribution of cardio exercises
|
|
130
|
+
* so their muscle fatigue scores are proportional to actual muscle damage/stress.
|
|
131
|
+
*
|
|
132
|
+
* 0.3 means cardio produces ~30% of the muscle fatigue that an equivalent
|
|
133
|
+
* "volume" of strength work would.
|
|
134
|
+
*/
|
|
135
|
+
export declare const CARDIO_MUSCLE_FATIGUE_DAMPENER = 0.3;
|
|
136
|
+
/**
|
|
137
|
+
* Load multiplier for plyometric / explosive reps-only exercises.
|
|
138
|
+
* Accounts for impact forces (3–5× BW) and neuromotor demand that
|
|
139
|
+
* simple BW × reps volume underestimates.
|
|
140
|
+
* Applied to volume load before fatigue distribution.
|
|
141
|
+
*/
|
|
142
|
+
export declare const PLYOMETRIC_LOAD_MULTIPLIER = 1.5;
|
|
143
|
+
/**
|
|
144
|
+
* Intensity factor for continuous-movement duration exercises
|
|
145
|
+
* (battle ropes, high knees, jump rope) when no speed/distance is available.
|
|
146
|
+
* Represents moderate continuous effort — higher than isometric holds but
|
|
147
|
+
* routed through the cardio dampener like other cardio types.
|
|
148
|
+
*/
|
|
149
|
+
export declare const CONTINUOUS_DURATION_INTENSITY_FACTOR = 0.5;
|
|
150
|
+
/**
|
|
151
|
+
* MET scale factor for loaded-carry exercises (farmer's walk, overhead carry).
|
|
152
|
+
* For every 100% of bodyweight carried, MET is boosted by this fraction.
|
|
153
|
+
* e.g. carrying 50% BW → MET × (1 + 0.5 × 0.5) = MET × 1.25
|
|
154
|
+
*/
|
|
155
|
+
export declare const LOADED_CARRY_WEIGHT_MET_SCALE = 0.5;
|
|
156
|
+
/**
|
|
157
|
+
* Reference max scaling by difficulty level.
|
|
158
|
+
* Maps difficultyLevel (0–4) → expected max weight as a fraction of bodyweight.
|
|
159
|
+
*
|
|
160
|
+
* This makes fatigue normalization exercise-aware:
|
|
161
|
+
* - Bicep curl (difficulty 1) → ref weight ~0.6× BW → smaller denominator → fair score
|
|
162
|
+
* - Squat (difficulty 3) → ref weight ~1.2× BW → larger denominator → fair score
|
|
163
|
+
*/
|
|
164
|
+
export declare const DIFFICULTY_TO_WEIGHT_FRACTION: (difficulty: number) => number;
|
|
165
|
+
/** Reference set count for "fully fatigued" benchmark */
|
|
166
|
+
export declare const REFERENCE_MAX_SETS = 5;
|
|
167
|
+
/** Reference rep count for "fully fatigued" benchmark */
|
|
168
|
+
export declare const REFERENCE_MAX_REPS = 12;
|
|
169
|
+
/** Reference effort multiplier for "fully fatigued" benchmark */
|
|
170
|
+
export declare const REFERENCE_MAX_EFFORT = 1.3;
|
|
171
|
+
/**
|
|
172
|
+
* Weights for the four quality sub-components.
|
|
173
|
+
* Must sum to 1.0.
|
|
174
|
+
*/
|
|
175
|
+
export declare const QUALITY_WEIGHTS: {
|
|
176
|
+
completion: number;
|
|
177
|
+
consistency: number;
|
|
178
|
+
effortAdequacy: number;
|
|
179
|
+
restDiscipline: number;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Consistency scoring: how aggressively CV (coefficient of variation) is penalized.
|
|
183
|
+
* consistencyScore = 100 - CV × PENALTY
|
|
184
|
+
* CV 0.0 → 100, CV 0.15 → 70, CV 0.50 → 0
|
|
185
|
+
*/
|
|
186
|
+
export declare const CONSISTENCY_CV_PENALTY = 200;
|
|
187
|
+
/** Minimum consistency score (even very inconsistent sets get some credit) */
|
|
188
|
+
export declare const CONSISTENCY_MIN_SCORE = 20;
|
|
189
|
+
/** Floor score for intentional progressive/descending sets (≥3 sets) */
|
|
190
|
+
export declare const PROGRESSIVE_OVERLOAD_FLOOR = 75;
|
|
191
|
+
/**
|
|
192
|
+
* Optimal RIR range for effort adequacy scoring.
|
|
193
|
+
* Within this range → 100 points. Outside → penalized by distance.
|
|
194
|
+
*/
|
|
195
|
+
export declare const OPTIMAL_RIR_RANGE: [number, number];
|
|
196
|
+
/** Optimal RPE range for effort adequacy scoring. */
|
|
197
|
+
export declare const OPTIMAL_RPE_RANGE: [number, number];
|
|
198
|
+
/** Penalty per unit of distance from the optimal effort range */
|
|
199
|
+
export declare const EFFORT_DISTANCE_PENALTY = 20;
|
|
200
|
+
/** Minimum effort adequacy score */
|
|
201
|
+
export declare const EFFORT_MIN_SCORE = 20;
|
|
202
|
+
/** Default effort adequacy score when no RPE/RIR data is available */
|
|
203
|
+
export declare const EFFORT_NO_DATA_SCORE = 70;
|
|
204
|
+
/** Score when rest is within the optimal range */
|
|
205
|
+
export declare const REST_OPTIMAL_SCORE = 100;
|
|
206
|
+
/** Score when rest is within acceptable (min–max) but not optimal */
|
|
207
|
+
export declare const REST_ACCEPTABLE_BASE = 70;
|
|
208
|
+
/** Score when rest is completely outside the acceptable range */
|
|
209
|
+
export declare const REST_OUTSIDE_SCORE = 50;
|
|
210
|
+
/** Default rest discipline score when no valid rest data exists */
|
|
211
|
+
export declare const REST_NO_DATA_SCORE = 75;
|