@dgpholdings/greatoak-shared 1.2.85 → 1.2.87

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 (79) hide show
  1. package/README.md +148 -148
  2. package/dist/__mocks__/exercises.mock.js +1 -0
  3. package/dist/constants/index.d.ts +1 -0
  4. package/dist/constants/index.js +1 -0
  5. package/dist/constants/quickStartIntents.d.ts +19 -0
  6. package/dist/constants/quickStartIntents.js +39 -0
  7. package/dist/types/TApiAiExerciseAnalysis.d.ts +2 -1
  8. package/dist/types/TApiClientConstellation.d.ts +33 -0
  9. package/dist/types/TApiClientConstellation.js +13 -0
  10. package/dist/types/TApiExercise.d.ts +5 -3
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/utils/constellation/computeNormalisedLoad.d.ts +48 -0
  13. package/dist/utils/constellation/computeNormalisedLoad.js +150 -0
  14. package/dist/utils/constellation/evaluateConstellation.d.ts +27 -0
  15. package/dist/utils/constellation/evaluateConstellation.js +135 -0
  16. package/dist/utils/constellation/index.d.ts +17 -0
  17. package/dist/utils/constellation/index.js +26 -0
  18. package/dist/utils/constellation/levelThresholds.d.ts +99 -0
  19. package/dist/utils/constellation/levelThresholds.js +123 -0
  20. package/dist/utils/constellation/starFoundation.d.ts +25 -0
  21. package/dist/utils/constellation/starFoundation.js +54 -0
  22. package/dist/utils/constellation/stars/consistency.d.ts +29 -0
  23. package/dist/utils/constellation/stars/consistency.js +142 -0
  24. package/dist/utils/constellation/stars/lowerBody.d.ts +17 -0
  25. package/dist/utils/constellation/stars/lowerBody.js +30 -0
  26. package/dist/utils/constellation/stars/pull.d.ts +11 -0
  27. package/dist/utils/constellation/stars/pull.js +24 -0
  28. package/dist/utils/constellation/stars/push.d.ts +11 -0
  29. package/dist/utils/constellation/stars/push.js +24 -0
  30. package/dist/utils/constellation/stars/quality.d.ts +19 -0
  31. package/dist/utils/constellation/stars/quality.js +98 -0
  32. package/dist/utils/constellation/stars/recovery.d.ts +29 -0
  33. package/dist/utils/constellation/stars/recovery.js +169 -0
  34. package/dist/utils/constellation/strengthStarHelpers.d.ts +41 -0
  35. package/dist/utils/constellation/strengthStarHelpers.js +104 -0
  36. package/dist/utils/constellation/types.d.ts +124 -0
  37. package/dist/utils/constellation/types.js +18 -0
  38. package/dist/utils/index.d.ts +5 -3
  39. package/dist/utils/index.js +1 -0
  40. package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +59 -36
  41. package/dist/utils/scoringWorkout/calculateQualityScore.js +234 -233
  42. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +8 -5
  43. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +72 -88
  44. package/dist/utils/scoringWorkout/constants.d.ts +20 -6
  45. package/dist/utils/scoringWorkout/constants.js +23 -9
  46. package/dist/utils/scoringWorkout/helpers.d.ts +7 -0
  47. package/dist/utils/scoringWorkout/helpers.js +24 -18
  48. package/dist/utils/scoringWorkout/index.d.ts +12 -8
  49. package/dist/utils/scoringWorkout/index.js +23 -15
  50. package/dist/utils/scoringWorkout/parseRecords.js +4 -3
  51. package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +210 -172
  52. package/dist/utils/scoringWorkout/types.d.ts +34 -14
  53. package/package.json +31 -31
  54. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +0 -30
  55. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +0 -138
  56. package/dist/utils/scaleProPlan.util.d.ts +0 -9
  57. package/dist/utils/scaleProPlan.util.js +0 -139
  58. package/dist/utils/scoring/calculateCalories.d.ts +0 -67
  59. package/dist/utils/scoring/calculateCalories.js +0 -345
  60. package/dist/utils/scoring/calculateMuscleFatiue.d.ts +0 -67
  61. package/dist/utils/scoring/calculateMuscleFatiue.js +0 -310
  62. package/dist/utils/scoring/calculateQualityScore.d.ts +0 -71
  63. package/dist/utils/scoring/calculateQualityScore.js +0 -334
  64. package/dist/utils/scoring/calculateTotalVolume.d.ts +0 -15
  65. package/dist/utils/scoring/calculateTotalVolume.js +0 -73
  66. package/dist/utils/scoring/constants.d.ts +0 -211
  67. package/dist/utils/scoring/constants.js +0 -247
  68. package/dist/utils/scoring/helpers.d.ts +0 -119
  69. package/dist/utils/scoring/helpers.js +0 -229
  70. package/dist/utils/scoring/index.d.ts +0 -28
  71. package/dist/utils/scoring/index.js +0 -47
  72. package/dist/utils/scoring/parseRecords.d.ts +0 -98
  73. package/dist/utils/scoring/parseRecords.js +0 -284
  74. package/dist/utils/scoring/types.d.ts +0 -86
  75. package/dist/utils/scoring/types.js +0 -11
  76. package/dist/utils/scoring.utils.d.ts +0 -14
  77. package/dist/utils/scoring.utils.js +0 -243
  78. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.d.ts → calculateMuscleFatigue.d.ts} +0 -0
  79. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.js → calculateMuscleFatigue.js} +0 -0
