@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.
@@ -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
  } & ({
@@ -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?: number;
25
+ fitnessLevel?: TActivityLevel;
25
26
  bodyFatPercentage?: number;
26
27
  metricSystem: "US" | "EU";
27
28
  subscriptionStatus: TSubscriptionStatus;
@@ -65,6 +65,7 @@ export type TTemplateExercise = {
65
65
  exerciseNote: string;
66
66
  restTimeSecs?: number;
67
67
  initialRecords: TRecord[];
68
+ isAutoScaled?: boolean;
68
69
  };
69
70
  export type TGdprData = {
70
71
  consentVersion: string;
@@ -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;
@@ -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";
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.34",
3
+ "version": "1.2.36",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",