@dgpholdings/greatoak-shared 1.2.54 → 1.2.55

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.
Files changed (41) hide show
  1. package/dist/__mocks__/exercises.mock.d.ts +35 -0
  2. package/dist/__mocks__/exercises.mock.js +144 -0
  3. package/dist/__mocks__/templateExercises.mock.d.ts +90 -0
  4. package/dist/__mocks__/templateExercises.mock.js +258 -0
  5. package/dist/__mocks__/user.mock.d.ts +2 -0
  6. package/dist/__mocks__/user.mock.js +36 -0
  7. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +30 -0
  8. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +138 -0
  9. package/dist/utils/exerciseRecord/recordValidator.d.ts +12 -0
  10. package/dist/utils/exerciseRecord/recordValidator.integration.test.d.ts +1 -0
  11. package/dist/utils/exerciseRecord/recordValidator.integration.test.js +51 -0
  12. package/dist/utils/exerciseRecord/recordValidator.js +85 -0
  13. package/dist/utils/exerciseRecord/recordValidator.test.d.ts +1 -0
  14. package/dist/utils/exerciseRecord/recordValidator.test.js +165 -0
  15. package/dist/utils/exerciseRecord/workoutMath.d.ts +28 -0
  16. package/dist/utils/exerciseRecord/workoutMath.js +116 -0
  17. package/dist/utils/exerciseRecord/workoutMath.test.d.ts +1 -0
  18. package/dist/utils/exerciseRecord/workoutMath.test.js +238 -0
  19. package/dist/utils/index.d.ts +3 -1
  20. package/dist/utils/index.js +5 -3
  21. package/dist/utils/scoringWorkout/calculateCalories.d.ts +67 -0
  22. package/dist/utils/scoringWorkout/calculateCalories.js +351 -0
  23. package/dist/utils/scoringWorkout/calculateMuscleFatiue.d.ts +67 -0
  24. package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +330 -0
  25. package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +73 -0
  26. package/dist/utils/scoringWorkout/calculateQualityScore.js +357 -0
  27. package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +15 -0
  28. package/dist/utils/scoringWorkout/calculateTotalVolume.js +73 -0
  29. package/dist/utils/scoringWorkout/constants.d.ts +211 -0
  30. package/dist/utils/scoringWorkout/constants.js +247 -0
  31. package/dist/utils/scoringWorkout/helpers.d.ts +127 -0
  32. package/dist/utils/scoringWorkout/helpers.js +245 -0
  33. package/dist/utils/scoringWorkout/index.d.ts +27 -0
  34. package/dist/utils/scoringWorkout/index.js +56 -0
  35. package/dist/utils/scoringWorkout/parseRecords.d.ts +68 -0
  36. package/dist/utils/scoringWorkout/parseRecords.js +281 -0
  37. package/dist/utils/scoringWorkout/scoringWorkout.integration.test.d.ts +1 -0
  38. package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +439 -0
  39. package/dist/utils/scoringWorkout/types.d.ts +104 -0
  40. package/dist/utils/scoringWorkout/types.js +11 -0
  41. package/package.json +6 -3