@@ -11,17 +11,22 @@
11
11
  *
12
12
  * Recovery model accounts for:
13
13
  * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
14
- * P2-8 Gender — females recover slightly faster (1.05×)
15
14
  * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
16
15
  * P3-7 Weekly volume — muscles trained multiple times this week recover slower
17
16
  * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
18
17
  *
19
18
  * Accumulation model:
20
- * P3-3 Real muscleScores → additive with diminishing returns (P3-5)
21
- * Legacy (muscleScores={}) → quality score × 0.7/0.3 proxy + MAX accumulation
19
+ * P3-3 Real per-session muscleScores → additive with diminishing returns (P3-5)
20
+ *
21
+ * Note: the legacy quality-score proxy path (pre-P3-1 sessions with empty
22
+ * muscleScores) has been removed — every session now saves real muscleScores.
23
+ * The P2-8 gender recovery factor has also been removed: the 1.05× female
24
+ * bump was within the noise of the model and not well-evidenced enough to
25
+ * justify a silent default-male assumption for unmentioned-gender users.
22
26
  */
23
27
  Object.defineProperty(exports, "__esModule", { value: true });
24
28
  exports.computeMuscleFatigueMap = computeMuscleFatigueMap;
29
+ const helpers_1 = require("./helpers");
25
30
  // ---------------------------------------------------------------------------
26
31
  // P2-7: Age-adjusted recovery multiplier
27
32
  // ---------------------------------------------------------------------------
@@ -29,26 +34,20 @@ function getAgeRecoveryMultiplier(age) {
29
34
  if (age < 25)
30
35
  return 1.05;
31
36
  if (age < 35)
32
- return 1.00;
37
+ return 1.0;
33
38
  if (age < 45)
34
39
  return 0.95;
35
40
  if (age < 55)
36
- return 0.90;
41
+ return 0.9;
37
42
  return 0.85;
38
43
  }
39
44
  // ---------------------------------------------------------------------------
40
- // P2-8: Gender-adjusted recovery multiplier
41
- // ---------------------------------------------------------------------------
42
- function getGenderRecoveryMultiplier(gender) {
43
- return gender === "female" ? 1.05 : 1.00;
44
- }
45
- // ---------------------------------------------------------------------------
46
45
  // P2-9: Fitness-level recovery multiplier
47
46
  // ---------------------------------------------------------------------------
48
47
  const FITNESS_RECOVERY_SCALE = {
49
- sedentary: 0.80,
50
- "lightly-active": 0.90,
51
- "moderately-active": 1.00,
48
+ sedentary: 0.8,
49
+ "lightly-active": 0.9,
50
+ "moderately-active": 1.0,
52
51
  "very-active": 1.15,
53
52
  };
54
53
  // ---------------------------------------------------------------------------
