@dgpholdings/greatoak-shared 1.2.86 → 1.2.88
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/README.md +148 -148
- package/dist/__mocks__/catalog.fixture.d.ts +2 -0
- package/dist/__mocks__/catalog.fixture.js +208 -0
- package/dist/__mocks__/exercises.mock.d.ts +4 -11
- package/dist/__mocks__/exercises.mock.js +82 -41
- package/dist/__mocks__/sessions.mock.d.ts +28 -0
- package/dist/__mocks__/sessions.mock.js +394 -0
- package/dist/__mocks__/testIds.d.ts +9 -0
- package/dist/__mocks__/testIds.js +13 -0
- package/dist/__mocks__/user.mock.js +3 -1
- package/dist/constants/goalJourney.d.ts +108 -0
- package/dist/constants/goalJourney.js +443 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/types/TApiAiExerciseAnalysis.d.ts +2 -1
- package/dist/types/TApiClientConstellation.d.ts +33 -0
- package/dist/types/TApiClientConstellation.js +13 -0
- package/dist/types/TApiExercise.d.ts +5 -3
- package/dist/types/TApiUser.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/adoptionEngine/scaleProPlan.util.js +9 -4
- package/dist/utils/constellation/computeNormalisedLoad.d.ts +48 -0
- package/dist/utils/constellation/computeNormalisedLoad.js +150 -0
- package/dist/utils/constellation/computeNormalisedLoad.test.d.ts +1 -0
- package/dist/utils/constellation/computeNormalisedLoad.test.js +218 -0
- package/dist/utils/constellation/evaluateConstellation.d.ts +27 -0
- package/dist/utils/constellation/evaluateConstellation.js +135 -0
- package/dist/utils/constellation/evaluateConstellation.test.d.ts +1 -0
- package/dist/utils/constellation/evaluateConstellation.test.js +93 -0
- package/dist/utils/constellation/index.d.ts +18 -0
- package/dist/utils/constellation/index.js +29 -0
- package/dist/utils/constellation/levelThresholds.d.ts +99 -0
- package/dist/utils/constellation/levelThresholds.js +123 -0
- package/dist/utils/constellation/starFoundation.d.ts +25 -0
- package/dist/utils/constellation/starFoundation.js +54 -0
- package/dist/utils/constellation/starFoundation.test.d.ts +1 -0
- package/dist/utils/constellation/starFoundation.test.js +75 -0
- package/dist/utils/constellation/stars/consistency.d.ts +29 -0
- package/dist/utils/constellation/stars/consistency.js +142 -0
- package/dist/utils/constellation/stars/consistency.test.d.ts +1 -0
- package/dist/utils/constellation/stars/consistency.test.js +94 -0
- package/dist/utils/constellation/stars/lowerBody.d.ts +17 -0
- package/dist/utils/constellation/stars/lowerBody.js +30 -0
- package/dist/utils/constellation/stars/pull.d.ts +11 -0
- package/dist/utils/constellation/stars/pull.js +24 -0
- package/dist/utils/constellation/stars/push.d.ts +11 -0
- package/dist/utils/constellation/stars/push.js +24 -0
- package/dist/utils/constellation/stars/quality.d.ts +19 -0
- package/dist/utils/constellation/stars/quality.js +98 -0
- package/dist/utils/constellation/stars/quality.test.d.ts +1 -0
- package/dist/utils/constellation/stars/quality.test.js +113 -0
- package/dist/utils/constellation/stars/recovery.d.ts +29 -0
- package/dist/utils/constellation/stars/recovery.js +169 -0
- package/dist/utils/constellation/stars/recovery.test.d.ts +1 -0
- package/dist/utils/constellation/stars/recovery.test.js +131 -0
- package/dist/utils/constellation/strengthStar.test.d.ts +1 -0
- package/dist/utils/constellation/strengthStar.test.js +190 -0
- package/dist/utils/constellation/strengthStarHelpers.d.ts +41 -0
- package/dist/utils/constellation/strengthStarHelpers.js +104 -0
- package/dist/utils/constellation/types.d.ts +124 -0
- package/dist/utils/constellation/types.js +18 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.js +1 -1
- package/dist/utils/exerciseRecord/recordValidator.js +1 -1
- package/dist/utils/exerciseRecord/recordValidator.test.js +8 -8
- package/dist/utils/index.d.ts +5 -3
- package/dist/utils/index.js +1 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +59 -36
- package/dist/utils/scoringWorkout/calculateQualityScore.js +234 -233
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +8 -5
- package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +72 -88
- package/dist/utils/scoringWorkout/constants.d.ts +20 -6
- package/dist/utils/scoringWorkout/constants.js +23 -9
- package/dist/utils/scoringWorkout/helpers.d.ts +7 -0
- package/dist/utils/scoringWorkout/helpers.js +24 -18
- package/dist/utils/scoringWorkout/index.d.ts +12 -8
- package/dist/utils/scoringWorkout/index.js +23 -15
- package/dist/utils/scoringWorkout/parseRecords.js +4 -3
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +223 -185
- package/dist/utils/scoringWorkout/types.d.ts +34 -14
- package/package.json +31 -31
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +0 -30
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +0 -138
- package/dist/utils/scaleProPlan.util.d.ts +0 -9
- package/dist/utils/scaleProPlan.util.js +0 -139
- package/dist/utils/scoring/calculateCalories.d.ts +0 -67
- package/dist/utils/scoring/calculateCalories.js +0 -345
- package/dist/utils/scoring/calculateMuscleFatiue.d.ts +0 -67
- package/dist/utils/scoring/calculateMuscleFatiue.js +0 -310
- package/dist/utils/scoring/calculateQualityScore.d.ts +0 -71
- package/dist/utils/scoring/calculateQualityScore.js +0 -334
- package/dist/utils/scoring/calculateTotalVolume.d.ts +0 -15
- package/dist/utils/scoring/calculateTotalVolume.js +0 -73
- package/dist/utils/scoring/constants.d.ts +0 -211
- package/dist/utils/scoring/constants.js +0 -247
- package/dist/utils/scoring/helpers.d.ts +0 -119
- package/dist/utils/scoring/helpers.js +0 -229
- package/dist/utils/scoring/index.d.ts +0 -28
- package/dist/utils/scoring/index.js +0 -47
- package/dist/utils/scoring/parseRecords.d.ts +0 -98
- package/dist/utils/scoring/parseRecords.js +0 -284
- package/dist/utils/scoring/types.d.ts +0 -86
- package/dist/utils/scoring/types.js +0 -11
- package/dist/utils/scoring.utils.d.ts +0 -14
- package/dist/utils/scoring.utils.js +0 -243
- /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.d.ts → calculateMuscleFatigue.d.ts} +0 -0
- /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.js → calculateMuscleFatigue.js} +0 -0
|
@@ -40,7 +40,7 @@ const getAdjustedBMIThreshold = (baseThreshold, activityLevel) => {
|
|
|
40
40
|
* Reduces volume or duration by roughly 30%.
|
|
41
41
|
*/
|
|
42
42
|
const applyFallbackScalingToRecord = (record) => {
|
|
43
|
-
const scaledRecord =
|
|
43
|
+
const scaledRecord = { ...record };
|
|
44
44
|
const SCALE_FACTOR = 0.7;
|
|
45
45
|
// Scale reps
|
|
46
46
|
if (scaledRecord.type === "reps-only" || scaledRecord.type === "weight-reps") {
|
|
@@ -119,7 +119,7 @@ const scaleProPlan = (masterPlan, userMetric, exercisesDictionary) => {
|
|
|
119
119
|
newRecords = newRecords.map((record) => {
|
|
120
120
|
if (record.type === "weight-reps") {
|
|
121
121
|
isModified = true;
|
|
122
|
-
return
|
|
122
|
+
return { ...record, kg: suggestedLoadKg.toString() };
|
|
123
123
|
}
|
|
124
124
|
// Guard against applying load to a falsy "0" aux weight
|
|
125
125
|
if (record.type === "reps-only" ||
|
|
@@ -128,7 +128,7 @@ const scaleProPlan = (masterPlan, userMetric, exercisesDictionary) => {
|
|
|
128
128
|
const currentAux = parseFloat(record.auxWeightKg || "0");
|
|
129
129
|
if (!isNaN(currentAux) && currentAux > 0) {
|
|
130
130
|
isModified = true;
|
|
131
|
-
return
|
|
131
|
+
return { ...record, auxWeightKg: suggestedLoadKg.toString() };
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
return record;
|
|
@@ -136,7 +136,12 @@ const scaleProPlan = (masterPlan, userMetric, exercisesDictionary) => {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
// Return the tailored template exercise
|
|
139
|
-
return
|
|
139
|
+
return {
|
|
140
|
+
...templateExercise,
|
|
141
|
+
exerciseId: newExerciseId,
|
|
142
|
+
initialRecords: newRecords,
|
|
143
|
+
isAutoScaled: isModified || undefined,
|
|
144
|
+
};
|
|
140
145
|
});
|
|
141
146
|
});
|
|
142
147
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Normalised Load (strength-star helper)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Turns a user's sessions into a single strength number for a movement pattern:
|
|
7
|
+
* the best estimated-1RM achieved on any matching exercise in the rolling
|
|
8
|
+
* window, expressed as a ratio of bodyweight.
|
|
9
|
+
*
|
|
10
|
+
* Estimated-1RM (Epley, rep-capped at 12) is used instead of raw top weight so
|
|
11
|
+
* that 60kg x5 ~ 70kg x1 — it rewards productive training over ego-lifting.
|
|
12
|
+
*
|
|
13
|
+
* Bodyweight loading (the home-user path): a bodyweight exercise's effective
|
|
14
|
+
* load is the REAL fraction of bodyweight it moves, taken from the exercise's
|
|
15
|
+
* own `weightMultiplier` (per-sex), falling back to a coarse map keyed off
|
|
16
|
+
* `bodyweightDependency`. This is what lets a home user honestly reach a
|
|
17
|
+
* strength threshold with pull-ups / pistol squats etc., instead of being
|
|
18
|
+
* capped by a flat guess.
|
|
19
|
+
*
|
|
20
|
+
* Returns a bodyweight ratio (e.g. 0.78 = "0.78x bodyweight"). 0 = no data.
|
|
21
|
+
*/
|
|
22
|
+
import type { TExercise } from "../../types";
|
|
23
|
+
import type { TSessionRecord } from "./types";
|
|
24
|
+
/** Epley reps cap: reps beyond this don't increase the 1RM estimate. */
|
|
25
|
+
export declare const EPLEY_REP_CAP = 12;
|
|
26
|
+
/** Estimated 1RM via Epley, with reps capped to avoid endurance-range inflation. */
|
|
27
|
+
export declare function estimateOneRepMax(weightKg: number, reps: number): number;
|
|
28
|
+
export interface INormalisedLoadResult {
|
|
29
|
+
/** Best estimated-1RM in the window as a ratio of bodyweight. 0 if none. */
|
|
30
|
+
ratio: number;
|
|
31
|
+
/** The raw best estimated-1RM in kg (pre-normalisation), for detail display. */
|
|
32
|
+
bestOneRepMaxKg: number;
|
|
33
|
+
/** Exercise id that produced the best load (for "your best: Bench Press"). */
|
|
34
|
+
bestExerciseId?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute the best normalised load for a set of matching exercises within the
|
|
38
|
+
* window.
|
|
39
|
+
*
|
|
40
|
+
* @param matchingExerciseIds Exercise ids belonging to the target pattern.
|
|
41
|
+
* @param sessions All sessions.
|
|
42
|
+
* @param exerciseCatalog Full catalog (unfiltered).
|
|
43
|
+
* @param bodyweightKg User bodyweight (falls back to default).
|
|
44
|
+
* @param gender User sex (selects the weightMultiplier column).
|
|
45
|
+
* @param windowStartMs Only sessions on/after this count.
|
|
46
|
+
* @param now Upper bound (sessions after `now` ignored).
|
|
47
|
+
*/
|
|
48
|
+
export declare function computeNormalisedLoad(matchingExerciseIds: Set<string>, sessions: TSessionRecord[], exerciseCatalog: Record<string, TExercise>, bodyweightKg: number, gender: string | undefined, windowStartMs: number, now: number): INormalisedLoadResult;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/computeNormalisedLoad.ts — Normalised Load (strength-star helper)
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Normalised Load (strength-star helper)
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Turns a user's sessions into a single strength number for a movement pattern:
|
|
9
|
+
* the best estimated-1RM achieved on any matching exercise in the rolling
|
|
10
|
+
* window, expressed as a ratio of bodyweight.
|
|
11
|
+
*
|
|
12
|
+
* Estimated-1RM (Epley, rep-capped at 12) is used instead of raw top weight so
|
|
13
|
+
* that 60kg x5 ~ 70kg x1 — it rewards productive training over ego-lifting.
|
|
14
|
+
*
|
|
15
|
+
* Bodyweight loading (the home-user path): a bodyweight exercise's effective
|
|
16
|
+
* load is the REAL fraction of bodyweight it moves, taken from the exercise's
|
|
17
|
+
* own `weightMultiplier` (per-sex), falling back to a coarse map keyed off
|
|
18
|
+
* `bodyweightDependency`. This is what lets a home user honestly reach a
|
|
19
|
+
* strength threshold with pull-ups / pistol squats etc., instead of being
|
|
20
|
+
* capped by a flat guess.
|
|
21
|
+
*
|
|
22
|
+
* Returns a bodyweight ratio (e.g. 0.78 = "0.78x bodyweight"). 0 = no data.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.EPLEY_REP_CAP = void 0;
|
|
26
|
+
exports.estimateOneRepMax = estimateOneRepMax;
|
|
27
|
+
exports.computeNormalisedLoad = computeNormalisedLoad;
|
|
28
|
+
// --- Tunables -------------------------------------------------------------
|
|
29
|
+
/** Epley reps cap: reps beyond this don't increase the 1RM estimate. */
|
|
30
|
+
exports.EPLEY_REP_CAP = 12;
|
|
31
|
+
/** Default bodyweight when the user profile lacks one. */
|
|
32
|
+
const DEFAULT_BODYWEIGHT_KG = 70;
|
|
33
|
+
/**
|
|
34
|
+
* Fallback bodyweight-load fractions when an exercise has no `weightMultiplier`.
|
|
35
|
+
* Keyed off `bodyweightDependency`: how much of your own body the move loads.
|
|
36
|
+
* high ~ pull-ups, dips, pistols (most of bodyweight)
|
|
37
|
+
* medium ~ lunges, inverted rows (about half)
|
|
38
|
+
* low ~ braced cable work (little)
|
|
39
|
+
* none ~ machine/bar carries the load — bodyweight isn't the resistance
|
|
40
|
+
*/
|
|
41
|
+
const BW_DEPENDENCY_FRACTION = {
|
|
42
|
+
high: 0.9,
|
|
43
|
+
medium: 0.6,
|
|
44
|
+
low: 0.3,
|
|
45
|
+
none: 0,
|
|
46
|
+
};
|
|
47
|
+
// --- Epley -----------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* Parse a numeric string from a record, guarding against NaN and negatives.
|
|
50
|
+
* parseFloat("abc") is NaN; a logged weight/reps should never be negative.
|
|
51
|
+
*/
|
|
52
|
+
function safeParseFloat(value, fallback = 0) {
|
|
53
|
+
const parsed = parseFloat(value !== null && value !== void 0 ? value : "");
|
|
54
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
55
|
+
}
|
|
56
|
+
/** Estimated 1RM via Epley, with reps capped to avoid endurance-range inflation. */
|
|
57
|
+
function estimateOneRepMax(weightKg, reps) {
|
|
58
|
+
if (weightKg <= 0 || reps <= 0)
|
|
59
|
+
return 0;
|
|
60
|
+
const cappedReps = Math.min(reps, exports.EPLEY_REP_CAP);
|
|
61
|
+
return weightKg * (1 + cappedReps / 30);
|
|
62
|
+
}
|
|
63
|
+
// --- Bodyweight load fraction ----------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* The fraction of bodyweight a bodyweight/reps-only exercise loads onto the
|
|
66
|
+
* target muscles. Prefers the exercise's own per-sex `weightMultiplier`; falls
|
|
67
|
+
* back to the `bodyweightDependency` map; final fallback is a mild 0.5.
|
|
68
|
+
*/
|
|
69
|
+
function bodyweightLoadFraction(exercise, gender) {
|
|
70
|
+
const wm = exercise.weightMultiplier;
|
|
71
|
+
if (wm) {
|
|
72
|
+
if (gender === "female")
|
|
73
|
+
return wm.female;
|
|
74
|
+
if (gender === "male")
|
|
75
|
+
return wm.male;
|
|
76
|
+
return wm.default;
|
|
77
|
+
}
|
|
78
|
+
const dep = exercise.bodyweightDependency;
|
|
79
|
+
if (dep && dep in BW_DEPENDENCY_FRACTION)
|
|
80
|
+
return BW_DEPENDENCY_FRACTION[dep];
|
|
81
|
+
return 0.5;
|
|
82
|
+
}
|
|
83
|
+
// --- Per-set effective load ------------------------------------------------
|
|
84
|
+
/**
|
|
85
|
+
* Best estimated-1RM contribution of a single completed set, in kg.
|
|
86
|
+
* Switches on the TRecord discriminated union (no casts). Non-strength record
|
|
87
|
+
* types (duration, cardio) contribute 0.
|
|
88
|
+
*/
|
|
89
|
+
function setEstimatedLoadKg(set, exercise, bodyweightKg, gender) {
|
|
90
|
+
if (!set.isDone)
|
|
91
|
+
return 0;
|
|
92
|
+
if (set.type === "weight-reps") {
|
|
93
|
+
const kg = safeParseFloat(set.kg);
|
|
94
|
+
const reps = safeParseFloat(set.reps);
|
|
95
|
+
// Unilateral: weight is logged per side -> double for total mechanical load.
|
|
96
|
+
const totalKg = kg * (exercise.isUnilateral ? 2 : 1);
|
|
97
|
+
return estimateOneRepMax(totalKg, reps);
|
|
98
|
+
}
|
|
99
|
+
if (set.type === "reps-only") {
|
|
100
|
+
const reps = safeParseFloat(set.reps);
|
|
101
|
+
const auxKg = safeParseFloat(set.auxWeightKg);
|
|
102
|
+
const fraction = bodyweightLoadFraction(exercise, gender);
|
|
103
|
+
const bodyweightLoad = fraction * bodyweightKg;
|
|
104
|
+
// Added weight (weighted pull-ups/dips) stacks on the bodyweight load.
|
|
105
|
+
const effectiveWeight = bodyweightLoad + auxKg;
|
|
106
|
+
return estimateOneRepMax(effectiveWeight, reps);
|
|
107
|
+
}
|
|
108
|
+
// duration / cardio-machine / cardio-free are not strength loads.
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Compute the best normalised load for a set of matching exercises within the
|
|
113
|
+
* window.
|
|
114
|
+
*
|
|
115
|
+
* @param matchingExerciseIds Exercise ids belonging to the target pattern.
|
|
116
|
+
* @param sessions All sessions.
|
|
117
|
+
* @param exerciseCatalog Full catalog (unfiltered).
|
|
118
|
+
* @param bodyweightKg User bodyweight (falls back to default).
|
|
119
|
+
* @param gender User sex (selects the weightMultiplier column).
|
|
120
|
+
* @param windowStartMs Only sessions on/after this count.
|
|
121
|
+
* @param now Upper bound (sessions after `now` ignored).
|
|
122
|
+
*/
|
|
123
|
+
function computeNormalisedLoad(matchingExerciseIds, sessions, exerciseCatalog, bodyweightKg, gender, windowStartMs, now) {
|
|
124
|
+
const bw = bodyweightKg > 0 ? bodyweightKg : DEFAULT_BODYWEIGHT_KG;
|
|
125
|
+
let bestKg = 0;
|
|
126
|
+
let bestExerciseId;
|
|
127
|
+
for (const session of sessions) {
|
|
128
|
+
if (session.date < windowStartMs || session.date > now)
|
|
129
|
+
continue;
|
|
130
|
+
for (const ex of session.exercises) {
|
|
131
|
+
if (!matchingExerciseIds.has(ex.exerciseId))
|
|
132
|
+
continue;
|
|
133
|
+
const exercise = exerciseCatalog[ex.exerciseId];
|
|
134
|
+
if (!exercise)
|
|
135
|
+
continue;
|
|
136
|
+
for (const set of ex.records) {
|
|
137
|
+
const loadKg = setEstimatedLoadKg(set, exercise, bw, gender);
|
|
138
|
+
if (loadKg > bestKg) {
|
|
139
|
+
bestKg = loadKg;
|
|
140
|
+
bestExerciseId = ex.exerciseId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
ratio: bestKg / bw,
|
|
147
|
+
bestOneRepMaxKg: bestKg,
|
|
148
|
+
bestExerciseId,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// shared/src/utils/constellation/computeNormalisedLoad.test.ts
|
|
4
|
+
const computeNormalisedLoad_1 = require("./computeNormalisedLoad");
|
|
5
|
+
const vitest_1 = require("vitest");
|
|
6
|
+
const NOW = Date.UTC(2026, 5, 1);
|
|
7
|
+
const day = (d) => NOW - d * 86400000;
|
|
8
|
+
const wr = (kg, reps, isDone = true) => ({ type: "weight-reps", kg, reps, isDone, isStrictMode: false });
|
|
9
|
+
const ro = (reps, auxWeightKg = "0", isDone = true) => ({
|
|
10
|
+
type: "reps-only",
|
|
11
|
+
reps,
|
|
12
|
+
auxWeightKg,
|
|
13
|
+
isDone,
|
|
14
|
+
isStrictMode: false,
|
|
15
|
+
});
|
|
16
|
+
const ex = (over) => ({
|
|
17
|
+
exerciseId: "e",
|
|
18
|
+
name: "E",
|
|
19
|
+
primaryMuscles: [],
|
|
20
|
+
secondaryMuscles: [],
|
|
21
|
+
difficultyLevel: 3,
|
|
22
|
+
movementPattern: "push_horizontal",
|
|
23
|
+
status: "active",
|
|
24
|
+
...over,
|
|
25
|
+
});
|
|
26
|
+
(0, vitest_1.describe)("estimateOneRepMax — Epley with rep cap", () => {
|
|
27
|
+
(0, vitest_1.it)("60kg x5 ≈ 70kg", () => (0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(60, 5)).toBeCloseTo(70, 1));
|
|
28
|
+
(0, vitest_1.it)("70kg x1 ≈ 72.33kg", () => (0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(70, 1)).toBeCloseTo(72.33, 1));
|
|
29
|
+
(0, vitest_1.it)("reps capped at 12: 50x30 == 50x12", () => (0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(50, 30)).toBe((0, computeNormalisedLoad_1.estimateOneRepMax)(50, computeNormalisedLoad_1.EPLEY_REP_CAP)));
|
|
30
|
+
(0, vitest_1.it)("zero or negative weight → 0", () => {
|
|
31
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(0, 5)).toBe(0);
|
|
32
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(-10, 5)).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.it)("zero reps → 0", () => (0, vitest_1.expect)((0, computeNormalisedLoad_1.estimateOneRepMax)(60, 0)).toBe(0));
|
|
35
|
+
});
|
|
36
|
+
(0, vitest_1.describe)("computeNormalisedLoad — weighted sets", () => {
|
|
37
|
+
const catalog = { bench: ex({ exerciseId: "bench" }) };
|
|
38
|
+
const ids = new Set(["bench"]);
|
|
39
|
+
const sessions = [
|
|
40
|
+
{
|
|
41
|
+
date: day(2),
|
|
42
|
+
exercises: [{ exerciseId: "bench", records: [wr("60", "5")] }],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
(0, vitest_1.it)("best 1RM as bodyweight ratio (60x5 @80kg → ~0.875)", () => {
|
|
46
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, sessions, catalog, 80, "male", day(56), NOW);
|
|
47
|
+
(0, vitest_1.expect)(r.ratio).toBeCloseTo(0.875, 2);
|
|
48
|
+
(0, vitest_1.expect)(r.bestExerciseId).toBe("bench");
|
|
49
|
+
});
|
|
50
|
+
(0, vitest_1.it)("picks the best set across sessions", () => {
|
|
51
|
+
const multi = [
|
|
52
|
+
{
|
|
53
|
+
date: day(10),
|
|
54
|
+
exercises: [{ exerciseId: "bench", records: [wr("60", "5")] }],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
date: day(2),
|
|
58
|
+
exercises: [{ exerciseId: "bench", records: [wr("80", "5")] }],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, multi, catalog, 80, "male", day(56), NOW);
|
|
62
|
+
(0, vitest_1.expect)(r.bestOneRepMaxKg).toBeCloseTo(93.33, 1);
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.it)("ignores sessions outside the window", () => {
|
|
65
|
+
const old = [
|
|
66
|
+
{
|
|
67
|
+
date: day(70),
|
|
68
|
+
exercises: [{ exerciseId: "bench", records: [wr("100", "5")] }],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, old, catalog, 80, "male", day(56), NOW).ratio).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
(0, vitest_1.it)("ignores non-matching exercise ids", () => {
|
|
74
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["other"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
75
|
+
(0, vitest_1.expect)(r.ratio).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
(0, vitest_1.it)("ignores incomplete sets (isDone false)", () => {
|
|
78
|
+
const undone = [
|
|
79
|
+
{
|
|
80
|
+
date: day(2),
|
|
81
|
+
exercises: [{ exerciseId: "bench", records: [wr("100", "5", false)] }],
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, undone, catalog, 80, "male", day(56), NOW)
|
|
85
|
+
.ratio).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
(0, vitest_1.describe)("computeNormalisedLoad — unilateral doubling", () => {
|
|
89
|
+
(0, vitest_1.it)("unilateral weight is doubled for total load", () => {
|
|
90
|
+
const catalog = { uni: ex({ exerciseId: "uni", isUnilateral: true }) };
|
|
91
|
+
const sessions = [
|
|
92
|
+
{
|
|
93
|
+
date: day(2),
|
|
94
|
+
exercises: [{ exerciseId: "uni", records: [wr("30", "5")] }],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
// 30*2=60 → 1RM 70 → /80 = 0.875
|
|
98
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["uni"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
99
|
+
(0, vitest_1.expect)(r.ratio).toBeCloseTo(0.875, 2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
(0, vitest_1.describe)("computeNormalisedLoad — bodyweight loading", () => {
|
|
103
|
+
(0, vitest_1.it)("uses weightMultiplier (male) for reps-only", () => {
|
|
104
|
+
const catalog = {
|
|
105
|
+
pull: ex({
|
|
106
|
+
exerciseId: "pull",
|
|
107
|
+
weightMultiplier: { male: 1.0, female: 0.95, default: 0.97 },
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
const sessions = [
|
|
111
|
+
{ date: day(2), exercises: [{ exerciseId: "pull", records: [ro("8")] }] },
|
|
112
|
+
];
|
|
113
|
+
// load = 1.0*80 = 80; 1RM = 80*(1+8/30)=101.3; /80 = 1.27
|
|
114
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["pull"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
115
|
+
(0, vitest_1.expect)(r.ratio).toBeCloseTo(1.27, 1);
|
|
116
|
+
});
|
|
117
|
+
(0, vitest_1.it)("falls back to bodyweightDependency when no weightMultiplier", () => {
|
|
118
|
+
const catalog = {
|
|
119
|
+
dip: ex({ exerciseId: "dip", bodyweightDependency: "high" }),
|
|
120
|
+
};
|
|
121
|
+
const sessions = [
|
|
122
|
+
{ date: day(2), exercises: [{ exerciseId: "dip", records: [ro("8")] }] },
|
|
123
|
+
];
|
|
124
|
+
// high → 0.9*80=72; 1RM=72*(1+8/30)=91.2; /80=1.14
|
|
125
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["dip"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
126
|
+
(0, vitest_1.expect)(r.ratio).toBeCloseTo(1.14, 1);
|
|
127
|
+
});
|
|
128
|
+
(0, vitest_1.it)("added aux weight stacks on bodyweight load", () => {
|
|
129
|
+
const catalog = {
|
|
130
|
+
pull: ex({
|
|
131
|
+
exerciseId: "pull",
|
|
132
|
+
weightMultiplier: { male: 1.0, female: 0.95, default: 0.97 },
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
const sessions = [
|
|
136
|
+
{
|
|
137
|
+
date: day(2),
|
|
138
|
+
exercises: [{ exerciseId: "pull", records: [ro("5", "20")] }],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
// load = 80 + 20 = 100; 1RM=100*(1+5/30)=116.7; /80=1.46
|
|
142
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["pull"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
143
|
+
(0, vitest_1.expect)(r.ratio).toBeCloseTo(1.46, 1);
|
|
144
|
+
});
|
|
145
|
+
(0, vitest_1.it)("female weightMultiplier column is used for female users", () => {
|
|
146
|
+
const catalog = {
|
|
147
|
+
pull: ex({
|
|
148
|
+
exerciseId: "pull",
|
|
149
|
+
weightMultiplier: { male: 1.0, female: 0.5, default: 0.97 },
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
const sessions = [
|
|
153
|
+
{ date: day(2), exercises: [{ exerciseId: "pull", records: [ro("8")] }] },
|
|
154
|
+
];
|
|
155
|
+
const male = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["pull"]), sessions, catalog, 80, "male", day(56), NOW);
|
|
156
|
+
const female = (0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["pull"]), sessions, catalog, 80, "female", day(56), NOW);
|
|
157
|
+
(0, vitest_1.expect)(female.ratio).toBeLessThan(male.ratio);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
(0, vitest_1.describe)("computeNormalisedLoad — safeParseFloat guards", () => {
|
|
161
|
+
const catalog = { bench: ex({ exerciseId: "bench" }) };
|
|
162
|
+
const ids = new Set(["bench"]);
|
|
163
|
+
(0, vitest_1.it)("malformed kg → 0 load, finite ratio", () => {
|
|
164
|
+
const s = [
|
|
165
|
+
{
|
|
166
|
+
date: day(2),
|
|
167
|
+
exercises: [{ exerciseId: "bench", records: [wr("abc", "5")] }],
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, s, catalog, 80, "male", day(56), NOW);
|
|
171
|
+
(0, vitest_1.expect)(Number.isFinite(r.ratio)).toBe(true);
|
|
172
|
+
(0, vitest_1.expect)(r.ratio).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
(0, vitest_1.it)("negative reps → 0 load", () => {
|
|
175
|
+
const s = [
|
|
176
|
+
{
|
|
177
|
+
date: day(2),
|
|
178
|
+
exercises: [{ exerciseId: "bench", records: [wr("60", "-5")] }],
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, s, catalog, 80, "male", day(56), NOW).ratio).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
(0, vitest_1.it)("zero bodyweight falls back to default (no divide-by-zero)", () => {
|
|
184
|
+
const s = [
|
|
185
|
+
{
|
|
186
|
+
date: day(2),
|
|
187
|
+
exercises: [{ exerciseId: "bench", records: [wr("60", "5")] }],
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
const r = (0, computeNormalisedLoad_1.computeNormalisedLoad)(ids, s, catalog, 0, "male", day(56), NOW);
|
|
191
|
+
(0, vitest_1.expect)(Number.isFinite(r.ratio)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
(0, vitest_1.describe)("computeNormalisedLoad — non-strength record types contribute 0", () => {
|
|
195
|
+
(0, vitest_1.it)("duration set → 0", () => {
|
|
196
|
+
const catalog = { plank: ex({ exerciseId: "plank" }) };
|
|
197
|
+
const s = [
|
|
198
|
+
{
|
|
199
|
+
date: day(2),
|
|
200
|
+
exercises: [
|
|
201
|
+
{
|
|
202
|
+
exerciseId: "plank",
|
|
203
|
+
records: [
|
|
204
|
+
{
|
|
205
|
+
type: "duration",
|
|
206
|
+
durationMmSs: "01:00",
|
|
207
|
+
auxWeightKg: "0",
|
|
208
|
+
isDone: true,
|
|
209
|
+
isStrictMode: false,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
(0, vitest_1.expect)((0, computeNormalisedLoad_1.computeNormalisedLoad)(new Set(["plank"]), s, catalog, 80, "male", day(56), NOW).ratio).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Orchestrator
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* The single entry point. Takes raw input (sessions, catalog, user, now) and
|
|
7
|
+
* returns a render-ready TConstellationState: six resolved stars plus the
|
|
8
|
+
* figure aggregate that drives the silhouette's aura.
|
|
9
|
+
*
|
|
10
|
+
* Purely live — everything is recomputed from raw records on every call.
|
|
11
|
+
* Nothing is persisted; the same input always yields the same output.
|
|
12
|
+
*
|
|
13
|
+
* Scoring happens ONCE here: every (session, exercise) is scored a single time
|
|
14
|
+
* into scoredSessions, which both quality and recovery read — no double work.
|
|
15
|
+
*
|
|
16
|
+
* Runs server-side (Lambda) so the evaluation logic can evolve independently of
|
|
17
|
+
* app releases.
|
|
18
|
+
*/
|
|
19
|
+
import type { TConstellationInput, TConstellationState } from "./types";
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate the full constellation from raw input.
|
|
22
|
+
*
|
|
23
|
+
* @param input sessions + exercise catalog (UNFILTERED) + user + now + level
|
|
24
|
+
* @returns render-ready state: six stars + figure aggregate
|
|
25
|
+
* @throws if `now` is not a finite timestamp
|
|
26
|
+
*/
|
|
27
|
+
export declare function evaluateConstellation(input: TConstellationInput): TConstellationState;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/evaluateConstellation.ts — Orchestrator
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Orchestrator
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* The single entry point. Takes raw input (sessions, catalog, user, now) and
|
|
9
|
+
* returns a render-ready TConstellationState: six resolved stars plus the
|
|
10
|
+
* figure aggregate that drives the silhouette's aura.
|
|
11
|
+
*
|
|
12
|
+
* Purely live — everything is recomputed from raw records on every call.
|
|
13
|
+
* Nothing is persisted; the same input always yields the same output.
|
|
14
|
+
*
|
|
15
|
+
* Scoring happens ONCE here: every (session, exercise) is scored a single time
|
|
16
|
+
* into scoredSessions, which both quality and recovery read — no double work.
|
|
17
|
+
*
|
|
18
|
+
* Runs server-side (Lambda) so the evaluation logic can evolve independently of
|
|
19
|
+
* app releases.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.evaluateConstellation = evaluateConstellation;
|
|
23
|
+
const scoringWorkout_1 = require("../scoringWorkout");
|
|
24
|
+
const starFoundation_1 = require("./starFoundation");
|
|
25
|
+
const levelThresholds_1 = require("./levelThresholds");
|
|
26
|
+
const consistency_1 = require("./stars/consistency");
|
|
27
|
+
const quality_1 = require("./stars/quality");
|
|
28
|
+
const push_1 = require("./stars/push");
|
|
29
|
+
const pull_1 = require("./stars/pull");
|
|
30
|
+
const lowerBody_1 = require("./stars/lowerBody");
|
|
31
|
+
const recovery_1 = require("./stars/recovery");
|
|
32
|
+
/**
|
|
33
|
+
* The six stars, in figure reading order: heart (consistency) and core
|
|
34
|
+
* (quality) first, then the limbs (push/pull/lowerBody), then the crown
|
|
35
|
+
* (recovery). Appending a level/goal star later = adding to this list.
|
|
36
|
+
*/
|
|
37
|
+
const STAR_DEFINITIONS = [
|
|
38
|
+
consistency_1.consistencyStar,
|
|
39
|
+
quality_1.qualityStar,
|
|
40
|
+
push_1.pushStar,
|
|
41
|
+
pull_1.pullStar,
|
|
42
|
+
lowerBody_1.lowerBodyStar,
|
|
43
|
+
recovery_1.recoveryStar,
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Score every (session, exercise) exactly once. Shared by quality (uses
|
|
47
|
+
* `score`) and recovery (uses `muscleScores`). An exercise missing from the
|
|
48
|
+
* catalog, or one the engine cannot score, yields score=null + empty muscles
|
|
49
|
+
* and is skipped downstream.
|
|
50
|
+
*/
|
|
51
|
+
function scoreAllSessions(input) {
|
|
52
|
+
var _a;
|
|
53
|
+
const { sessions, exerciseCatalog, user, now } = input;
|
|
54
|
+
const scored = [];
|
|
55
|
+
for (const session of sessions) {
|
|
56
|
+
if (session.date > now)
|
|
57
|
+
continue; // ignore future-dated sessions
|
|
58
|
+
const exercises = [];
|
|
59
|
+
for (const ex of session.exercises) {
|
|
60
|
+
const exercise = exerciseCatalog[ex.exerciseId];
|
|
61
|
+
if (!exercise) {
|
|
62
|
+
exercises.push({
|
|
63
|
+
exerciseId: ex.exerciseId,
|
|
64
|
+
score: null,
|
|
65
|
+
muscleScores: {},
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const result = (0, scoringWorkout_1.calculateExerciseScoreV2)({
|
|
71
|
+
exercise,
|
|
72
|
+
record: ex.records,
|
|
73
|
+
user: user,
|
|
74
|
+
});
|
|
75
|
+
exercises.push({
|
|
76
|
+
exerciseId: ex.exerciseId,
|
|
77
|
+
score: typeof result.score === "number" ? result.score : null,
|
|
78
|
+
muscleScores: (_a = result.muscleScores) !== null && _a !== void 0 ? _a : {},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
exercises.push({
|
|
83
|
+
exerciseId: ex.exerciseId,
|
|
84
|
+
score: null,
|
|
85
|
+
muscleScores: {},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
scored.push({ date: session.date, exercises });
|
|
90
|
+
}
|
|
91
|
+
return scored;
|
|
92
|
+
}
|
|
93
|
+
/** Build the figure aggregate from the resolved stars. */
|
|
94
|
+
function computeFigure(stars) {
|
|
95
|
+
if (stars.length === 0) {
|
|
96
|
+
return { auraIntensity: 0, fullStarCount: 0 };
|
|
97
|
+
}
|
|
98
|
+
const totalBrightness = stars.reduce((sum, s) => sum + s.brightness, 0);
|
|
99
|
+
const auraIntensity = totalBrightness / stars.length;
|
|
100
|
+
const fullStarCount = stars.filter((s) => s.tier === 3).length;
|
|
101
|
+
return {
|
|
102
|
+
auraIntensity: Math.round(auraIntensity * 10) / 10,
|
|
103
|
+
fullStarCount,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Evaluate the full constellation from raw input.
|
|
108
|
+
*
|
|
109
|
+
* @param input sessions + exercise catalog (UNFILTERED) + user + now + level
|
|
110
|
+
* @returns render-ready state: six stars + figure aggregate
|
|
111
|
+
* @throws if `now` is not a finite timestamp
|
|
112
|
+
*/
|
|
113
|
+
function evaluateConstellation(input) {
|
|
114
|
+
if (!Number.isFinite(input.now)) {
|
|
115
|
+
throw new Error("evaluateConstellation: `now` must be a finite UNIX ms timestamp");
|
|
116
|
+
}
|
|
117
|
+
const level = (0, levelThresholds_1.clampLevel)(input.currentLevel);
|
|
118
|
+
const scoredSessions = scoreAllSessions(input);
|
|
119
|
+
const ctx = {
|
|
120
|
+
sessions: input.sessions,
|
|
121
|
+
scoredSessions,
|
|
122
|
+
exerciseCatalog: input.exerciseCatalog,
|
|
123
|
+
user: input.user,
|
|
124
|
+
now: input.now,
|
|
125
|
+
level,
|
|
126
|
+
};
|
|
127
|
+
const stars = STAR_DEFINITIONS.map((def) => (0, starFoundation_1.resolveStar)(def, ctx));
|
|
128
|
+
const figure = computeFigure(stars);
|
|
129
|
+
return {
|
|
130
|
+
stars,
|
|
131
|
+
figure,
|
|
132
|
+
currentLevel: level,
|
|
133
|
+
evaluatedAt: new Date(input.now).toISOString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|