@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,227 @@
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.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
+ * Reference max scaling by difficulty level.
170
+ * Maps difficultyLevel (0–4) → expected max weight as a fraction of bodyweight.
171
+ *
172
+ * This makes fatigue normalization exercise-aware:
173
+ * - Bicep curl (difficulty 1) → ref weight ~0.6× BW → smaller denominator → fair score
174
+ * - Squat (difficulty 3) → ref weight ~1.2× BW → larger denominator → fair score
175
+ */
176
+ const DIFFICULTY_TO_WEIGHT_FRACTION = (difficulty) => 0.3 + difficulty * 0.3;
177
+ exports.DIFFICULTY_TO_WEIGHT_FRACTION = DIFFICULTY_TO_WEIGHT_FRACTION;
178
+ /** Reference set count for "fully fatigued" benchmark */
179
+ exports.REFERENCE_MAX_SETS = 5;
180
+ /** Reference rep count for "fully fatigued" benchmark */
181
+ exports.REFERENCE_MAX_REPS = 12;
182
+ /** Reference effort multiplier for "fully fatigued" benchmark */
183
+ exports.REFERENCE_MAX_EFFORT = 1.3;
184
+ // ---------------------------------------------------------------------------
185
+ // Quality Score
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Weights for the four quality sub-components.
189
+ * Must sum to 1.0.
190
+ */
191
+ exports.QUALITY_WEIGHTS = {
192
+ completion: 0.2,
193
+ consistency: 0.35,
194
+ effortAdequacy: 0.3,
195
+ restDiscipline: 0.15,
196
+ };
197
+ /**
198
+ * Consistency scoring: how aggressively CV (coefficient of variation) is penalized.
199
+ * consistencyScore = 100 - CV × PENALTY
200
+ * CV 0.0 → 100, CV 0.15 → 70, CV 0.50 → 0
201
+ */
202
+ exports.CONSISTENCY_CV_PENALTY = 200;
203
+ /** Minimum consistency score (even very inconsistent sets get some credit) */
204
+ exports.CONSISTENCY_MIN_SCORE = 20;
205
+ /** Floor score for intentional progressive/descending sets (≥3 sets) */
206
+ exports.PROGRESSIVE_OVERLOAD_FLOOR = 75;
207
+ /**
208
+ * Optimal RIR range for effort adequacy scoring.
209
+ * Within this range → 100 points. Outside → penalized by distance.
210
+ */
211
+ exports.OPTIMAL_RIR_RANGE = [1, 4];
212
+ /** Optimal RPE range for effort adequacy scoring. */
213
+ exports.OPTIMAL_RPE_RANGE = [6, 9];
214
+ /** Penalty per unit of distance from the optimal effort range */
215
+ exports.EFFORT_DISTANCE_PENALTY = 20;
216
+ /** Minimum effort adequacy score */
217
+ exports.EFFORT_MIN_SCORE = 20;
218
+ /** Default effort adequacy score when no RPE/RIR data is available */
219
+ exports.EFFORT_NO_DATA_SCORE = 70;
220
+ /** Score when rest is within the optimal range */
221
+ exports.REST_OPTIMAL_SCORE = 100;
222
+ /** Score when rest is within acceptable (min–max) but not optimal */
223
+ exports.REST_ACCEPTABLE_BASE = 60;
224
+ /** Score when rest is completely outside the acceptable range */
225
+ exports.REST_OUTSIDE_SCORE = 30;
226
+ /** Default rest discipline score when no valid rest data exists */
227
+ exports.REST_NO_DATA_SCORE = 65;
@@ -0,0 +1,119 @@
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
+ }): IUserContext;
@@ -0,0 +1,229 @@
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
+ const constants_1 = require("./constants");
15
+ // We reference these types but don't import them to keep this module decoupled.
16
+ // The caller passes the raw values; we just parse them.
17
+ // ---------------------------------------------------------------------------
18
+ // Numeric Parsing
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Safely parse a string to a float.
22
+ * Returns `fallback` if the input is undefined, empty, NaN, or negative.
23
+ *
24
+ * @example
25
+ * safeParseFloat("10.5") // 10.5
26
+ * safeParseFloat("") // 0
27
+ * safeParseFloat(undefined) // 0
28
+ * safeParseFloat("abc", 5) // 5
29
+ */
30
+ function safeParseFloat(value, fallback = 0) {
31
+ if (value === undefined || value === null || value === "")
32
+ return fallback;
33
+ const parsed = parseFloat(value);
34
+ if (isNaN(parsed) || parsed < 0)
35
+ return fallback;
36
+ return parsed;
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Duration Parsing
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Parse a "MM:SS" or "M:SS" or "HH:MM:SS" duration string into total seconds.
43
+ *
44
+ * Handles edge cases:
45
+ * - "04:55" → 295 seconds
46
+ * - "10:00" → 600 seconds
47
+ * - "1:30:00" → 5400 seconds
48
+ * - "0:45" → 45 seconds (could be MM:SS with 0 min, or just 45 sec)
49
+ * - "" or invalid → fallbackSecs
50
+ *
51
+ * @param duration The "MM:SS" string from TRecord.durationMmSs
52
+ * @param fallbackSecs Returned when parsing fails (default: 0)
53
+ */
54
+ function parseDurationMmSs(duration, fallbackSecs = 0) {
55
+ if (!duration || typeof duration !== "string")
56
+ return fallbackSecs;
57
+ const parts = duration.split(":").map(Number);
58
+ // Filter out NaN parts
59
+ if (parts.some(isNaN))
60
+ return fallbackSecs;
61
+ if (parts.length === 3) {
62
+ // HH:MM:SS
63
+ const [hours, minutes, seconds] = parts;
64
+ const total = hours * 3600 + minutes * 60 + seconds;
65
+ return total > 0 ? total : fallbackSecs;
66
+ }
67
+ if (parts.length === 2) {
68
+ // MM:SS
69
+ const [minutes, seconds] = parts;
70
+ const total = minutes * 60 + seconds;
71
+ return total > 0 ? total : fallbackSecs;
72
+ }
73
+ if (parts.length === 1 && parts[0] > 0) {
74
+ // Just seconds
75
+ return parts[0];
76
+ }
77
+ return fallbackSecs;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Math Utilities
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Clamp a value between min and max (inclusive).
84
+ */
85
+ function clamp(value, min, max) {
86
+ return Math.min(Math.max(value, min), max);
87
+ }
88
+ /**
89
+ * Calculate the arithmetic mean of an array of numbers.
90
+ * Returns 0 for an empty array.
91
+ */
92
+ function mean(values) {
93
+ if (values.length === 0)
94
+ return 0;
95
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
96
+ }
97
+ /**
98
+ * Calculate the standard deviation (population) of an array of numbers.
99
+ * Returns 0 for arrays with fewer than 2 elements.
100
+ */
101
+ function standardDeviation(values) {
102
+ if (values.length < 2)
103
+ return 0;
104
+ const avg = mean(values);
105
+ const squaredDiffs = values.map((v) => Math.pow((v - avg), 2));
106
+ return Math.sqrt(mean(squaredDiffs));
107
+ }
108
+ /**
109
+ * Coefficient of variation: stdDev / mean.
110
+ * Returns 0 if mean is 0 (avoids division by zero).
111
+ * A CV of 0 means perfect consistency; higher = more variable.
112
+ */
113
+ function coefficientOfVariation(values) {
114
+ const avg = mean(values);
115
+ if (avg === 0)
116
+ return 0;
117
+ return standardDeviation(values) / avg;
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Effort Conversion
121
+ // ---------------------------------------------------------------------------
122
+ /**
123
+ * Convert RIR (Reps In Reserve) to an effort fraction (0.0 – 1.0).
124
+ *
125
+ * RIR scale: 0 = at failure (max effort), 10 = trivial (minimal effort).
126
+ *
127
+ * Uses a non-linear curve (exponent 1.3) because the perceptual and
128
+ * physiological cost of the last 2–3 reps near failure is disproportionately
129
+ * higher than the middle reps.
130
+ *
131
+ * Output range: ~0.50 (RIR 10) → ~1.30 (RIR 0)
132
+ * This is an "effort multiplier" not a pure 0–1, but the naming matches
133
+ * its usage in calorie and fatigue calculations.
134
+ *
135
+ * @example
136
+ * rirToEffortFraction(0) // ~1.30 (at failure)
137
+ * rirToEffortFraction(3) // ~0.94
138
+ * rirToEffortFraction(5) // ~0.82
139
+ * rirToEffortFraction(10) // ~0.50
140
+ */
141
+ function rirToEffortFraction(rir) {
142
+ const clampedRir = clamp(rir, 0, 10);
143
+ const normalized = (10 - clampedRir) / 10; // 0 at RIR 10, 1 at RIR 0
144
+ return (constants_1.EFFORT_FRACTION_BASE +
145
+ constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
146
+ }
147
+ /**
148
+ * Convert RPE (Rate of Perceived Exertion) to an effort fraction.
149
+ *
150
+ * RPE scale: 1 = very easy, 10 = maximal effort.
151
+ * Same non-linear curve as RIR conversion.
152
+ *
153
+ * @example
154
+ * rpeToEffortFraction(10) // ~1.30 (max effort)
155
+ * rpeToEffortFraction(7) // ~0.94
156
+ * rpeToEffortFraction(5) // ~0.82
157
+ * rpeToEffortFraction(1) // ~0.54
158
+ */
159
+ function rpeToEffortFraction(rpe) {
160
+ const clampedRpe = clamp(rpe, 1, 10);
161
+ const normalized = clampedRpe / 10; // 0.1 at RPE 1, 1.0 at RPE 10
162
+ return (constants_1.EFFORT_FRACTION_BASE +
163
+ constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
164
+ }
165
+ /**
166
+ * Extract effort fraction from a raw TRecord.
167
+ *
168
+ * Priority: RIR > RPE > default (0.6).
169
+ * RIR is preferred because it's more objective (countable reps left)
170
+ * while RPE is subjective.
171
+ *
172
+ * @param record - Any TRecord (we only read the rpe/rir fields)
173
+ */
174
+ function getEffortFraction(record) {
175
+ // Try RIR first (more objective)
176
+ if (record.rir !== undefined && record.rir !== "") {
177
+ const rir = safeParseFloat(record.rir, -1);
178
+ if (rir >= 0)
179
+ return rirToEffortFraction(rir);
180
+ }
181
+ // Fall back to RPE
182
+ if (record.rpe !== undefined && record.rpe !== "") {
183
+ const rpe = safeParseFloat(record.rpe, -1);
184
+ if (rpe >= 1)
185
+ return rpeToEffortFraction(rpe);
186
+ }
187
+ // No effort data available
188
+ return constants_1.DEFAULT_EFFORT_FRACTION;
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // User Context
192
+ // ---------------------------------------------------------------------------
193
+ /**
194
+ * Extract a validated user context from TUserMetric.
195
+ * Guarantees all fields have usable values (no undefined).
196
+ *
197
+ * @param user - The raw TUserMetric object
198
+ */
199
+ function extractUserContext(user) {
200
+ const weightKg = user.weightKg && user.weightKg > 20 && user.weightKg < 300
201
+ ? user.weightKg
202
+ : constants_1.DEFAULT_USER_WEIGHT_KG;
203
+ const heightCm = user.heightCm && user.heightCm > 100 && user.heightCm < 250
204
+ ? user.heightCm
205
+ : constants_1.DEFAULT_USER_HEIGHT_CM;
206
+ let age = constants_1.DEFAULT_USER_AGE;
207
+ if (user.dob) {
208
+ const dob = typeof user.dob === "string" ? new Date(user.dob) : user.dob;
209
+ if (!isNaN(dob.getTime())) {
210
+ const today = new Date();
211
+ age = today.getFullYear() - dob.getFullYear();
212
+ // Adjust if birthday hasn't happened yet this year
213
+ const monthDiff = today.getMonth() - dob.getMonth();
214
+ if (monthDiff < 0 ||
215
+ (monthDiff === 0 && today.getDate() < dob.getDate())) {
216
+ age--;
217
+ }
218
+ // Sanity check
219
+ if (age < 10 || age > 100)
220
+ age = constants_1.DEFAULT_USER_AGE;
221
+ }
222
+ }
223
+ return {
224
+ weightKg,
225
+ heightCm,
226
+ gender: user.gender || "unmentioned",
227
+ age,
228
+ };
229
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM
4
+ * ============================================================================
5
+ *
6
+ * Matches existing app signature exactly:
7
+ *
8
+ * calculateExerciseScore({ exercise, record, user }) => IScoreResult
9
+ *
10
+ * IScoreResult = { score: number, muscleScores: Record<string, number> }
11
+ *
12
+ * Internally computes calorie burn (Pillar 1), muscle fatigue (Pillar 2),
13
+ * and quality score (Pillar 3), but only exposes what the existing
14
+ * interface expects.
15
+ */
16
+ import { TExercise, TRecord, TUserMetric } from "../../types";
17
+ interface IScoreResult {
18
+ score: number;
19
+ muscleScores: {
20
+ [muscleGroup: string]: number;
21
+ };
22
+ }
23
+ export declare const calculateExerciseScoreV2: (param: {
24
+ exercise: TExercise;
25
+ record: TRecord[];
26
+ user: TUserMetric;
27
+ }) => IScoreResult;
28
+ export {};
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ /**
3
+ * ============================================================================
4
+ * FITFRIX EXERCISE SCORING SYSTEM
5
+ * ============================================================================
6
+ *
7
+ * Matches existing app signature exactly:
8
+ *
9
+ * calculateExerciseScore({ exercise, record, user }) => IScoreResult
10
+ *
11
+ * IScoreResult = { score: number, muscleScores: Record<string, number> }
12
+ *
13
+ * Internally computes calorie burn (Pillar 1), muscle fatigue (Pillar 2),
14
+ * and quality score (Pillar 3), but only exposes what the existing
15
+ * interface expects.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.calculateExerciseScoreV2 = void 0;
19
+ const calculateMuscleFatiue_1 = require("./calculateMuscleFatiue");
20
+ const calculateQualityScore_1 = require("./calculateQualityScore");
21
+ const helpers_1 = require("./helpers");
22
+ const parseRecords_1 = require("./parseRecords");
23
+ // ---------------------------------------------------------------------------
24
+ // Main Function — existing signature
25
+ // ---------------------------------------------------------------------------
26
+ const calculateExerciseScoreV2 = (param) => {
27
+ const { exercise, record, user } = param;
28
+ const userContext = (0, helpers_1.extractUserContext)(user);
29
+ const parsedSets = (0, parseRecords_1.parseRecords)(record, exercise.timingGuardrails);
30
+ // Pillar 2: Muscle Fatigue → muscleScores
31
+ const muscleScores = (0, calculateMuscleFatiue_1.calculateMuscleFatigue)(parsedSets, {
32
+ primaryMuscles: exercise.primaryMuscles,
33
+ secondaryMuscles: exercise.secondaryMuscles,
34
+ difficultyLevel: exercise.difficultyLevel,
35
+ metabolicData: {
36
+ compoundMultiplier: exercise.metabolicData.compoundMultiplier,
37
+ muscleGroupFactor: exercise.metabolicData.muscleGroupFactor,
38
+ },
39
+ }, userContext, exercise.timingGuardrails);
40
+ // Pillar 3: Quality Score → score
41
+ const { score } = (0, calculateQualityScore_1.calculateQualityScore)(parsedSets, record, exercise.timingGuardrails);
42
+ return { score, muscleScores };
43
+ };
44
+ exports.calculateExerciseScoreV2 = calculateExerciseScoreV2;