@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.
Files changed (106) hide show
  1. package/README.md +148 -148
  2. package/dist/__mocks__/catalog.fixture.d.ts +2 -0
  3. package/dist/__mocks__/catalog.fixture.js +208 -0
  4. package/dist/__mocks__/exercises.mock.d.ts +4 -11
  5. package/dist/__mocks__/exercises.mock.js +82 -41
  6. package/dist/__mocks__/sessions.mock.d.ts +28 -0
  7. package/dist/__mocks__/sessions.mock.js +394 -0
  8. package/dist/__mocks__/testIds.d.ts +9 -0
  9. package/dist/__mocks__/testIds.js +13 -0
  10. package/dist/__mocks__/user.mock.js +3 -1
  11. package/dist/constants/goalJourney.d.ts +108 -0
  12. package/dist/constants/goalJourney.js +443 -0
  13. package/dist/constants/index.d.ts +1 -0
  14. package/dist/constants/index.js +1 -0
  15. package/dist/types/TApiAiExerciseAnalysis.d.ts +2 -1
  16. package/dist/types/TApiClientConstellation.d.ts +33 -0
  17. package/dist/types/TApiClientConstellation.js +13 -0
  18. package/dist/types/TApiExercise.d.ts +5 -3
  19. package/dist/types/TApiUser.d.ts +2 -0
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/utils/adoptionEngine/scaleProPlan.util.js +9 -4
  22. package/dist/utils/constellation/computeNormalisedLoad.d.ts +48 -0
  23. package/dist/utils/constellation/computeNormalisedLoad.js +150 -0
  24. package/dist/utils/constellation/computeNormalisedLoad.test.d.ts +1 -0
  25. package/dist/utils/constellation/computeNormalisedLoad.test.js +218 -0
  26. package/dist/utils/constellation/evaluateConstellation.d.ts +27 -0
  27. package/dist/utils/constellation/evaluateConstellation.js +135 -0
  28. package/dist/utils/constellation/evaluateConstellation.test.d.ts +1 -0
  29. package/dist/utils/constellation/evaluateConstellation.test.js +93 -0
  30. package/dist/utils/constellation/index.d.ts +18 -0
  31. package/dist/utils/constellation/index.js +29 -0
  32. package/dist/utils/constellation/levelThresholds.d.ts +99 -0
  33. package/dist/utils/constellation/levelThresholds.js +123 -0
  34. package/dist/utils/constellation/starFoundation.d.ts +25 -0
  35. package/dist/utils/constellation/starFoundation.js +54 -0
  36. package/dist/utils/constellation/starFoundation.test.d.ts +1 -0
  37. package/dist/utils/constellation/starFoundation.test.js +75 -0
  38. package/dist/utils/constellation/stars/consistency.d.ts +29 -0
  39. package/dist/utils/constellation/stars/consistency.js +142 -0
  40. package/dist/utils/constellation/stars/consistency.test.d.ts +1 -0
  41. package/dist/utils/constellation/stars/consistency.test.js +94 -0
  42. package/dist/utils/constellation/stars/lowerBody.d.ts +17 -0
  43. package/dist/utils/constellation/stars/lowerBody.js +30 -0
  44. package/dist/utils/constellation/stars/pull.d.ts +11 -0
  45. package/dist/utils/constellation/stars/pull.js +24 -0
  46. package/dist/utils/constellation/stars/push.d.ts +11 -0
  47. package/dist/utils/constellation/stars/push.js +24 -0
  48. package/dist/utils/constellation/stars/quality.d.ts +19 -0
  49. package/dist/utils/constellation/stars/quality.js +98 -0
  50. package/dist/utils/constellation/stars/quality.test.d.ts +1 -0
  51. package/dist/utils/constellation/stars/quality.test.js +113 -0
  52. package/dist/utils/constellation/stars/recovery.d.ts +29 -0
  53. package/dist/utils/constellation/stars/recovery.js +169 -0
  54. package/dist/utils/constellation/stars/recovery.test.d.ts +1 -0
  55. package/dist/utils/constellation/stars/recovery.test.js +131 -0
  56. package/dist/utils/constellation/strengthStar.test.d.ts +1 -0
  57. package/dist/utils/constellation/strengthStar.test.js +190 -0
  58. package/dist/utils/constellation/strengthStarHelpers.d.ts +41 -0
  59. package/dist/utils/constellation/strengthStarHelpers.js +104 -0
  60. package/dist/utils/constellation/types.d.ts +124 -0
  61. package/dist/utils/constellation/types.js +18 -0
  62. package/dist/utils/exerciseRecord/recordValidator.integration.test.js +1 -1
  63. package/dist/utils/exerciseRecord/recordValidator.js +1 -1
  64. package/dist/utils/exerciseRecord/recordValidator.test.js +8 -8
  65. package/dist/utils/index.d.ts +5 -3
  66. package/dist/utils/index.js +1 -0
  67. package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +59 -36
  68. package/dist/utils/scoringWorkout/calculateQualityScore.js +234 -233
  69. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +8 -5
  70. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +72 -88
  71. package/dist/utils/scoringWorkout/constants.d.ts +20 -6
  72. package/dist/utils/scoringWorkout/constants.js +23 -9
  73. package/dist/utils/scoringWorkout/helpers.d.ts +7 -0
  74. package/dist/utils/scoringWorkout/helpers.js +24 -18
  75. package/dist/utils/scoringWorkout/index.d.ts +12 -8
  76. package/dist/utils/scoringWorkout/index.js +23 -15
  77. package/dist/utils/scoringWorkout/parseRecords.js +4 -3
  78. package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +223 -185
  79. package/dist/utils/scoringWorkout/types.d.ts +34 -14
  80. package/package.json +31 -31
  81. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +0 -30
  82. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +0 -138
  83. package/dist/utils/scaleProPlan.util.d.ts +0 -9
  84. package/dist/utils/scaleProPlan.util.js +0 -139
  85. package/dist/utils/scoring/calculateCalories.d.ts +0 -67
  86. package/dist/utils/scoring/calculateCalories.js +0 -345
  87. package/dist/utils/scoring/calculateMuscleFatiue.d.ts +0 -67
  88. package/dist/utils/scoring/calculateMuscleFatiue.js +0 -310
  89. package/dist/utils/scoring/calculateQualityScore.d.ts +0 -71
  90. package/dist/utils/scoring/calculateQualityScore.js +0 -334
  91. package/dist/utils/scoring/calculateTotalVolume.d.ts +0 -15
  92. package/dist/utils/scoring/calculateTotalVolume.js +0 -73
  93. package/dist/utils/scoring/constants.d.ts +0 -211
  94. package/dist/utils/scoring/constants.js +0 -247
  95. package/dist/utils/scoring/helpers.d.ts +0 -119
  96. package/dist/utils/scoring/helpers.js +0 -229
  97. package/dist/utils/scoring/index.d.ts +0 -28
  98. package/dist/utils/scoring/index.js +0 -47
  99. package/dist/utils/scoring/parseRecords.d.ts +0 -98
  100. package/dist/utils/scoring/parseRecords.js +0 -284
  101. package/dist/utils/scoring/types.d.ts +0 -86
  102. package/dist/utils/scoring/types.js +0 -11
  103. package/dist/utils/scoring.utils.d.ts +0 -14
  104. package/dist/utils/scoring.utils.js +0 -243
  105. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.d.ts → calculateMuscleFatigue.d.ts} +0 -0
  106. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.js → calculateMuscleFatigue.js} +0 -0
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ // utils/constellation/stars/recovery.ts — Recovery Star (the crown)
3
+ /**
4
+ * ============================================================================
5
+ * FITFRIX CONSTELLATION — Recovery Star (the crown)
6
+ * ============================================================================
7
+ *
8
+ * Recovery intelligence: does the user let trained muscles fully recover, not
9
+ * just grind through fatigue? Brightness reflects how many RECOVERY CYCLES the
10
+ * user achieved in the trailing window.
11
+ *
12
+ * ZONE-BASED (not all-or-nothing). The body is split into three independent
13
+ * recovery zones — upper, lower, core. A split-routine lifter (e.g. Push/Pull/
14
+ * Legs) almost never has the WHOLE body fresh at once, yet is recovering
15
+ * intelligently zone by zone; an all-muscles-at-once rule would wrongly score
16
+ * them near zero. So we count cycles PER ZONE.
17
+ *
18
+ * A zone "cycle" = a transition where that zone goes fatigued -> recovered
19
+ * (a multi-day rest counts once). Crucially, a zone only earns cycles if the
20
+ * user actually TRAINS it in the window — an untrained zone is "always fresh"
21
+ * and must not hand out free recovery credit. Total cycles = sum across the
22
+ * zones the user trains, capped at the level target.
23
+ *
24
+ * Reads PRE-SCORED sessions from context (orchestrator scores once); builds the
25
+ * enriched history the fatigue model consumes, then replays the model across
26
+ * each day of the window. This star never calls the scoring engine itself.
27
+ *
28
+ * Thresholds come from RECOVERY_THRESHOLDS[level].
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.recoveryStar = void 0;
32
+ const computeMuscleFatigueMap_1 = require("../../scoringWorkout/computeMuscleFatigueMap");
33
+ const levelThresholds_1 = require("../levelThresholds");
34
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
35
+ /**
36
+ * Recovery zones — independent regions that recover on their own clocks. Each
37
+ * lists the major muscle keys that define it. A zone is "recovered" on a given
38
+ * day when ALL of its listed muscles are below the fatigue threshold (or carry
39
+ * no tracked fatigue).
40
+ */
41
+ const RECOVERY_ZONES = {
42
+ upper: [
43
+ "pectoralis-major",
44
+ "latissimus-dorsi",
45
+ "deltoids-anterior",
46
+ "tricep-brachii-long",
47
+ ],
48
+ lower: ["quadriceps", "hamstrings", "glutes-maximus"],
49
+ core: ["abs-upper", "abs-lower", "obliques"],
50
+ };
51
+ const ZONE_NAMES = Object.keys(RECOVERY_ZONES);
52
+ // --- Enriched history (feed for the fatigue map) ----------------------------
53
+ /**
54
+ * Turn the orchestrator's pre-scored sessions into the fatigue model's inputs.
55
+ * Also records which zones the user actually trained (any tracked fatigue in a
56
+ * zone's muscles at any point), so untrained zones earn no recovery credit.
57
+ */
58
+ function buildEnrichedHistory(ctx) {
59
+ var _a;
60
+ var _b;
61
+ const exercises = {};
62
+ const byExercise = {};
63
+ const trainedMuscles = new Set();
64
+ for (const session of ctx.scoredSessions) {
65
+ for (const ex of session.exercises) {
66
+ if (ex.score === null)
67
+ continue;
68
+ const exercise = ctx.exerciseCatalog[ex.exerciseId];
69
+ if (!exercise)
70
+ continue;
71
+ if (!exercises[ex.exerciseId]) {
72
+ exercises[ex.exerciseId] = {
73
+ primaryMuscles: exercise.primaryMuscles,
74
+ secondaryMuscles: exercise.secondaryMuscles,
75
+ };
76
+ }
77
+ for (const m of exercise.primaryMuscles)
78
+ trainedMuscles.add(m);
79
+ for (const m of exercise.secondaryMuscles)
80
+ trainedMuscles.add(m);
81
+ ((_a = byExercise[_b = ex.exerciseId]) !== null && _a !== void 0 ? _a : (byExercise[_b] = [])).push({
82
+ score: ex.score,
83
+ recordDate: session.date,
84
+ muscleScores: ex.muscleScores,
85
+ });
86
+ }
87
+ }
88
+ const scoreHistory = Object.entries(byExercise).map(([exerciseId, recordScore]) => ({ exerciseId, recordScore }));
89
+ // A zone is "trained" if the user touched any of its muscles in the window.
90
+ const trainedZones = new Set();
91
+ for (const zone of ZONE_NAMES) {
92
+ if (RECOVERY_ZONES[zone].some((m) => trainedMuscles.has(m))) {
93
+ trainedZones.add(zone);
94
+ }
95
+ }
96
+ return { exercises, scoreHistory, trainedZones };
97
+ }
98
+ /** Is a single zone recovered (all its muscles below threshold) on a day's map? */
99
+ function isZoneRecovered(zone, fatigueMap, recoveredBelow) {
100
+ for (const muscle of RECOVERY_ZONES[zone]) {
101
+ const entry = fatigueMap[muscle];
102
+ if (entry && entry.fatigue >= recoveredBelow)
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+ // --- Detail builders --------------------------------------------------------
108
+ function cyclesMeasure(cycles, target) {
109
+ return {
110
+ value: cycles,
111
+ unit: "cycles",
112
+ display: {
113
+ translationKey: "constellation.recovery.measure.cycles",
114
+ params: [Math.min(cycles, target), target],
115
+ },
116
+ };
117
+ }
118
+ function buildGap(cycles, target, hasHistory) {
119
+ if (!hasHistory)
120
+ return { translationKey: "constellation.recovery.gap.noData" };
121
+ if (cycles >= target)
122
+ return { translationKey: "constellation.recovery.gap.full" };
123
+ return {
124
+ translationKey: "constellation.recovery.gap.moreCycles",
125
+ params: [target - cycles],
126
+ };
127
+ }
128
+ // --- Evaluator --------------------------------------------------------------
129
+ function evaluateRecovery(ctx) {
130
+ const { targetCycles, windowDays, recoveredBelow } = levelThresholds_1.RECOVERY_THRESHOLDS[ctx.level];
131
+ const { exercises, scoreHistory, trainedZones } = buildEnrichedHistory(ctx);
132
+ const hasHistory = scoreHistory.length > 0;
133
+ const startDay = Math.floor((ctx.now - windowDays * MS_PER_DAY) / MS_PER_DAY);
134
+ const endDay = Math.floor(ctx.now / MS_PER_DAY);
135
+ // Per-zone cycle counting. Only zones the user trains can earn cycles.
136
+ // Seed each trained zone as "recovered" (pre-window baseline = fresh), so the
137
+ // first cycle is only credited after the zone has actually been fatigued.
138
+ let totalCycles = 0;
139
+ for (const zone of trainedZones) {
140
+ let wasRecovered = true;
141
+ for (let d = startDay; d <= endDay; d++) {
142
+ const dayDate = new Date(d * MS_PER_DAY + MS_PER_DAY / 2); // midday
143
+ const map = (0, computeMuscleFatigueMap_1.computeMuscleFatigueMap)(exercises, scoreHistory, ctx.user, dayDate);
144
+ const recovered = isZoneRecovered(zone, map, recoveredBelow);
145
+ if (recovered && !wasRecovered)
146
+ totalCycles++;
147
+ wasRecovered = recovered;
148
+ }
149
+ }
150
+ const brightness = Math.min(100, (totalCycles / targetCycles) * 100);
151
+ return {
152
+ brightness,
153
+ detail: {
154
+ currentState: cyclesMeasure(totalCycles, targetCycles),
155
+ target: cyclesMeasure(targetCycles, targetCycles),
156
+ gap: buildGap(totalCycles, targetCycles, hasHistory),
157
+ },
158
+ };
159
+ }
160
+ // --- Definition -------------------------------------------------------------
161
+ exports.recoveryStar = {
162
+ id: "recovery",
163
+ displayName: { translationKey: "constellation.recovery.name" },
164
+ color: "#c8d8ff", // silver-white — moonlight, the quiet phase
165
+ figurePosition: "crown",
166
+ objective: { translationKey: "constellation.recovery.objective" },
167
+ rationale: { translationKey: "constellation.recovery.rationale" },
168
+ evaluate: evaluateRecovery,
169
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // shared/src/utils/constellation/stars/recovery.test.ts
4
+ const starFoundation_1 = require("../starFoundation");
5
+ const recovery_1 = require("../stars/recovery");
6
+ const vitest_1 = require("vitest");
7
+ const NOW = Date.UTC(2026, 5, 1);
8
+ const day = (d) => NOW - d * 86400000;
9
+ // Exercises with muscles in specific zones (upper: pec/lat; lower: quad/ham/glute; core: abs).
10
+ const catalog = {
11
+ bench: {
12
+ exerciseId: "bench",
13
+ name: "Bench",
14
+ primaryMuscles: ["pectoralis-major"],
15
+ secondaryMuscles: ["deltoids-anterior"],
16
+ difficultyLevel: 3,
17
+ movementPattern: "push_horizontal",
18
+ status: "active",
19
+ },
20
+ squat: {
21
+ exerciseId: "squat",
22
+ name: "Squat",
23
+ primaryMuscles: ["quadriceps"],
24
+ secondaryMuscles: ["glutes-maximus"],
25
+ difficultyLevel: 3,
26
+ movementPattern: "squat",
27
+ status: "active",
28
+ },
29
+ row: {
30
+ exerciseId: "row",
31
+ name: "Row",
32
+ primaryMuscles: ["latissimus-dorsi"],
33
+ secondaryMuscles: [],
34
+ difficultyLevel: 3,
35
+ movementPattern: "pull_horizontal",
36
+ status: "active",
37
+ },
38
+ };
39
+ const scoredEx = (id, muscles) => ({
40
+ exerciseId: id,
41
+ score: 75,
42
+ muscleScores: Object.fromEntries(muscles.map((m) => [m, 70])),
43
+ });
44
+ const ctx = (scoredSessions, level = 1) => ({
45
+ sessions: [],
46
+ scoredSessions,
47
+ exerciseCatalog: catalog,
48
+ user: { weightKg: 80, gender: "male" },
49
+ now: NOW,
50
+ level,
51
+ });
52
+ (0, vitest_1.describe)("recovery — no data", () => {
53
+ (0, vitest_1.it)("empty history → noData gap, 0 brightness", () => {
54
+ const r = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx([]));
55
+ (0, vitest_1.expect)(r.brightness).toBe(0);
56
+ (0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.recovery.gap.noData");
57
+ });
58
+ });
59
+ (0, vitest_1.describe)("recovery — earns cycles from trained zones", () => {
60
+ (0, vitest_1.it)("a split lifter (alternating zones, spaced) earns recovery cycles", () => {
61
+ // train each zone, leave gaps so it recovers, train again → transitions
62
+ const s = [];
63
+ const sched = [
64
+ [40, "bench", ["pectoralis-major"]],
65
+ [37, "row", ["latissimus-dorsi"]],
66
+ [34, "squat", ["quadriceps"]],
67
+ [27, "bench", ["pectoralis-major"]],
68
+ [24, "row", ["latissimus-dorsi"]],
69
+ [21, "squat", ["quadriceps"]],
70
+ [14, "bench", ["pectoralis-major"]],
71
+ [11, "row", ["latissimus-dorsi"]],
72
+ [8, "squat", ["quadriceps"]],
73
+ ];
74
+ for (const [d, id, m] of sched)
75
+ s.push({ date: day(d), exercises: [scoredEx(id, m)] });
76
+ const r = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx(s));
77
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
78
+ (0, vitest_1.expect)(r.currentState.value).toBeGreaterThanOrEqual(1);
79
+ });
80
+ });
81
+ (0, vitest_1.describe)("recovery — penalises the daily grinder", () => {
82
+ (0, vitest_1.it)("training all zones every day → low/zero recovery", () => {
83
+ const s = [];
84
+ for (let d = 40; d >= 0; d--) {
85
+ s.push({
86
+ date: day(d),
87
+ exercises: [
88
+ scoredEx("bench", ["pectoralis-major"]),
89
+ scoredEx("squat", ["quadriceps"]),
90
+ scoredEx("row", ["latissimus-dorsi"]),
91
+ ],
92
+ });
93
+ }
94
+ const r = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx(s));
95
+ (0, vitest_1.expect)(r.brightness).toBeLessThan(50);
96
+ });
97
+ });
98
+ (0, vitest_1.describe)("recovery — untrained zones earn no free credit", () => {
99
+ (0, vitest_1.it)("training only upper does not manufacture lower/core cycles", () => {
100
+ // only upper trained, spaced so it cycles a few times
101
+ const s = [];
102
+ for (const d of [40, 33, 26, 19, 12, 5])
103
+ s.push({
104
+ date: day(d),
105
+ exercises: [scoredEx("bench", ["pectoralis-major"])],
106
+ });
107
+ const r = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx(s));
108
+ // cycles come ONLY from the upper zone; if lower/core counted freely,
109
+ // brightness would be far higher. Bounded sanity check:
110
+ (0, vitest_1.expect)(r.currentState.value).toBeGreaterThanOrEqual(1);
111
+ (0, vitest_1.expect)(r.brightness).toBeLessThanOrEqual(100);
112
+ });
113
+ });
114
+ (0, vitest_1.describe)("recovery — level scaling", () => {
115
+ (0, vitest_1.it)("same history is dimmer at L2 (needs more cycles)", () => {
116
+ const s = [];
117
+ const sched = [
118
+ [40, "bench", ["pectoralis-major"]],
119
+ [34, "squat", ["quadriceps"]],
120
+ [27, "bench", ["pectoralis-major"]],
121
+ [21, "squat", ["quadriceps"]],
122
+ [14, "bench", ["pectoralis-major"]],
123
+ [8, "squat", ["quadriceps"]],
124
+ ];
125
+ for (const [d, id, m] of sched)
126
+ s.push({ date: day(d), exercises: [scoredEx(id, m)] });
127
+ const l1 = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx(s, 1));
128
+ const l2 = (0, starFoundation_1.resolveStar)(recovery_1.recoveryStar, ctx(s, 2));
129
+ (0, vitest_1.expect)(l2.brightness).toBeLessThanOrEqual(l1.brightness);
130
+ });
131
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // shared/src/utils/constellation/__tests__/strengthStars.test.ts
4
+ const starFoundation_1 = require("./starFoundation");
5
+ const push_1 = require("./stars/push");
6
+ const pull_1 = require("./stars/pull");
7
+ const lowerBody_1 = require("./stars/lowerBody");
8
+ const vitest_1 = require("vitest");
9
+ const NOW = Date.UTC(2026, 5, 1);
10
+ const day = (d) => NOW - d * 86400000;
11
+ const wr = (kg, reps) => ({ type: "weight-reps", kg, reps, isDone: true, isStrictMode: false });
12
+ const ro = (reps, aux = "0") => ({
13
+ type: "reps-only",
14
+ reps,
15
+ auxWeightKg: aux,
16
+ isDone: true,
17
+ isStrictMode: false,
18
+ });
19
+ const ex = (id, mp, over = {}) => ({
20
+ exerciseId: id,
21
+ name: id,
22
+ primaryMuscles: [],
23
+ secondaryMuscles: [],
24
+ difficultyLevel: 3,
25
+ movementPattern: mp,
26
+ status: "active",
27
+ ...over,
28
+ });
29
+ const catalog = {
30
+ bench: ex("bench", "push_horizontal"),
31
+ ohp: ex("ohp", "push_vertical"),
32
+ row: ex("row", "pull_horizontal"),
33
+ pullup: ex("pullup", "pull_vertical", {
34
+ weightMultiplier: { male: 1.0, female: 0.95, default: 0.97 },
35
+ }),
36
+ squat: ex("squat", "squat"),
37
+ rdl: ex("rdl", "hinge"),
38
+ lunge: ex("lunge", "lunge"),
39
+ pistol: ex("pistol", "squat", {
40
+ weightMultiplier: { male: 0.95, female: 0.9, default: 0.9 },
41
+ }),
42
+ };
43
+ const ctx = (sessions, user = {}, level = 1) => ({
44
+ sessions,
45
+ scoredSessions: [],
46
+ exerciseCatalog: catalog,
47
+ user: { weightKg: 80, gender: "male", ...user },
48
+ now: NOW,
49
+ level,
50
+ });
51
+ (0, vitest_1.describe)("push star — pattern filtering & thresholds", () => {
52
+ (0, vitest_1.it)("lights from a strong bench (L1 0.60 male)", () => {
53
+ const r = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx([
54
+ {
55
+ date: day(2),
56
+ exercises: [{ exerciseId: "bench", records: [wr("60", "5")] }],
57
+ },
58
+ ]));
59
+ // 60x5 → 70/80 = 0.875 vs 0.60 → capped 100
60
+ (0, vitest_1.expect)(r.brightness).toBe(100);
61
+ (0, vitest_1.expect)(r.tier).toBe(3);
62
+ (0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.push.gap.full");
63
+ });
64
+ (0, vitest_1.it)("counts vertical push (OHP) too", () => {
65
+ const r = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx([
66
+ {
67
+ date: day(2),
68
+ exercises: [{ exerciseId: "ohp", records: [wr("40", "5")] }],
69
+ },
70
+ ]));
71
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
72
+ });
73
+ (0, vitest_1.it)("ignores pull movements", () => {
74
+ const r = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx([
75
+ {
76
+ date: day(2),
77
+ exercises: [{ exerciseId: "row", records: [wr("100", "5")] }],
78
+ },
79
+ ]));
80
+ (0, vitest_1.expect)(r.brightness).toBe(0);
81
+ });
82
+ (0, vitest_1.it)("partial brightness below threshold gives a toGo gap", () => {
83
+ const r = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx([
84
+ {
85
+ date: day(2),
86
+ exercises: [{ exerciseId: "bench", records: [wr("25", "5")] }],
87
+ },
88
+ ]));
89
+ // 25x5 → 29.2/80 = 0.365 vs 0.60 → ~61%
90
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
91
+ (0, vitest_1.expect)(r.brightness).toBeLessThan(100);
92
+ (0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.push.gap.toGo");
93
+ });
94
+ });
95
+ (0, vitest_1.describe)("strength — gendered thresholds", () => {
96
+ (0, vitest_1.it)("female has a lower bar (same lift lights more)", () => {
97
+ const sessions = [
98
+ {
99
+ date: day(2),
100
+ exercises: [{ exerciseId: "bench", records: [wr("30", "5")] }],
101
+ },
102
+ ];
103
+ const male = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, { gender: "male" }));
104
+ const female = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, { gender: "female" }));
105
+ (0, vitest_1.expect)(female.brightness).toBeGreaterThanOrEqual(male.brightness);
106
+ });
107
+ (0, vitest_1.it)("unmentioned gender resolves to the male (higher) bar", () => {
108
+ const sessions = [
109
+ {
110
+ date: day(2),
111
+ exercises: [{ exerciseId: "bench", records: [wr("30", "5")] }],
112
+ },
113
+ ];
114
+ const male = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, { gender: "male" }));
115
+ const unmentioned = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, { gender: undefined }));
116
+ (0, vitest_1.expect)(unmentioned.brightness).toBe(male.brightness);
117
+ });
118
+ });
119
+ (0, vitest_1.describe)("strength — level scaling", () => {
120
+ (0, vitest_1.it)("a fixed lift is dimmer at L2 than L1 (higher bar)", () => {
121
+ const sessions = [
122
+ {
123
+ date: day(2),
124
+ exercises: [{ exerciseId: "bench", records: [wr("40", "5")] }],
125
+ },
126
+ ];
127
+ // 40x5 → 46.7/80 = 0.583. L1 push 0.60 → ~97%; L2 push 1.0 → ~58%
128
+ const l1 = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, {}, 1));
129
+ const l2 = (0, starFoundation_1.resolveStar)(push_1.pushStar, ctx(sessions, {}, 2));
130
+ (0, vitest_1.expect)(l2.brightness).toBeLessThan(l1.brightness);
131
+ });
132
+ });
133
+ (0, vitest_1.describe)("pull star", () => {
134
+ (0, vitest_1.it)("lights from rows", () => {
135
+ const r = (0, starFoundation_1.resolveStar)(pull_1.pullStar, ctx([
136
+ {
137
+ date: day(2),
138
+ exercises: [{ exerciseId: "row", records: [wr("50", "5")] }],
139
+ },
140
+ ]));
141
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
142
+ });
143
+ (0, vitest_1.it)("bodyweight pull-ups light via weightMultiplier", () => {
144
+ const r = (0, starFoundation_1.resolveStar)(pull_1.pullStar, ctx([
145
+ {
146
+ date: day(2),
147
+ exercises: [{ exerciseId: "pullup", records: [ro("8")] }],
148
+ },
149
+ ]));
150
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
151
+ });
152
+ });
153
+ (0, vitest_1.describe)("lowerBody star — combined patterns & home reachability", () => {
154
+ (0, vitest_1.it)("squat, hinge, and lunge all count", () => {
155
+ for (const id of ["squat", "rdl", "lunge"]) {
156
+ const r = (0, starFoundation_1.resolveStar)(lowerBody_1.lowerBodyStar, ctx([
157
+ {
158
+ date: day(2),
159
+ exercises: [{ exerciseId: id, records: [wr("80", "5")] }],
160
+ },
161
+ ]));
162
+ (0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
163
+ }
164
+ });
165
+ (0, vitest_1.it)("HOME user reaches full lower body via bodyweight pistol squats", () => {
166
+ const r = (0, starFoundation_1.resolveStar)(lowerBody_1.lowerBodyStar, ctx([
167
+ {
168
+ date: day(2),
169
+ exercises: [{ exerciseId: "pistol", records: [ro("8")] }],
170
+ },
171
+ ], { weightKg: 75 }));
172
+ // pistol 0.95*75=71.25; 1RM=71.25*(1+8/30)=90.25; /75=1.20 vs L1 1.0 → 100
173
+ (0, vitest_1.expect)(r.brightness).toBe(100);
174
+ });
175
+ (0, vitest_1.it)("takes the best across the three patterns", () => {
176
+ const sessions = [
177
+ {
178
+ date: day(5),
179
+ exercises: [{ exerciseId: "squat", records: [wr("50", "5")] }],
180
+ },
181
+ {
182
+ date: day(2),
183
+ exercises: [{ exerciseId: "rdl", records: [wr("100", "5")] }],
184
+ },
185
+ ];
186
+ const r = (0, starFoundation_1.resolveStar)(lowerBody_1.lowerBodyStar, ctx(sessions));
187
+ // rdl 100x5 → 116.7/80 = 1.46 → capped 100
188
+ (0, vitest_1.expect)(r.brightness).toBe(100);
189
+ });
190
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ============================================================================
3
+ * FITFRIX CONSTELLATION — Strength Star Helpers
4
+ * ============================================================================
5
+ *
6
+ * Shared by the three strength stars (push, pull, lowerBody). Each star is a
7
+ * thin definition that names its movement patterns and its threshold key; this
8
+ * helper does the rest: filter the catalog to those patterns, compute the
9
+ * normalised load, and turn the ratio into brightness + i18n detail.
10
+ *
11
+ * Thresholds come from STRENGTH_THRESHOLDS[key][level], resolved by sex.
12
+ */
13
+ import type { TUserMetric } from "../../types";
14
+ import { type ISexThreshold } from "./levelThresholds";
15
+ import type { TStarContext, TStarEvaluation, TLocalizedText } from "./types";
16
+ /** Rolling window for strength PRs: 8 weeks. */
17
+ export declare const STRENGTH_WINDOW_DAYS = 56;
18
+ /** Resolve the threshold ratio for this user. unmentioned -> male (higher bar). */
19
+ export declare function resolveThreshold(threshold: ISexThreshold, gender: string | undefined): number;
20
+ /** User bodyweight with fallback. */
21
+ export declare function userBodyweight(user: Partial<TUserMetric>): number;
22
+ /**
23
+ * Brightness from current ratio vs threshold, graded & continuous.
24
+ * brightness = (currentRatio / thresholdRatio) * 100, capped at 100.
25
+ * Faint from the first rep; full at threshold. No wall.
26
+ */
27
+ export declare function ratioToBrightness(currentRatio: number, thresholdRatio: number): number;
28
+ /**
29
+ * Build the "what's missing" gap text. At/above threshold -> a "full" message;
30
+ * otherwise the remaining kg on the user's best lift to reach the threshold.
31
+ */
32
+ export declare function buildGap(gapKeyPrefix: string, currentRatio: number, thresholdRatio: number, bodyweightKg: number): TLocalizedText;
33
+ /**
34
+ * The full strength-star evaluation, shared by push/pull/lowerBody.
35
+ *
36
+ * @param ctx star context (sessions, catalog, user, now, level)
37
+ * @param strengthKey which STRENGTH_THRESHOLDS row to use ("push" | "pull" | "lowerBody")
38
+ * @param matchingPatterns movement patterns this star counts
39
+ * @param gapKeyPrefix translation-key prefix (e.g. "constellation.push")
40
+ */
41
+ export declare function evaluateStrengthStar(ctx: TStarContext, strengthKey: "push" | "pull" | "lowerBody", matchingPatterns: string[], gapKeyPrefix: string): TStarEvaluation;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ // utils/constellation/strengthStarHelpers.ts — Strength Star Helpers
3
+ /**
4
+ * ============================================================================
5
+ * FITFRIX CONSTELLATION — Strength Star Helpers
6
+ * ============================================================================
7
+ *
8
+ * Shared by the three strength stars (push, pull, lowerBody). Each star is a
9
+ * thin definition that names its movement patterns and its threshold key; this
10
+ * helper does the rest: filter the catalog to those patterns, compute the
11
+ * normalised load, and turn the ratio into brightness + i18n detail.
12
+ *
13
+ * Thresholds come from STRENGTH_THRESHOLDS[key][level], resolved by sex.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.STRENGTH_WINDOW_DAYS = void 0;
17
+ exports.resolveThreshold = resolveThreshold;
18
+ exports.userBodyweight = userBodyweight;
19
+ exports.ratioToBrightness = ratioToBrightness;
20
+ exports.buildGap = buildGap;
21
+ exports.evaluateStrengthStar = evaluateStrengthStar;
22
+ const computeNormalisedLoad_1 = require("./computeNormalisedLoad");
23
+ const levelThresholds_1 = require("./levelThresholds");
24
+ /** Rolling window for strength PRs: 8 weeks. */
25
+ exports.STRENGTH_WINDOW_DAYS = 56;
26
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
27
+ const DEFAULT_BODYWEIGHT_KG = 70;
28
+ /** Resolve the threshold ratio for this user. unmentioned -> male (higher bar). */
29
+ function resolveThreshold(threshold, gender) {
30
+ return gender === "female" ? threshold.female : threshold.male;
31
+ }
32
+ /** User bodyweight with fallback. */
33
+ function userBodyweight(user) {
34
+ const bw = user.weightKg;
35
+ return bw && bw > 0 ? bw : DEFAULT_BODYWEIGHT_KG;
36
+ }
37
+ /**
38
+ * Brightness from current ratio vs threshold, graded & continuous.
39
+ * brightness = (currentRatio / thresholdRatio) * 100, capped at 100.
40
+ * Faint from the first rep; full at threshold. No wall.
41
+ */
42
+ function ratioToBrightness(currentRatio, thresholdRatio) {
43
+ if (thresholdRatio <= 0)
44
+ return 0;
45
+ return Math.min(100, (currentRatio / thresholdRatio) * 100);
46
+ }
47
+ /** Build a bodyweight-ratio measure for the detail panel. */
48
+ function bwRatioMeasure(ratio) {
49
+ const rounded = Math.round(ratio * 100) / 100;
50
+ return {
51
+ value: rounded,
52
+ unit: "bw_ratio",
53
+ display: {
54
+ translationKey: "constellation.measure.bwRatio",
55
+ params: [rounded],
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * Build the "what's missing" gap text. At/above threshold -> a "full" message;
61
+ * otherwise the remaining kg on the user's best lift to reach the threshold.
62
+ */
63
+ function buildGap(gapKeyPrefix, currentRatio, thresholdRatio, bodyweightKg) {
64
+ if (currentRatio >= thresholdRatio) {
65
+ return { translationKey: `${gapKeyPrefix}.gap.full` };
66
+ }
67
+ const remainingKg = Math.max(0, Math.round((thresholdRatio - currentRatio) * bodyweightKg));
68
+ return {
69
+ translationKey: `${gapKeyPrefix}.gap.toGo`,
70
+ params: [remainingKg],
71
+ };
72
+ }
73
+ /**
74
+ * The full strength-star evaluation, shared by push/pull/lowerBody.
75
+ *
76
+ * @param ctx star context (sessions, catalog, user, now, level)
77
+ * @param strengthKey which STRENGTH_THRESHOLDS row to use ("push" | "pull" | "lowerBody")
78
+ * @param matchingPatterns movement patterns this star counts
79
+ * @param gapKeyPrefix translation-key prefix (e.g. "constellation.push")
80
+ */
81
+ function evaluateStrengthStar(ctx, strengthKey, matchingPatterns, gapKeyPrefix) {
82
+ const bw = userBodyweight(ctx.user);
83
+ const gender = ctx.user.gender;
84
+ const thresholdRatio = resolveThreshold(levelThresholds_1.STRENGTH_THRESHOLDS[strengthKey][ctx.level], gender);
85
+ // Build the matching-exercise id set from the catalog by movement pattern.
86
+ const patternSet = new Set(matchingPatterns);
87
+ const matchingIds = new Set();
88
+ for (const id of Object.keys(ctx.exerciseCatalog)) {
89
+ const ex = ctx.exerciseCatalog[id];
90
+ if (patternSet.has(ex.movementPattern))
91
+ matchingIds.add(id);
92
+ }
93
+ const windowStart = ctx.now - exports.STRENGTH_WINDOW_DAYS * MS_PER_DAY;
94
+ const load = (0, computeNormalisedLoad_1.computeNormalisedLoad)(matchingIds, ctx.sessions, ctx.exerciseCatalog, bw, gender, windowStart, ctx.now);
95
+ const brightness = ratioToBrightness(load.ratio, thresholdRatio);
96
+ return {
97
+ brightness,
98
+ detail: {
99
+ currentState: bwRatioMeasure(load.ratio),
100
+ target: bwRatioMeasure(thresholdRatio),
101
+ gap: buildGap(gapKeyPrefix, load.ratio, thresholdRatio, bw),
102
+ },
103
+ };
104
+ }