@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.
- package/dist/__mocks__/exercises.mock.d.ts +35 -0
- package/dist/__mocks__/exercises.mock.js +144 -0
- package/dist/__mocks__/templateExercises.mock.d.ts +90 -0
- package/dist/__mocks__/templateExercises.mock.js +258 -0
- package/dist/__mocks__/user.mock.d.ts +2 -0
- package/dist/__mocks__/user.mock.js +36 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +30 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +138 -0
- package/dist/utils/exerciseRecord/recordValidator.d.ts +12 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.js +51 -0
- package/dist/utils/exerciseRecord/recordValidator.js +85 -0
- package/dist/utils/exerciseRecord/recordValidator.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.test.js +165 -0
- package/dist/utils/exerciseRecord/workoutMath.d.ts +28 -0
- package/dist/utils/exerciseRecord/workoutMath.js +116 -0
- package/dist/utils/exerciseRecord/workoutMath.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/workoutMath.test.js +238 -0
- package/dist/utils/index.d.ts +3 -1
- package/dist/utils/index.js +5 -3
- package/dist/utils/scoringWorkout/calculateCalories.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateCalories.js +351 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +330 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +73 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.js +357 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +15 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.js +73 -0
- package/dist/utils/scoringWorkout/constants.d.ts +211 -0
- package/dist/utils/scoringWorkout/constants.js +247 -0
- package/dist/utils/scoringWorkout/helpers.d.ts +127 -0
- package/dist/utils/scoringWorkout/helpers.js +245 -0
- package/dist/utils/scoringWorkout/index.d.ts +27 -0
- package/dist/utils/scoringWorkout/index.js +56 -0
- package/dist/utils/scoringWorkout/parseRecords.d.ts +68 -0
- package/dist/utils/scoringWorkout/parseRecords.js +281 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.d.ts +1 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +439 -0
- package/dist/utils/scoringWorkout/types.d.ts +104 -0
- package/dist/utils/scoringWorkout/types.js +11 -0
- 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 @@
|
|
|
1
|
+
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|