@dgpholdings/greatoak-shared 1.1.60 → 1.1.62
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/types/TApiRecord.d.ts +50 -32
- package/dist/types/TApiTemplateData.d.ts +1 -1
- package/dist/types/TApiUserData.d.ts +16 -16
- package/dist/types/commonTypes.d.ts +3 -3
- package/dist/utils/index.d.ts +1 -6
- package/dist/utils/index.js +2 -7
- package/dist/utils/record.utils.d.ts +33 -28
- package/dist/utils/record.utils.js +142 -41
- package/dist/utils/scoring.utils.d.ts +12 -36
- package/dist/utils/scoring.utils.js +243 -475
- package/dist/utils/workoutSummary.util.js +362 -294
- package/package.json +1 -1
|
@@ -1,508 +1,276 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* GIST: Advanced Exercise Scoring Algorithm
|
|
2
|
+
/*
|
|
3
|
+
* Exercise Scoring System Documentation
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* OVERVIEW:
|
|
6
|
+
* Evaluates workout performance (0-100) with overall score and muscle fatigue ratings.
|
|
7
|
+
* Designed to motivate users by quantifying effort and tracking muscle engagement.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
9
|
+
* SCORE COMPOSITION:
|
|
10
|
+
* - Volume (35%): Total work (weight×reps, duration×difficulty, distance×speed)
|
|
11
|
+
* - Intensity (35%): Effort level (relative weight, rep ranges, speed, RPE when provided)
|
|
12
|
+
* - Quality (30%): Set consistency and performance maintenance
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
14
|
+
* SCORE INTERPRETATION:
|
|
15
|
+
* 90-100: Exceptional - peak performance with high volume/intensity/consistency
|
|
16
|
+
* 75-89: Excellent - strong effort with good form and progression
|
|
17
|
+
* 60-74: Good - solid baseline performance, room for improvement
|
|
18
|
+
* 45-59: Moderate - acceptable but consider increasing intensity/volume
|
|
19
|
+
* 0-44: Light - warm-up sets or recovery day
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
* -
|
|
21
|
+
* MUSCLE FATIGUE (0-100):
|
|
22
|
+
* Primary muscles get 70% fatigue impact, secondary muscles get 30%
|
|
23
|
+
* 80-100: Fully fatigued - heavily worked, needs recovery
|
|
24
|
+
* 60-79: High fatigue - significant work, approaching limit
|
|
25
|
+
* 40-59: Moderate fatigue - good activation, can handle more
|
|
26
|
+
* 20-39: Light fatigue - warmed up, plenty capacity remaining
|
|
27
|
+
* 0-19: Minimal fatigue - barely engaged
|
|
24
28
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
* -
|
|
29
|
-
* -
|
|
29
|
+
* QUALITY SCORE EXPLAINED:
|
|
30
|
+
* Measures how well performance is maintained across sets (starts at 85 points)
|
|
31
|
+
* - Performance drop <10% between sets: +5 points
|
|
32
|
+
* - Performance drop 10-20%: +2 points
|
|
33
|
+
* - Performance drop >30%: -10 points
|
|
34
|
+
* - Consistent RIR/RPE between sets: +3 points
|
|
35
|
+
* - Completing 3+ sets: +5 points, 4+ sets: +8 total points
|
|
30
36
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
37
|
+
* KEY FACTORS:
|
|
38
|
+
* - User fitness level (higher level = higher expectations)
|
|
39
|
+
* - Exercise difficulty (harder exercises boost score)
|
|
40
|
+
* - RPE (1-10): When provided, adjusts intensity (higher RPE = higher intensity)
|
|
41
|
+
* - RIR (0+): When provided, checks consistency between sets
|
|
42
|
+
* - Fatigue multiplier (later sets get 8% bonus per set for accumulated fatigue)
|
|
36
43
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
else {
|
|
47
|
-
bmr = 10 * weight + 6.25 * height - 5 * age + 5;
|
|
48
|
-
}
|
|
49
|
-
// Adjust for body composition if available
|
|
50
|
-
if (userProfile.bodyFatPercentage) {
|
|
51
|
-
const leanBodyMass = weight * (1 - userProfile.bodyFatPercentage / 100);
|
|
52
|
-
const lbmAdjustment = leanBodyMass * 0.56;
|
|
53
|
-
bmr += lbmAdjustment;
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.calculateExerciseScore = void 0;
|
|
46
|
+
const time_util_1 = require("./time.util");
|
|
47
|
+
const calculateExerciseScore = (param) => {
|
|
48
|
+
const { exercise, record, user } = param;
|
|
49
|
+
// Filter only completed sets
|
|
50
|
+
const completedSets = record.filter((r) => r.isDone);
|
|
51
|
+
if (completedSets.length === 0) {
|
|
52
|
+
return { score: 0, muscleScores: {} };
|
|
54
53
|
}
|
|
55
|
-
|
|
54
|
+
// Calculate base performance metrics
|
|
55
|
+
const totalVolume = calculateTotalVolume(completedSets, user);
|
|
56
|
+
const avgIntensity = calculateAverageIntensity(completedSets, exercise, user);
|
|
57
|
+
const setQuality = calculateSetQuality(completedSets);
|
|
58
|
+
// Calculate muscle fatigue scores
|
|
59
|
+
const muscleScores = calculateMuscleScores(exercise, completedSets, totalVolume);
|
|
60
|
+
// Final score composition
|
|
61
|
+
const volumeComponent = normalizeVolume(totalVolume, exercise, user) * 0.35;
|
|
62
|
+
const intensityComponent = avgIntensity * 0.35;
|
|
63
|
+
const qualityComponent = setQuality * 0.3;
|
|
64
|
+
const score = Math.round(volumeComponent + intensityComponent + qualityComponent);
|
|
65
|
+
return {
|
|
66
|
+
score: Math.min(100, Math.max(0, score)),
|
|
67
|
+
muscleScores,
|
|
68
|
+
};
|
|
56
69
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
switch (record.type) {
|
|
74
|
-
case "weight-reps": {
|
|
75
|
-
if (metabolicData.weightFactors) {
|
|
76
|
-
const weightRatio = record.kg / userWeight;
|
|
77
|
-
let weightMultiplier = metabolicData.weightFactors.moderateWeight;
|
|
78
|
-
if (weightRatio < 0.5) {
|
|
79
|
-
weightMultiplier = metabolicData.weightFactors.lightWeight;
|
|
80
|
-
}
|
|
81
|
-
else if (weightRatio > 1.0) {
|
|
82
|
-
weightMultiplier = metabolicData.weightFactors.heavyWeight;
|
|
83
|
-
}
|
|
84
|
-
calculatedMET *= weightMultiplier;
|
|
85
|
-
}
|
|
86
|
-
// Apply rep-based intensity scaling
|
|
87
|
-
const repAdjustment = getRepIntensityAdjustment(record.reps, metabolicData.intensityScaling);
|
|
88
|
-
calculatedMET *= repAdjustment;
|
|
89
|
-
break;
|
|
70
|
+
exports.calculateExerciseScore = calculateExerciseScore;
|
|
71
|
+
const calculateTotalVolume = (record, user) => {
|
|
72
|
+
return record.reduce((total, set) => {
|
|
73
|
+
const weight = parseFloat(set.type === "weight-reps"
|
|
74
|
+
? set.kg
|
|
75
|
+
: set.type === "duration" || set.type === "reps-only"
|
|
76
|
+
? set.auxWeightKg
|
|
77
|
+
: "0") || 0;
|
|
78
|
+
const reps = parseFloat(set.type === "weight-reps" || set.type === "reps-only" ? set.reps : "0") || 0;
|
|
79
|
+
const duration = set.type === "duration" ||
|
|
80
|
+
set.type === "cardio-machine" ||
|
|
81
|
+
set.type === "cardio-free"
|
|
82
|
+
? (0, time_util_1.mmssToSecs)(set.durationMmSs)
|
|
83
|
+
: 0;
|
|
84
|
+
if (set.type === "weight-reps") {
|
|
85
|
+
return total + reps * weight;
|
|
90
86
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
// Bodyweight exercises scale with user weight efficiency
|
|
96
|
-
if (userWeight > 80) {
|
|
97
|
-
calculatedMET *= 1.1; // Heavier people work harder
|
|
98
|
-
}
|
|
99
|
-
else if (userWeight < 60) {
|
|
100
|
-
calculatedMET *= 0.95; // Lighter people have slight advantage
|
|
101
|
-
}
|
|
102
|
-
break;
|
|
87
|
+
else if (set.type === "reps-only") {
|
|
88
|
+
const bodyweight = user.weightKg || 70;
|
|
89
|
+
const effectiveWeight = weight > 0 ? weight : bodyweight * 0.3;
|
|
90
|
+
return total + reps * effectiveWeight;
|
|
103
91
|
}
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
let durationMultiplier = metabolicData.durationFactors.mediumDuration;
|
|
108
|
-
if (durationSecs < 30) {
|
|
109
|
-
durationMultiplier = metabolicData.durationFactors.shortDuration;
|
|
110
|
-
}
|
|
111
|
-
else if (durationSecs > 120) {
|
|
112
|
-
durationMultiplier = metabolicData.durationFactors.longDuration;
|
|
113
|
-
}
|
|
114
|
-
calculatedMET *= durationMultiplier;
|
|
115
|
-
}
|
|
116
|
-
break;
|
|
92
|
+
else if (set.type === "duration") {
|
|
93
|
+
const weightFactor = weight > 0 ? 1 + weight / 100 : 1;
|
|
94
|
+
return total + duration * 10 * weightFactor;
|
|
117
95
|
}
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
// Calculate intensity from speed and resistance combination
|
|
123
|
-
const intensityFactor = (speed * resistance) / 100; // Normalize intensity
|
|
124
|
-
// Apply intensity multiplier to base MET
|
|
125
|
-
const intensityMultiplier = 0.8 + intensityFactor * 0.4; // 0.8 to 1.2x range
|
|
126
|
-
calculatedMET *= Math.min(2.0, intensityMultiplier); // Cap at 2x
|
|
127
|
-
// Duration bonus for sustained cardio
|
|
128
|
-
if (durationSecs > 600) {
|
|
129
|
-
// 10+ minutes
|
|
130
|
-
calculatedMET *= 1.1; // 10% bonus for sustained effort
|
|
131
|
-
}
|
|
132
|
-
break;
|
|
96
|
+
else if (set.type === "cardio-machine") {
|
|
97
|
+
const avgSpeed = (parseFloat(set.speedMin) + parseFloat(set.speedMax)) / 2 || 10;
|
|
98
|
+
const distance = parseFloat(set.distance || "0") || (avgSpeed * duration) / 3600;
|
|
99
|
+
return total + distance * 1000 * (1 + avgSpeed / 20);
|
|
133
100
|
}
|
|
134
|
-
|
|
135
|
-
const distance =
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const speedKmh = (distance * 3600) / durationSecs;
|
|
139
|
-
// Find closest pace factor
|
|
140
|
-
let closestMET = metabolicData.baseMET;
|
|
141
|
-
let closestSpeed = Infinity;
|
|
142
|
-
const paceKeys = Object.keys(metabolicData.paceFactors);
|
|
143
|
-
for (let i = 0; i < paceKeys.length; i++) {
|
|
144
|
-
const paceStr = paceKeys[i];
|
|
145
|
-
const metVal = metabolicData.paceFactors[paceStr];
|
|
146
|
-
const pace = parseFloat(paceStr);
|
|
147
|
-
if (Math.abs(speedKmh - pace) < Math.abs(speedKmh - closestSpeed)) {
|
|
148
|
-
closestSpeed = pace;
|
|
149
|
-
closestMET = metVal;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
calculatedMET = closestMET;
|
|
153
|
-
}
|
|
154
|
-
break;
|
|
101
|
+
else if (set.type === "cardio-free") {
|
|
102
|
+
const distance = parseFloat(set.distance) || 0;
|
|
103
|
+
const speed = distance / (duration / 3600);
|
|
104
|
+
return total + distance * 1000 * (1 + speed / 20);
|
|
155
105
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const effortMultiplier = 0.7 + (effortFactor / 10) * 0.5; // 0.7 to 1.2x
|
|
159
|
-
calculatedMET *= effortMultiplier;
|
|
160
|
-
// Apply interval training bonus
|
|
161
|
-
if (record.workDurationSecs && record.restDurationSecs) {
|
|
162
|
-
const workToRestRatio = record.workDurationSecs / record.restDurationSecs;
|
|
163
|
-
const intervalBonus = 1 + Math.min(0.3, workToRestRatio * 0.08); // Up to 30% bonus
|
|
164
|
-
calculatedMET *= intervalBonus;
|
|
165
|
-
}
|
|
166
|
-
// Apply fitness level efficiency
|
|
167
|
-
calculatedMET *= efficiencyFactor;
|
|
168
|
-
// Ensure MET stays within exercise's defined range
|
|
169
|
-
const [minMET, maxMET] = metabolicData.metRange;
|
|
170
|
-
calculatedMET = Math.max(minMET, Math.min(maxMET, calculatedMET));
|
|
171
|
-
return parseFloat(calculatedMET.toFixed(1));
|
|
106
|
+
return total;
|
|
107
|
+
}, 0);
|
|
172
108
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return Math.max(0.7, 1 + repDiff * 0.04);
|
|
189
|
-
default:
|
|
190
|
-
return 1.0;
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
/**
|
|
194
|
-
* Calculate exercise duration in minutes using timing guardrails
|
|
195
|
-
*/
|
|
196
|
-
const getExerciseDurationMinutes = (record, exercise) => {
|
|
197
|
-
let durationSecs = 0;
|
|
198
|
-
const timingGuardrails = exercise.timingGuardrails;
|
|
199
|
-
// Use actual recorded work duration if available and in strict mode
|
|
200
|
-
if (record.workDurationSecs && record.isStrictMode) {
|
|
201
|
-
durationSecs = record.workDurationSecs;
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
// Calculate expected duration using timing guardrails
|
|
205
|
-
switch (record.type) {
|
|
206
|
-
case "weight-reps":
|
|
207
|
-
case "reps-only": {
|
|
208
|
-
if (timingGuardrails &&
|
|
209
|
-
(timingGuardrails.type === "weight-reps" ||
|
|
210
|
-
timingGuardrails.type === "reps-only") &&
|
|
211
|
-
timingGuardrails.singleRep) {
|
|
212
|
-
const repTime = timingGuardrails.singleRep.typical;
|
|
213
|
-
const setupTime = timingGuardrails.setupTypicalSecs || 0;
|
|
214
|
-
durationSecs = record.reps * repTime + setupTime;
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
// Fallback to difficulty-based estimation
|
|
218
|
-
const baseTimePerRep = exercise.difficultyLevel > 7 ? 3.5 : 2.5;
|
|
219
|
-
durationSecs = record.reps * baseTimePerRep;
|
|
109
|
+
const calculateAverageIntensity = (record, exercise, user) => {
|
|
110
|
+
const intensities = record.map((set, index) => {
|
|
111
|
+
let baseIntensity = 0;
|
|
112
|
+
if (set.type === "weight-reps") {
|
|
113
|
+
const weight = parseFloat(set.kg) || 0;
|
|
114
|
+
const reps = parseFloat(set.reps) || 0;
|
|
115
|
+
const userWeight = user.weightKg || 70;
|
|
116
|
+
const relativeLoad = weight / userWeight;
|
|
117
|
+
const repIntensity = getRepIntensity(reps);
|
|
118
|
+
baseIntensity = (relativeLoad * 40 + repIntensity * 60) / 100;
|
|
119
|
+
// RPE adjustment (only if provided and valid)
|
|
120
|
+
if (set.rpe && set.rpe !== "0") {
|
|
121
|
+
const rpe = parseFloat(set.rpe);
|
|
122
|
+
if (rpe > 0 && rpe <= 10) {
|
|
123
|
+
baseIntensity *= 0.5 + rpe / 20; // Scale from 0.55x to 1.0x
|
|
220
124
|
}
|
|
221
|
-
break;
|
|
222
125
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
break;
|
|
126
|
+
}
|
|
127
|
+
else if (set.type === "reps-only") {
|
|
128
|
+
const reps = parseFloat(set.reps) || 0;
|
|
129
|
+
baseIntensity = getRepIntensity(reps) / 100;
|
|
130
|
+
if (set.workDurationSecs) {
|
|
131
|
+
const tempo = (reps * 3) / set.workDurationSecs;
|
|
132
|
+
baseIntensity *= Math.min(1.2, Math.max(0.8, tempo));
|
|
231
133
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
else {
|
|
238
|
-
durationSecs = record.durationSecs;
|
|
134
|
+
// RPE adjustment for bodyweight exercises
|
|
135
|
+
if (set.rpe && set.rpe !== "0") {
|
|
136
|
+
const rpe = parseFloat(set.rpe);
|
|
137
|
+
if (rpe > 0 && rpe <= 10) {
|
|
138
|
+
baseIntensity *= 0.5 + rpe / 20;
|
|
239
139
|
}
|
|
240
|
-
break;
|
|
241
140
|
}
|
|
242
141
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
return Math.max(0.5, durationSecs / 60);
|
|
251
|
-
};
|
|
252
|
-
/**
|
|
253
|
-
* Calculate comprehensive calorie burn including EPOC
|
|
254
|
-
*/
|
|
255
|
-
const calculateCalorieBurn = (record, exercise, metValue, durationMinutes, userProfile) => {
|
|
256
|
-
var _a;
|
|
257
|
-
const userWeight = (_a = userProfile.weightKg) !== null && _a !== void 0 ? _a : 70;
|
|
258
|
-
const bmr = calculateBMR(userProfile);
|
|
259
|
-
const bmrPerMinute = bmr / (24 * 60);
|
|
260
|
-
// Exercise calories: MET × weight(kg) × duration(hours)
|
|
261
|
-
const exerciseCalories = metValue * userWeight * (durationMinutes / 60);
|
|
262
|
-
// EPOC (Excess Post-Exercise Oxygen Consumption) calories
|
|
263
|
-
const epocCalories = exerciseCalories * exercise.metabolicData.epocFactor;
|
|
264
|
-
// BMR calories during exercise time
|
|
265
|
-
const bmrCaloriesDuringExercise = bmrPerMinute * durationMinutes;
|
|
266
|
-
// Total calories = exercise + EPOC + BMR during exercise
|
|
267
|
-
const totalCalories = exerciseCalories + epocCalories + bmrCaloriesDuringExercise;
|
|
268
|
-
return {
|
|
269
|
-
exerciseCalories: parseFloat(exerciseCalories.toFixed(1)),
|
|
270
|
-
epocCalories: parseFloat(epocCalories.toFixed(1)),
|
|
271
|
-
totalCalories: parseFloat(totalCalories.toFixed(1)),
|
|
272
|
-
};
|
|
273
|
-
};
|
|
274
|
-
/**
|
|
275
|
-
* Calculate timing adherence bonus/penalty using timing guardrails
|
|
276
|
-
*/
|
|
277
|
-
const calculateTimingAdherence = (record, exercise, workingScore) => {
|
|
278
|
-
const timingGuardrails = exercise.timingGuardrails;
|
|
279
|
-
if (!timingGuardrails || !record.workDurationSecs) {
|
|
280
|
-
return { bonus: 0, penalty: 0, reason: "no_timing_data" };
|
|
281
|
-
}
|
|
282
|
-
let expectedDuration = 0;
|
|
283
|
-
let tolerance = 0;
|
|
284
|
-
switch (record.type) {
|
|
285
|
-
case "weight-reps":
|
|
286
|
-
case "reps-only": {
|
|
287
|
-
if ((timingGuardrails.type === "weight-reps" ||
|
|
288
|
-
timingGuardrails.type === "reps-only") &&
|
|
289
|
-
timingGuardrails.singleRep) {
|
|
290
|
-
const repTime = timingGuardrails.singleRep.typical;
|
|
291
|
-
const setupTime = timingGuardrails.setupTypicalSecs || 0;
|
|
292
|
-
expectedDuration = record.reps * repTime + setupTime;
|
|
293
|
-
tolerance = expectedDuration * 0.25; // 25% tolerance
|
|
142
|
+
else if (set.type === "duration") {
|
|
143
|
+
const duration = (0, time_util_1.mmssToSecs)(set.durationMmSs);
|
|
144
|
+
const weight = parseFloat(set.auxWeightKg) || 0;
|
|
145
|
+
baseIntensity = Math.min(100, duration * 1.5) / 100;
|
|
146
|
+
if (weight > 0) {
|
|
147
|
+
baseIntensity *= 1 + weight / 100;
|
|
294
148
|
}
|
|
295
|
-
break;
|
|
296
149
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
150
|
+
else if (set.type === "cardio-machine" || set.type === "cardio-free") {
|
|
151
|
+
const duration = (0, time_util_1.mmssToSecs)(set.durationMmSs);
|
|
152
|
+
if (set.type === "cardio-machine") {
|
|
153
|
+
const avgSpeed = (parseFloat(set.speedMin) + parseFloat(set.speedMax)) / 2 || 0;
|
|
154
|
+
baseIntensity = Math.min(100, avgSpeed * 5.5) / 100;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const distance = parseFloat(set.distance) || 0;
|
|
158
|
+
const speedKmh = duration > 0 ? distance / (duration / 3600) : 0;
|
|
159
|
+
baseIntensity = Math.min(100, speedKmh * 5.5) / 100;
|
|
302
160
|
}
|
|
303
|
-
break;
|
|
304
|
-
}
|
|
305
|
-
case "cardio-machine":
|
|
306
|
-
case "cardio-free": {
|
|
307
|
-
// For cardio exercises, timing adherence is less critical
|
|
308
|
-
// Users can go longer or shorter based on their fitness level
|
|
309
|
-
return { bonus: 0, penalty: 0, reason: "cardio_flexible_timing" };
|
|
310
161
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
const actualDuration = record.workDurationSecs;
|
|
316
|
-
const deviation = Math.abs(actualDuration - expectedDuration);
|
|
317
|
-
// Strict mode gets bonus for precision
|
|
318
|
-
if (record.isStrictMode && deviation <= tolerance) {
|
|
319
|
-
const precisionBonus = workingScore * 0.05; // 5% bonus for precise timing
|
|
320
|
-
return {
|
|
321
|
-
bonus: precisionBonus,
|
|
322
|
-
penalty: 0,
|
|
323
|
-
reason: "strict_mode_precision",
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
// Penalty for significant deviation (only in strict mode)
|
|
327
|
-
if (record.isStrictMode && deviation > tolerance) {
|
|
328
|
-
const deviationPercent = (deviation - tolerance) / expectedDuration;
|
|
329
|
-
const timingPenalty = Math.min(workingScore * 0.08, // Max 8% penalty
|
|
330
|
-
deviationPercent * workingScore * 0.03);
|
|
331
|
-
return { bonus: 0, penalty: timingPenalty, reason: "timing_deviation" };
|
|
332
|
-
}
|
|
333
|
-
return { bonus: 0, penalty: 0, reason: "relaxed_mode" };
|
|
334
|
-
};
|
|
335
|
-
/**
|
|
336
|
-
* Enhanced training stress score computation using metabolic data and timing guardrails
|
|
337
|
-
* This is for scoring a SINGLE record of an exercise. Ref: TRecord, TRefinedRecord.
|
|
338
|
-
* To calculate complete scoring/summary of workout use: calculateWorkoutSummary(...) function
|
|
339
|
-
*/
|
|
340
|
-
const computeScoreFromRecord = ({ avgRestDurationSecs, record, exercise, userProfile, isTimeIntervalModeEnabled = false, }) => {
|
|
341
|
-
var _a;
|
|
342
|
-
if (!record.isDone) {
|
|
343
|
-
return {
|
|
344
|
-
baseScore: 0,
|
|
345
|
-
plus: [],
|
|
346
|
-
minus: [],
|
|
347
|
-
finalScore: 0,
|
|
348
|
-
caloriesBurned: 0,
|
|
349
|
-
metValue: 0,
|
|
350
|
-
epocCalories: 0,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
const plus = [];
|
|
354
|
-
const minus = [];
|
|
355
|
-
// Calculate effort factor from RPE/RIR
|
|
356
|
-
let effortFactor = 5; // Default moderate effort
|
|
357
|
-
if (typeof record.rpe === "number") {
|
|
358
|
-
effortFactor = Math.max(1, Math.min(10, record.rpe));
|
|
359
|
-
}
|
|
360
|
-
else if ("rir" in record && typeof record.rir === "number") {
|
|
361
|
-
effortFactor = Math.max(1, Math.min(10, 10 - record.rir));
|
|
362
|
-
}
|
|
363
|
-
// Calculate precise MET value using exercise metabolic data
|
|
364
|
-
const metValue = calculateMETValue(record, exercise, effortFactor, userProfile || {});
|
|
365
|
-
// Calculate exercise duration using timing guardrails
|
|
366
|
-
const durationMinutes = getExerciseDurationMinutes(record, exercise);
|
|
367
|
-
// Calculate comprehensive calorie burn
|
|
368
|
-
const calorieData = calculateCalorieBurn(record, exercise, metValue, durationMinutes, userProfile || {});
|
|
369
|
-
// Base score from calorie burn (more accurate than arbitrary load calculations)
|
|
370
|
-
const calorieBasedLoad = calorieData.exerciseCalories + calorieData.epocCalories * 2; // EPOC counts double
|
|
371
|
-
const base = Math.log10(1 + Math.max(5, calorieBasedLoad));
|
|
372
|
-
let workingScore = base;
|
|
373
|
-
// Exercise difficulty bonus (from database)
|
|
374
|
-
const difficultyMultiplier = 0.8 + (exercise.difficultyLevel / 10) * 0.4; // 0.8 to 1.2x
|
|
375
|
-
workingScore *= difficultyMultiplier;
|
|
376
|
-
plus.push({
|
|
377
|
-
exerciseDifficulty: parseFloat((difficultyMultiplier - 1).toFixed(3)),
|
|
162
|
+
// Apply fatigue factor for later sets (8% bonus per set)
|
|
163
|
+
const fatigueMultiplier = 1 + index * 0.08;
|
|
164
|
+
return Math.min(100, baseIntensity * fatigueMultiplier * 100);
|
|
378
165
|
});
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const avgLevel = (exercise.strengthGainLevel +
|
|
404
|
-
exercise.hypertrophyLevel +
|
|
405
|
-
exercise.enduranceLevel +
|
|
406
|
-
exercise.flexibilityLevel) /
|
|
407
|
-
4;
|
|
408
|
-
goalMultiplier = 1 + (avgLevel / 10) * 0.1;
|
|
409
|
-
break;
|
|
166
|
+
const avgIntensity = intensities.reduce((a, b) => a + b, 0) / intensities.length;
|
|
167
|
+
const difficultyBonus = exercise.difficultyLevel * 5;
|
|
168
|
+
return Math.min(100, avgIntensity + difficultyBonus);
|
|
169
|
+
};
|
|
170
|
+
const calculateSetQuality = (record) => {
|
|
171
|
+
if (record.length === 0)
|
|
172
|
+
return 0;
|
|
173
|
+
if (record.length === 1)
|
|
174
|
+
return 75;
|
|
175
|
+
let qualityScore = 85; // Base score
|
|
176
|
+
// Check performance drop-off between sets
|
|
177
|
+
for (let i = 1; i < record.length; i++) {
|
|
178
|
+
const curr = record[i];
|
|
179
|
+
const prev = record[i - 1];
|
|
180
|
+
if (curr.type === "weight-reps" && prev.type === "weight-reps") {
|
|
181
|
+
const currWork = parseFloat(curr.reps) * parseFloat(curr.kg);
|
|
182
|
+
const prevWork = parseFloat(prev.reps) * parseFloat(prev.kg);
|
|
183
|
+
const dropOff = prevWork > 0 ? 1 - currWork / prevWork : 0;
|
|
184
|
+
if (dropOff < 0.1)
|
|
185
|
+
qualityScore += 5; // Excellent maintenance
|
|
186
|
+
else if (dropOff < 0.2)
|
|
187
|
+
qualityScore += 2; // Good maintenance
|
|
188
|
+
else if (dropOff > 0.3)
|
|
189
|
+
qualityScore -= 10; // Poor pacing
|
|
410
190
|
}
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
191
|
+
else if (curr.type === "reps-only" && prev.type === "reps-only") {
|
|
192
|
+
const currReps = parseFloat(curr.reps);
|
|
193
|
+
const prevReps = parseFloat(prev.reps);
|
|
194
|
+
const dropOff = prevReps > 0 ? 1 - currReps / prevReps : 0;
|
|
195
|
+
if (dropOff < 0.15)
|
|
196
|
+
qualityScore += 5;
|
|
197
|
+
else if (dropOff > 0.3)
|
|
198
|
+
qualityScore -= 8;
|
|
414
199
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (timingAdherence.bonus > 0) {
|
|
440
|
-
workingScore += timingAdherence.bonus;
|
|
441
|
-
plus.push({
|
|
442
|
-
timingPrecision: parseFloat(timingAdherence.bonus.toFixed(3)),
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
if (timingAdherence.penalty > 0) {
|
|
446
|
-
workingScore -= timingAdherence.penalty;
|
|
447
|
-
minus.push({
|
|
448
|
-
timingDeviation: parseFloat(timingAdherence.penalty.toFixed(3)),
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
// User profile adjustments
|
|
452
|
-
let profileMultiplier = 1;
|
|
453
|
-
if ((userProfile === null || userProfile === void 0 ? void 0 : userProfile.gender) === "female") {
|
|
454
|
-
profileMultiplier *= 1.08; // Reduced from 1.12 since metabolic calculations are more accurate
|
|
455
|
-
}
|
|
456
|
-
if (userProfile === null || userProfile === void 0 ? void 0 : userProfile.age) {
|
|
457
|
-
if (userProfile.age > 50)
|
|
458
|
-
profileMultiplier *= 1.06;
|
|
459
|
-
if (userProfile.age > 65)
|
|
460
|
-
profileMultiplier *= 1.1;
|
|
461
|
-
}
|
|
462
|
-
if ((userProfile === null || userProfile === void 0 ? void 0 : userProfile.weightKg) && userProfile.weightKg > 0) {
|
|
463
|
-
const weightAdjustment = 70 / userProfile.weightKg;
|
|
464
|
-
profileMultiplier *= Math.max(0.85, Math.min(1.25, weightAdjustment));
|
|
465
|
-
}
|
|
466
|
-
if (profileMultiplier !== 1) {
|
|
467
|
-
workingScore *= profileMultiplier;
|
|
468
|
-
plus.push({
|
|
469
|
-
profileAdjustment: parseFloat((profileMultiplier - 1).toFixed(3)),
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
// Enhanced rest efficiency penalty using timing guardrails
|
|
473
|
-
if (typeof record.restDurationSecs === "number" &&
|
|
474
|
-
typeof avgRestDurationSecs === "number" &&
|
|
475
|
-
((_a = exercise.timingGuardrails) === null || _a === void 0 ? void 0 : _a.restPeriods)) {
|
|
476
|
-
const restUsed = record.restDurationSecs;
|
|
477
|
-
const restGuardrails = exercise.timingGuardrails.restPeriods;
|
|
478
|
-
const optimalRest = restGuardrails.typical;
|
|
479
|
-
const maxAcceptableRest = restGuardrails.maximum;
|
|
480
|
-
// Calculate stress-based rest bonus from guardrails
|
|
481
|
-
const stressBonus = exercise.timingGuardrails.stressRestBonus || 0;
|
|
482
|
-
const adjustedOptimalRest = optimalRest + (effortFactor > 7 ? stressBonus : 0);
|
|
483
|
-
if (restUsed > adjustedOptimalRest) {
|
|
484
|
-
const excessRest = restUsed - adjustedOptimalRest;
|
|
485
|
-
const maxExcess = maxAcceptableRest - adjustedOptimalRest;
|
|
486
|
-
if (excessRest > 0) {
|
|
487
|
-
const penaltyRatio = Math.min(1, excessRest / maxExcess);
|
|
488
|
-
const restPenalty = penaltyRatio * 0.1 * workingScore; // Up to 10% penalty
|
|
489
|
-
workingScore -= restPenalty;
|
|
490
|
-
minus.push({ excessRestPenalty: parseFloat(restPenalty.toFixed(3)) });
|
|
200
|
+
// Check effort consistency using either RIR or RPE (whichever is provided)
|
|
201
|
+
// RIR consistency check
|
|
202
|
+
if ("rir" in curr &&
|
|
203
|
+
"rir" in prev &&
|
|
204
|
+
curr.rir &&
|
|
205
|
+
prev.rir &&
|
|
206
|
+
curr.rir !== "0" &&
|
|
207
|
+
prev.rir !== "0") {
|
|
208
|
+
const currRir = parseFloat(curr.rir);
|
|
209
|
+
const prevRir = parseFloat(prev.rir);
|
|
210
|
+
if (!isNaN(currRir) && !isNaN(prevRir)) {
|
|
211
|
+
const rirDiff = Math.abs(currRir - prevRir);
|
|
212
|
+
if (rirDiff <= 1)
|
|
213
|
+
qualityScore += 3; // Consistent effort
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// RPE consistency check (alternative to RIR)
|
|
217
|
+
else if (curr.rpe && prev.rpe && curr.rpe !== "0" && prev.rpe !== "0") {
|
|
218
|
+
const currRpe = parseFloat(curr.rpe);
|
|
219
|
+
const prevRpe = parseFloat(prev.rpe);
|
|
220
|
+
if (!isNaN(currRpe) && !isNaN(prevRpe)) {
|
|
221
|
+
const rpeDiff = Math.abs(currRpe - prevRpe);
|
|
222
|
+
if (rpeDiff <= 1)
|
|
223
|
+
qualityScore += 3; // Consistent effort
|
|
491
224
|
}
|
|
492
225
|
}
|
|
493
226
|
}
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
227
|
+
// Bonus for multiple quality sets
|
|
228
|
+
if (record.length >= 3)
|
|
229
|
+
qualityScore += 5;
|
|
230
|
+
if (record.length >= 4)
|
|
231
|
+
qualityScore += 3;
|
|
232
|
+
return Math.min(100, Math.max(0, qualityScore));
|
|
233
|
+
};
|
|
234
|
+
const calculateMuscleScores = (exercise, record, totalVolume) => {
|
|
235
|
+
const muscleScores = {};
|
|
236
|
+
const volumePerSet = totalVolume / record.length;
|
|
237
|
+
// Primary muscles take 70% of the fatigue
|
|
238
|
+
exercise.primaryMuscles.forEach((muscle) => {
|
|
239
|
+
const baseFatigue = Math.min(100, (volumePerSet / 500) * 70);
|
|
240
|
+
const setMultiplier = Math.min(1.5, 1 + (record.length - 1) * 0.15);
|
|
241
|
+
muscleScores[muscle] = Math.min(100, baseFatigue * setMultiplier);
|
|
242
|
+
});
|
|
243
|
+
// Secondary muscles take 30% of the fatigue
|
|
244
|
+
exercise.secondaryMuscles.forEach((muscle) => {
|
|
245
|
+
const baseFatigue = Math.min(100, (volumePerSet / 500) * 30);
|
|
246
|
+
const setMultiplier = Math.min(1.3, 1 + (record.length - 1) * 0.1);
|
|
247
|
+
muscleScores[muscle] = Math.min(100, baseFatigue * setMultiplier);
|
|
248
|
+
});
|
|
249
|
+
return muscleScores;
|
|
250
|
+
};
|
|
251
|
+
const normalizeVolume = (totalVolume, exercise, user) => {
|
|
252
|
+
const userWeight = user.weightKg || 70;
|
|
253
|
+
const fitnessLevel = user.fitnessLevel || 2;
|
|
254
|
+
// Expected volume baseline
|
|
255
|
+
const baseExpected = userWeight * 15 * fitnessLevel;
|
|
256
|
+
const difficultyAdjustment = 1 + exercise.difficultyLevel / 8;
|
|
257
|
+
const expectedVolume = baseExpected * difficultyAdjustment;
|
|
258
|
+
// Sigmoid normalization for smooth scoring
|
|
259
|
+
const ratio = totalVolume / expectedVolume;
|
|
260
|
+
return 100 / (1 + Math.exp(-3 * (ratio - 0.8)));
|
|
261
|
+
};
|
|
262
|
+
const getRepIntensity = (reps) => {
|
|
263
|
+
if (reps <= 3)
|
|
264
|
+
return 95;
|
|
265
|
+
if (reps <= 5)
|
|
266
|
+
return 87;
|
|
267
|
+
if (reps <= 8)
|
|
268
|
+
return 80;
|
|
269
|
+
if (reps <= 12)
|
|
270
|
+
return 70;
|
|
271
|
+
if (reps <= 15)
|
|
272
|
+
return 65;
|
|
273
|
+
if (reps <= 20)
|
|
274
|
+
return 60;
|
|
275
|
+
return 50;
|
|
507
276
|
};
|
|
508
|
-
exports.computeScoreFromRecord = computeScoreFromRecord;
|