@dgpholdings/greatoak-shared 1.2.16 → 1.2.17

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.
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ // calculateMuscleFatiue.ts
3
+ /**
4
+ * ============================================================================
5
+ * FITFRIX EXERCISE SCORING SYSTEM — Pillar 2: Muscle Fatigue
6
+ * ============================================================================
7
+ *
8
+ * Calculates per-muscle fatigue scores (0–100) for a single exercise.
9
+ *
10
+ * HOW IT WORKS:
11
+ *
12
+ * 1. For each set, compute a "fatigue stimulus" — how much mechanical work
13
+ * the muscles experienced. This is type-specific:
14
+ * weight-reps: kg × reps
15
+ * reps-only: (bodyweightLoad + auxWeight) × reps
16
+ * duration: durationSecs × (difficultyLevel + 1) × auxBoost
17
+ * cardio: durationSecs × speedFactor
18
+ *
19
+ * 2. Multiply by effort (RPE/RIR → 0.5–1.3) and the exercise's
20
+ * fatigueMultiplier from timingGuardrails.
21
+ *
22
+ * 3. Accumulate across sets with DIMINISHING RETURNS — later sets contribute
23
+ * less because the muscle is pre-fatigued and can't generate as much force.
24
+ * Decay: setDecay = 1 / (1 + 0.15 × setIndex)
25
+ *
26
+ * 4. Distribute the accumulated fatigue to muscles:
27
+ * Primary muscles → 100% of stimulus
28
+ * Secondary muscles → 35% of stimulus
29
+ *
30
+ * 5. Normalize to 0–100 using an EXERCISE-AWARE reference maximum.
31
+ * The reference max is what "5 hard sets of THIS exercise" would produce,
32
+ * scaled by the exercise's difficultyLevel so bicep curls and squats
33
+ * get fair scores.
34
+ *
35
+ * IMPORTANT: This function scores ONE exercise. The caller aggregates
36
+ * across exercises for full-workout muscle fatigue maps.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.calculateMuscleFatigue = calculateMuscleFatigue;
40
+ const constants_1 = require("./constants");
41
+ const helpers_1 = require("./helpers");
42
+ // ---------------------------------------------------------------------------
43
+ // Main Fatigue Calculation
44
+ // ---------------------------------------------------------------------------
45
+ /**
46
+ * Calculate per-muscle fatigue scores for an exercise.
47
+ *
48
+ * @param sets Parsed & validated sets (from parseRecords)
49
+ * @param exercise Exercise metadata (muscles, difficulty, metabolic)
50
+ * @param user Validated user context
51
+ * @param timingGuardrails For fatigueMultiplier
52
+ * @returns Map of muscle key → fatigue score (0–100)
53
+ *
54
+ * @example
55
+ * const fatigue = calculateMuscleFatigue(parsedSets, exercise, userCtx, exercise.timingGuardrails);
56
+ * // → { "pectoralis-major": 24, "pectoralis-minor": 24, "deltoids-anterior": 8, ... }
57
+ */
58
+ function calculateMuscleFatigue(sets, exercise, user, timingGuardrails) {
59
+ var _a;
60
+ if (sets.length === 0)
61
+ return {};
62
+ const fatigueMultiplier = (_a = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.fatigueMultiplier) !== null && _a !== void 0 ? _a : constants_1.FALLBACK_FATIGUE_MULTIPLIER;
63
+ // --- Step 1–3: Compute cumulative fatigue stimulus ---
64
+ const cumulativeFatigue = computeCumulativeFatigue(sets, exercise.difficultyLevel, user, fatigueMultiplier);
65
+ // --- Step 4: Distribute to muscles ---
66
+ const rawMuscleFatigue = distributeFatigueToMuscles(cumulativeFatigue, exercise.primaryMuscles, exercise.secondaryMuscles);
67
+ // --- Step 5: Normalize to 0–100 ---
68
+ const referenceMax = computeReferenceMax(sets[0].type, exercise.difficultyLevel, user, exercise.metabolicData.muscleGroupFactor);
69
+ return normalizeScores(rawMuscleFatigue, referenceMax);
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Step 1: Volume Load per Set
73
+ // ---------------------------------------------------------------------------
74
+ /**
75
+ * Calculate raw volume load for a single set.
76
+ *
77
+ * Volume load represents the mechanical work experienced by the muscles.
78
+ * Different exercise types produce volume in fundamentally different ways:
79
+ *
80
+ * weight-reps: Force (kg) × repetitions
81
+ * reps-only: Effective load (BW fraction + aux) × repetitions
82
+ * duration: Time under tension × difficulty scaling × aux boost
83
+ * cardio: Duration × speed factor (represents sustained effort)
84
+ */
85
+ function computeVolumeLoad(set, difficultyLevel, user) {
86
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
87
+ switch (set.type) {
88
+ case "weight-reps": {
89
+ const kg = (_a = set.kg) !== null && _a !== void 0 ? _a : 0;
90
+ const reps = (_b = set.reps) !== null && _b !== void 0 ? _b : 0;
91
+ return kg * reps;
92
+ }
93
+ case "reps-only": {
94
+ const reps = (_c = set.reps) !== null && _c !== void 0 ? _c : 0;
95
+ const auxWeightKg = (_d = set.auxWeightKg) !== null && _d !== void 0 ? _d : 0;
96
+ // Estimate how much bodyweight the exercise loads
97
+ // difficulty 0 → 0%, difficulty 2 → ~33%, difficulty 4 → 65%
98
+ const bodyweightLoad = (difficultyLevel / 4) * constants_1.BW_FRACTION_SCALE * user.weightKg;
99
+ const totalLoad = bodyweightLoad + auxWeightKg;
100
+ return totalLoad * reps;
101
+ }
102
+ case "duration": {
103
+ const durationSecs = (_e = set.durationSecs) !== null && _e !== void 0 ? _e : set.activeDurationSecs;
104
+ const auxWeightKg = (_f = set.auxWeightKg) !== null && _f !== void 0 ? _f : 0;
105
+ // Duration exercises: time under tension is the primary driver
106
+ // Scale by (difficultyLevel + 1) so even difficulty-0 produces some fatigue
107
+ // Aux weight adds load (holding a plate during plank, etc.)
108
+ const auxBoost = 1 + (auxWeightKg / user.weightKg) * 0.3;
109
+ return durationSecs * (difficultyLevel + 1) * auxBoost;
110
+ }
111
+ case "cardio-machine": {
112
+ const durationSecs = (_g = set.cardioDurationSecs) !== null && _g !== void 0 ? _g : set.activeDurationSecs;
113
+ const speedMin = (_h = set.speedMin) !== null && _h !== void 0 ? _h : 0;
114
+ const speedMax = (_j = set.speedMax) !== null && _j !== void 0 ? _j : 0;
115
+ const avgSpeed = (speedMin + speedMax) / 2;
116
+ // Speed factor: faster pace = more muscle engagement per second
117
+ // Normalize against expected speed range (3–20 km/h)
118
+ const speedFactor = avgSpeed > 0 ? (0, helpers_1.clamp)(avgSpeed / constants_1.CARDIO_SPEED_RANGE.max, 0.1, 1.5) : 0.3; // fallback: light effort
119
+ // Cardio dampener: running fatigues muscles less than lifting
120
+ // A 30-min jog ≠ 30 min of squats for muscle fatigue
121
+ return durationSecs * speedFactor * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
122
+ }
123
+ case "cardio-free": {
124
+ const durationSecs = (_k = set.cardioDurationSecs) !== null && _k !== void 0 ? _k : set.activeDurationSecs;
125
+ const distance = (_l = set.distance) !== null && _l !== void 0 ? _l : 0;
126
+ let baseVolume;
127
+ if (distance > 0 && durationSecs > 0) {
128
+ const speedKmh = distance / (durationSecs / 3600);
129
+ const speedFactor = (0, helpers_1.clamp)(speedKmh / constants_1.CARDIO_SPEED_RANGE.max, 0.1, 1.5);
130
+ baseVolume = durationSecs * speedFactor;
131
+ }
132
+ else {
133
+ baseVolume = durationSecs * 0.3;
134
+ }
135
+ return baseVolume * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
136
+ }
137
+ default:
138
+ return 0;
139
+ }
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Steps 2–3: Effort × Decay Accumulation
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * Compute cumulative fatigue stimulus across all sets.
146
+ *
147
+ * For each set:
148
+ * stimulus = volumeLoad × effortFraction × fatigueMultiplier
149
+ *
150
+ * Then accumulated with diminishing returns:
151
+ * total += stimulus × (1 / (1 + 0.15 × setIndex))
152
+ *
153
+ * The decay models real physiology: set 1 creates the most novel stimulus,
154
+ * later sets work a pre-fatigued muscle that can't generate as much force.
155
+ *
156
+ * @returns Single number representing total fatigue stimulus
157
+ */
158
+ function computeCumulativeFatigue(sets, difficultyLevel, user, fatigueMultiplier) {
159
+ let cumulative = 0;
160
+ for (let i = 0; i < sets.length; i++) {
161
+ const set = sets[i];
162
+ // Step 1: Raw volume for this set
163
+ const volumeLoad = computeVolumeLoad(set, difficultyLevel, user);
164
+ // Step 2: Scale by effort and exercise fatigue multiplier
165
+ const stimulus = volumeLoad * set.effortFraction * fatigueMultiplier;
166
+ // Step 3: Apply diminishing returns decay
167
+ // set 0: 1.000 (full stimulus)
168
+ // set 1: 0.870
169
+ // set 2: 0.769
170
+ // set 3: 0.690
171
+ // set 4: 0.625
172
+ const setDecay = 1 / (1 + constants_1.SET_FATIGUE_DECAY_RATE * i);
173
+ cumulative += stimulus * setDecay;
174
+ }
175
+ return cumulative;
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Step 4: Distribute to Muscles
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * Distribute cumulative fatigue to individual muscles.
182
+ *
183
+ * Primary muscles receive 100% of the stimulus (they're doing the work).
184
+ * Secondary muscles receive 35% (they assist but aren't the prime movers).
185
+ *
186
+ * If a muscle appears in BOTH primary and secondary (unlikely but possible
187
+ * with data inconsistencies), the values are summed — not overwritten.
188
+ */
189
+ function distributeFatigueToMuscles(cumulativeFatigue, primaryMuscles, secondaryMuscles) {
190
+ var _a, _b;
191
+ const muscleFatigue = {};
192
+ for (const muscle of primaryMuscles) {
193
+ muscleFatigue[muscle] =
194
+ ((_a = muscleFatigue[muscle]) !== null && _a !== void 0 ? _a : 0) +
195
+ cumulativeFatigue * constants_1.PRIMARY_MUSCLE_ALLOCATION;
196
+ }
197
+ for (const muscle of secondaryMuscles) {
198
+ muscleFatigue[muscle] =
199
+ ((_b = muscleFatigue[muscle]) !== null && _b !== void 0 ? _b : 0) +
200
+ cumulativeFatigue * constants_1.SECONDARY_MUSCLE_ALLOCATION;
201
+ }
202
+ return muscleFatigue;
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Step 5: Normalize to 0–100
206
+ // ---------------------------------------------------------------------------
207
+ /**
208
+ * Compute the reference maximum for normalization.
209
+ *
210
+ * This represents "what would 5 hard sets of THIS exercise produce?"
211
+ * The answer depends on:
212
+ * - Exercise type (weight vs duration vs cardio)
213
+ * - Difficulty level (scales expected weight/load)
214
+ * - User weight (heavier users move more mass)
215
+ *
216
+ * NOTE: fatigueMultiplier is intentionally NOT included here.
217
+ * It only applies to the stimulus (numerator), not the reference (denominator).
218
+ * This way, exercises with high fatigueMultiplier (e.g., bench press 1.3)
219
+ * actually produce HIGHER fatigue scores — the multiplier amplifies the
220
+ * stimulus without raising the bar to score against.
221
+ *
222
+ * muscleGroupFactor IS included because it's a property of the exercise's
223
+ * muscle engagement pattern, not the user's effort. It scales both sides
224
+ * proportionally so exercises with more muscle groups don't get inflated scores.
225
+ *
226
+ * WHY exercise-aware: A bicep curl (difficulty 1) and a squat (difficulty 3)
227
+ * should both be able to score 80–100 if performed well. Without scaling,
228
+ * a squat would always dominate because it uses more weight absolutely.
229
+ */
230
+ function computeReferenceMax(exerciseType, difficultyLevel, user, muscleGroupFactor) {
231
+ let singleSetMax;
232
+ switch (exerciseType) {
233
+ case "weight-reps": {
234
+ // Expected max weight for this difficulty: 0.3×BW (easy) → 1.5×BW (hard)
235
+ const typicalMaxWeight = (0, constants_1.DIFFICULTY_TO_WEIGHT_FRACTION)(difficultyLevel) * user.weightKg;
236
+ singleSetMax = typicalMaxWeight * constants_1.REFERENCE_MAX_REPS;
237
+ break;
238
+ }
239
+ case "reps-only": {
240
+ const bodyweightLoad = (difficultyLevel / 4) * constants_1.BW_FRACTION_SCALE * user.weightKg;
241
+ const typicalAux = 5; // assume up to 5kg aux for reference
242
+ singleSetMax = (bodyweightLoad + typicalAux) * 15;
243
+ break;
244
+ }
245
+ case "duration": {
246
+ // Max hold time scales with difficulty:
247
+ // difficulty 0 → 60s (easy stretch)
248
+ // difficulty 2 → 120s (plank, side plank)
249
+ // difficulty 4 → 180s (advanced isometric hold)
250
+ const maxHoldSecs = 60 + difficultyLevel * 30;
251
+ singleSetMax = maxHoldSecs * (difficultyLevel + 1);
252
+ break;
253
+ }
254
+ case "cardio-machine":
255
+ case "cardio-free": {
256
+ // 30 minutes at 75% max speed is a strong cardio effort
257
+ // Apply same dampener as volume calculation for consistent scaling
258
+ singleSetMax = 1800 * 0.75 * constants_1.CARDIO_MUSCLE_FATIGUE_DAMPENER;
259
+ break;
260
+ }
261
+ default:
262
+ singleSetMax = 100;
263
+ }
264
+ // Simulate 5 sets with decay (same decay as actual calculation)
265
+ let totalWithDecay = 0;
266
+ for (let i = 0; i < constants_1.REFERENCE_MAX_SETS; i++) {
267
+ const setDecay = 1 / (1 + constants_1.SET_FATIGUE_DECAY_RATE * i);
268
+ totalWithDecay += setDecay;
269
+ }
270
+ return (singleSetMax * constants_1.REFERENCE_MAX_EFFORT * totalWithDecay * muscleGroupFactor);
271
+ }
272
+ /**
273
+ * Normalize raw fatigue values to 0–100 scale using the reference maximum.
274
+ *
275
+ * muscleGroupFactor is already baked into the reference max, so it
276
+ * naturally adjusts: exercises that engage more/larger muscles (higher factor)
277
+ * have a proportionally higher reference max, keeping scores fair.
278
+ */
279
+ function normalizeScores(rawFatigue, referenceMax) {
280
+ if (referenceMax <= 0)
281
+ return rawFatigue;
282
+ const normalized = {};
283
+ const keys = Object.keys(rawFatigue);
284
+ for (let i = 0; i < keys.length; i++) {
285
+ const muscle = keys[i];
286
+ const raw = rawFatigue[muscle];
287
+ normalized[muscle] = Math.round((0, helpers_1.clamp)((raw / referenceMax) * 100, 0, 100));
288
+ }
289
+ return normalized;
290
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM — Pillar 3: Quality Score
4
+ * ============================================================================
5
+ *
6
+ * Measures HOW WELL the user performed the exercise (0–100).
7
+ * This is NOT about volume or calories — those are separate pillars.
8
+ * Quality is about execution: did you finish, stay consistent, push hard
9
+ * enough, and rest appropriately?
10
+ *
11
+ * FOUR SUB-COMPONENTS:
12
+ *
13
+ * ┌─────────────────────┬────────┬──────────────────────────────────────────┐
14
+ * │ Component │ Weight │ What it measures │
15
+ * ├─────────────────────┼────────┼──────────────────────────────────────────┤
16
+ * │ Completion │ 20% │ Did you finish all planned sets? │
17
+ * │ Consistency │ 35% │ Were sets stable or intentionally │
18
+ * │ │ │ progressive? (not random drops) │
19
+ * │ Effort Adequacy │ 30% │ Were you in the productive effort zone? │
20
+ * │ │ │ (RPE 6–9 or RIR 1–4) │
21
+ * │ Rest Discipline │ 15% │ Did you respect optimal rest windows? │
22
+ * └─────────────────────┴────────┴──────────────────────────────────────────┘
23
+ *
24
+ * Each sub-component produces 0–100. The final score is a weighted average.
25
+ *
26
+ * DESIGN PRINCIPLES:
27
+ * - Motivational: even a mediocre workout should score 40–60, not 10
28
+ * - Honest: perfect scores require real effort and discipline
29
+ * - Fair across types: cardio and strength use the same framework
30
+ * - Transparent: the breakdown is returned so the UI can explain the score
31
+ */
32
+ import type { IParsedSet, IQualityBreakdown } from "./types";
33
+ import type { ITimingGuardrails } from "./parseRecords";
34
+ /**
35
+ * Raw record shape — we need the original RPE/RIR strings and isDone flag
36
+ * that aren't in IParsedSet (which only contains completed sets).
37
+ */
38
+ interface IRawRecord {
39
+ type: string;
40
+ isDone: boolean;
41
+ rpe?: string;
42
+ rir?: string;
43
+ kg?: string;
44
+ reps?: string;
45
+ durationMmSs?: string;
46
+ auxWeightKg?: string;
47
+ speedMin?: string;
48
+ speedMax?: string;
49
+ distance?: string;
50
+ restDurationSecs?: number;
51
+ }
52
+ /**
53
+ * Calculate the overall quality score and its breakdown.
54
+ *
55
+ * @param parsedSets Cleaned sets (from parseRecords) — only completed sets
56
+ * @param rawRecords Original TRecord[] — needed for completion count (includes skipped)
57
+ * @param timingGuardrails Exercise's guardrails — for rest period validation
58
+ * @returns { score: 0–100, breakdown: { completion, consistency, effortAdequacy, restDiscipline } }
59
+ *
60
+ * @example
61
+ * const { score, breakdown } = calculateQualityScore(parsed, raw, guardrails);
62
+ * // score: 81
63
+ * // breakdown: { completion: 100, consistency: 75, effortAdequacy: 80, restDiscipline: 73 }
64
+ */
65
+ export declare function calculateQualityScore(parsedSets: IParsedSet[], rawRecords: IRawRecord[], timingGuardrails?: ITimingGuardrails): {
66
+ score: number;
67
+ breakdown: IQualityBreakdown;
68
+ };
69
+ export {};