@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,247 @@
1
+ "use strict";
2
+ // constants.ts
3
+ /**
4
+ * ============================================================================
5
+ * FITFRIX EXERCISE SCORING SYSTEM — Constants
6
+ * ============================================================================
7
+ *
8
+ * All magic numbers, default values, and tuning parameters live here.
9
+ * When we need to adjust scoring behavior, this is the ONLY file to change.
10
+ *
11
+ * Naming convention:
12
+ * DEFAULT_* = user-related fallbacks (weight, height, age)
13
+ * FALLBACK_* = exercise timing fallbacks (only when timingGuardrails is missing)
14
+ * ABSOLUTE_* = hard safety bounds (catches truly broken data)
15
+ * QUALITY_* = quality score weights and thresholds
16
+ * FACTOR_* = multipliers used in formulas
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.REST_NO_DATA_SCORE = exports.REST_OUTSIDE_SCORE = exports.REST_ACCEPTABLE_BASE = exports.REST_OPTIMAL_SCORE = exports.EFFORT_NO_DATA_SCORE = exports.EFFORT_MIN_SCORE = exports.EFFORT_DISTANCE_PENALTY = exports.OPTIMAL_RPE_RANGE = exports.OPTIMAL_RIR_RANGE = exports.PROGRESSIVE_OVERLOAD_FLOOR = exports.CONSISTENCY_MIN_SCORE = exports.CONSISTENCY_CV_PENALTY = exports.QUALITY_WEIGHTS = exports.REFERENCE_MAX_EFFORT = exports.REFERENCE_MAX_REPS = exports.REFERENCE_MAX_SETS = exports.DIFFICULTY_TO_WEIGHT_FRACTION = exports.LOADED_CARRY_WEIGHT_MET_SCALE = exports.CONTINUOUS_DURATION_INTENSITY_FACTOR = exports.PLYOMETRIC_LOAD_MULTIPLIER = exports.CARDIO_MUSCLE_FATIGUE_DAMPENER = exports.SET_FATIGUE_DECAY_RATE = exports.SECONDARY_MUSCLE_ALLOCATION = exports.PRIMARY_MUSCLE_ALLOCATION = exports.CARDIO_SPEED_RANGE = exports.BW_FRACTION_SCALE = exports.DURATION_CATEGORY_THRESHOLDS = exports.DEFAULT_DURATION_FACTORS = exports.WEIGHT_CATEGORY_THRESHOLDS = exports.DEFAULT_WEIGHT_FACTORS = exports.REST_MET = exports.EFFORT_FRACTION_RANGE = exports.EFFORT_FRACTION_BASE = exports.EFFORT_CURVE_EXPONENT = exports.DEFAULT_EFFORT_FRACTION = exports.ABSOLUTE_WORK_MAX = exports.ABSOLUTE_WORK_MIN = exports.ABSOLUTE_REST_MAX = exports.ABSOLUTE_REST_MIN = exports.FALLBACK_STRESS_REST_BONUS = exports.FALLBACK_FATIGUE_MULTIPLIER = exports.FALLBACK_REST_SECS = exports.FALLBACK_SET_DURATION_SECS = exports.FALLBACK_SECS_PER_REP = exports.DEFAULT_USER_AGE = exports.DEFAULT_USER_HEIGHT_CM = exports.DEFAULT_USER_WEIGHT_KG = void 0;
20
+ // ---------------------------------------------------------------------------
21
+ // User Defaults (when TUserMetric has missing fields)
22
+ // ---------------------------------------------------------------------------
23
+ /** Average adult weight when user hasn't provided theirs */
24
+ exports.DEFAULT_USER_WEIGHT_KG = 70;
25
+ /** Average adult height */
26
+ exports.DEFAULT_USER_HEIGHT_CM = 170;
27
+ /** Assumed age when DOB is missing */
28
+ exports.DEFAULT_USER_AGE = 30;
29
+ // ---------------------------------------------------------------------------
30
+ // Timing: Last-Resort Fallbacks
31
+ // ---------------------------------------------------------------------------
32
+ //
33
+ // IMPORTANT: These are only used when timingGuardrails is COMPLETELY MISSING
34
+ // from the exercise. In normal operation, validation bounds and defaults come
35
+ // from the exercise's own timingGuardrails:
36
+ //
37
+ // Rest validation → timingGuardrails.restPeriods.minimum / maximum
38
+ // Rest fallback → timingGuardrails.restPeriods.typical
39
+ // Work duration → timingGuardrails.setDuration.min / max (duration type)
40
+ // → timingGuardrails.singleRep.min / max (rep-based types)
41
+ // Fatigue scaling → timingGuardrails.fatigueMultiplier
42
+ // Rest quality → timingGuardrails.stressRestBonus
43
+ //
44
+ // The constants below are emergency defaults for malformed exercise data.
45
+ /** Seconds per rep when timingGuardrails.singleRep is missing entirely */
46
+ exports.FALLBACK_SECS_PER_REP = 3;
47
+ /** Set duration when timingGuardrails.setDuration is missing entirely */
48
+ exports.FALLBACK_SET_DURATION_SECS = 30;
49
+ /** Rest period when timingGuardrails.restPeriods is missing entirely */
50
+ exports.FALLBACK_REST_SECS = 90;
51
+ /** Fatigue multiplier when timingGuardrails.fatigueMultiplier is missing */
52
+ exports.FALLBACK_FATIGUE_MULTIPLIER = 1.0;
53
+ /** Stress rest bonus when timingGuardrails.stressRestBonus is missing */
54
+ exports.FALLBACK_STRESS_REST_BONUS = 1.0;
55
+ /**
56
+ * Absolute sanity bounds — these catch truly absurd sensor/input errors
57
+ * that even the exercise's own guardrails wouldn't expect.
58
+ * These are NOT the exercise's min/max; they're a safety net.
59
+ *
60
+ * Example: a restDurationSecs of 86400 (24 hours) is clearly a bug,
61
+ * regardless of what the exercise's guardrails say.
62
+ */
63
+ exports.ABSOLUTE_REST_MIN = 0;
64
+ exports.ABSOLUTE_REST_MAX = 3600; // 1 hour — no exercise rests longer
65
+ exports.ABSOLUTE_WORK_MIN = 1; // At least 1 second of work
66
+ exports.ABSOLUTE_WORK_MAX = 14400; // 4 hours (ultra marathon cardio)
67
+ // ---------------------------------------------------------------------------
68
+ // Effort Conversion
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Effort fraction when neither RPE nor RIR is provided.
72
+ * 0.6 = moderate effort assumption.
73
+ */
74
+ exports.DEFAULT_EFFORT_FRACTION = 0.6;
75
+ /**
76
+ * Exponent for non-linear RPE/RIR → effort conversion.
77
+ * Values > 1.0 make the last reps near failure disproportionately harder,
78
+ * which matches real physiology (RPE 9→10 is a bigger jump than 5→6).
79
+ */
80
+ exports.EFFORT_CURVE_EXPONENT = 1.3;
81
+ /**
82
+ * Effort fraction output range.
83
+ * effort = BASE + RANGE × (normalized_input ^ EXPONENT)
84
+ */
85
+ exports.EFFORT_FRACTION_BASE = 0.5;
86
+ exports.EFFORT_FRACTION_RANGE = 0.8;
87
+ // ---------------------------------------------------------------------------
88
+ // Calorie Calculation
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * MET value during rest periods.
92
+ * Not true resting (1.0) because the body is still elevated after a set.
93
+ * 1.5 accounts for elevated heart rate and recovery metabolism.
94
+ */
95
+ exports.REST_MET = 1.5;
96
+ /**
97
+ * Default weight intensity multipliers when metabolicData.weightFactors
98
+ * is missing. Keyed by intensity category.
99
+ */
100
+ exports.DEFAULT_WEIGHT_FACTORS = {
101
+ light: 0.8, // < 25% bodyweight
102
+ moderate: 1.0, // 25–50% bodyweight
103
+ heavy: 1.3, // > 50% bodyweight
104
+ };
105
+ /** Thresholds for weight category classification (as ratio of bodyweight) */
106
+ exports.WEIGHT_CATEGORY_THRESHOLDS = {
107
+ lightMax: 0.25,
108
+ moderateMax: 0.5,
109
+ };
110
+ /**
111
+ * Default duration multipliers when metabolicData.durationFactors is missing.
112
+ */
113
+ exports.DEFAULT_DURATION_FACTORS = {
114
+ short: 0.9, // < 30 seconds
115
+ medium: 1.0, // 30s – 2min
116
+ long: 1.2, // > 2 min
117
+ };
118
+ /** Thresholds for duration category classification (in seconds) */
119
+ exports.DURATION_CATEGORY_THRESHOLDS = {
120
+ shortMax: 30,
121
+ mediumMax: 120,
122
+ };
123
+ /**
124
+ * For reps-only / bodyweight exercises: how much of bodyweight is "the load".
125
+ * Scaled by exercise difficultyLevel (0–4):
126
+ * load = (difficultyLevel / 4) × BW_FRACTION_SCALE × userWeightKg
127
+ *
128
+ * difficulty 0 → 0.0 × 0.65 = 0% (basically no BW load, e.g. finger exercise)
129
+ * difficulty 1 → 0.25 × 0.65 = 16% (light BW, e.g. easy crunches)
130
+ * difficulty 2 → 0.50 × 0.65 = 33% (moderate, e.g. push-ups)
131
+ * difficulty 4 → 1.00 × 0.65 = 65% (heavy, e.g. pistol squats)
132
+ */
133
+ exports.BW_FRACTION_SCALE = 0.65;
134
+ /**
135
+ * Expected min/max speed range for cardio (km/h) when paceFactors are missing.
136
+ * Used to normalize speed into a 0–1 fraction for MET interpolation.
137
+ */
138
+ exports.CARDIO_SPEED_RANGE = {
139
+ min: 3, // slow walk
140
+ max: 20, // fast sprint
141
+ };
142
+ // ---------------------------------------------------------------------------
143
+ // Muscle Fatigue
144
+ // ---------------------------------------------------------------------------
145
+ /** Fraction of total fatigue stimulus allocated to primary muscles */
146
+ exports.PRIMARY_MUSCLE_ALLOCATION = 1.0;
147
+ /** Fraction of total fatigue stimulus allocated to secondary muscles */
148
+ exports.SECONDARY_MUSCLE_ALLOCATION = 0.35;
149
+ /**
150
+ * Diminishing returns decay rate for consecutive sets.
151
+ * setDecay = 1 / (1 + DECAY_RATE × setIndex)
152
+ *
153
+ * With 0.15: set0=1.00, set1=0.87, set2=0.77, set3=0.69, set4=0.63
154
+ */
155
+ exports.SET_FATIGUE_DECAY_RATE = 0.15;
156
+ /**
157
+ * Cardio exercises create cardiovascular fatigue, not the same mechanical
158
+ * muscle fatigue as strength training. A 30-minute jog doesn't fatigue
159
+ * your quads the way 5 sets of heavy squats does.
160
+ *
161
+ * This dampener reduces the volume-load contribution of cardio exercises
162
+ * so their muscle fatigue scores are proportional to actual muscle damage/stress.
163
+ *
164
+ * 0.3 means cardio produces ~30% of the muscle fatigue that an equivalent
165
+ * "volume" of strength work would.
166
+ */
167
+ exports.CARDIO_MUSCLE_FATIGUE_DAMPENER = 0.3;
168
+ /**
169
+ * Load multiplier for plyometric / explosive reps-only exercises.
170
+ * Accounts for impact forces (3–5× BW) and neuromotor demand that
171
+ * simple BW × reps volume underestimates.
172
+ * Applied to volume load before fatigue distribution.
173
+ */
174
+ exports.PLYOMETRIC_LOAD_MULTIPLIER = 1.5;
175
+ /**
176
+ * Intensity factor for continuous-movement duration exercises
177
+ * (battle ropes, high knees, jump rope) when no speed/distance is available.
178
+ * Represents moderate continuous effort — higher than isometric holds but
179
+ * routed through the cardio dampener like other cardio types.
180
+ */
181
+ exports.CONTINUOUS_DURATION_INTENSITY_FACTOR = 0.5;
182
+ /**
183
+ * MET scale factor for loaded-carry exercises (farmer's walk, overhead carry).
184
+ * For every 100% of bodyweight carried, MET is boosted by this fraction.
185
+ * e.g. carrying 50% BW → MET × (1 + 0.5 × 0.5) = MET × 1.25
186
+ */
187
+ exports.LOADED_CARRY_WEIGHT_MET_SCALE = 0.5;
188
+ /**
189
+ * Reference max scaling by difficulty level.
190
+ * Maps difficultyLevel (0–4) → expected max weight as a fraction of bodyweight.
191
+ *
192
+ * This makes fatigue normalization exercise-aware:
193
+ * - Bicep curl (difficulty 1) → ref weight ~0.6× BW → smaller denominator → fair score
194
+ * - Squat (difficulty 3) → ref weight ~1.2× BW → larger denominator → fair score
195
+ */
196
+ const DIFFICULTY_TO_WEIGHT_FRACTION = (difficulty) => 0.3 + difficulty * 0.3;
197
+ exports.DIFFICULTY_TO_WEIGHT_FRACTION = DIFFICULTY_TO_WEIGHT_FRACTION;
198
+ /** Reference set count for "fully fatigued" benchmark */
199
+ exports.REFERENCE_MAX_SETS = 5;
200
+ /** Reference rep count for "fully fatigued" benchmark */
201
+ exports.REFERENCE_MAX_REPS = 12;
202
+ /** Reference effort multiplier for "fully fatigued" benchmark */
203
+ exports.REFERENCE_MAX_EFFORT = 1.3;
204
+ // ---------------------------------------------------------------------------
205
+ // Quality Score
206
+ // ---------------------------------------------------------------------------
207
+ /**
208
+ * Weights for the four quality sub-components.
209
+ * Must sum to 1.0.
210
+ */
211
+ exports.QUALITY_WEIGHTS = {
212
+ completion: 0.2,
213
+ consistency: 0.35,
214
+ effortAdequacy: 0.3,
215
+ restDiscipline: 0.15,
216
+ };
217
+ /**
218
+ * Consistency scoring: how aggressively CV (coefficient of variation) is penalized.
219
+ * consistencyScore = 100 - CV × PENALTY
220
+ * CV 0.0 → 100, CV 0.15 → 70, CV 0.50 → 0
221
+ */
222
+ exports.CONSISTENCY_CV_PENALTY = 200;
223
+ /** Minimum consistency score (even very inconsistent sets get some credit) */
224
+ exports.CONSISTENCY_MIN_SCORE = 20;
225
+ /** Floor score for intentional progressive/descending sets (≥3 sets) */
226
+ exports.PROGRESSIVE_OVERLOAD_FLOOR = 75;
227
+ /**
228
+ * Optimal RIR range for effort adequacy scoring.
229
+ * Within this range → 100 points. Outside → penalized by distance.
230
+ */
231
+ exports.OPTIMAL_RIR_RANGE = [1, 4];
232
+ /** Optimal RPE range for effort adequacy scoring. */
233
+ exports.OPTIMAL_RPE_RANGE = [6, 9];
234
+ /** Penalty per unit of distance from the optimal effort range */
235
+ exports.EFFORT_DISTANCE_PENALTY = 20;
236
+ /** Minimum effort adequacy score */
237
+ exports.EFFORT_MIN_SCORE = 20;
238
+ /** Default effort adequacy score when no RPE/RIR data is available */
239
+ exports.EFFORT_NO_DATA_SCORE = 70;
240
+ /** Score when rest is within the optimal range */
241
+ exports.REST_OPTIMAL_SCORE = 100;
242
+ /** Score when rest is within acceptable (min–max) but not optimal */
243
+ exports.REST_ACCEPTABLE_BASE = 70;
244
+ /** Score when rest is completely outside the acceptable range */
245
+ exports.REST_OUTSIDE_SCORE = 50;
246
+ /** Default rest discipline score when no valid rest data exists */
247
+ exports.REST_NO_DATA_SCORE = 75;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM — Helpers
4
+ * ============================================================================
5
+ *
6
+ * Pure utility functions with no business logic. These handle:
7
+ * - Safe parsing of string values from TRecord
8
+ * - Duration parsing ("MM:SS" → seconds)
9
+ * - Clamping and validation
10
+ * - Effort fraction conversion (RPE/RIR → 0–1)
11
+ * - User context extraction with fallbacks
12
+ *
13
+ * All functions are deterministic and side-effect free.
14
+ */
15
+ import { TGender } from "../../types";
16
+ import type { IUserContext } from "./types";
17
+ /**
18
+ * Safely parse a string to a float.
19
+ * Returns `fallback` if the input is undefined, empty, NaN, or negative.
20
+ *
21
+ * @example
22
+ * safeParseFloat("10.5") // 10.5
23
+ * safeParseFloat("") // 0
24
+ * safeParseFloat(undefined) // 0
25
+ * safeParseFloat("abc", 5) // 5
26
+ */
27
+ export declare function safeParseFloat(value: string | undefined | null, fallback?: number): number;
28
+ /**
29
+ * Parse a "MM:SS" or "M:SS" or "HH:MM:SS" duration string into total seconds.
30
+ *
31
+ * Handles edge cases:
32
+ * - "04:55" → 295 seconds
33
+ * - "10:00" → 600 seconds
34
+ * - "1:30:00" → 5400 seconds
35
+ * - "0:45" → 45 seconds (could be MM:SS with 0 min, or just 45 sec)
36
+ * - "" or invalid → fallbackSecs
37
+ *
38
+ * @param duration The "MM:SS" string from TRecord.durationMmSs
39
+ * @param fallbackSecs Returned when parsing fails (default: 0)
40
+ */
41
+ export declare function parseDurationMmSs(duration: string | undefined | null, fallbackSecs?: number): number;
42
+ /**
43
+ * Clamp a value between min and max (inclusive).
44
+ */
45
+ export declare function clamp(value: number, min: number, max: number): number;
46
+ /**
47
+ * Calculate the arithmetic mean of an array of numbers.
48
+ * Returns 0 for an empty array.
49
+ */
50
+ export declare function mean(values: number[]): number;
51
+ /**
52
+ * Calculate the standard deviation (population) of an array of numbers.
53
+ * Returns 0 for arrays with fewer than 2 elements.
54
+ */
55
+ export declare function standardDeviation(values: number[]): number;
56
+ /**
57
+ * Coefficient of variation: stdDev / mean.
58
+ * Returns 0 if mean is 0 (avoids division by zero).
59
+ * A CV of 0 means perfect consistency; higher = more variable.
60
+ */
61
+ export declare function coefficientOfVariation(values: number[]): number;
62
+ /**
63
+ * Convert RIR (Reps In Reserve) to an effort fraction (0.0 – 1.0).
64
+ *
65
+ * RIR scale: 0 = at failure (max effort), 10 = trivial (minimal effort).
66
+ *
67
+ * Uses a non-linear curve (exponent 1.3) because the perceptual and
68
+ * physiological cost of the last 2–3 reps near failure is disproportionately
69
+ * higher than the middle reps.
70
+ *
71
+ * Output range: ~0.50 (RIR 10) → ~1.30 (RIR 0)
72
+ * This is an "effort multiplier" not a pure 0–1, but the naming matches
73
+ * its usage in calorie and fatigue calculations.
74
+ *
75
+ * @example
76
+ * rirToEffortFraction(0) // ~1.30 (at failure)
77
+ * rirToEffortFraction(3) // ~0.94
78
+ * rirToEffortFraction(5) // ~0.82
79
+ * rirToEffortFraction(10) // ~0.50
80
+ */
81
+ export declare function rirToEffortFraction(rir: number): number;
82
+ /**
83
+ * Convert RPE (Rate of Perceived Exertion) to an effort fraction.
84
+ *
85
+ * RPE scale: 1 = very easy, 10 = maximal effort.
86
+ * Same non-linear curve as RIR conversion.
87
+ *
88
+ * @example
89
+ * rpeToEffortFraction(10) // ~1.30 (max effort)
90
+ * rpeToEffortFraction(7) // ~0.94
91
+ * rpeToEffortFraction(5) // ~0.82
92
+ * rpeToEffortFraction(1) // ~0.54
93
+ */
94
+ export declare function rpeToEffortFraction(rpe: number): number;
95
+ /**
96
+ * Extract effort fraction from a raw TRecord.
97
+ *
98
+ * Priority: RIR > RPE > default (0.6).
99
+ * RIR is preferred because it's more objective (countable reps left)
100
+ * while RPE is subjective.
101
+ *
102
+ * @param record - Any TRecord (we only read the rpe/rir fields)
103
+ */
104
+ export declare function getEffortFraction(record: {
105
+ rpe?: string;
106
+ rir?: string;
107
+ }): number;
108
+ /**
109
+ * Extract a validated user context from TUserMetric.
110
+ * Guarantees all fields have usable values (no undefined).
111
+ *
112
+ * @param user - The raw TUserMetric object
113
+ */
114
+ export declare function extractUserContext(user: {
115
+ weightKg?: number;
116
+ heightCm?: number;
117
+ gender?: TGender;
118
+ dob?: Date | string;
119
+ fitnessLevel?: import("../../types").TActivityLevel;
120
+ fitnessGoal?: import("../../types").TFitnessGoal;
121
+ bodyFatPercentage?: number;
122
+ }): IUserContext;
123
+ /**
124
+ * Derives a user's training age bracket based on their account age and workout frequency.
125
+ * Used for RIR attenuation and reference max scaling (P2-10).
126
+ */
127
+ export declare function deriveTrainingAgeBracket(accountAgeDays: number, totalSessionCount: number): import("./types").TTrainingAgeBracket;
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ // helpers.ts
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.safeParseFloat = safeParseFloat;
5
+ exports.parseDurationMmSs = parseDurationMmSs;
6
+ exports.clamp = clamp;
7
+ exports.mean = mean;
8
+ exports.standardDeviation = standardDeviation;
9
+ exports.coefficientOfVariation = coefficientOfVariation;
10
+ exports.rirToEffortFraction = rirToEffortFraction;
11
+ exports.rpeToEffortFraction = rpeToEffortFraction;
12
+ exports.getEffortFraction = getEffortFraction;
13
+ exports.extractUserContext = extractUserContext;
14
+ exports.deriveTrainingAgeBracket = deriveTrainingAgeBracket;
15
+ const constants_1 = require("./constants");
16
+ // We reference these types but don't import them to keep this module decoupled.
17
+ // The caller passes the raw values; we just parse them.
18
+ // ---------------------------------------------------------------------------
19
+ // Numeric Parsing
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * Safely parse a string to a float.
23
+ * Returns `fallback` if the input is undefined, empty, NaN, or negative.
24
+ *
25
+ * @example
26
+ * safeParseFloat("10.5") // 10.5
27
+ * safeParseFloat("") // 0
28
+ * safeParseFloat(undefined) // 0
29
+ * safeParseFloat("abc", 5) // 5
30
+ */
31
+ function safeParseFloat(value, fallback = 0) {
32
+ if (value === undefined || value === null || value === "")
33
+ return fallback;
34
+ const parsed = parseFloat(value);
35
+ if (isNaN(parsed) || parsed < 0)
36
+ return fallback;
37
+ return parsed;
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Duration Parsing
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * Parse a "MM:SS" or "M:SS" or "HH:MM:SS" duration string into total seconds.
44
+ *
45
+ * Handles edge cases:
46
+ * - "04:55" → 295 seconds
47
+ * - "10:00" → 600 seconds
48
+ * - "1:30:00" → 5400 seconds
49
+ * - "0:45" → 45 seconds (could be MM:SS with 0 min, or just 45 sec)
50
+ * - "" or invalid → fallbackSecs
51
+ *
52
+ * @param duration The "MM:SS" string from TRecord.durationMmSs
53
+ * @param fallbackSecs Returned when parsing fails (default: 0)
54
+ */
55
+ function parseDurationMmSs(duration, fallbackSecs = 0) {
56
+ if (!duration || typeof duration !== "string")
57
+ return fallbackSecs;
58
+ const parts = duration.split(":").map(Number);
59
+ // Filter out NaN parts
60
+ if (parts.some(isNaN))
61
+ return fallbackSecs;
62
+ if (parts.length === 3) {
63
+ // HH:MM:SS
64
+ const [hours, minutes, seconds] = parts;
65
+ const total = hours * 3600 + minutes * 60 + seconds;
66
+ return total > 0 ? total : fallbackSecs;
67
+ }
68
+ if (parts.length === 2) {
69
+ // MM:SS
70
+ const [minutes, seconds] = parts;
71
+ const total = minutes * 60 + seconds;
72
+ return total > 0 ? total : fallbackSecs;
73
+ }
74
+ if (parts.length === 1 && parts[0] > 0) {
75
+ // Just seconds
76
+ return parts[0];
77
+ }
78
+ return fallbackSecs;
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Math Utilities
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * Clamp a value between min and max (inclusive).
85
+ */
86
+ function clamp(value, min, max) {
87
+ return Math.min(Math.max(value, min), max);
88
+ }
89
+ /**
90
+ * Calculate the arithmetic mean of an array of numbers.
91
+ * Returns 0 for an empty array.
92
+ */
93
+ function mean(values) {
94
+ if (values.length === 0)
95
+ return 0;
96
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
97
+ }
98
+ /**
99
+ * Calculate the standard deviation (population) of an array of numbers.
100
+ * Returns 0 for arrays with fewer than 2 elements.
101
+ */
102
+ function standardDeviation(values) {
103
+ if (values.length < 2)
104
+ return 0;
105
+ const avg = mean(values);
106
+ const squaredDiffs = values.map((v) => Math.pow((v - avg), 2));
107
+ return Math.sqrt(mean(squaredDiffs));
108
+ }
109
+ /**
110
+ * Coefficient of variation: stdDev / mean.
111
+ * Returns 0 if mean is 0 (avoids division by zero).
112
+ * A CV of 0 means perfect consistency; higher = more variable.
113
+ */
114
+ function coefficientOfVariation(values) {
115
+ const avg = mean(values);
116
+ if (avg === 0)
117
+ return 0;
118
+ return standardDeviation(values) / avg;
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Effort Conversion
122
+ // ---------------------------------------------------------------------------
123
+ /**
124
+ * Convert RIR (Reps In Reserve) to an effort fraction (0.0 – 1.0).
125
+ *
126
+ * RIR scale: 0 = at failure (max effort), 10 = trivial (minimal effort).
127
+ *
128
+ * Uses a non-linear curve (exponent 1.3) because the perceptual and
129
+ * physiological cost of the last 2–3 reps near failure is disproportionately
130
+ * higher than the middle reps.
131
+ *
132
+ * Output range: ~0.50 (RIR 10) → ~1.30 (RIR 0)
133
+ * This is an "effort multiplier" not a pure 0–1, but the naming matches
134
+ * its usage in calorie and fatigue calculations.
135
+ *
136
+ * @example
137
+ * rirToEffortFraction(0) // ~1.30 (at failure)
138
+ * rirToEffortFraction(3) // ~0.94
139
+ * rirToEffortFraction(5) // ~0.82
140
+ * rirToEffortFraction(10) // ~0.50
141
+ */
142
+ function rirToEffortFraction(rir) {
143
+ const clampedRir = clamp(rir, 0, 10);
144
+ const normalized = (10 - clampedRir) / 10; // 0 at RIR 10, 1 at RIR 0
145
+ return (constants_1.EFFORT_FRACTION_BASE +
146
+ constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
147
+ }
148
+ /**
149
+ * Convert RPE (Rate of Perceived Exertion) to an effort fraction.
150
+ *
151
+ * RPE scale: 1 = very easy, 10 = maximal effort.
152
+ * Same non-linear curve as RIR conversion.
153
+ *
154
+ * @example
155
+ * rpeToEffortFraction(10) // ~1.30 (max effort)
156
+ * rpeToEffortFraction(7) // ~0.94
157
+ * rpeToEffortFraction(5) // ~0.82
158
+ * rpeToEffortFraction(1) // ~0.54
159
+ */
160
+ function rpeToEffortFraction(rpe) {
161
+ const clampedRpe = clamp(rpe, 1, 10);
162
+ const normalized = clampedRpe / 10; // 0.1 at RPE 1, 1.0 at RPE 10
163
+ return (constants_1.EFFORT_FRACTION_BASE +
164
+ constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
165
+ }
166
+ /**
167
+ * Extract effort fraction from a raw TRecord.
168
+ *
169
+ * Priority: RIR > RPE > default (0.6).
170
+ * RIR is preferred because it's more objective (countable reps left)
171
+ * while RPE is subjective.
172
+ *
173
+ * @param record - Any TRecord (we only read the rpe/rir fields)
174
+ */
175
+ function getEffortFraction(record) {
176
+ // Try RIR first (more objective)
177
+ if (record.rir !== undefined && record.rir !== "") {
178
+ const rir = safeParseFloat(record.rir, -1);
179
+ if (rir >= 0)
180
+ return rirToEffortFraction(rir);
181
+ }
182
+ // Fall back to RPE
183
+ if (record.rpe !== undefined && record.rpe !== "") {
184
+ const rpe = safeParseFloat(record.rpe, -1);
185
+ if (rpe >= 1)
186
+ return rpeToEffortFraction(rpe);
187
+ }
188
+ // No effort data available
189
+ return constants_1.DEFAULT_EFFORT_FRACTION;
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // User Context
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * Extract a validated user context from TUserMetric.
196
+ * Guarantees all fields have usable values (no undefined).
197
+ *
198
+ * @param user - The raw TUserMetric object
199
+ */
200
+ function extractUserContext(user) {
201
+ var _a, _b, _c;
202
+ const weightKg = user.weightKg && user.weightKg > 20 && user.weightKg < 300
203
+ ? user.weightKg
204
+ : constants_1.DEFAULT_USER_WEIGHT_KG;
205
+ const heightCm = user.heightCm && user.heightCm > 100 && user.heightCm < 250
206
+ ? user.heightCm
207
+ : constants_1.DEFAULT_USER_HEIGHT_CM;
208
+ let age = constants_1.DEFAULT_USER_AGE;
209
+ if (user.dob) {
210
+ const dob = typeof user.dob === "string" ? new Date(user.dob) : user.dob;
211
+ if (!isNaN(dob.getTime())) {
212
+ const today = new Date();
213
+ age = today.getFullYear() - dob.getFullYear();
214
+ // Adjust if birthday hasn't happened yet this year
215
+ const monthDiff = today.getMonth() - dob.getMonth();
216
+ if (monthDiff < 0 ||
217
+ (monthDiff === 0 && today.getDate() < dob.getDate())) {
218
+ age--;
219
+ }
220
+ // Sanity check
221
+ if (age < 10 || age > 100)
222
+ age = constants_1.DEFAULT_USER_AGE;
223
+ }
224
+ }
225
+ return {
226
+ weightKg,
227
+ heightCm,
228
+ gender: user.gender || "unmentioned",
229
+ age,
230
+ fitnessLevel: (_a = user.fitnessLevel) !== null && _a !== void 0 ? _a : "moderately-active",
231
+ fitnessGoal: (_b = user.fitnessGoal) !== null && _b !== void 0 ? _b : "general",
232
+ bodyFatPercentage: (_c = user.bodyFatPercentage) !== null && _c !== void 0 ? _c : 20,
233
+ };
234
+ }
235
+ /**
236
+ * Derives a user's training age bracket based on their account age and workout frequency.
237
+ * Used for RIR attenuation and reference max scaling (P2-10).
238
+ */
239
+ function deriveTrainingAgeBracket(accountAgeDays, totalSessionCount) {
240
+ if (accountAgeDays < 90 || totalSessionCount < 20)
241
+ return "beginner";
242
+ if (accountAgeDays < 365 || totalSessionCount < 100)
243
+ return "intermediate";
244
+ return "advanced";
245
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM
4
+ * ============================================================================
5
+ *
6
+ * calculateExerciseScoreV2({ exercise, record, user, historicalContext? }) => IScoreResult
7
+ *
8
+ * IScoreResult = {
9
+ * score: number, // 0–100 quality of execution
10
+ * muscleScores: Record<string, number>, // 0–100 per muscle fatigue
11
+ * calorieBurn: number, // kcal (placeholder: 0 until Phase 3)
12
+ * qualityBreakdown: IQualityBreakdown // completion/consistency/effort/rest sub-scores
13
+ * }
14
+ *
15
+ * Internally computes muscle fatigue (Pillar 2) and quality score (Pillar 3).
16
+ * Calorie burn (Pillar 1) is implemented in calculateCalories.ts but not yet
17
+ * wired into the save flow — scheduled for Phase 3.
18
+ */
19
+ import { TExercise, TRecord, TUserMetric } from "../../types";
20
+ import type { IScoreResult } from "./types";
21
+ export { calculateTotalVolume } from "./calculateTotalVolume";
22
+ export declare const calculateExerciseScoreV2: (param: {
23
+ exercise: TExercise;
24
+ record: TRecord[];
25
+ user: TUserMetric;
26
+ historicalContext?: import("./types").IHistoricalContext;
27
+ }) => IScoreResult;