@dgpholdings/greatoak-shared 1.2.34 → 1.2.36
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/types/TApiExercise.d.ts +12 -0
- package/dist/types/TApiProPlan.d.ts +4 -0
- package/dist/types/TApiUser.d.ts +2 -1
- package/dist/types/commonTypes.d.ts +1 -0
- package/dist/utils/adoptionEngine/scaleProPlan.util.d.ts +9 -0
- package/dist/utils/adoptionEngine/scaleProPlan.util.js +139 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +4 -1
- package/dist/utils/scaleProPlan.util.d.ts +9 -0
- package/dist/utils/scaleProPlan.util.js +139 -0
- package/package.json +1 -1
|
@@ -123,6 +123,18 @@ export type TExercise = {
|
|
|
123
123
|
isFavorite?: boolean;
|
|
124
124
|
scoringSpecialHandling?: "plyometric" | "stretch-mobility" | "continuous-duration" | "loaded-carry";
|
|
125
125
|
isFlaggedCorrection?: boolean;
|
|
126
|
+
regressionExerciseId?: string;
|
|
127
|
+
progressionExerciseId?: string;
|
|
128
|
+
bodyweightDependency?: "high" | "medium" | "low" | "none";
|
|
129
|
+
bmiThresholds?: {
|
|
130
|
+
overweightTrigger?: number;
|
|
131
|
+
obeseTrigger?: number;
|
|
132
|
+
};
|
|
133
|
+
weightMultiplier?: {
|
|
134
|
+
male: number;
|
|
135
|
+
female: number;
|
|
136
|
+
default: number;
|
|
137
|
+
};
|
|
126
138
|
};
|
|
127
139
|
export type TBodyPartExercises = Record<TBodyPart, TExercise[]>;
|
|
128
140
|
export type TApiCreateOrUpdateExerciseReq = {
|
|
@@ -37,8 +37,12 @@ export type TProPlan = {
|
|
|
37
37
|
fr?: string;
|
|
38
38
|
};
|
|
39
39
|
exercises: TTemplateExercise[];
|
|
40
|
+
estimatedDuration: number;
|
|
40
41
|
planThumbnailImageUrl?: string;
|
|
41
42
|
}[];
|
|
43
|
+
progressionRequirements?: {
|
|
44
|
+
noOfWeeks?: number;
|
|
45
|
+
};
|
|
42
46
|
createdAt?: Date;
|
|
43
47
|
updatedAt?: Date;
|
|
44
48
|
} & ({
|
package/dist/types/TApiUser.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type TFitnessGoal = "strength" | "hypertrophy" | "endurance" | "general"
|
|
|
8
8
|
export type TSubscriptionStatus = "active" | "cancelled" | "expired" | "trial" | "grace" | "none";
|
|
9
9
|
export type TSubscriptionType = "monthly" | "yearly" | "trial";
|
|
10
10
|
export type TAppStore = "app_store" | "play_store" | null;
|
|
11
|
+
export type TActivityLevel = "sedentary" | "lightly-active" | "moderately-active" | "very-active";
|
|
11
12
|
export type TUserMetric = {
|
|
12
13
|
userId: string;
|
|
13
14
|
dob: Date;
|
|
@@ -21,7 +22,7 @@ export type TUserMetric = {
|
|
|
21
22
|
weightKg?: number;
|
|
22
23
|
heightCm?: number;
|
|
23
24
|
emailAddress?: string;
|
|
24
|
-
fitnessLevel?:
|
|
25
|
+
fitnessLevel?: TActivityLevel;
|
|
25
26
|
bodyFatPercentage?: number;
|
|
26
27
|
metricSystem: "US" | "EU";
|
|
27
28
|
subscriptionStatus: TSubscriptionStatus;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { TProPlan, TUserMetric, TExercise } from "../../types";
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the BMI (Body Mass Index).
|
|
4
|
+
*/
|
|
5
|
+
export declare const calculateBMI: (weightKg: number, heightCm: number) => number;
|
|
6
|
+
/**
|
|
7
|
+
* The Adoption Engine: Safely scales a Master Pro Plan to fit an individual user's body type.
|
|
8
|
+
*/
|
|
9
|
+
export declare const scaleProPlan: (masterPlan: TProPlan, userMetric: TUserMetric, exercisesDictionary: Record<string, TExercise>) => TProPlan;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scaleProPlan = exports.calculateBMI = void 0;
|
|
4
|
+
const NO_THRESHOLD = Infinity;
|
|
5
|
+
/**
|
|
6
|
+
* Calculates the BMI (Body Mass Index).
|
|
7
|
+
*/
|
|
8
|
+
const calculateBMI = (weightKg, heightCm) => {
|
|
9
|
+
if (!weightKg || !heightCm || heightCm <= 0)
|
|
10
|
+
return 0;
|
|
11
|
+
const heightM = heightCm / 100;
|
|
12
|
+
return Number((weightKg / (heightM * heightM)).toFixed(1));
|
|
13
|
+
};
|
|
14
|
+
exports.calculateBMI = calculateBMI;
|
|
15
|
+
/**
|
|
16
|
+
* Rounds a calculated weight load to the nearest sensible gym increment (e.g. 2.5kg).
|
|
17
|
+
*/
|
|
18
|
+
const roundToNearest = (val, increment) => Math.round(val / increment) * increment;
|
|
19
|
+
/**
|
|
20
|
+
* Calculates effective BMI threshold based on the user's fitness activity level.
|
|
21
|
+
* Active users carry more muscle, so their BMI threshold for bodyweight safety triggers is raised.
|
|
22
|
+
*/
|
|
23
|
+
const getAdjustedBMIThreshold = (baseThreshold, activityLevel) => {
|
|
24
|
+
if (!baseThreshold)
|
|
25
|
+
return NO_THRESHOLD;
|
|
26
|
+
switch (activityLevel) {
|
|
27
|
+
case "very-active":
|
|
28
|
+
return baseThreshold + 5; // e.g. Obese trigger moves from 30 to 35
|
|
29
|
+
case "moderately-active":
|
|
30
|
+
return baseThreshold + 3; // e.g. Obese trigger moves from 30 to 33
|
|
31
|
+
case "lightly-active":
|
|
32
|
+
return baseThreshold + 1;
|
|
33
|
+
case "sedentary":
|
|
34
|
+
default:
|
|
35
|
+
return baseThreshold; // No adjustment
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Modifies an individual record based on fallback logic (when no regression exercise exists, or for softer triggers).
|
|
40
|
+
* Reduces volume or duration by roughly 30%.
|
|
41
|
+
*/
|
|
42
|
+
const applyFallbackScalingToRecord = (record) => {
|
|
43
|
+
const scaledRecord = Object.assign({}, record);
|
|
44
|
+
const SCALE_FACTOR = 0.7;
|
|
45
|
+
// Scale reps
|
|
46
|
+
if (scaledRecord.type === "reps-only" || scaledRecord.type === "weight-reps") {
|
|
47
|
+
const currentReps = parseInt(scaledRecord.reps, 10);
|
|
48
|
+
if (!isNaN(currentReps) && currentReps > 0) {
|
|
49
|
+
scaledRecord.reps = Math.max(1, Math.round(currentReps * SCALE_FACTOR)).toString();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Scale duration for all time-based records
|
|
53
|
+
if (scaledRecord.type === "duration" ||
|
|
54
|
+
scaledRecord.type === "cardio-machine" ||
|
|
55
|
+
scaledRecord.type === "cardio-free") {
|
|
56
|
+
const [mm, ss] = scaledRecord.durationMmSs.split(":").map(Number);
|
|
57
|
+
if (!isNaN(mm) && !isNaN(ss)) {
|
|
58
|
+
const totalSecs = mm * 60 + ss;
|
|
59
|
+
const newSecs = Math.max(5, Math.round(totalSecs * SCALE_FACTOR)); // minimum 5 seconds
|
|
60
|
+
const newMm = Math.floor(newSecs / 60).toString();
|
|
61
|
+
const newMmPadded = newMm.length < 2 ? "0" + newMm : newMm;
|
|
62
|
+
const newSs = (newSecs % 60).toString();
|
|
63
|
+
const newSsPadded = newSs.length < 2 ? "0" + newSs : newSs;
|
|
64
|
+
scaledRecord.durationMmSs = `${newMmPadded}:${newSsPadded}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return scaledRecord;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The Adoption Engine: Safely scales a Master Pro Plan to fit an individual user's body type.
|
|
71
|
+
*/
|
|
72
|
+
const scaleProPlan = (masterPlan, userMetric, exercisesDictionary) => {
|
|
73
|
+
// We don't mutate the original
|
|
74
|
+
const tailoredPlan = JSON.parse(JSON.stringify(masterPlan));
|
|
75
|
+
const { weightKg, heightCm, gender, fitnessLevel } = userMetric;
|
|
76
|
+
const bmi = weightKg && heightCm ? (0, exports.calculateBMI)(weightKg, heightCm) : 0;
|
|
77
|
+
tailoredPlan.days.forEach((day) => {
|
|
78
|
+
day.exercises = day.exercises.map((templateExercise) => {
|
|
79
|
+
const exerciseMeta = exercisesDictionary[templateExercise.exerciseId];
|
|
80
|
+
// If we can't find metadata, return as-is
|
|
81
|
+
if (!exerciseMeta)
|
|
82
|
+
return templateExercise;
|
|
83
|
+
let isModified = false;
|
|
84
|
+
let newExerciseId = templateExercise.exerciseId;
|
|
85
|
+
let newRecords = [...templateExercise.initialRecords];
|
|
86
|
+
// 1. Check Safety Triggers (BMI & Bodyweight Dependency)
|
|
87
|
+
if (bmi > 0 &&
|
|
88
|
+
(exerciseMeta.bodyweightDependency === "high" || exerciseMeta.bodyweightDependency === "medium") &&
|
|
89
|
+
exerciseMeta.bmiThresholds) {
|
|
90
|
+
const obeseTrigger = getAdjustedBMIThreshold(exerciseMeta.bmiThresholds.obeseTrigger, fitnessLevel);
|
|
91
|
+
const overweightTrigger = getAdjustedBMIThreshold(exerciseMeta.bmiThresholds.overweightTrigger, fitnessLevel);
|
|
92
|
+
if (bmi >= obeseTrigger) {
|
|
93
|
+
// Tier 1: Hard swap to regression (if available) AND scale down volume
|
|
94
|
+
newRecords = newRecords.map(applyFallbackScalingToRecord);
|
|
95
|
+
isModified = true;
|
|
96
|
+
if (exerciseMeta.regressionExerciseId && exercisesDictionary[exerciseMeta.regressionExerciseId]) {
|
|
97
|
+
newExerciseId = exerciseMeta.regressionExerciseId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (bmi >= overweightTrigger) {
|
|
101
|
+
// Tier 2: Softer response. Keep the exercise, but scale down the volume/duration.
|
|
102
|
+
newRecords = newRecords.map(applyFallbackScalingToRecord);
|
|
103
|
+
isModified = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 2. Check Load Multipliers
|
|
107
|
+
if (weightKg && exerciseMeta.weightMultiplier && newRecords.length > 0) {
|
|
108
|
+
const multiplier = gender === "male"
|
|
109
|
+
? exerciseMeta.weightMultiplier.male
|
|
110
|
+
: gender === "female"
|
|
111
|
+
? exerciseMeta.weightMultiplier.female
|
|
112
|
+
: exerciseMeta.weightMultiplier.default;
|
|
113
|
+
if (multiplier && multiplier > 0) {
|
|
114
|
+
// Round to nearest 2.5kg to match real-world gym equipment
|
|
115
|
+
const suggestedLoadKg = roundToNearest(weightKg * multiplier, 2.5);
|
|
116
|
+
newRecords = newRecords.map((record) => {
|
|
117
|
+
if (record.type === "weight-reps") {
|
|
118
|
+
isModified = true;
|
|
119
|
+
return Object.assign(Object.assign({}, record), { kg: suggestedLoadKg.toString() });
|
|
120
|
+
}
|
|
121
|
+
// Guard against applying load to a falsy "0" aux weight
|
|
122
|
+
if (record.type === "reps-only" || record.type === "duration" || record.type === "cardio-free") {
|
|
123
|
+
const currentAux = parseFloat(record.auxWeightKg || "0");
|
|
124
|
+
if (!isNaN(currentAux) && currentAux > 0) {
|
|
125
|
+
isModified = true;
|
|
126
|
+
return Object.assign(Object.assign({}, record), { auxWeightKg: suggestedLoadKg.toString() });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return record;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Return the tailored template exercise
|
|
134
|
+
return Object.assign(Object.assign({}, templateExercise), { exerciseId: newExerciseId, initialRecords: newRecords, isAutoScaled: isModified || undefined });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
return tailoredPlan;
|
|
138
|
+
};
|
|
139
|
+
exports.scaleProPlan = scaleProPlan;
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ export { generatePlanCode } from "./planCode.util";
|
|
|
8
8
|
export { maskEmail, isAnonymousEmail, isEmail } from "./email.utils";
|
|
9
9
|
export { NOOP } from "./noop.utils";
|
|
10
10
|
export { calculateExerciseScoreV2, calculateTotalVolume } from "./scoring";
|
|
11
|
+
export { scaleProPlan, calculateBMI } from "./adoptionEngine/scaleProPlan.util";
|
package/dist/utils/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.calculateTotalVolume = exports.calculateExerciseScoreV2 = exports.NOOP = exports.isEmail = exports.isAnonymousEmail = exports.maskEmail = exports.generatePlanCode = exports.toError = exports.slugifyText = exports.isDefinedNumber = exports.isDefined = exports.countryToCurrencyCode = exports.getDaysAndHoursDifference = exports.isUserAllowedToUpdate = exports.mmssToSecs = exports.toNumber = void 0;
|
|
3
|
+
exports.calculateBMI = exports.scaleProPlan = exports.calculateTotalVolume = exports.calculateExerciseScoreV2 = exports.NOOP = exports.isEmail = exports.isAnonymousEmail = exports.maskEmail = exports.generatePlanCode = exports.toError = exports.slugifyText = exports.isDefinedNumber = exports.isDefined = exports.countryToCurrencyCode = exports.getDaysAndHoursDifference = exports.isUserAllowedToUpdate = exports.mmssToSecs = exports.toNumber = void 0;
|
|
4
4
|
var number_util_1 = require("./number.util");
|
|
5
5
|
Object.defineProperty(exports, "toNumber", { enumerable: true, get: function () { return number_util_1.toNumber; } });
|
|
6
6
|
var time_util_1 = require("./time.util");
|
|
@@ -27,3 +27,6 @@ Object.defineProperty(exports, "NOOP", { enumerable: true, get: function () { re
|
|
|
27
27
|
var scoring_1 = require("./scoring");
|
|
28
28
|
Object.defineProperty(exports, "calculateExerciseScoreV2", { enumerable: true, get: function () { return scoring_1.calculateExerciseScoreV2; } });
|
|
29
29
|
Object.defineProperty(exports, "calculateTotalVolume", { enumerable: true, get: function () { return scoring_1.calculateTotalVolume; } });
|
|
30
|
+
var scaleProPlan_util_1 = require("./adoptionEngine/scaleProPlan.util");
|
|
31
|
+
Object.defineProperty(exports, "scaleProPlan", { enumerable: true, get: function () { return scaleProPlan_util_1.scaleProPlan; } });
|
|
32
|
+
Object.defineProperty(exports, "calculateBMI", { enumerable: true, get: function () { return scaleProPlan_util_1.calculateBMI; } });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { TProPlan, TUserMetric, TExercise } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the BMI (Body Mass Index).
|
|
4
|
+
*/
|
|
5
|
+
export declare const calculateBMI: (weightKg: number, heightCm: number) => number;
|
|
6
|
+
/**
|
|
7
|
+
* The Adoption Engine: Safely scales a Master Pro Plan to fit an individual user's body type.
|
|
8
|
+
*/
|
|
9
|
+
export declare const scaleProPlan: (masterPlan: TProPlan, userMetric: TUserMetric, exercisesDictionary: Record<string, TExercise>) => TProPlan;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scaleProPlan = exports.calculateBMI = void 0;
|
|
4
|
+
const NO_THRESHOLD = Infinity;
|
|
5
|
+
/**
|
|
6
|
+
* Calculates the BMI (Body Mass Index).
|
|
7
|
+
*/
|
|
8
|
+
const calculateBMI = (weightKg, heightCm) => {
|
|
9
|
+
if (!weightKg || !heightCm || heightCm <= 0)
|
|
10
|
+
return 0;
|
|
11
|
+
const heightM = heightCm / 100;
|
|
12
|
+
return Number((weightKg / (heightM * heightM)).toFixed(1));
|
|
13
|
+
};
|
|
14
|
+
exports.calculateBMI = calculateBMI;
|
|
15
|
+
/**
|
|
16
|
+
* Rounds a calculated weight load to the nearest sensible gym increment (e.g. 2.5kg).
|
|
17
|
+
*/
|
|
18
|
+
const roundToNearest = (val, increment) => Math.round(val / increment) * increment;
|
|
19
|
+
/**
|
|
20
|
+
* Calculates effective BMI threshold based on the user's fitness activity level.
|
|
21
|
+
* Active users carry more muscle, so their BMI threshold for bodyweight safety triggers is raised.
|
|
22
|
+
*/
|
|
23
|
+
const getAdjustedBMIThreshold = (baseThreshold, activityLevel) => {
|
|
24
|
+
if (!baseThreshold)
|
|
25
|
+
return NO_THRESHOLD;
|
|
26
|
+
switch (activityLevel) {
|
|
27
|
+
case "very-active":
|
|
28
|
+
return baseThreshold + 5; // e.g. Obese trigger moves from 30 to 35
|
|
29
|
+
case "moderately-active":
|
|
30
|
+
return baseThreshold + 3; // e.g. Obese trigger moves from 30 to 33
|
|
31
|
+
case "lightly-active":
|
|
32
|
+
return baseThreshold + 1;
|
|
33
|
+
case "sedentary":
|
|
34
|
+
default:
|
|
35
|
+
return baseThreshold; // No adjustment
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Modifies an individual record based on fallback logic (when no regression exercise exists, or for softer triggers).
|
|
40
|
+
* Reduces volume or duration by roughly 30%.
|
|
41
|
+
*/
|
|
42
|
+
const applyFallbackScalingToRecord = (record) => {
|
|
43
|
+
const scaledRecord = Object.assign({}, record);
|
|
44
|
+
const SCALE_FACTOR = 0.7;
|
|
45
|
+
// Scale reps
|
|
46
|
+
if (scaledRecord.type === "reps-only" || scaledRecord.type === "weight-reps") {
|
|
47
|
+
const currentReps = parseInt(scaledRecord.reps, 10);
|
|
48
|
+
if (!isNaN(currentReps) && currentReps > 0) {
|
|
49
|
+
scaledRecord.reps = Math.max(1, Math.round(currentReps * SCALE_FACTOR)).toString();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Scale duration for all time-based records
|
|
53
|
+
if (scaledRecord.type === "duration" ||
|
|
54
|
+
scaledRecord.type === "cardio-machine" ||
|
|
55
|
+
scaledRecord.type === "cardio-free") {
|
|
56
|
+
const [mm, ss] = scaledRecord.durationMmSs.split(":").map(Number);
|
|
57
|
+
if (!isNaN(mm) && !isNaN(ss)) {
|
|
58
|
+
const totalSecs = mm * 60 + ss;
|
|
59
|
+
const newSecs = Math.max(5, Math.round(totalSecs * SCALE_FACTOR)); // minimum 5 seconds
|
|
60
|
+
const newMm = Math.floor(newSecs / 60).toString();
|
|
61
|
+
const newMmPadded = newMm.length < 2 ? "0" + newMm : newMm;
|
|
62
|
+
const newSs = (newSecs % 60).toString();
|
|
63
|
+
const newSsPadded = newSs.length < 2 ? "0" + newSs : newSs;
|
|
64
|
+
scaledRecord.durationMmSs = `${newMmPadded}:${newSsPadded}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return scaledRecord;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The Adoption Engine: Safely scales a Master Pro Plan to fit an individual user's body type.
|
|
71
|
+
*/
|
|
72
|
+
const scaleProPlan = (masterPlan, userMetric, exercisesDictionary) => {
|
|
73
|
+
// We don't mutate the original
|
|
74
|
+
const tailoredPlan = JSON.parse(JSON.stringify(masterPlan));
|
|
75
|
+
const { weightKg, heightCm, gender, fitnessLevel } = userMetric;
|
|
76
|
+
const bmi = weightKg && heightCm ? (0, exports.calculateBMI)(weightKg, heightCm) : 0;
|
|
77
|
+
tailoredPlan.days.forEach((day) => {
|
|
78
|
+
day.exercises = day.exercises.map((templateExercise) => {
|
|
79
|
+
const exerciseMeta = exercisesDictionary[templateExercise.exerciseId];
|
|
80
|
+
// If we can't find metadata, return as-is
|
|
81
|
+
if (!exerciseMeta)
|
|
82
|
+
return templateExercise;
|
|
83
|
+
let isModified = false;
|
|
84
|
+
let newExerciseId = templateExercise.exerciseId;
|
|
85
|
+
let newRecords = [...templateExercise.initialRecords];
|
|
86
|
+
// 1. Check Safety Triggers (BMI & Bodyweight Dependency)
|
|
87
|
+
if (bmi > 0 &&
|
|
88
|
+
(exerciseMeta.bodyweightDependency === "high" || exerciseMeta.bodyweightDependency === "medium") &&
|
|
89
|
+
exerciseMeta.bmiThresholds) {
|
|
90
|
+
const obeseTrigger = getAdjustedBMIThreshold(exerciseMeta.bmiThresholds.obeseTrigger, fitnessLevel);
|
|
91
|
+
const overweightTrigger = getAdjustedBMIThreshold(exerciseMeta.bmiThresholds.overweightTrigger, fitnessLevel);
|
|
92
|
+
if (bmi >= obeseTrigger) {
|
|
93
|
+
// Tier 1: Hard swap to regression (if available) AND scale down volume
|
|
94
|
+
newRecords = newRecords.map(applyFallbackScalingToRecord);
|
|
95
|
+
isModified = true;
|
|
96
|
+
if (exerciseMeta.regressionExerciseId && exercisesDictionary[exerciseMeta.regressionExerciseId]) {
|
|
97
|
+
newExerciseId = exerciseMeta.regressionExerciseId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (bmi >= overweightTrigger) {
|
|
101
|
+
// Tier 2: Softer response. Keep the exercise, but scale down the volume/duration.
|
|
102
|
+
newRecords = newRecords.map(applyFallbackScalingToRecord);
|
|
103
|
+
isModified = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 2. Check Load Multipliers
|
|
107
|
+
if (weightKg && exerciseMeta.weightMultiplier && newRecords.length > 0) {
|
|
108
|
+
const multiplier = gender === "male"
|
|
109
|
+
? exerciseMeta.weightMultiplier.male
|
|
110
|
+
: gender === "female"
|
|
111
|
+
? exerciseMeta.weightMultiplier.female
|
|
112
|
+
: exerciseMeta.weightMultiplier.default;
|
|
113
|
+
if (multiplier && multiplier > 0) {
|
|
114
|
+
// Round to nearest 2.5kg to match real-world gym equipment
|
|
115
|
+
const suggestedLoadKg = roundToNearest(weightKg * multiplier, 2.5);
|
|
116
|
+
newRecords = newRecords.map((record) => {
|
|
117
|
+
if (record.type === "weight-reps") {
|
|
118
|
+
isModified = true;
|
|
119
|
+
return Object.assign(Object.assign({}, record), { kg: suggestedLoadKg.toString() });
|
|
120
|
+
}
|
|
121
|
+
// Guard against applying load to a falsy "0" aux weight
|
|
122
|
+
if (record.type === "reps-only" || record.type === "duration" || record.type === "cardio-free") {
|
|
123
|
+
const currentAux = parseFloat(record.auxWeightKg || "0");
|
|
124
|
+
if (!isNaN(currentAux) && currentAux > 0) {
|
|
125
|
+
isModified = true;
|
|
126
|
+
return Object.assign(Object.assign({}, record), { auxWeightKg: suggestedLoadKg.toString() });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return record;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Return the tailored template exercise
|
|
134
|
+
return Object.assign(Object.assign({}, templateExercise), { exerciseId: newExerciseId, initialRecords: newRecords, isAutoScaled: isModified || undefined });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
return tailoredPlan;
|
|
138
|
+
};
|
|
139
|
+
exports.scaleProPlan = scaleProPlan;
|