@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,97 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM — Record Parser
4
+ * ============================================================================
5
+ *
6
+ * Transforms raw TRecord[] into clean IParsedSet[].
7
+ *
8
+ * This is the SINGLE POINT of data cleaning. After this step, all downstream
9
+ * code (calories, fatigue, quality) works with guaranteed-valid numbers.
10
+ *
11
+ * VALIDATION STRATEGY:
12
+ * The exercise's `timingGuardrails` is the PRIMARY source of truth for
13
+ * what constitutes valid timing data. Each exercise defines its own:
14
+ * - restPeriods.minimum / maximum → acceptable rest range
15
+ * - setDuration.min / max → acceptable work duration (duration type)
16
+ * - singleRep.min / max → acceptable rep timing (rep-based types)
17
+ *
18
+ * Global constants (ABSOLUTE_* and FALLBACK_*) are ONLY used when
19
+ * timingGuardrails is completely missing from the exercise.
20
+ *
21
+ * Responsibilities:
22
+ * 1. Filter out incomplete sets (isDone === false)
23
+ * 2. Parse all string fields to numbers
24
+ * 3. Estimate active work duration when not measured
25
+ * 4. Validate timing against exercise-specific guardrails
26
+ * 5. Compute effort fraction per set
27
+ */
28
+ import type { IParsedSet } from "./types";
29
+ /**
30
+ * Minimal shape of TRecord that we need. Avoids importing the full
31
+ * app type, making the scoring module independently testable.
32
+ */
33
+ interface IRawRecord {
34
+ type: string;
35
+ isDone: boolean;
36
+ isStrictMode: boolean;
37
+ rpe?: string;
38
+ rir?: string;
39
+ workDurationSecs?: number;
40
+ restDurationSecs?: number;
41
+ kg?: string;
42
+ reps?: string;
43
+ durationMmSs?: string;
44
+ auxWeightKg?: string;
45
+ speedMin?: string;
46
+ speedMax?: string;
47
+ distance?: string;
48
+ }
49
+ /**
50
+ * Full timing guardrails shape from TExercise.
51
+ *
52
+ * Every field is optional because:
53
+ * - Different exercise types have different sub-shapes (singleRep vs setDuration)
54
+ * - Some exercises might have incomplete guardrails
55
+ * - We need graceful fallback for each missing piece
56
+ */
57
+ export interface ITimingGuardrails {
58
+ type?: string;
59
+ stressRestBonus?: number;
60
+ fatigueMultiplier?: number;
61
+ setupTypicalSecs?: number;
62
+ restPeriods?: {
63
+ minimum?: number;
64
+ typical?: number;
65
+ maximum?: number;
66
+ optimalRange?: [number, number];
67
+ };
68
+ singleRep?: {
69
+ min?: number;
70
+ max?: number;
71
+ typical?: number;
72
+ };
73
+ setDuration?: {
74
+ min?: number;
75
+ max?: number;
76
+ typical?: number;
77
+ };
78
+ pacing?: {
79
+ minPacePerUnit?: number;
80
+ maxPacePerUnit?: number;
81
+ typicalPacePerUnit?: number;
82
+ };
83
+ }
84
+ /**
85
+ * Parse and clean raw records into a validated set array.
86
+ *
87
+ * @param records Raw TRecord[] from the workout
88
+ * @param timingGuardrails Exercise's timing guardrails (for validation & fallbacks)
89
+ * @returns Cleaned IParsedSet[] with only completed, valid sets
90
+ *
91
+ * @example
92
+ * const parsed = parseRecords(workout.records, exercise.timingGuardrails);
93
+ * // parsed[0].activeDurationSecs → guaranteed valid number
94
+ * // parsed[0].effortFraction → guaranteed 0.5–1.3
95
+ */
96
+ export declare function parseRecords(records: IRawRecord[], timingGuardrails?: ITimingGuardrails): IParsedSet[];
97
+ export {};
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+ // parseRecords.ts
3
+ /**
4
+ * ============================================================================
5
+ * FITFRIX EXERCISE SCORING SYSTEM — Record Parser
6
+ * ============================================================================
7
+ *
8
+ * Transforms raw TRecord[] into clean IParsedSet[].
9
+ *
10
+ * This is the SINGLE POINT of data cleaning. After this step, all downstream
11
+ * code (calories, fatigue, quality) works with guaranteed-valid numbers.
12
+ *
13
+ * VALIDATION STRATEGY:
14
+ * The exercise's `timingGuardrails` is the PRIMARY source of truth for
15
+ * what constitutes valid timing data. Each exercise defines its own:
16
+ * - restPeriods.minimum / maximum → acceptable rest range
17
+ * - setDuration.min / max → acceptable work duration (duration type)
18
+ * - singleRep.min / max → acceptable rep timing (rep-based types)
19
+ *
20
+ * Global constants (ABSOLUTE_* and FALLBACK_*) are ONLY used when
21
+ * timingGuardrails is completely missing from the exercise.
22
+ *
23
+ * Responsibilities:
24
+ * 1. Filter out incomplete sets (isDone === false)
25
+ * 2. Parse all string fields to numbers
26
+ * 3. Estimate active work duration when not measured
27
+ * 4. Validate timing against exercise-specific guardrails
28
+ * 5. Compute effort fraction per set
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.parseRecords = parseRecords;
32
+ const constants_1 = require("./constants");
33
+ const helpers_1 = require("./helpers");
34
+ // ---------------------------------------------------------------------------
35
+ // Main Parser
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Parse and clean raw records into a validated set array.
39
+ *
40
+ * @param records Raw TRecord[] from the workout
41
+ * @param timingGuardrails Exercise's timing guardrails (for validation & fallbacks)
42
+ * @returns Cleaned IParsedSet[] with only completed, valid sets
43
+ *
44
+ * @example
45
+ * const parsed = parseRecords(workout.records, exercise.timingGuardrails);
46
+ * // parsed[0].activeDurationSecs → guaranteed valid number
47
+ * // parsed[0].effortFraction → guaranteed 0.5–1.3
48
+ */
49
+ function parseRecords(records, timingGuardrails) {
50
+ return records
51
+ .filter((r) => r.isDone) // Only completed sets count
52
+ .map((record, index) => parseOneRecord(record, index, records.length, timingGuardrails))
53
+ .filter((set) => set !== null);
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Single Record Parser
57
+ // ---------------------------------------------------------------------------
58
+ function parseOneRecord(record, index, totalSets, guardrails) {
59
+ const type = record.type;
60
+ const effortFraction = (0, helpers_1.getEffortFraction)(record);
61
+ // Parse rest duration using exercise-specific guardrails for validation
62
+ const restDurationSecs = resolveRestDuration(record.restDurationSecs, index, totalSets, guardrails);
63
+ switch (type) {
64
+ case "weight-reps":
65
+ return parseWeightReps(record, effortFraction, restDurationSecs, guardrails);
66
+ case "reps-only":
67
+ return parseRepsOnly(record, effortFraction, restDurationSecs, guardrails);
68
+ case "duration":
69
+ return parseDuration(record, effortFraction, restDurationSecs, guardrails);
70
+ case "cardio-machine":
71
+ return parseCardioMachine(record, effortFraction, restDurationSecs);
72
+ case "cardio-free":
73
+ return parseCardioFree(record, effortFraction, restDurationSecs);
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Type-Specific Parsers
80
+ // ---------------------------------------------------------------------------
81
+ function parseWeightReps(record, effortFraction, restDurationSecs, guardrails) {
82
+ var _a, _b;
83
+ const kg = (0, helpers_1.safeParseFloat)(record.kg);
84
+ const reps = (0, helpers_1.safeParseFloat)(record.reps);
85
+ // Skip sets with zero reps (likely user error / empty row)
86
+ if (reps === 0)
87
+ return null;
88
+ // Estimate active duration: reps × seconds-per-rep
89
+ // PRIMARY: exercise's own singleRep.typical
90
+ // FALLBACK: global default
91
+ const secsPerRep = (_b = (_a = guardrails === null || guardrails === void 0 ? void 0 : guardrails.singleRep) === null || _a === void 0 ? void 0 : _a.typical) !== null && _b !== void 0 ? _b : constants_1.FALLBACK_SECS_PER_REP;
92
+ const estimatedDuration = reps * secsPerRep;
93
+ const activeDurationSecs = resolveWorkDuration(record.workDurationSecs, estimatedDuration, guardrails);
94
+ return {
95
+ type: "weight-reps",
96
+ activeDurationSecs,
97
+ restDurationSecs,
98
+ effortFraction,
99
+ kg,
100
+ reps,
101
+ };
102
+ }
103
+ function parseRepsOnly(record, effortFraction, restDurationSecs, guardrails) {
104
+ var _a, _b;
105
+ const reps = (0, helpers_1.safeParseFloat)(record.reps);
106
+ const auxWeightKg = (0, helpers_1.safeParseFloat)(record.auxWeightKg);
107
+ if (reps === 0)
108
+ return null;
109
+ const secsPerRep = (_b = (_a = guardrails === null || guardrails === void 0 ? void 0 : guardrails.singleRep) === null || _a === void 0 ? void 0 : _a.typical) !== null && _b !== void 0 ? _b : constants_1.FALLBACK_SECS_PER_REP;
110
+ const estimatedDuration = reps * secsPerRep;
111
+ const activeDurationSecs = resolveWorkDuration(record.workDurationSecs, estimatedDuration, guardrails);
112
+ return {
113
+ type: "reps-only",
114
+ activeDurationSecs,
115
+ restDurationSecs,
116
+ effortFraction,
117
+ reps,
118
+ auxWeightKg,
119
+ };
120
+ }
121
+ function parseDuration(record, effortFraction, restDurationSecs, guardrails) {
122
+ var _a, _b;
123
+ // Primary: parse the user's recorded duration
124
+ // Fallback: exercise's typical set duration → global fallback
125
+ const fallbackDuration = (_b = (_a = guardrails === null || guardrails === void 0 ? void 0 : guardrails.setDuration) === null || _a === void 0 ? void 0 : _a.typical) !== null && _b !== void 0 ? _b : constants_1.FALLBACK_SET_DURATION_SECS;
126
+ const durationSecs = (0, helpers_1.parseDurationMmSs)(record.durationMmSs, fallbackDuration);
127
+ const auxWeightKg = (0, helpers_1.safeParseFloat)(record.auxWeightKg);
128
+ if (durationSecs === 0)
129
+ return null;
130
+ // For duration exercises, the parsed durationMmSs IS the active duration
131
+ // We validate it against guardrails to get the final value
132
+ const activeDurationSecs = resolveWorkDuration(durationSecs, fallbackDuration, guardrails);
133
+ return {
134
+ type: "duration",
135
+ activeDurationSecs,
136
+ restDurationSecs,
137
+ effortFraction,
138
+ // Use the SAME validated value for both — activeDurationSecs is used
139
+ // for calorie time calculation, durationSecs is used for the
140
+ // short/medium/long multiplier classification. They must agree.
141
+ durationSecs: activeDurationSecs,
142
+ auxWeightKg,
143
+ };
144
+ }
145
+ function parseCardioMachine(record, effortFraction, restDurationSecs) {
146
+ const speedMin = (0, helpers_1.safeParseFloat)(record.speedMin);
147
+ const speedMax = (0, helpers_1.safeParseFloat)(record.speedMax);
148
+ const distance = (0, helpers_1.safeParseFloat)(record.distance);
149
+ const cardioDurationSecs = (0, helpers_1.parseDurationMmSs)(record.durationMmSs);
150
+ // Need at least duration or distance to be meaningful
151
+ if (cardioDurationSecs === 0 && distance === 0)
152
+ return null;
153
+ const activeDurationSecs = cardioDurationSecs > 0 ? cardioDurationSecs : 0;
154
+ return {
155
+ type: "cardio-machine",
156
+ activeDurationSecs,
157
+ restDurationSecs,
158
+ effortFraction,
159
+ speedMin,
160
+ speedMax,
161
+ distance,
162
+ cardioDurationSecs,
163
+ };
164
+ }
165
+ function parseCardioFree(record, effortFraction, restDurationSecs) {
166
+ const distance = (0, helpers_1.safeParseFloat)(record.distance);
167
+ const cardioDurationSecs = (0, helpers_1.parseDurationMmSs)(record.durationMmSs);
168
+ if (cardioDurationSecs === 0 && distance === 0)
169
+ return null;
170
+ const activeDurationSecs = cardioDurationSecs > 0 ? cardioDurationSecs : 0;
171
+ return {
172
+ type: "cardio-free",
173
+ activeDurationSecs,
174
+ restDurationSecs,
175
+ effortFraction,
176
+ distance,
177
+ cardioDurationSecs,
178
+ };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Duration Resolution Helpers
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Resolve and validate work duration for a set.
185
+ *
186
+ * Validation cascade:
187
+ * ┌──────────────────────────────────────────────────────────────┐
188
+ * │ 1. Absolute sanity check (catches truly broken data) │
189
+ * │ measured < 1s or > 14400s → reject │
190
+ * │ │
191
+ * │ 2. Exercise-specific validation (PRIMARY) │
192
+ * │ duration type: check against setDuration.min / max │
193
+ * │ rep type: check against estimate ± tolerance │
194
+ * │ Grace factor: 2× on max, 0.5× on min │
195
+ * │ │
196
+ * │ 3. Passed all checks → use measured value │
197
+ * │ │
198
+ * │ 4. No measured value → use estimated │
199
+ * └──────────────────────────────────────────────────────────────┘
200
+ */
201
+ function resolveWorkDuration(measured, estimated, guardrails) {
202
+ var _a, _b;
203
+ if (measured !== undefined && measured > 0) {
204
+ // 1. Absolute sanity check
205
+ if (measured < constants_1.ABSOLUTE_WORK_MIN || measured > constants_1.ABSOLUTE_WORK_MAX) {
206
+ return Math.max(estimated, constants_1.ABSOLUTE_WORK_MIN);
207
+ }
208
+ // 2. Exercise-specific validation for duration-type exercises
209
+ if (guardrails === null || guardrails === void 0 ? void 0 : guardrails.setDuration) {
210
+ const minReasonable = ((_a = guardrails.setDuration.min) !== null && _a !== void 0 ? _a : 1) * 0.5;
211
+ const maxReasonable = ((_b = guardrails.setDuration.max) !== null && _b !== void 0 ? _b : constants_1.ABSOLUTE_WORK_MAX) * 2;
212
+ if (measured < minReasonable || measured > maxReasonable) {
213
+ return estimated; // Measured seems wrong for this exercise
214
+ }
215
+ }
216
+ // 2b. Exercise-specific validation for rep-based exercises
217
+ if ((guardrails === null || guardrails === void 0 ? void 0 : guardrails.singleRep) && estimated > 0) {
218
+ // Measured should be within 3× of our estimate
219
+ // (generous because user might pause mid-set)
220
+ if (measured > estimated * 3 || measured < estimated * 0.2) {
221
+ return estimated;
222
+ }
223
+ }
224
+ // 3. Passed validation
225
+ return measured;
226
+ }
227
+ // 4. No measured value
228
+ return Math.max(estimated, constants_1.ABSOLUTE_WORK_MIN);
229
+ }
230
+ /**
231
+ * Resolve and validate rest duration for a set.
232
+ *
233
+ * Validation cascade:
234
+ * ┌──────────────────────────────────────────────────────────────┐
235
+ * │ 1. Last set with no data → null (no rest after final set) │
236
+ * │ │
237
+ * │ 2. Absolute sanity check (< 0 or > 3600s) │
238
+ * │ → use exercise typical rest │
239
+ * │ │
240
+ * │ 3. Exercise-specific validation (PRIMARY) │
241
+ * │ Check against restPeriods.minimum / maximum │
242
+ * │ Grace factor: 50% on each side │
243
+ * │ Outside → use exercise typical rest │
244
+ * │ │
245
+ * │ 4. Passed checks → use measured value │
246
+ * │ │
247
+ * │ 5. No measured data → exercise typical → global fallback │
248
+ * └──────────────────────────────────────────────────────────────┘
249
+ */
250
+ function resolveRestDuration(measured, setIndex, totalSets, guardrails) {
251
+ var _a, _b, _c;
252
+ // 1. Last set with no rest data → null
253
+ if (setIndex === totalSets - 1 &&
254
+ (measured === undefined || measured === 0)) {
255
+ return null;
256
+ }
257
+ // Extract exercise-specific rest config
258
+ const exerciseRestMin = (_a = guardrails === null || guardrails === void 0 ? void 0 : guardrails.restPeriods) === null || _a === void 0 ? void 0 : _a.minimum;
259
+ const exerciseRestMax = (_b = guardrails === null || guardrails === void 0 ? void 0 : guardrails.restPeriods) === null || _b === void 0 ? void 0 : _b.maximum;
260
+ const exerciseRestTypical = (_c = guardrails === null || guardrails === void 0 ? void 0 : guardrails.restPeriods) === null || _c === void 0 ? void 0 : _c.typical;
261
+ if (measured !== undefined && measured > 0) {
262
+ // 2. Absolute sanity check
263
+ if (measured < constants_1.ABSOLUTE_REST_MIN || measured > constants_1.ABSOLUTE_REST_MAX) {
264
+ return exerciseRestTypical !== null && exerciseRestTypical !== void 0 ? exerciseRestTypical : constants_1.FALLBACK_REST_SECS;
265
+ }
266
+ // 3. Exercise-specific validation
267
+ if (exerciseRestMin !== undefined && exerciseRestMax !== undefined) {
268
+ const graceFactor = 0.5;
269
+ const lowerBound = exerciseRestMin * (1 - graceFactor);
270
+ const upperBound = exerciseRestMax * (1 + graceFactor);
271
+ if (measured < lowerBound || measured > upperBound) {
272
+ return exerciseRestTypical !== null && exerciseRestTypical !== void 0 ? exerciseRestTypical : constants_1.FALLBACK_REST_SECS;
273
+ }
274
+ }
275
+ // 4. Passed validation
276
+ return measured;
277
+ }
278
+ // 5. No measured data → exercise typical → global fallback
279
+ return exerciseRestTypical !== null && exerciseRestTypical !== void 0 ? exerciseRestTypical : constants_1.FALLBACK_REST_SECS;
280
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
4
+ * ============================================================================
5
+ *
6
+ * Central type definitions for the scoring algorithm.
7
+ * This file defines the output shape and internal types used across all three
8
+ * scoring pillars (Calories, Muscle Fatigue, Quality).
9
+ */
10
+ import { TGender, TRecord } from "../../types";
11
+ /**
12
+ * Quality score breakdown — lets the UI show users WHY they got their score.
13
+ * Each sub-score is 0–100.
14
+ */
15
+ export interface IQualityBreakdown {
16
+ /** Did the user complete all sets? */
17
+ completion: number;
18
+ /** Were sets consistent in output (or intentionally progressive)? */
19
+ consistency: number;
20
+ /** Was effort in the productive RPE/RIR zone? */
21
+ effortAdequacy: number;
22
+ /** Were rest periods within the exercise's optimal range? */
23
+ restDiscipline: number;
24
+ }
25
+ /**
26
+ * The final result returned by calculateExerciseScore.
27
+ *
28
+ * - score: 0–100 overall quality of execution
29
+ * - muscleScores: per-muscle fatigue map (keyed by EBodyParts key, value 0–100)
30
+ * - calorieBurn: estimated kilocalories burned (gross, including EPOC)
31
+ * - qualityBreakdown: transparent sub-scores for the UI
32
+ */
33
+ export interface IScoreResult {
34
+ score: number;
35
+ muscleScores: Record<string, number>;
36
+ calorieBurn: number;
37
+ qualityBreakdown: IQualityBreakdown;
38
+ }
39
+ /**
40
+ * A cleaned, parsed version of a single TRecord.
41
+ * All string fields are parsed to numbers, unreliable timings are replaced
42
+ * with fallbacks, and skipped sets are filtered out before this stage.
43
+ *
44
+ * This is the ONLY representation the scoring pillars work with — they never
45
+ * touch raw TRecord directly.
46
+ */
47
+ export interface IParsedSet {
48
+ type: TRecord["type"];
49
+ /** Active work duration for this set in seconds (estimated or measured) */
50
+ activeDurationSecs: number;
51
+ /** Rest duration after this set in seconds (validated or fallback) */
52
+ restDurationSecs: number | null;
53
+ /**
54
+ * Effort fraction: 0.0 (no effort) to 1.0 (max effort).
55
+ * Derived from RPE, RIR, or fallback (0.6).
56
+ */
57
+ effortFraction: number;
58
+ /** weight-reps: weight in kg */
59
+ kg?: number;
60
+ /** weight-reps / reps-only: rep count */
61
+ reps?: number;
62
+ /** reps-only / duration: auxiliary weight in kg */
63
+ auxWeightKg?: number;
64
+ /** duration: hold time in seconds */
65
+ durationSecs?: number;
66
+ /** cardio-machine: min speed */
67
+ speedMin?: number;
68
+ /** cardio-machine: max speed */
69
+ speedMax?: number;
70
+ /** cardio-machine / cardio-free: distance (km) */
71
+ distance?: number;
72
+ /** cardio-free / cardio-machine: session duration in seconds */
73
+ cardioDurationSecs?: number;
74
+ }
75
+ /**
76
+ * Validated user context with guaranteed fallbacks.
77
+ * No optional fields — everything has a sensible default.
78
+ */
79
+ export interface IUserContext {
80
+ weightKg: number;
81
+ heightCm: number;
82
+ gender: TGender;
83
+ age: number;
84
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * ============================================================================
4
+ * FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
5
+ * ============================================================================
6
+ *
7
+ * Central type definitions for the scoring algorithm.
8
+ * This file defines the output shape and internal types used across all three
9
+ * scoring pillars (Calories, Muscle Fatigue, Quality).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.16",
3
+ "version": "1.2.17",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",