@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,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mockExercisesDictionary = exports.mockExerciseCardioFree = exports.mockExerciseCardioMachine = exports.mockExerciseDuration = exports.mockExerciseRepsOnly = exports.mockExerciseWeightReps = void 0;
4
+ /**
5
+ * MOCK EXERCISE: Weight-Reps
6
+ * Simulates a standard compound lift (e.g., Barbell Squat).
7
+ */
8
+ exports.mockExerciseWeightReps = {
9
+ exerciseId: "mock-exercise-weight-reps-123",
10
+ name: "Generic Barbell Squat",
11
+ bodyPart: ["Legs"],
12
+ recordType: "weight-reps",
13
+ primaryMuscles: ["quadriceps"],
14
+ secondaryMuscles: ["glutes-maximus"],
15
+ trainingTypes: ["weight"],
16
+ difficultyLevel: 2,
17
+ hypertrophyLevel: 4,
18
+ strengthGainLevel: 4,
19
+ flexibilityLevel: 1,
20
+ calorieBurnLevel: 3,
21
+ stabilityLevel: 2,
22
+ enduranceLevel: 1,
23
+ metabolicData: {
24
+ baseMET: 6.0,
25
+ metRange: [4.0, 8.0],
26
+ compoundMultiplier: 1.5,
27
+ muscleGroupFactor: 2.0,
28
+ intensityScaling: "exponential",
29
+ epocFactor: 0.15,
30
+ },
31
+ timingGuardrails: {
32
+ type: "weight-reps",
33
+ stressRestBonus: 5,
34
+ fatigueMultiplier: 1.2,
35
+ setupTypicalSecs: 10,
36
+ restPeriods: {
37
+ minimum: 60,
38
+ typical: 120,
39
+ maximum: 300,
40
+ optimalRange: [90, 180],
41
+ },
42
+ singleRep: {
43
+ min: 2.0,
44
+ max: 5.0,
45
+ typical: 3.5,
46
+ },
47
+ },
48
+ youtubeVideoUrl: ["https://youtube.com/watch?v=mock1"],
49
+ modelVideoUrl: "https://example.com/mock-video.mp4",
50
+ thumbnailUrl: "https://example.com/mock-thumb.png",
51
+ instructionsHtml: "<ul><li>Mock instruction 1</li></ul>",
52
+ importantTipsHtml: "<ul><li>Mock tip 1</li></ul>",
53
+ popularityIndex: 90,
54
+ };
55
+ /**
56
+ * MOCK EXERCISE: Reps-Only
57
+ * Simulates a bodyweight movement (e.g., Push-up).
58
+ */
59
+ exports.mockExerciseRepsOnly = Object.assign(Object.assign({}, exports.mockExerciseWeightReps), { exerciseId: "mock-exercise-reps-only-456", name: "Generic Push-Up", bodyPart: ["Chest", "Core"], recordType: "reps-only", primaryMuscles: ["pectoralis-major"], secondaryMuscles: ["tricep-brachii-lateral", "abs-lower"], trainingTypes: ["body-weight"], timingGuardrails: {
60
+ type: "reps-only",
61
+ stressRestBonus: 2,
62
+ fatigueMultiplier: 1.05,
63
+ setupTypicalSecs: 5,
64
+ restPeriods: {
65
+ minimum: 30,
66
+ typical: 60,
67
+ maximum: 180,
68
+ optimalRange: [45, 90],
69
+ },
70
+ singleRep: {
71
+ min: 1.0,
72
+ max: 3.0,
73
+ typical: 1.8,
74
+ },
75
+ } });
76
+ /**
77
+ * MOCK EXERCISE: Duration
78
+ * Simulates an isometric hold (e.g., Plank).
79
+ */
80
+ exports.mockExerciseDuration = Object.assign(Object.assign({}, exports.mockExerciseWeightReps), { exerciseId: "mock-exercise-duration-789", name: "Generic Forearm Plank", bodyPart: ["Core"], recordType: "duration", primaryMuscles: ["abs-lower", "abs-upper"], secondaryMuscles: ["lower-back"], trainingTypes: ["body-weight", "isometric"], timingGuardrails: {
81
+ type: "duration",
82
+ stressRestBonus: 3,
83
+ fatigueMultiplier: 1.1,
84
+ setupTypicalSecs: 5,
85
+ restPeriods: {
86
+ minimum: 30,
87
+ typical: 60,
88
+ maximum: 120,
89
+ optimalRange: [45, 60],
90
+ },
91
+ setDuration: {
92
+ min: 15,
93
+ max: 300,
94
+ typical: 60,
95
+ },
96
+ } });
97
+ /**
98
+ * MOCK EXERCISE: Cardio-Machine
99
+ * Simulates a machine cardio session (e.g., Treadmill).
100
+ */
101
+ exports.mockExerciseCardioMachine = Object.assign(Object.assign({}, exports.mockExerciseWeightReps), { exerciseId: "mock-exercise-cardio-machine-101", name: "Generic Treadmill Run", bodyPart: ["Legs"], recordType: "cardio-machine", primaryMuscles: ["quadriceps", "hamstrings", "calves"], secondaryMuscles: ["glutes-maximus"], trainingTypes: ["cardio"], timingGuardrails: {
102
+ type: "cardio-machine",
103
+ stressRestBonus: 0,
104
+ fatigueMultiplier: 1.0,
105
+ setupTypicalSecs: 15,
106
+ restPeriods: {
107
+ minimum: 0,
108
+ typical: 0,
109
+ maximum: 300,
110
+ optimalRange: [0, 60],
111
+ },
112
+ } });
113
+ /**
114
+ * MOCK EXERCISE: Cardio-Free
115
+ * Simulates an outdoor/untracked cardio session (e.g., Outdoor Run).
116
+ */
117
+ exports.mockExerciseCardioFree = Object.assign(Object.assign({}, exports.mockExerciseWeightReps), { exerciseId: "mock-exercise-cardio-free-202", name: "Generic Outdoor Jog", bodyPart: ["Legs"], recordType: "cardio-free", primaryMuscles: ["quadriceps", "hamstrings", "calves"], secondaryMuscles: ["glutes-maximus"], trainingTypes: ["cardio"], timingGuardrails: {
118
+ type: "cardio-free",
119
+ stressRestBonus: 0,
120
+ fatigueMultiplier: 1.0,
121
+ setupTypicalSecs: 0,
122
+ restPeriods: {
123
+ minimum: 0,
124
+ typical: 0,
125
+ maximum: 300,
126
+ optimalRange: [0, 0],
127
+ },
128
+ } });
129
+ /**
130
+ * Helper dictionary containing all mock exercises mapped by their ID.
131
+ */
132
+ exports.mockExercisesDictionary = {
133
+ [exports.mockExerciseWeightReps.exerciseId]: exports.mockExerciseWeightReps,
134
+ [exports.mockExerciseRepsOnly.exerciseId]: exports.mockExerciseRepsOnly,
135
+ [exports.mockExerciseDuration.exerciseId]: exports.mockExerciseDuration,
136
+ [exports.mockExerciseCardioMachine.exerciseId]: exports.mockExerciseCardioMachine,
137
+ [exports.mockExerciseCardioFree.exerciseId]: exports.mockExerciseCardioFree,
138
+ };
@@ -0,0 +1,12 @@
1
+ import { TRecord, TExerciseConfig, TExercise } from "../../types";
2
+ type TValidationResult = {
3
+ isValid: boolean;
4
+ sanitizedRecord: TRecord | null;
5
+ errors: string[];
6
+ };
7
+ /**
8
+ * Validates a record against the base exercise type and the plan's specific configuration.
9
+ * Also performs sanitization by stripping out fields that are disabled in the config.
10
+ */
11
+ export declare const validateAndSanitizeRecord: (record: TRecord, baseExercise: TExercise, config: TExerciseConfig) => TValidationResult;
12
+ export {};
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const recordValidator_1 = require("./recordValidator");
5
+ const templateExercises_mock_1 = require("../../__mocks__/templateExercises.mock");
6
+ const exercises_mock_1 = require("../../__mocks__/exercises.mock");
7
+ (0, vitest_1.describe)('recordValidator - Scenario Integration (End-to-End)', () => {
8
+ (0, vitest_1.it)('Scenario: "Dirty payload" -> strips disabled features (RPE, RIR, setNote)', () => {
9
+ // The dirty payload mock has a config where everything is disabled,
10
+ // but the record payload contains rpe, rir, and setNote.
11
+ const mockTemplate = templateExercises_mock_1.mockTemplateExercisesDictionary.dirtyPayload;
12
+ const baseExercise = exercises_mock_1.mockExercisesDictionary[mockTemplate.exerciseId];
13
+ // We pass the first (and only) record through the gatekeeper
14
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(mockTemplate.initialRecords[0], baseExercise, mockTemplate.config);
15
+ (0, vitest_1.expect)(result.isValid).toBe(true);
16
+ (0, vitest_1.expect)(result.errors).toHaveLength(0);
17
+ // Core payload must survive
18
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('kg', '100');
19
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('reps', '10');
20
+ // Disabled properties MUST be stripped
21
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('rpe');
22
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('rir');
23
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('setNote');
24
+ });
25
+ (0, vitest_1.it)('Scenario: "Non-sequential" -> successfully passes validation across a realistic partial array', () => {
26
+ var _a;
27
+ const mockTemplate = templateExercises_mock_1.mockTemplateExercisesDictionary.nonSequential;
28
+ const baseExercise = exercises_mock_1.mockExercisesDictionary[mockTemplate.exerciseId];
29
+ const results = mockTemplate.initialRecords.map(record => (0, recordValidator_1.validateAndSanitizeRecord)(record, baseExercise, mockTemplate.config));
30
+ // Set 1 (Done)
31
+ (0, vitest_1.expect)(results[0].isValid).toBe(true);
32
+ (0, vitest_1.expect)(results[0].sanitizedRecord).toHaveProperty('reps', '10');
33
+ // Set 2 (Undone) - Should pass through trivially
34
+ (0, vitest_1.expect)(results[1].isValid).toBe(true);
35
+ (0, vitest_1.expect)((_a = results[1].sanitizedRecord) === null || _a === void 0 ? void 0 : _a.isDone).toBe(false);
36
+ // Set 3 (Done)
37
+ (0, vitest_1.expect)(results[2].isValid).toBe(true);
38
+ (0, vitest_1.expect)(results[2].sanitizedRecord).toHaveProperty('reps', '10');
39
+ });
40
+ (0, vitest_1.it)('Scenario: cardio-free -> distance NOT stripped even if config.enableDistance is missing/false', () => {
41
+ const mockTemplate = templateExercises_mock_1.mockTemplateExercisesDictionary.cardioFree;
42
+ const baseExercise = exercises_mock_1.mockExercisesDictionary[mockTemplate.exerciseId];
43
+ // Artificially disable enableDistance to prove regression safety
44
+ const hostileConfig = Object.assign(Object.assign({}, mockTemplate.config), { enableDistance: false });
45
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(mockTemplate.initialRecords[0], baseExercise, hostileConfig);
46
+ (0, vitest_1.expect)(result.isValid).toBe(true);
47
+ // Distance is structurally required for cardio-free, the config cannot turn it off.
48
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('distance', '5.0');
49
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('durationMmSs', '25:30');
50
+ });
51
+ });
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateAndSanitizeRecord = void 0;
4
+ /**
5
+ * Validates a record against the base exercise type and the plan's specific configuration.
6
+ * Also performs sanitization by stripping out fields that are disabled in the config.
7
+ */
8
+ const validateAndSanitizeRecord = (record, baseExercise, config) => {
9
+ const errors = [];
10
+ // 1. Completion check
11
+ if (!record.isDone) {
12
+ return { isValid: true, sanitizedRecord: record, errors: [] };
13
+ }
14
+ // 2. Structural Type Check
15
+ // The record type MUST match the master recordType of the exercise in the DB.
16
+ if (record.type !== baseExercise.recordType) {
17
+ errors.push(`Record type mismatch. Expected "${baseExercise.recordType}", but received "${record.type}".`);
18
+ return { isValid: false, sanitizedRecord: null, errors };
19
+ }
20
+ // 3. Create a clean clone for sanitization
21
+ const sanitized = Object.assign({}, record);
22
+ // 4. Config-Driven Sanitization
23
+ // If a field is disabled in the TExerciseConfig, we remove it from the payload.
24
+ if (!config.enableRpe) {
25
+ delete sanitized.rpe;
26
+ }
27
+ if (!config.enableSetNote) {
28
+ delete sanitized.setNote;
29
+ }
30
+ if (record.type === "weight-reps" || record.type === "reps-only") {
31
+ if (!config.enableRir) {
32
+ delete sanitized.rir;
33
+ }
34
+ }
35
+ if (record.type === "reps-only" ||
36
+ record.type === "duration" ||
37
+ record.type === "cardio-free") {
38
+ if (!config.enableAuxWeight) {
39
+ // auxWeightKg is required in the TRecord union — zero it out rather than deleting
40
+ sanitized.auxWeightKg = "";
41
+ }
42
+ }
43
+ if (record.type === "cardio-machine") {
44
+ if (!config.enableInclinePercentage)
45
+ delete sanitized.inclinePercentage;
46
+ if (!config.enableResistanceLevel)
47
+ delete sanitized.resistanceLevel;
48
+ if (!config.enableSpeed)
49
+ delete sanitized.speed;
50
+ if (!config.enableDistance)
51
+ delete sanitized.distance;
52
+ }
53
+ // 5. Value Integrity Checks
54
+ if (record.type === "weight-reps") {
55
+ if (isNaN(parseFloat(record.kg || ""))) {
56
+ errors.push("Weight (kg) must be a parseable number.");
57
+ }
58
+ if (isNaN(parseFloat(record.reps || ""))) {
59
+ errors.push("Reps must be a parseable number.");
60
+ }
61
+ }
62
+ if (record.type === "reps-only") {
63
+ if (isNaN(parseFloat(record.reps || ""))) {
64
+ errors.push("Reps must be a parseable number.");
65
+ }
66
+ }
67
+ if (record.type === "duration" ||
68
+ record.type === "cardio-free" ||
69
+ record.type === "cardio-machine") {
70
+ if (!record.durationMmSs || record.durationMmSs === "00:00") {
71
+ errors.push("Duration (MM:SS) must be present and non-zero.");
72
+ }
73
+ }
74
+ if (record.type === "cardio-free") {
75
+ if (isNaN(parseFloat(record.distance || ""))) {
76
+ errors.push("Distance must be a parseable number.");
77
+ }
78
+ }
79
+ return {
80
+ isValid: errors.length === 0,
81
+ sanitizedRecord: errors.length === 0 ? sanitized : null,
82
+ errors,
83
+ };
84
+ };
85
+ exports.validateAndSanitizeRecord = validateAndSanitizeRecord;
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const recordValidator_1 = require("./recordValidator");
5
+ (0, vitest_1.describe)('recordValidator - Gatekeeper', () => {
6
+ const mockBaseExercise = {
7
+ exerciseId: 'test-id',
8
+ recordType: 'weight-reps',
9
+ };
10
+ const mockConfigAllEnabled = {
11
+ enableRpe: true,
12
+ enableRir: true,
13
+ enableSetNote: true,
14
+ enableAuxWeight: true,
15
+ enableDistance: true,
16
+ enableInclinePercentage: true,
17
+ enableResistanceLevel: true,
18
+ enableSpeed: true,
19
+ };
20
+ const mockConfigAllDisabled = {};
21
+ (0, vitest_1.it)('passes through undone records without validation errors', () => {
22
+ const record = { type: 'weight-reps', isDone: false, isStrictMode: false, kg: 'invalid', reps: 'invalid' };
23
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllEnabled);
24
+ (0, vitest_1.expect)(result.isValid).toBe(true);
25
+ (0, vitest_1.expect)(result.errors).toHaveLength(0);
26
+ });
27
+ (0, vitest_1.it)('rejects records with a mismatched type and asserts sanitizedRecord is null', () => {
28
+ const record = { type: 'duration', isDone: true, isStrictMode: false, durationMmSs: '05:00', auxWeightKg: '0' };
29
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllEnabled);
30
+ (0, vitest_1.expect)(result.isValid).toBe(false);
31
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
32
+ (0, vitest_1.expect)(result.errors[0]).toContain('Record type mismatch');
33
+ });
34
+ (0, vitest_1.it)('strips disabled fields for weight-reps', () => {
35
+ const record = {
36
+ type: 'weight-reps',
37
+ isDone: true,
38
+ isStrictMode: false,
39
+ kg: '100',
40
+ reps: '10',
41
+ rpe: '8',
42
+ rir: '2',
43
+ setNote: 'Felt heavy',
44
+ };
45
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllDisabled);
46
+ (0, vitest_1.expect)(result.isValid).toBe(true);
47
+ // Values should be stripped
48
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('rpe');
49
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('rir');
50
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('setNote');
51
+ // Core values must remain
52
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('kg', '100');
53
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('reps', '10');
54
+ });
55
+ (0, vitest_1.it)('retains fields when enabled in config', () => {
56
+ const record = {
57
+ type: 'weight-reps',
58
+ isDone: true,
59
+ isStrictMode: false,
60
+ kg: '100',
61
+ reps: '10',
62
+ rpe: '8',
63
+ rir: '2',
64
+ setNote: 'Felt heavy',
65
+ };
66
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllEnabled);
67
+ (0, vitest_1.expect)(result.isValid).toBe(true);
68
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('rpe', '8');
69
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('rir', '2');
70
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('setNote', 'Felt heavy');
71
+ });
72
+ (0, vitest_1.it)('validates numeric integrity of weight-reps and collects multiple errors', () => {
73
+ const record = { type: 'weight-reps', isDone: true, isStrictMode: false, kg: 'invalid', reps: 'invalid' };
74
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllEnabled);
75
+ (0, vitest_1.expect)(result.isValid).toBe(false);
76
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
77
+ (0, vitest_1.expect)(result.errors).toHaveLength(2);
78
+ (0, vitest_1.expect)(result.errors[0]).toContain('Weight (kg) must be a parseable number');
79
+ (0, vitest_1.expect)(result.errors[1]).toContain('Reps must be a parseable number');
80
+ });
81
+ (0, vitest_1.it)('treats 0 as a valid number for weight-reps (Failed Set scenario)', () => {
82
+ const record = { type: 'weight-reps', isDone: true, isStrictMode: false, kg: '100', reps: '0' };
83
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, mockBaseExercise, mockConfigAllEnabled);
84
+ (0, vitest_1.expect)(result.isValid).toBe(true);
85
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('reps', '0');
86
+ });
87
+ (0, vitest_1.it)('validates numeric integrity of reps-only and collects error', () => {
88
+ const baseRepExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'reps-only' });
89
+ const record = { type: 'reps-only', isDone: true, isStrictMode: false, reps: 'abc', auxWeightKg: '20' };
90
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseRepExercise, mockConfigAllEnabled);
91
+ (0, vitest_1.expect)(result.isValid).toBe(false);
92
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
93
+ (0, vitest_1.expect)(result.errors).toHaveLength(1);
94
+ (0, vitest_1.expect)(result.errors[0]).toContain('Reps must be a parseable number');
95
+ });
96
+ (0, vitest_1.it)('zeroes out required auxWeightKg instead of deleting it on reps-only when disabled', () => {
97
+ const baseRepExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'reps-only' });
98
+ const record = { type: 'reps-only', isDone: true, isStrictMode: false, reps: '10', auxWeightKg: '20' };
99
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseRepExercise, mockConfigAllDisabled);
100
+ (0, vitest_1.expect)(result.isValid).toBe(true);
101
+ // auxWeightKg should be present but empty, to satisfy union requirements
102
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('auxWeightKg', '');
103
+ });
104
+ (0, vitest_1.it)('validates duration type successfully (Happy Path)', () => {
105
+ const baseDurationExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'duration' });
106
+ const record = { type: 'duration', isDone: true, isStrictMode: false, durationMmSs: '05:00', auxWeightKg: '0' };
107
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseDurationExercise, mockConfigAllEnabled);
108
+ (0, vitest_1.expect)(result.isValid).toBe(true);
109
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('durationMmSs', '05:00');
110
+ });
111
+ (0, vitest_1.it)('rejects cardio-machine records with zero duration', () => {
112
+ const baseMachineExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'cardio-machine' });
113
+ const record = { type: 'cardio-machine', isDone: true, isStrictMode: false, durationMmSs: '00:00' };
114
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseMachineExercise, mockConfigAllEnabled);
115
+ (0, vitest_1.expect)(result.isValid).toBe(false);
116
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
117
+ (0, vitest_1.expect)(result.errors[0]).toContain('Duration (MM:SS) must be present and non-zero');
118
+ });
119
+ (0, vitest_1.it)('strips optional fields from cardio-machine when disabled', () => {
120
+ const baseMachineExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'cardio-machine' });
121
+ const record = {
122
+ type: 'cardio-machine',
123
+ isDone: true,
124
+ isStrictMode: false,
125
+ durationMmSs: '15:00',
126
+ distance: '5',
127
+ speed: '12',
128
+ inclinePercentage: '2',
129
+ resistanceLevel: '5'
130
+ };
131
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseMachineExercise, mockConfigAllDisabled);
132
+ (0, vitest_1.expect)(result.isValid).toBe(true);
133
+ // Fields should be stripped
134
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('distance');
135
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('speed');
136
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('inclinePercentage');
137
+ (0, vitest_1.expect)(result.sanitizedRecord).not.toHaveProperty('resistanceLevel');
138
+ // Core duration must remain
139
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('durationMmSs', '15:00');
140
+ });
141
+ (0, vitest_1.it)('validates duration format for cardio-free', () => {
142
+ const baseCardioExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'cardio-free' });
143
+ // Missing durationMmSs
144
+ const record = { type: 'cardio-free', isDone: true, isStrictMode: false, distance: '5', durationMmSs: '00:00' };
145
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseCardioExercise, mockConfigAllEnabled);
146
+ (0, vitest_1.expect)(result.isValid).toBe(false);
147
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
148
+ (0, vitest_1.expect)(result.errors[0]).toContain('Duration (MM:SS) must be present and non-zero');
149
+ });
150
+ (0, vitest_1.it)('validates distance format for cardio-free', () => {
151
+ const baseCardioExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'cardio-free' });
152
+ const record = { type: 'cardio-free', isDone: true, isStrictMode: false, distance: 'invalid', durationMmSs: '15:00' };
153
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseCardioExercise, mockConfigAllEnabled);
154
+ (0, vitest_1.expect)(result.isValid).toBe(false);
155
+ (0, vitest_1.expect)(result.sanitizedRecord).toBeNull();
156
+ (0, vitest_1.expect)(result.errors[0]).toContain('Distance must be a parseable number');
157
+ });
158
+ (0, vitest_1.it)('retains required distance on cardio-free even if enableDistance is false (Regression)', () => {
159
+ const baseCardioExercise = Object.assign(Object.assign({}, mockBaseExercise), { recordType: 'cardio-free' });
160
+ const record = { type: 'cardio-free', isDone: true, isStrictMode: false, distance: '5', durationMmSs: '15:00' };
161
+ const result = (0, recordValidator_1.validateAndSanitizeRecord)(record, baseCardioExercise, mockConfigAllDisabled);
162
+ (0, vitest_1.expect)(result.isValid).toBe(true);
163
+ (0, vitest_1.expect)(result.sanitizedRecord).toHaveProperty('distance', '5'); // Must NOT be stripped
164
+ });
165
+ });
@@ -0,0 +1,28 @@
1
+ import { TRecord, TTimingGuardrails } from "../../types";
2
+ /**
3
+ * Parses a time string (MM:SS or HH:MM:SS) into total seconds.
4
+ */
5
+ export declare const parseMmSsToSecs: (mmss?: string) => number;
6
+ /**
7
+ * Estimates the "Time Under Tension" for a set using Claude's "Derive, Don't Measure" strategy.
8
+ * For cardio/duration, it uses the explicit input.
9
+ * For reps-based exercises, it uses timingGuardrails.
10
+ */
11
+ export declare const estimateWorkDuration: (record: TRecord, guardrails?: TTimingGuardrails) => number;
12
+ /**
13
+ * Determines the rest period duration.
14
+ * Prioritizes actual recorded rest if available, otherwise falls back to typical rest periods.
15
+ */
16
+ export declare const estimateRestDuration: (record: TRecord, guardrails?: TTimingGuardrails) => number;
17
+ /**
18
+ * Calculates total exercise duration.
19
+ * Formula: Sum(Work) + Sum(Rest) - Rest[LastSet]
20
+ */
21
+ export declare const calculateTotalExerciseDuration: (records: TRecord[], guardrails?: TTimingGuardrails) => number;
22
+ /**
23
+ * Calculates volume for a single set.
24
+ * CRITICAL: This math assumes the weight is stored in KILOGRAMS.
25
+ * If the weight string looks like LBS (e.g. > 200kg on a lateral raise),
26
+ * it is a sign of a conversion error in the frontend.
27
+ */
28
+ export declare const calculateSetVolume: (record: TRecord) => number;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculateSetVolume = exports.calculateTotalExerciseDuration = exports.estimateRestDuration = exports.estimateWorkDuration = exports.parseMmSsToSecs = void 0;
4
+ /**
5
+ * Parses a time string (MM:SS or HH:MM:SS) into total seconds.
6
+ */
7
+ const parseMmSsToSecs = (mmss) => {
8
+ if (!mmss)
9
+ return 0;
10
+ const parts = mmss.split(':');
11
+ if (parts.length === 2) {
12
+ return (parseInt(parts[0], 10) || 0) * 60 + (parseInt(parts[1], 10) || 0);
13
+ }
14
+ if (parts.length === 3) {
15
+ return ((parseInt(parts[0], 10) || 0) * 3600 +
16
+ (parseInt(parts[1], 10) || 0) * 60 +
17
+ (parseInt(parts[2], 10) || 0));
18
+ }
19
+ return 0;
20
+ };
21
+ exports.parseMmSsToSecs = parseMmSsToSecs;
22
+ /**
23
+ * Estimates the "Time Under Tension" for a set using Claude's "Derive, Don't Measure" strategy.
24
+ * For cardio/duration, it uses the explicit input.
25
+ * For reps-based exercises, it uses timingGuardrails.
26
+ */
27
+ const estimateWorkDuration = (record, guardrails) => {
28
+ var _a;
29
+ if (!record.isDone)
30
+ return 0;
31
+ if (record.type === "duration" ||
32
+ record.type === "cardio-machine" ||
33
+ record.type === "cardio-free") {
34
+ return (0, exports.parseMmSsToSecs)(record.durationMmSs);
35
+ }
36
+ if (record.type === "weight-reps" || record.type === "reps-only") {
37
+ const reps = parseFloat(record.reps || "0");
38
+ const parsedReps = Math.max(0, isNaN(reps) ? 0 : reps);
39
+ // Use guardrails if they match the type
40
+ if (guardrails &&
41
+ (guardrails.type === "weight-reps" || guardrails.type === "reps-only")) {
42
+ const singleRep = ((_a = guardrails.singleRep) === null || _a === void 0 ? void 0 : _a.typical) || 2.5;
43
+ const setup = guardrails.setupTypicalSecs || 3;
44
+ return Math.round(parsedReps * singleRep + setup);
45
+ }
46
+ // Fallback defaults
47
+ return Math.round(parsedReps * 2.5 + 3);
48
+ }
49
+ return 0;
50
+ };
51
+ exports.estimateWorkDuration = estimateWorkDuration;
52
+ /**
53
+ * Determines the rest period duration.
54
+ * Prioritizes actual recorded rest if available, otherwise falls back to typical rest periods.
55
+ */
56
+ const estimateRestDuration = (record, guardrails) => {
57
+ var _a, _b;
58
+ if (!record.isDone)
59
+ return 0;
60
+ const typicalRest = ((_a = guardrails === null || guardrails === void 0 ? void 0 : guardrails.restPeriods) === null || _a === void 0 ? void 0 : _a.typical) || 60;
61
+ const maxRest = ((_b = guardrails === null || guardrails === void 0 ? void 0 : guardrails.restPeriods) === null || _b === void 0 ? void 0 : _b.maximum) || 300;
62
+ if (record.restDurationSecs !== undefined && !isNaN(record.restDurationSecs) && record.restDurationSecs > 0) {
63
+ // We clamp the rest duration to avoid "Distracted Logger" outliers (e.g. 20 min rest)
64
+ return Math.min(record.restDurationSecs, maxRest);
65
+ }
66
+ return typicalRest;
67
+ };
68
+ exports.estimateRestDuration = estimateRestDuration;
69
+ /**
70
+ * Calculates total exercise duration.
71
+ * Formula: Sum(Work) + Sum(Rest) - Rest[LastSet]
72
+ */
73
+ const calculateTotalExerciseDuration = (records, guardrails) => {
74
+ const doneRecords = records.filter((r) => r.isDone);
75
+ if (doneRecords.length === 0)
76
+ return 0;
77
+ let totalSecs = 0;
78
+ for (let i = 0; i < doneRecords.length; i++) {
79
+ totalSecs += (0, exports.estimateWorkDuration)(doneRecords[i], guardrails);
80
+ // Only add rest if it's not the final completed set
81
+ if (i < doneRecords.length - 1) {
82
+ totalSecs += (0, exports.estimateRestDuration)(doneRecords[i], guardrails);
83
+ }
84
+ }
85
+ return totalSecs;
86
+ };
87
+ exports.calculateTotalExerciseDuration = calculateTotalExerciseDuration;
88
+ /**
89
+ * Calculates volume for a single set.
90
+ * CRITICAL: This math assumes the weight is stored in KILOGRAMS.
91
+ * If the weight string looks like LBS (e.g. > 200kg on a lateral raise),
92
+ * it is a sign of a conversion error in the frontend.
93
+ */
94
+ const calculateSetVolume = (record) => {
95
+ if (!record.isDone)
96
+ return 0;
97
+ if (record.type === "weight-reps") {
98
+ const kg = parseFloat(record.kg || "0");
99
+ const reps = parseFloat(record.reps || "0");
100
+ if (isNaN(kg) || isNaN(reps))
101
+ return 0;
102
+ return kg * reps;
103
+ }
104
+ if (record.type === "reps-only") {
105
+ const aux = parseFloat(record.auxWeightKg || "0");
106
+ const reps = parseFloat(record.reps || "0");
107
+ if (isNaN(reps))
108
+ return 0;
109
+ // Bodyweight volume = reps (1 unit per rep). Aux weight adds on top.
110
+ // Callers needing absolute volume (e.g. calorie burn) must inject user bodyweight externally.
111
+ const effectiveWeight = isNaN(aux) || aux <= 0 ? 1 : 1 + aux;
112
+ return effectiveWeight * reps;
113
+ }
114
+ return 0;
115
+ };
116
+ exports.calculateSetVolume = calculateSetVolume;
@@ -0,0 +1 @@
1
+ export {};