@@ -57,22 +56,34 @@ const FITNESS_RECOVERY_SCALE = {
57
56
  // ---------------------------------------------------------------------------
58
57
  const MUSCLE_RECOVERY_RATE = {
59
58
  // Core — very fast
60
- "abs-upper": 1.8, "abs-lower": 1.8, "obliques": 1.8,
59
+ "abs-upper": 1.8,
60
+ "abs-lower": 1.8,
61
+ obliques: 1.8,
61
62
  // Small arm muscles — fast
62
- "bicep-short-inner": 1.4, "bicep-long-outer": 1.4,
63
- "tricep-brachii-long": 1.4, "tricep-brachii-lateral": 1.4,
64
- "fore-arm-inner": 1.5, "fore-arm-outer": 1.5,
63
+ "bicep-short-inner": 1.4,
64
+ "bicep-long-outer": 1.4,
65
+ "tricep-brachii-long": 1.4,
66
+ "tricep-brachii-lateral": 1.4,
67
+ "fore-arm-inner": 1.5,
68
+ "fore-arm-outer": 1.5,
65
69
  // Calves — fast
66
- "calf-inner": 1.3, "calf-outer": 1.3,
70
+ "calf-inner": 1.3,
71
+ "calf-outer": 1.3,
67
72
  // Shoulders / upper back — moderate
68
- "deltoids-anterior": 1.1, "deltoids-middle": 1.1,
69
- "trapezius": 1.0, "rhomboids": 1.0,
73
+ "deltoids-anterior": 1.1,
74
+ "deltoids-middle": 1.1,
75
+ trapezius: 1.0,
76
+ rhomboids: 1.0,
70
77
  // Chest — moderate
71
- "pectoralis-major": 1.0, "pectoralis-minor": 1.0,
78
+ "pectoralis-major": 1.0,
79
+ "pectoralis-minor": 1.0,
72
80
  // Large compound — slow
73
81
  "latissimus-dorsi": 0.85,
74
- "quadriceps": 0.80, "hamstrings": 0.80, "adductors": 0.80,
75
- "glutes-maximus": 0.80, "glutes-medius": 0.85,
82
+ quadriceps: 0.8,
83
+ hamstrings: 0.8,
84
+ adductors: 0.8,
85
+ "glutes-maximus": 0.8,
86
+ "glutes-medius": 0.85,
76
87
  "lower-back": 0.75,
77
88
  };
78
89
  function getMuscleRecoveryRate(muscle) {
@@ -98,7 +109,7 @@ function getMuscleRecoveryRate(muscle) {
98
109
  const WEEKLY_VOLUME_BASELINE = 80; // ~1 normal session score
99
110
  const WEEKLY_VOLUME_STEP = 80; // score units per step
100
111
  const WEEKLY_VOLUME_REDUCTION_PER_STEP = 0.05; // 5% per additional session
101
- const WEEKLY_VOLUME_MAX_REDUCTION = 0.40;
112
+ const WEEKLY_VOLUME_MAX_REDUCTION = 0.4;
102
113
  function getWeeklyVolumeMultiplier(muscle, weeklyVolume) {
103
114
  var _a;
104
115
  const total = (_a = weeklyVolume[muscle]) !== null && _a !== void 0 ? _a : 0;
@@ -127,14 +138,11 @@ function calculateRecoveryFatigue(originalFatigue, hoursSinceWorkout, recoveryMu
127
138
  const recovered = originalFatigue * ((recoveryRatePerHour * hoursSinceWorkout) / 100);
128
139
  return Math.max(0, originalFatigue - recovered);
129
140
  }
130
- function updateEntry(map, muscle, remainingFatigue, workoutDate, useAdditive) {
141
+ function updateEntry(map, muscle, remainingFatigue, workoutDate) {
131
142
  var _a;
132
143
  const existing = (_a = map.get(muscle)) !== null && _a !== void 0 ? _a : { fatigue: 0, lastWorked: workoutDate };
133
- // P3-5: additive with diminishing returns for real muscleScores
134
- // Legacy sessions keep MAX to avoid overcorrection with the proxy
135
- const newFatigue = useAdditive
136
- ? existing.fatigue + remainingFatigue * (1 - existing.fatigue / 100)
137
- : Math.max(existing.fatigue, remainingFatigue);
144
+ // P3-5: additive with diminishing returns.
145
+ const newFatigue = existing.fatigue + remainingFatigue * (1 - existing.fatigue / 100);
138
146
  map.set(muscle, {
139
147
  fatigue: Math.min(100, newFatigue),
140
148
  lastWorked: existing.lastWorked > workoutDate ? existing.lastWorked : workoutDate,
@@ -154,17 +162,15 @@ const RECOVERY_WINDOW_HOURS = 168; // 7 days
154
162
  * @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
155
163
  */
156
164
  function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = new Date()) {
157
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
158
- // Build combined user recovery multiplier (P2-7, P2-8, P2-9)
165
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
166
+ // Build combined user recovery multiplier (P2-7, P2-9).
167
+ // Age uses the shared deriveAge helper so it is never computed two ways.
159
168
  let userRecoveryMultiplier = 1.0;
160
169
  if (user) {
161
- const age = user.dob
162
- ? currentDate.getFullYear() - new Date(user.dob).getFullYear()
163
- : 30;
170
+ const age = (0, helpers_1.deriveAge)(user.dob, currentDate);
164
171
  userRecoveryMultiplier =
165
172
  getAgeRecoveryMultiplier(age) *
166
- getGenderRecoveryMultiplier((_a = user.gender) !== null && _a !== void 0 ? _a : "unmentioned") *
167
- ((_c = FITNESS_RECOVERY_SCALE[(_b = user.fitnessLevel) !== null && _b !== void 0 ? _b : "moderately-active"]) !== null && _c !== void 0 ? _c : 1.0);
173
+ ((_b = FITNESS_RECOVERY_SCALE[(_a = user.fitnessLevel) !== null && _a !== void 0 ? _a : "moderately-active"]) !== null && _b !== void 0 ? _b : 1.0);
168
174
  }
169
175
  const cutoffTime = currentDate.getTime() - RECOVERY_WINDOW_HOURS * 3600000;
170
176
  const weekCutoff = currentDate.getTime() - 7 * 24 * 3600000; // same as RECOVERY_WINDOW_HOURS
@@ -180,12 +186,12 @@ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = ne
180
186
  .reduce((sum, r) => sum + r.score, 0);
181
187
  if (weeklyScore === 0)
182
188
  continue;
183
- for (const muscle of (_d = exercise.primaryMuscles) !== null && _d !== void 0 ? _d : []) {
184
- weeklyVolume[muscle] = ((_e = weeklyVolume[muscle]) !== null && _e !== void 0 ? _e : 0) + weeklyScore;
189
+ for (const muscle of (_c = exercise.primaryMuscles) !== null && _c !== void 0 ? _c : []) {
190
+ weeklyVolume[muscle] = ((_d = weeklyVolume[muscle]) !== null && _d !== void 0 ? _d : 0) + weeklyScore;
185
191
  }
186
- for (const muscle of (_f = exercise.secondaryMuscles) !== null && _f !== void 0 ? _f : []) {
192
+ for (const muscle of (_e = exercise.secondaryMuscles) !== null && _e !== void 0 ? _e : []) {
187
193
  // Secondary muscles receive 35% of the stimulus
188
- weeklyVolume[muscle] = ((_g = weeklyVolume[muscle]) !== null && _g !== void 0 ? _g : 0) + weeklyScore * 0.35;
194
+ weeklyVolume[muscle] = ((_f = weeklyVolume[muscle]) !== null && _f !== void 0 ? _f : 0) + weeklyScore * 0.35;
189
195
  }
190
196
  }
191
197
  const internalMap = new Map();
@@ -197,58 +203,36 @@ function computeMuscleFatigueMap(exercises, scoreHistory, user, currentDate = ne
197
203
  for (const record of relevantRecords) {
198
204
  const workoutDate = new Date(record.recordDate);
199
205
  const hoursSince = (currentDate.getTime() - workoutDate.getTime()) / 3600000;
200
- // P3-3: Real path when muscleScores is available (sessions saved after P3-1)
201
- const hasRealMuscleScores = record.muscleScores && Object.keys(record.muscleScores).length > 0;
202
- if (hasRealMuscleScores) {
203
- const allMuscles = new Set([
204
- ...((_h = exercise.primaryMuscles) !== null && _h !== void 0 ? _h : []),
205
- ...((_j = exercise.secondaryMuscles) !== null && _j !== void 0 ? _j : []),
206
- ]);
207
- for (const muscle of allMuscles) {
208
- const baseFatigue = (_k = record.muscleScores[muscle]) !== null && _k !== void 0 ? _k : 0;
209
- if (baseFatigue <= 0)
210
- continue;
211
- // P3-7: weekly volume slows recovery for heavily-trained muscles
212
- const multiplier = userRecoveryMultiplier
213
- * getMuscleRecoveryRate(muscle)
214
- * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
215
- const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
216
- if (remaining > 0)
217
- updateEntry(internalMap, muscle, remaining, workoutDate, true);
218
- }
219
- }
220
- else {
221
- // Legacy path: quality score × 0.7/0.3 proxy (P1-7 band-aid)
222
- const MIN_MEANINGFUL_SCORE = 88;
223
- const MAX_SCORE = 100;
224
- const intensityFraction = record.score <= MIN_MEANINGFUL_SCORE
225
- ? (record.score / MIN_MEANINGFUL_SCORE) * 0.3
226
- : ((record.score - MIN_MEANINGFUL_SCORE) / (MAX_SCORE - MIN_MEANINGFUL_SCORE)) * 0.7 + 0.3;
227
- for (const muscle of (_l = exercise.primaryMuscles) !== null && _l !== void 0 ? _l : []) {
228
- const baseFatigue = record.score * 0.7 * intensityFraction;
229
- const multiplier = userRecoveryMultiplier
230
- * getMuscleRecoveryRate(muscle)
231
- * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
232
- const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
233
- if (remaining > 0)
234
- updateEntry(internalMap, muscle, remaining, workoutDate, false);
235
- }
236
- for (const muscle of (_m = exercise.secondaryMuscles) !== null && _m !== void 0 ? _m : []) {
237
- const baseFatigue = record.score * 0.3 * intensityFraction;
238
- const multiplier = userRecoveryMultiplier
239
- * getMuscleRecoveryRate(muscle)
240
- * getWeeklyVolumeMultiplier(muscle, weeklyVolume);
241
- const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
242
- if (remaining > 0)
243
- updateEntry(internalMap, muscle, remaining, workoutDate, false);
244
- }
206
+ // Real per-session muscleScores is the only path. Skip records that
207
+ // somehow carry none (defensive — should not occur post-P3-1).
208
+ const muscleScores = record.muscleScores;
209
+ if (!muscleScores || Object.keys(muscleScores).length === 0)
210
+ continue;
211
+ const allMuscles = new Set([
212
+ ...((_g = exercise.primaryMuscles) !== null && _g !== void 0 ? _g : []),
213
+ ...((_h = exercise.secondaryMuscles) !== null && _h !== void 0 ? _h : []),
214
+ ]);
215
+ for (const muscle of allMuscles) {
216
+ const baseFatigue = (_j = muscleScores[muscle]) !== null && _j !== void 0 ? _j : 0;
217
+ if (baseFatigue <= 0)
218
+ continue;
219
+ // P3-7: weekly volume slows recovery for heavily-trained muscles
220
+ const multiplier = userRecoveryMultiplier *
221
+ getMuscleRecoveryRate(muscle) *
222
+ getWeeklyVolumeMultiplier(muscle, weeklyVolume);
223
+ const remaining = calculateRecoveryFatigue(baseFatigue, hoursSince, multiplier);
224
+ if (remaining > 0)
225
+ updateEntry(internalMap, muscle, remaining, workoutDate);
245
226
  }
246
227
  }
247
228
  }
248
229
  // Convert internal Map → plain object for the public return type
249
230
  const result = {};
250
231
  internalMap.forEach((entry, muscle) => {
251
- result[muscle] = { fatigue: Math.round(entry.fatigue), lastWorked: entry.lastWorked };
232
+ result[muscle] = {
233
+ fatigue: Math.round(entry.fatigue),
234
+ lastWorked: entry.lastWorked,
235
+ };
252
236
  });
253
237
  return result;
254
238
  }
@@ -169,8 +169,12 @@ export declare const REFERENCE_MAX_REPS = 12;
169
169
  /** Reference effort multiplier for "fully fatigued" benchmark */
170
170
  export declare const REFERENCE_MAX_EFFORT = 1.3;
171
171
  /**
172
- * Weights for the four quality sub-components.
173
- * Must sum to 1.0.
172
+ * Weights for the four quality sub-components. Must sum to 1.0.
173
+ *
174
+ * Effort leads (0.40): now that RPE/RIR is captured on every set, effort is
175
+ * the strongest available proxy for whether a session drove adaptation.
176
+ * Consistency demoted (0.20): it is the weakest quality signal and mildly
177
+ * penalises genuine fatigue-driven rep drop-off, so it should not dominate.
174
178
  */
175
179
  export declare const QUALITY_WEIGHTS: {
176
180
  completion: number;
@@ -197,10 +201,20 @@ export declare const OPTIMAL_RIR_RANGE: [number, number];
197
201
  export declare const OPTIMAL_RPE_RANGE: [number, number];
198
202
  /** Penalty per unit of distance from the optimal effort range */
199
203
  export declare const EFFORT_DISTANCE_PENALTY = 20;
200
- /** Minimum effort adequacy score */
201
- export declare const EFFORT_MIN_SCORE = 20;
202
- /** Default effort adequacy score when no RPE/RIR data is available */
203
- export declare const EFFORT_NO_DATA_SCORE = 70;
204
+ /**
205
+ * Minimum effort adequacy score.
206
+ * Set equal to EFFORT_NO_DATA_SCORE so that logging any RPE/RIR value
207
+ * is never worse than logging nothing. Honest data is always safe to log.
208
+ */
209
+ export declare const EFFORT_MIN_SCORE = 50;
210
+ /**
211
+ * Default effort adequacy score when no RPE/RIR data is available.
212
+ * RPE and RIR are optional — users may choose not to log effort intensity.
213
+ * Set equal to EFFORT_MIN_SCORE (50) so that omitting effort data never
214
+ * produces a higher score than honestly logging any RPE or RIR value.
215
+ * There is no incentive to withhold effort data.
216
+ */
217
+ export declare const EFFORT_NO_DATA_SCORE = 50;
204
218
  /** Score when rest is within the optimal range */
205
219
  export declare const REST_OPTIMAL_SCORE = 100;
206
220
  /** Score when rest is within acceptable (min–max) but not optimal */
@@ -205,13 +205,17 @@ exports.REFERENCE_MAX_EFFORT = 1.3;
205
205
  // Quality Score
206
206
  // ---------------------------------------------------------------------------
207
207
  /**
208
- * Weights for the four quality sub-components.
209
- * Must sum to 1.0.
208
+ * Weights for the four quality sub-components. Must sum to 1.0.
209
+ *
210
+ * Effort leads (0.40): now that RPE/RIR is captured on every set, effort is
211
+ * the strongest available proxy for whether a session drove adaptation.
212
+ * Consistency demoted (0.20): it is the weakest quality signal and mildly
213
+ * penalises genuine fatigue-driven rep drop-off, so it should not dominate.
210
214
  */
211
215
  exports.QUALITY_WEIGHTS = {
212
- completion: 0.2,
213
- consistency: 0.35,
214
- effortAdequacy: 0.3,
216
+ completion: 0.25,
217
+ consistency: 0.2,
218
+ effortAdequacy: 0.4,
215
219
  restDiscipline: 0.15,
216
220
  };
217
221
  /**
@@ -233,10 +237,20 @@ exports.OPTIMAL_RIR_RANGE = [1, 4];
233
237
  exports.OPTIMAL_RPE_RANGE = [6, 9];
234
238
  /** Penalty per unit of distance from the optimal effort range */
235
239
  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
+ /**
241
+ * Minimum effort adequacy score.
242
+ * Set equal to EFFORT_NO_DATA_SCORE so that logging any RPE/RIR value
243
+ * is never worse than logging nothing. Honest data is always safe to log.
244
+ */
245
+ exports.EFFORT_MIN_SCORE = 50;
246
+ /**
247
+ * Default effort adequacy score when no RPE/RIR data is available.
248
+ * RPE and RIR are optional — users may choose not to log effort intensity.
249
+ * Set equal to EFFORT_MIN_SCORE (50) so that omitting effort data never
250
+ * produces a higher score than honestly logging any RPE or RIR value.
251
+ * There is no incentive to withhold effort data.
252
+ */
253
+ exports.EFFORT_NO_DATA_SCORE = 50;
240
254
  /** Score when rest is within the optimal range */
241
255
  exports.REST_OPTIMAL_SCORE = 100;
242
256
  /** Score when rest is within acceptable (min–max) but not optimal */
@@ -125,3 +125,10 @@ export declare function extractUserContext(user: {
125
125
  * Used for RIR attenuation and reference max scaling (P2-10).
126
126
  */
127
127
  export declare function deriveTrainingAgeBracket(accountAgeDays: number, totalSessionCount: number): import("./types").TTrainingAgeBracket;
128
+ /**
129
+ * Derive integer age from a date of birth, accounting for whether this year's
130
+ * birthday has occurred yet. Sanity-clamped to 10–100; returns DEFAULT_USER_AGE
131
+ * on missing or invalid input. Shared by extractUserContext and the fatigue
132
+ * recovery model so age is never computed two different ways.
133
+ */
134
+ export declare function deriveAge(dob: Date | string | undefined | null, now?: Date): number;
@@ -12,6 +12,7 @@ exports.rpeToEffortFraction = rpeToEffortFraction;
12
12
  exports.getEffortFraction = getEffortFraction;
13
13
  exports.extractUserContext = extractUserContext;
14
14
  exports.deriveTrainingAgeBracket = deriveTrainingAgeBracket;
15
+ exports.deriveAge = deriveAge;
15
16
  const constants_1 = require("./constants");
16
17
  // We reference these types but don't import them to keep this module decoupled.
17
18
  // The caller passes the raw values; we just parse them.
@@ -103,7 +104,7 @@ function standardDeviation(values) {
103
104
  if (values.length < 2)
104
105
  return 0;
105
106
  const avg = mean(values);
106
- const squaredDiffs = values.map((v) => Math.pow((v - avg), 2));
107
+ const squaredDiffs = values.map((v) => (v - avg) ** 2);
107
108
  return Math.sqrt(mean(squaredDiffs));
108
109
  }
109
110
  /**
@@ -205,23 +206,7 @@ function extractUserContext(user) {
205
206
  const heightCm = user.heightCm && user.heightCm > 100 && user.heightCm < 250
206
207
  ? user.heightCm
207
208
  : 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
- }
209
+ const age = deriveAge(user.dob);
225
210
  return {
226
211
  weightKg,
227
212
  heightCm,
@@ -243,3 +228,24 @@ function deriveTrainingAgeBracket(accountAgeDays, totalSessionCount) {
243
228
  return "intermediate";
244
229
  return "advanced";
245
230
  }
231
+ /**
232
+ * Derive integer age from a date of birth, accounting for whether this year's
233
+ * birthday has occurred yet. Sanity-clamped to 10–100; returns DEFAULT_USER_AGE
234
+ * on missing or invalid input. Shared by extractUserContext and the fatigue
235
+ * recovery model so age is never computed two different ways.
236
+ */
237
+ function deriveAge(dob, now = new Date()) {
238
+ if (!dob)
239
+ return constants_1.DEFAULT_USER_AGE;
240
+ const birth = typeof dob === "string" ? new Date(dob) : dob;
241
+ if (isNaN(birth.getTime()))
242
+ return constants_1.DEFAULT_USER_AGE;
243
+ let age = now.getFullYear() - birth.getFullYear();
244
+ const monthDiff = now.getMonth() - birth.getMonth();
245
+ if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
246
+ age--;
247
+ }
248
+ if (age < 10 || age > 100)
249
+ return constants_1.DEFAULT_USER_AGE;
250
+ return age;
251
+ }
@@ -3,16 +3,20 @@
3
3
  * FITFRIX EXERCISE SCORING SYSTEM
4
4
  * ============================================================================
5
5
  *
6
- * calculateExerciseScoreV2({ exercise, record, user, historicalContext? }) => IScoreResult
6
+ * calculateExerciseScoreV2({ exercise, record, user, historicalContext? })
7
+ * => IScoreResult
7
8
  *
8
9
  * 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
10
+ * score: number, // 0–100 quality of execution
11
+ * qualityBreakdown: IQualityBreakdown, // completion/consistency/effort/rest
12
+ * muscleScores: Record<string, number>, // 0–100 per muscle, keyed by EBodyParts
13
+ * calorieBurn: number, // kcal placeholder 0 until Phase 3
14
+ * restDisciplineActive: boolean, // false when no rest data was logged
13
15
  * }
14
16
  *
15
- * Internally computes muscle fatigue (Pillar 2) and quality score (Pillar 3).
17
+ * Internally computes:
18
+ * Pillar 2: Muscle Fatigue → muscleScores
19
+ * Pillar 3: Quality Score → single score + breakdown (not per-goal)
16
20
  * Calorie burn (Pillar 1) is implemented in calculateCalories.ts but not yet
17
21
  * wired into the save flow — scheduled for Phase 3.
18
22
  */
@@ -23,10 +27,10 @@ export { calculateTotalVolume } from "./calculateTotalVolume";
23
27
  export { computeMuscleFatigueMap } from "./computeMuscleFatigueMap";
24
28
  export { deriveTrainingAgeBracket };
25
29
  export type { IHistoricalContext, TTrainingAgeBracket };
26
- export type { TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult } from "./types";
30
+ export type { TEnrichedSessionScore, TEnrichedExerciseRecord, TMuscleFatigueEntry, TMuscleFatigueResult, } from "./types";
27
31
  export declare const calculateExerciseScoreV2: (param: {
28
32
  exercise: TExercise;
29
33
  record: TRecord[];
30
34
  user: TUserMetric;
31
- historicalContext?: import("./types").IHistoricalContext;
35
+ historicalContext?: IHistoricalContext;
32
36
  }) => IScoreResult;
@@ -4,22 +4,26 @@
4
4
  * FITFRIX EXERCISE SCORING SYSTEM
5
5
  * ============================================================================
6
6
  *
7
- * calculateExerciseScoreV2({ exercise, record, user, historicalContext? }) => IScoreResult
7
+ * calculateExerciseScoreV2({ exercise, record, user, historicalContext? })
8
+ * => IScoreResult
8
9
  *
9
10
  * IScoreResult = {
10
- * score: number, // 0–100 quality of execution
11
- * muscleScores: Record<string, number>, // 0–100 per muscle fatigue
12
- * calorieBurn: number, // kcal (placeholder: 0 until Phase 3)
13
- * qualityBreakdown: IQualityBreakdown // completion/consistency/effort/rest sub-scores
11
+ * score: number, // 0–100 quality of execution
12
+ * qualityBreakdown: IQualityBreakdown, // completion/consistency/effort/rest
13
+ * muscleScores: Record<string, number>, // 0–100 per muscle, keyed by EBodyParts
14
+ * calorieBurn: number, // kcal placeholder 0 until Phase 3
15
+ * restDisciplineActive: boolean, // false when no rest data was logged
14
16
  * }
15
17
  *
16
- * Internally computes muscle fatigue (Pillar 2) and quality score (Pillar 3).
18
+ * Internally computes:
19
+ * Pillar 2: Muscle Fatigue → muscleScores
20
+ * Pillar 3: Quality Score → single score + breakdown (not per-goal)
17
21
  * Calorie burn (Pillar 1) is implemented in calculateCalories.ts but not yet
18
22
  * wired into the save flow — scheduled for Phase 3.
19
23
  */
20
24
  Object.defineProperty(exports, "__esModule", { value: true });
21
25
  exports.calculateExerciseScoreV2 = exports.deriveTrainingAgeBracket = exports.computeMuscleFatigueMap = exports.calculateTotalVolume = void 0;
22
- const calculateMuscleFatiue_1 = require("./calculateMuscleFatiue");
26
+ const calculateMuscleFatigue_1 = require("./calculateMuscleFatigue");
23
27
  const calculateQualityScore_1 = require("./calculateQualityScore");
24
28
  const helpers_1 = require("./helpers");
25
29
  Object.defineProperty(exports, "deriveTrainingAgeBracket", { enumerable: true, get: function () { return helpers_1.deriveTrainingAgeBracket; } });
@@ -29,14 +33,14 @@ Object.defineProperty(exports, "calculateTotalVolume", { enumerable: true, get:
29
33
  var computeMuscleFatigueMap_1 = require("./computeMuscleFatigueMap");
30
34
  Object.defineProperty(exports, "computeMuscleFatigueMap", { enumerable: true, get: function () { return computeMuscleFatigueMap_1.computeMuscleFatigueMap; } });
31
35
  // ---------------------------------------------------------------------------
32
- // Main Function — existing signature
36
+ // Main Function
33
37
  // ---------------------------------------------------------------------------
34
38
  const calculateExerciseScoreV2 = (param) => {
35
39
  const { exercise, record, user, historicalContext } = param;
36
40
  const userContext = (0, helpers_1.extractUserContext)(user);
37
41
  const parsedSets = (0, parseRecords_1.parseRecords)(record, exercise.timingGuardrails, historicalContext);
38
- // Pillar 2: Muscle Fatigue → muscleScores
39
- const muscleScores = (0, calculateMuscleFatiue_1.calculateMuscleFatigue)(parsedSets, {
42
+ // ── Pillar 2: Muscle Fatigue ─────────────────────────────────────────────
43
+ const muscleScores = (0, calculateMuscleFatigue_1.calculateMuscleFatigue)(parsedSets, {
40
44
  primaryMuscles: exercise.primaryMuscles,
41
45
  secondaryMuscles: exercise.secondaryMuscles,
42
46
  difficultyLevel: exercise.difficultyLevel,
@@ -47,13 +51,17 @@ const calculateExerciseScoreV2 = (param) => {
47
51
  scoringSpecialHandling: exercise.scoringSpecialHandling,
48
52
  isUnilateral: exercise.isUnilateral,
49
53
  }, userContext, exercise.timingGuardrails, historicalContext);
50
- // Pillar 3: Quality Score → score
51
- const isStrictTimingModeScoring = record.some((r) => r.isStrictMode);
52
- const { scoresByGoal } = (0, calculateQualityScore_1.calculateQualityScore)(parsedSets, record, exercise.timingGuardrails, isStrictTimingModeScoring, userContext, historicalContext);
54
+ // ── Pillar 3: Quality Score ──────────────────────────────────────────────
55
+ // userContext is intentionally not passed — quality scoring is goal-agnostic.
56
+ // Goal-specific logic lives in the gate system and quick plan generator.
57
+ const { score, qualityBreakdown, restDisciplineActive } = (0, calculateQualityScore_1.calculateQualityScore)(parsedSets, record, exercise.timingGuardrails, historicalContext);
58
+ // ── Result (Option A flat shape) ─────────────────────────────────────────
53
59
  return {
60
+ score,
61
+ qualityBreakdown,
54
62
  muscleScores,
55
- calorieBurn: 0, // Placeholder until Pillar 1 is fully wired in Phase 3
56
- scoresByGoal,
63
+ calorieBurn: 0, // placeholder Pillar 1 wired in Phase 3
64
+ restDisciplineActive,
57
65
  };
58
66
  };
59
67
  exports.calculateExerciseScoreV2 = calculateExerciseScoreV2;
@@ -250,9 +250,10 @@ function resolveWorkDuration(measured, estimated, guardrails) {
250
250
  */
251
251
  function resolveRestDuration(measured, setIndex, totalSets, guardrails) {
252
252
  var _a, _b, _c;
253
- // 1. Last set with no rest data null
254
- if (setIndex === totalSets - 1 &&
255
- (measured === undefined || measured === 0)) {
253
+ // 1. Last set null. Rest after the final set is not training rest —
254
+ // there is no subsequent set it prepares for. Holds whether or not a
255
+ // value was logged: a measured rest on the final set is still not scorable.
256
+ if (setIndex === totalSets - 1) {
256
257
  return null;
257
258
  }
258
259
  // Extract exercise-specific rest config