@@ -0,0 +1,439 @@
1
+ "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ const vitest_1 = require("vitest");
15
+ const index_1 = require("./index");
16
+ const parseRecords_1 = require("./parseRecords");
17
+ const templateExercises_mock_1 = require("../../__mocks__/templateExercises.mock");
18
+ const exercises_mock_1 = require("../../__mocks__/exercises.mock");
19
+ const user_mock_1 = require("../../__mocks__/user.mock");
20
+ (0, vitest_1.describe)("Scoring Engine Phase 1 Integration Tests", () => {
21
+ (0, vitest_1.it)("P1-1 proper proof: Partial workout with undefined rest on last done set + strict mode", () => {
22
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsPartial;
23
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
24
+ // Modify to prove P1-1: the LAST done set (index 1) must have NO rest data.
25
+ // Set 0 has good rest, Set 1 has no rest. If P1-1 is fixed, Set 1 gets null rest
26
+ // and is excluded from the rest average, meaning only Set 0's perfect rest is scored.
27
+ const strictRecords = template.initialRecords.map((r, i) => {
28
+ if (i === 1) {
29
+ const { restDurationSecs } = r, rest = __rest(r, ["restDurationSecs"]); // remove rest data
30
+ return Object.assign(Object.assign({}, rest), { isStrictMode: true });
31
+ }
32
+ return Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: 120 }); // 120 is optimal
33
+ });
34
+ // Direct parseRecords assertion — the real proof of P1-1.
35
+ // With fix (totalSets=doneRecords.length=2): Set1 index=1 === 2-1 → null rest.
36
+ // Without fix (totalSets=records.length=4): Set1 index=1 !== 3 → gets typical (120s).
37
+ const parsed = (0, parseRecords_1.parseRecords)(strictRecords, exercise.timingGuardrails);
38
+ (0, vitest_1.expect)(parsed[1].restDurationSecs).toBeNull();
39
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
40
+ exercise,
41
+ record: strictRecords,
42
+ user: user_mock_1.mockUser,
43
+ });
44
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(50); // 2/4 done
45
+ // Only Set0 (120s, optimal) contributes to rest — Set1 is correctly excluded
46
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBeGreaterThan(95);
47
+ });
48
+ (0, vitest_1.it)("P1-2: mockTemplateWeightRepsFailedSet - zero rep set still counts towards completion if isDone is true", () => {
49
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsFailedSet;
50
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
51
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
52
+ exercise,
53
+ record: template.initialRecords,
54
+ user: user_mock_1.mockUser,
55
+ });
56
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(67); // 2/3 done
57
+ });
58
+ (0, vitest_1.it)("P1-1 + P1-3: mockTemplateLazyLogger - handles missing data gracefully using fallbacks", () => {
59
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.lazyLogger;
60
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
61
+ const { score, qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
62
+ exercise,
63
+ record: template.initialRecords,
64
+ user: user_mock_1.mockUser,
65
+ });
66
+ (0, vitest_1.expect)(score).toBeGreaterThan(50);
67
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(100);
68
+ });
69
+ (0, vitest_1.it)("Distracted logger: clamping path (Original 600s rest → clamped to typical → good rest score)", () => {
70
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.distractedLogger;
71
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
72
+ // Use 600s rest. 600 > 300 * 1.5, so it gets clamped to typical (120s)
73
+ // 120s is in the optimal range (90-180), so rest score should be 100.
74
+ const strictRecords = template.initialRecords.map((r, i) => (Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: i === 0 ? 600 : 120 })));
75
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
76
+ exercise,
77
+ record: strictRecords,
78
+ user: user_mock_1.mockUser,
79
+ });
80
+ // Both sets end up evaluating as 120s (optimal) due to clamping + explicit
81
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(100);
82
+ });
83
+ (0, vitest_1.it)("Distracted logger: penalty path (350s rest → outside acceptableMax → score 50)", () => {
84
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.distractedLogger;
85
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
86
+ // 350s is within grace bounds (450s) but outside acceptableMax (300s).
87
+ const strictRecords = template.initialRecords.map((r, i) => (Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: i === 0 ? 350 : 120 })));
88
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
89
+ exercise,
90
+ record: strictRecords,
91
+ user: user_mock_1.mockUser,
92
+ });
93
+ // Set 1 (350s) -> 50. Set 2 (120s) -> 100. Avg = 75. Plus 5 point bonus = 80.
94
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(80);
95
+ });
96
+ (0, vitest_1.it)("mockTemplateAllUndone - Score = 0, no errors", () => {
97
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.allUndone;
98
+ const exercise = exercises_mock_1.mockExerciseWeightReps;
99
+ const { score, qualityBreakdown, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
100
+ exercise,
101
+ record: template.initialRecords,
102
+ user: user_mock_1.mockUser,
103
+ });
104
+ (0, vitest_1.expect)(score).toBe(0);
105
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(0);
106
+ (0, vitest_1.expect)(qualityBreakdown.consistency).toBe(0);
107
+ (0, vitest_1.expect)(qualityBreakdown.effortAdequacy).toBe(0);
108
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(0);
109
+ (0, vitest_1.expect)(Object.keys(muscleScores).length).toBe(0);
110
+ });
111
+ (0, vitest_1.it)("Trivial workout: low muscleScores (3s duration → primary muscle value < 5)", () => {
112
+ const exercise = exercises_mock_1.mockExerciseDuration;
113
+ const trivialRecord = [
114
+ {
115
+ type: "duration",
116
+ isDone: true,
117
+ isStrictMode: false,
118
+ durationMmSs: "00:03", // 3 seconds
119
+ auxWeightKg: "0",
120
+ }
121
+ ];
122
+ const { score, qualityBreakdown, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
123
+ exercise,
124
+ record: trivialRecord,
125
+ user: user_mock_1.mockUser,
126
+ });
127
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(100);
128
+ (0, vitest_1.expect)(score).toBe(87);
129
+ // Check fatigue pillar correctness
130
+ const primaryMuscle = exercise.primaryMuscles[0];
131
+ (0, vitest_1.expect)(muscleScores[primaryMuscle]).toBeLessThan(5);
132
+ });
133
+ (0, vitest_1.it)("P1-3 direct proof: parseRecords includes setupTypicalSecs in activeDurationSecs", () => {
134
+ var _a, _b, _c;
135
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
136
+ const parsed = (0, parseRecords_1.parseRecords)(template.initialRecords, exercises_mock_1.mockExerciseWeightReps.timingGuardrails);
137
+ const record = template.initialRecords[0];
138
+ const reps = parseFloat(record.type === "weight-reps" ? record.reps : "0");
139
+ const guardrails = exercises_mock_1.mockExerciseWeightReps.timingGuardrails;
140
+ const typicalSecs = (guardrails === null || guardrails === void 0 ? void 0 : guardrails.type) === "weight-reps" ? ((_b = (_a = guardrails.singleRep) === null || _a === void 0 ? void 0 : _a.typical) !== null && _b !== void 0 ? _b : 0) : 0;
141
+ const setupSecs = (_c = guardrails === null || guardrails === void 0 ? void 0 : guardrails.setupTypicalSecs) !== null && _c !== void 0 ? _c : 0;
142
+ const expectedDuration = reps * typicalSecs + setupSecs; // 10 * 3.5 + 10 = 45
143
+ (0, vitest_1.expect)(parsed[0].activeDurationSecs).toBe(expectedDuration);
144
+ });
145
+ (0, vitest_1.it)("Muscle fatigue shape: quadriceps and glutes-maximus present in [0, 100]", () => {
146
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
147
+ const { muscleScores } = (0, index_1.calculateExerciseScoreV2)({
148
+ exercise: exercises_mock_1.mockExerciseWeightReps,
149
+ record: template.initialRecords,
150
+ user: user_mock_1.mockUser,
151
+ });
152
+ (0, vitest_1.expect)(muscleScores).toHaveProperty("quadriceps");
153
+ (0, vitest_1.expect)(muscleScores).toHaveProperty("glutes-maximus");
154
+ (0, vitest_1.expect)(muscleScores["quadriceps"]).toBeGreaterThanOrEqual(0);
155
+ (0, vitest_1.expect)(muscleScores["quadriceps"]).toBeLessThanOrEqual(100);
156
+ });
157
+ (0, vitest_1.it)("stretch-mobility = zero fatigue: muscleScores should be empty", () => {
158
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.duration;
159
+ const exercise = Object.assign(Object.assign({}, exercises_mock_1.mockExerciseDuration), { scoringSpecialHandling: "stretch-mobility" });
160
+ const { muscleScores } = (0, index_1.calculateExerciseScoreV2)({
161
+ exercise,
162
+ record: template.initialRecords,
163
+ user: user_mock_1.mockUser,
164
+ });
165
+ (0, vitest_1.expect)(Object.keys(muscleScores).length).toBe(0);
166
+ });
167
+ (0, vitest_1.it)("no guardrails doesn't crash: mockExerciseNoGuardrails scores > 0", () => {
168
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
169
+ const { score } = (0, index_1.calculateExerciseScoreV2)({
170
+ exercise: exercises_mock_1.mockExerciseNoGuardrails,
171
+ record: template.initialRecords,
172
+ user: user_mock_1.mockUser,
173
+ });
174
+ (0, vitest_1.expect)(score).toBeGreaterThan(0);
175
+ });
176
+ (0, vitest_1.it)("RPE/RIR scoring: effortAdequacy calculates correctly for various inputs", () => {
177
+ const records = [
178
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rpe: "8" }, // 100
179
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rpe: "4" }, // 60 (Distance = 2 -> 100 - 2*20 = 60)
180
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rir: "0" }, // 80 (Distance = 1 -> 100 - 1*20 = 80)
181
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10" } // 70 (No data)
182
+ ];
183
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
184
+ exercise: exercises_mock_1.mockExerciseWeightReps,
185
+ record: records,
186
+ user: user_mock_1.mockUser,
187
+ });
188
+ // Average: (100 + 60 + 80 + 70) / 4 = 77.5 -> 78
189
+ (0, vitest_1.expect)(qualityBreakdown.effortAdequacy).toBe(78);
190
+ });
191
+ (0, vitest_1.it)("Progressive overload detection: mockTemplateWeightRepsMicroLoaded -> consistency >= 75", () => {
192
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsMicroLoaded;
193
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
194
+ exercise: exercises_mock_1.mockExerciseWeightReps,
195
+ record: template.initialRecords,
196
+ user: user_mock_1.mockUser,
197
+ });
198
+ (0, vitest_1.expect)(qualityBreakdown.consistency).toBeGreaterThanOrEqual(75);
199
+ });
200
+ (0, vitest_1.it)("Single-set rest is neutral: mockTemplateSingleSet -> restDiscipline = 75", () => {
201
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.singleSet;
202
+ const strictRecords = template.initialRecords.map(r => {
203
+ const { restDurationSecs } = r, rest = __rest(r, ["restDurationSecs"]); // remove rest data so it becomes the null case
204
+ return Object.assign(Object.assign({}, rest), { isStrictMode: true });
205
+ });
206
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
207
+ exercise: exercises_mock_1.mockExerciseWeightReps,
208
+ record: strictRecords,
209
+ user: user_mock_1.mockUser,
210
+ });
211
+ // Single set → last set gets null rest → setsWithRest=[] → REST_NO_DATA_SCORE (75)
212
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(75);
213
+ });
214
+ (0, vitest_1.it)("Non-sequential completion: mockTemplateNonSequential -> completion = 67%", () => {
215
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.nonSequential;
216
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
217
+ exercise: exercises_mock_1.mockExerciseWeightReps,
218
+ record: template.initialRecords,
219
+ user: user_mock_1.mockUser,
220
+ });
221
+ (0, vitest_1.expect)(qualityBreakdown.completion).toBe(67);
222
+ });
223
+ (0, vitest_1.it)("IScoreResult has 4 fields: calorieBurn and qualityBreakdown are present", () => {
224
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
225
+ const result = (0, index_1.calculateExerciseScoreV2)({
226
+ exercise: exercises_mock_1.mockExerciseWeightReps,
227
+ record: template.initialRecords,
228
+ user: user_mock_1.mockUser,
229
+ });
230
+ (0, vitest_1.expect)(result).toHaveProperty("score");
231
+ (0, vitest_1.expect)(result).toHaveProperty("muscleScores");
232
+ (0, vitest_1.expect)(result).toHaveProperty("calorieBurn");
233
+ (0, vitest_1.expect)(result).toHaveProperty("qualityBreakdown");
234
+ });
235
+ (0, vitest_1.it)("Cardio exercises: mockTemplateCardioMachine/Free score > 0 and use correct muscles", () => {
236
+ const machineResult = (0, index_1.calculateExerciseScoreV2)({
237
+ exercise: exercises_mock_1.mockExerciseCardioMachine,
238
+ record: templateExercises_mock_1.mockTemplateExercisesDictionary.cardioMachine.initialRecords,
239
+ user: user_mock_1.mockUser,
240
+ });
241
+ (0, vitest_1.expect)(machineResult.score).toBeGreaterThan(0);
242
+ (0, vitest_1.expect)(machineResult.muscleScores).toHaveProperty("quadriceps");
243
+ const freeResult = (0, index_1.calculateExerciseScoreV2)({
244
+ exercise: exercises_mock_1.mockExerciseCardioFree,
245
+ record: templateExercises_mock_1.mockTemplateExercisesDictionary.cardioFree.initialRecords,
246
+ user: user_mock_1.mockUser,
247
+ });
248
+ (0, vitest_1.expect)(freeResult.score).toBeGreaterThan(0);
249
+ (0, vitest_1.expect)(freeResult.muscleScores).toHaveProperty("quadriceps");
250
+ });
251
+ (0, vitest_1.it)("reps-only with aux weight: mockTemplateRepsOnly -> score > 0, pectoralis-major mapped", () => {
252
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.repsOnly;
253
+ const { score, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
254
+ exercise: exercises_mock_1.mockExerciseRepsOnly,
255
+ record: template.initialRecords,
256
+ user: user_mock_1.mockUser,
257
+ });
258
+ (0, vitest_1.expect)(score).toBeGreaterThan(0);
259
+ (0, vitest_1.expect)(muscleScores).toHaveProperty("pectoralis-major");
260
+ });
261
+ (0, vitest_1.it)("Duration exercise: mockTemplateDuration -> abs-lower/abs-upper in muscleScores", () => {
262
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.duration;
263
+ const { score, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
264
+ exercise: exercises_mock_1.mockExerciseDuration,
265
+ record: template.initialRecords,
266
+ user: user_mock_1.mockUser,
267
+ });
268
+ (0, vitest_1.expect)(score).toBeGreaterThan(0);
269
+ (0, vitest_1.expect)(muscleScores).toHaveProperty("abs-lower");
270
+ (0, vitest_1.expect)(muscleScores).toHaveProperty("abs-upper");
271
+ });
272
+ (0, vitest_1.it)("Default restDiscipline (non-strict): Any non-strict call -> restDiscipline === 75", () => {
273
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
274
+ // Standard mock is `isStrictMode: false`
275
+ const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
276
+ exercise: exercises_mock_1.mockExerciseWeightReps,
277
+ record: template.initialRecords,
278
+ user: user_mock_1.mockUser,
279
+ });
280
+ (0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(75); // REST_NO_DATA_SCORE from constants.ts
281
+ });
282
+ });
283
+ (0, vitest_1.describe)("Scoring Engine Phase 2 Integration Tests", () => {
284
+ // ---------------------------------------------------------------------------
285
+ // P2-1: fitnessLevel scales referenceMax
286
+ // ---------------------------------------------------------------------------
287
+ (0, vitest_1.it)("P2-1: sedentary user scores higher muscle fatigue than very-active for identical work", () => {
288
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
289
+ const sedentaryUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessLevel: "sedentary" });
290
+ const veryActiveUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessLevel: "very-active" });
291
+ const sedentaryResult = (0, index_1.calculateExerciseScoreV2)({
292
+ exercise: exercises_mock_1.mockExerciseWeightReps,
293
+ record: template.initialRecords,
294
+ user: sedentaryUser,
295
+ });
296
+ const veryActiveResult = (0, index_1.calculateExerciseScoreV2)({
297
+ exercise: exercises_mock_1.mockExerciseWeightReps,
298
+ record: template.initialRecords,
299
+ user: veryActiveUser,
300
+ });
301
+ // sedentary: activityScale 0.70 → lower referenceMax denominator → higher normalised score
302
+ // very-active: activityScale 1.20 → higher referenceMax denominator → lower normalised score
303
+ (0, vitest_1.expect)(sedentaryResult.muscleScores["quadriceps"]).toBeGreaterThan(veryActiveResult.muscleScores["quadriceps"]);
304
+ });
305
+ // ---------------------------------------------------------------------------
306
+ // P2-3: fitnessGoal-aware quality weights
307
+ // ---------------------------------------------------------------------------
308
+ (0, vitest_1.it)("P2-3: strength goal scores higher than hypertrophy when effort is good but completion is 50%", () => {
309
+ // 2 of 4 sets done (50% completion) with RPE 8 (effort in productive zone)
310
+ // strength weights effortAdequacy 0.45 vs hypertrophy 0.30 — effort matters more
311
+ const partialWithEffort = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsPartial.initialRecords.map((r) => (Object.assign(Object.assign({}, r), { rpe: "8" })));
312
+ const strengthUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessGoal: "strength" });
313
+ const hypertrophyUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessGoal: "hypertrophy" });
314
+ const strengthResult = (0, index_1.calculateExerciseScoreV2)({
315
+ exercise: exercises_mock_1.mockExerciseWeightReps,
316
+ record: partialWithEffort,
317
+ user: strengthUser,
318
+ });
319
+ const hypertrophyResult = (0, index_1.calculateExerciseScoreV2)({
320
+ exercise: exercises_mock_1.mockExerciseWeightReps,
321
+ record: partialWithEffort,
322
+ user: hypertrophyUser,
323
+ });
324
+ // completion=50, consistency=100, effortAdequacy=100, restDiscipline=75
325
+ // strength: 50×0.15 + 100×0.25 + 100×0.45 + 75×0.15 = 88.75 → 89
326
+ // hypertrophy: 50×0.20 + 100×0.35 + 100×0.30 + 75×0.15 = 86.25 → 86
327
+ (0, vitest_1.expect)(strengthResult.score).toBe(89);
328
+ (0, vitest_1.expect)(hypertrophyResult.score).toBe(86);
329
+ (0, vitest_1.expect)(strengthResult.score).toBeGreaterThan(hypertrophyResult.score);
330
+ });
331
+ // ---------------------------------------------------------------------------
332
+ // P2-5: estimatedOneRepMax in referenceMax
333
+ // ---------------------------------------------------------------------------
334
+ (0, vitest_1.it)("P2-5: user near their 1RM shows higher muscle fatigue than a strong user doing the same weight", () => {
335
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
336
+ // Weak user: 1RM=60kg — the session's top set (85kg) exceeds their 1RM,
337
+ // so referenceMax is low → same absolute volume normalises to a higher score
338
+ const weakUserResult = (0, index_1.calculateExerciseScoreV2)({
339
+ exercise: exercises_mock_1.mockExerciseWeightReps,
340
+ record: template.initialRecords,
341
+ user: user_mock_1.mockUser,
342
+ historicalContext: { estimatedOneRepMax: 60 },
343
+ });
344
+ // Strong user: 1RM=200kg — 85kg is only ~43% of max → low relative effort
345
+ // referenceMax is high → same absolute volume normalises to a lower score
346
+ const strongUserResult = (0, index_1.calculateExerciseScoreV2)({
347
+ exercise: exercises_mock_1.mockExerciseWeightReps,
348
+ record: template.initialRecords,
349
+ user: user_mock_1.mockUser,
350
+ historicalContext: { estimatedOneRepMax: 200 },
351
+ });
352
+ (0, vitest_1.expect)(weakUserResult.muscleScores["quadriceps"]).toBeGreaterThan(strongUserResult.muscleScores["quadriceps"]);
353
+ });
354
+ // ---------------------------------------------------------------------------
355
+ // P2-6: previousSessionVolume cross-session progressive overload
356
+ // ---------------------------------------------------------------------------
357
+ (0, vitest_1.it)("P2-6: beating previous session volume floors consistency at 75 even with high CV", () => {
358
+ // Highly inconsistent sets: [100kg×10, 50kg×5, 90kg×8]
359
+ // volume metric: [1000, 250, 720]
360
+ // CV ≈ 0.47 → base consistency score = clamp(100 - 0.47×200, 20, 100) = 20
361
+ const inconsistentRecords = [
362
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "100", reps: "10" },
363
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "5" },
364
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "90", reps: "8" },
365
+ ];
366
+ const withoutContext = (0, index_1.calculateExerciseScoreV2)({
367
+ exercise: exercises_mock_1.mockExerciseWeightReps,
368
+ record: inconsistentRecords,
369
+ user: user_mock_1.mockUser,
370
+ });
371
+ // currentSessionVolume = 1970, previousSessionVolume = 1000 → 1970 >= 1000 → progressive
372
+ const withContext = (0, index_1.calculateExerciseScoreV2)({
373
+ exercise: exercises_mock_1.mockExerciseWeightReps,
374
+ record: inconsistentRecords,
375
+ user: user_mock_1.mockUser,
376
+ historicalContext: { previousSessionVolume: 1000 },
377
+ });
378
+ (0, vitest_1.expect)(withoutContext.qualityBreakdown.consistency).toBe(20); // CONSISTENCY_MIN_SCORE
379
+ (0, vitest_1.expect)(withContext.qualityBreakdown.consistency).toBe(75); // PROGRESSIVE_OVERLOAD_FLOOR
380
+ });
381
+ (0, vitest_1.it)("P2-6: not beating previous session volume does NOT trigger the floor", () => {
382
+ const inconsistentRecords = [
383
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "100", reps: "10" },
384
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "5" },
385
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "90", reps: "8" },
386
+ ];
387
+ // currentSessionVolume = 1970, previousSessionVolume = 3000 → 1970 < 3000 → no floor
388
+ const result = (0, index_1.calculateExerciseScoreV2)({
389
+ exercise: exercises_mock_1.mockExerciseWeightReps,
390
+ record: inconsistentRecords,
391
+ user: user_mock_1.mockUser,
392
+ historicalContext: { previousSessionVolume: 3000 },
393
+ });
394
+ (0, vitest_1.expect)(result.qualityBreakdown.consistency).toBe(20); // no floor applied
395
+ });
396
+ // ---------------------------------------------------------------------------
397
+ // P2-10: trainingAgeBracket RIR attenuation + referenceMax scaling
398
+ // ---------------------------------------------------------------------------
399
+ (0, vitest_1.it)("P2-10: beginner bracket attenuates near-failure effort → lower muscle fatigue than no bracket", () => {
400
+ // RIR 0 = near-failure: effortFraction ≈ 1.3 without attenuation
401
+ // With beginner: effortFraction = 0.6 + (1.3 - 0.6) × 0.5 = 0.95
402
+ const nearFailureRecords = [
403
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
404
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
405
+ { type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
406
+ ];
407
+ const noContext = (0, index_1.calculateExerciseScoreV2)({
408
+ exercise: exercises_mock_1.mockExerciseWeightReps,
409
+ record: nearFailureRecords,
410
+ user: user_mock_1.mockUser,
411
+ });
412
+ const beginnerContext = (0, index_1.calculateExerciseScoreV2)({
413
+ exercise: exercises_mock_1.mockExerciseWeightReps,
414
+ record: nearFailureRecords,
415
+ user: user_mock_1.mockUser,
416
+ historicalContext: { trainingAgeBracket: "beginner" },
417
+ });
418
+ // Beginner: lower effort (attenuated) + lower referenceMax (0.80 scale)
419
+ // Net effect: ~9% lower score. Verified: noContext ≈ 20, beginnerContext ≈ 18
420
+ (0, vitest_1.expect)(beginnerContext.muscleScores["quadriceps"]).toBeLessThan(noContext.muscleScores["quadriceps"]);
421
+ });
422
+ (0, vitest_1.it)("P2-10: advanced bracket raises referenceMax → lower fatigue score for same work than intermediate", () => {
423
+ const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
424
+ const intermediateResult = (0, index_1.calculateExerciseScoreV2)({
425
+ exercise: exercises_mock_1.mockExerciseWeightReps,
426
+ record: template.initialRecords,
427
+ user: user_mock_1.mockUser,
428
+ historicalContext: { trainingAgeBracket: "intermediate" },
429
+ });
430
+ const advancedResult = (0, index_1.calculateExerciseScoreV2)({
431
+ exercise: exercises_mock_1.mockExerciseWeightReps,
432
+ record: template.initialRecords,
433
+ user: user_mock_1.mockUser,
434
+ historicalContext: { trainingAgeBracket: "advanced" },
435
+ });
436
+ // advanced: referenceMax × 1.15 → higher denominator → lower normalised fatigue
437
+ (0, vitest_1.expect)(advancedResult.muscleScores["quadriceps"]).toBeLessThan(intermediateResult.muscleScores["quadriceps"]);
438
+ });
439
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
4
+ * ============================================================================
5
+ *
6
+ * Central type definitions for the scoring algorithm.
7
+ * This file defines the output shape and internal types used across all three
8
+ * scoring pillars (Calories, Muscle Fatigue, Quality).
9
+ */
10
+ import { TActivityLevel, TFitnessGoal, TGender, TRecord } from "../../types";
11
+ /**
12
+ * Quality score breakdown — lets the UI show users WHY they got their score.
13
+ * Each sub-score is 0–100.
14
+ */
15
+ export interface IQualityBreakdown {
16
+ /** Did the user complete all sets? */
17
+ completion: number;
18
+ /** Were sets consistent in output (or intentionally progressive)? */
19
+ consistency: number;
20
+ /** Was effort in the productive RPE/RIR zone? */
21
+ effortAdequacy: number;
22
+ /** Were rest periods within the exercise's optimal range? */
23
+ restDiscipline: number;
24
+ }
25
+ /**
26
+ * The final result returned by calculateExerciseScore.
27
+ *
28
+ * - score: 0–100 overall quality of execution
29
+ * - muscleScores: per-muscle fatigue map (keyed by EBodyParts key, value 0–100)
30
+ * - calorieBurn: estimated kilocalories burned (gross, including EPOC)
31
+ * - qualityBreakdown: transparent sub-scores for the UI
32
+ */
33
+ export interface IScoreResult {
34
+ score: number;
35
+ muscleScores: Record<string, number>;
36
+ calorieBurn: number;
37
+ qualityBreakdown: IQualityBreakdown;
38
+ }
39
+ export type TTrainingAgeBracket = "beginner" | "intermediate" | "advanced";
40
+ /**
41
+ * Historical context derived from previous sessions, passed from the app
42
+ * at save time to provide a cross-session perspective for scoring.
43
+ */
44
+ export interface IHistoricalContext {
45
+ /** Best Epley 1RM from previous sessions (weight-reps only) */
46
+ estimatedOneRepMax?: number;
47
+ /** Last session total volume for this exercise */
48
+ previousSessionVolume?: number;
49
+ /** Account age and workout frequency mapping */
50
+ trainingAgeBracket?: TTrainingAgeBracket;
51
+ /** Pre-workout readiness rating (1-5) (P4-2 planned) */
52
+ readiness?: 1 | 2 | 3 | 4 | 5;
53
+ }
54
+ /**
55
+ * A cleaned, parsed version of a single TRecord.
56
+ * All string fields are parsed to numbers, unreliable timings are replaced
57
+ * with fallbacks, and skipped sets are filtered out before this stage.
58
+ *
59
+ * This is the ONLY representation the scoring pillars work with — they never
60
+ * touch raw TRecord directly.
61
+ */
62
+ export interface IParsedSet {
63
+ type: TRecord["type"];
64
+ /** Active work duration for this set in seconds (estimated or measured) */
65
+ activeDurationSecs: number;
66
+ /** Rest duration after this set in seconds (validated or fallback) */
67
+ restDurationSecs: number | null;
68
+ /**
69
+ * Effort fraction: 0.0 (no effort) to 1.0 (max effort).
70
+ * Derived from RPE, RIR, or fallback (0.6).
71
+ */
72
+ effortFraction: number;
73
+ /** weight-reps: weight in kg */
74
+ kg?: number;
75
+ /** weight-reps / reps-only: rep count */
76
+ reps?: number;
77
+ /** reps-only / duration: auxiliary weight in kg */
78
+ auxWeightKg?: number;
79
+ /** duration: hold time in seconds */
80
+ durationSecs?: number;
81
+ /** cardio-machine: speed */
82
+ speed?: number;
83
+ /** cardio-machine: incline percentage (Treadmill) */
84
+ inclinePercentage?: number;
85
+ /** cardio-machine: resistance level (Elliptical, Cycling) */
86
+ resistanceLevel?: number;
87
+ /** cardio-machine / cardio-free: distance (km) */
88
+ distance?: number;
89
+ /** cardio-free / cardio-machine: session duration in seconds */
90
+ cardioDurationSecs?: number;
91
+ }
92
+ /**
93
+ * Validated user context with guaranteed fallbacks.
94
+ * No optional fields — everything has a sensible default.
95
+ */
96
+ export interface IUserContext {
97
+ weightKg: number;
98
+ heightCm: number;
99
+ gender: TGender;
100
+ age: number;
101
+ fitnessLevel: TActivityLevel;
102
+ fitnessGoal: TFitnessGoal;
103
+ bodyFatPercentage: number;
104
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * ============================================================================
4
+ * FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
5
+ * ============================================================================
6
+ *
7
+ * Central type definitions for the scoring algorithm.
8
+ * This file defines the output shape and internal types used across all three
9
+ * scoring pillars (Calories, Muscle Fatigue, Quality).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.54",
3
+ "version": "1.2.55",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,7 +11,9 @@
11
11
  "build": "tsc",
12
12
  "pub": "npm run build && npm version patch && npm publish",
13
13
  "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
14
- "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
14
+ "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
15
+ "test": "vitest run src",
16
+ "test:watch": "vitest src"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
@@ -20,7 +22,8 @@
20
22
  "author": "Siddhartha Chowdhury",
21
23
  "license": "MIT",
22
24
  "devDependencies": {
23
- "typescript": "^5.8.2"
25
+ "typescript": "^5.8.2",
26
+ "vitest": "^4.1.3"
24
27
  },
25
28
  "dependencies": {
26
29
  "react-native-uuid": "^2.0.3"