@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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/stars/consistency.ts — Consistency Star (the heart)
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Consistency Star (the heart)
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* The live "showing up" star. Brightness reflects the user's CURRENT training
|
|
9
|
+
* habit, recomputed every call from recent session dates. The one star meant to
|
|
10
|
+
* be maintained, not achieved — it brightens with a steady habit and dims when
|
|
11
|
+
* the user goes quiet.
|
|
12
|
+
*
|
|
13
|
+
* Full brightness requires BOTH:
|
|
14
|
+
* - requiredSessions training days within the trailing window, AND
|
|
15
|
+
* - the trailing idle gap (last session -> now) within maxGapDays.
|
|
16
|
+
*
|
|
17
|
+
* Brightness is graded below full so progress is always visible:
|
|
18
|
+
* - countComponent — progress toward the required session count
|
|
19
|
+
* - gapComponent — a live penalty as the TRAILING gap exceeds maxGapDays
|
|
20
|
+
*
|
|
21
|
+
* Note: brightness uses the TRAILING gap, not interior gaps. An old gap the user
|
|
22
|
+
* has since recovered from should not permanently dim a live habit signal.
|
|
23
|
+
* Interior gaps still inform the gap MESSAGE for context.
|
|
24
|
+
*
|
|
25
|
+
* Counting rule: a session counts as a training day only if it has >=1 completed
|
|
26
|
+
* set. Multiple sessions on one calendar day = one training day.
|
|
27
|
+
*
|
|
28
|
+
* Thresholds come from CONSISTENCY_THRESHOLDS[level].
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.consistencyStar = void 0;
|
|
32
|
+
const levelThresholds_1 = require("../levelThresholds");
|
|
33
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
34
|
+
// --- Helpers ----------------------------------------------------------------
|
|
35
|
+
/** Does a session have at least one completed set? (isDone is a guaranteed bool on TRecord) */
|
|
36
|
+
function hasCompletedSet(session) {
|
|
37
|
+
for (const ex of session.exercises) {
|
|
38
|
+
for (const set of ex.records) {
|
|
39
|
+
if (set.isDone)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function countMeasure(sessions, required) {
|
|
46
|
+
return {
|
|
47
|
+
value: sessions,
|
|
48
|
+
unit: "sessions",
|
|
49
|
+
display: {
|
|
50
|
+
translationKey: "constellation.consistency.measure.sessions",
|
|
51
|
+
params: [Math.min(sessions, required), required],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function buildGap(sessionsInWindow, longestGapDays, trailingGapDays, required, maxGapDays, warningAtIdleDays) {
|
|
56
|
+
// Brand-new user (no training days in window): invite, don't scold. Without
|
|
57
|
+
// this, a zero-session user trips the "broken" branch (trailingGap = window).
|
|
58
|
+
if (sessionsInWindow === 0) {
|
|
59
|
+
return { translationKey: "constellation.consistency.gap.getStarted" };
|
|
60
|
+
}
|
|
61
|
+
// Cooling warning: trailing gap is creeping toward the max.
|
|
62
|
+
if (trailingGapDays >= warningAtIdleDays && trailingGapDays <= maxGapDays) {
|
|
63
|
+
const daysRemaining = maxGapDays - trailingGapDays + 1;
|
|
64
|
+
return {
|
|
65
|
+
translationKey: "constellation.consistency.gap.cooling",
|
|
66
|
+
params: [daysRemaining],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (longestGapDays > maxGapDays) {
|
|
70
|
+
return { translationKey: "constellation.consistency.gap.broken" };
|
|
71
|
+
}
|
|
72
|
+
const remaining = Math.max(0, required - sessionsInWindow);
|
|
73
|
+
if (remaining > 0) {
|
|
74
|
+
return {
|
|
75
|
+
translationKey: "constellation.consistency.gap.moreSessions",
|
|
76
|
+
params: [remaining],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { translationKey: "constellation.consistency.gap.full" };
|
|
80
|
+
}
|
|
81
|
+
// --- Evaluator --------------------------------------------------------------
|
|
82
|
+
function evaluateConsistency(ctx) {
|
|
83
|
+
const { requiredSessions, windowDays, maxGapDays, warningAtIdleDays } = levelThresholds_1.CONSISTENCY_THRESHOLDS[ctx.level];
|
|
84
|
+
const { now } = ctx;
|
|
85
|
+
const windowStart = now - windowDays * MS_PER_DAY;
|
|
86
|
+
const nowDay = Math.floor(now / MS_PER_DAY);
|
|
87
|
+
// Training days = sessions with >=1 completed set, deduped to calendar day,
|
|
88
|
+
// within the trailing window, sorted ascending. Upper bound is by CALENDAR
|
|
89
|
+
// DAY (<= today) so a session logged later today than `now` still counts.
|
|
90
|
+
const dayIndices = Array.from(new Set(ctx.sessions
|
|
91
|
+
.filter((s) => {
|
|
92
|
+
if (!hasCompletedSet(s))
|
|
93
|
+
return false;
|
|
94
|
+
const d = Math.floor(s.date / MS_PER_DAY);
|
|
95
|
+
return s.date > windowStart && d <= nowDay;
|
|
96
|
+
})
|
|
97
|
+
.map((s) => Math.floor(s.date / MS_PER_DAY)))).sort((a, b) => a - b);
|
|
98
|
+
const sessionsInWindow = dayIndices.length;
|
|
99
|
+
// Longest interior gap (for the message only).
|
|
100
|
+
let longestGapDays = 0;
|
|
101
|
+
for (let i = 1; i < dayIndices.length; i++) {
|
|
102
|
+
const gap = dayIndices[i] - dayIndices[i - 1];
|
|
103
|
+
if (gap > longestGapDays)
|
|
104
|
+
longestGapDays = gap;
|
|
105
|
+
}
|
|
106
|
+
// Trailing gap: last training day -> today. No sessions -> maximally idle.
|
|
107
|
+
const trailingGap = dayIndices.length > 0
|
|
108
|
+
? nowDay - dayIndices[dayIndices.length - 1]
|
|
109
|
+
: windowDays;
|
|
110
|
+
if (trailingGap > longestGapDays)
|
|
111
|
+
longestGapDays = trailingGap;
|
|
112
|
+
// Brightness: two multiplicative components, both 0..1.
|
|
113
|
+
const countComponent = Math.min(1, sessionsInWindow / requiredSessions);
|
|
114
|
+
let gapComponent;
|
|
115
|
+
if (trailingGap <= maxGapDays) {
|
|
116
|
+
gapComponent = 1; // within allowed idle window — no penalty
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const over = trailingGap - maxGapDays;
|
|
120
|
+
const decayRange = Math.max(1, windowDays - maxGapDays);
|
|
121
|
+
gapComponent = Math.max(0, 1 - over / decayRange);
|
|
122
|
+
}
|
|
123
|
+
const brightness = Math.min(100, countComponent * gapComponent * 100);
|
|
124
|
+
return {
|
|
125
|
+
brightness,
|
|
126
|
+
detail: {
|
|
127
|
+
currentState: countMeasure(sessionsInWindow, requiredSessions),
|
|
128
|
+
target: countMeasure(requiredSessions, requiredSessions),
|
|
129
|
+
gap: buildGap(sessionsInWindow, longestGapDays, trailingGap, requiredSessions, maxGapDays, warningAtIdleDays),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// --- Definition -------------------------------------------------------------
|
|
134
|
+
exports.consistencyStar = {
|
|
135
|
+
id: "consistency",
|
|
136
|
+
displayName: { translationKey: "constellation.consistency.name" },
|
|
137
|
+
color: "#ff8c00", // amber-gold — the spark of the flame, the heart
|
|
138
|
+
figurePosition: "heart",
|
|
139
|
+
objective: { translationKey: "constellation.consistency.objective" },
|
|
140
|
+
rationale: { translationKey: "constellation.consistency.rationale" },
|
|
141
|
+
evaluate: evaluateConsistency,
|
|
142
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// shared/src/utils/constellation/stars/consistency.test.ts
|
|
4
|
+
const vitest_1 = require("vitest");
|
|
5
|
+
const starFoundation_1 = require("../starFoundation");
|
|
6
|
+
const consistency_1 = require("../stars/consistency");
|
|
7
|
+
const NOW = Date.UTC(2026, 5, 1);
|
|
8
|
+
const day = (d) => NOW - d * 86400000;
|
|
9
|
+
const set = (isDone = true) => ({
|
|
10
|
+
type: "weight-reps",
|
|
11
|
+
kg: "20",
|
|
12
|
+
reps: "5",
|
|
13
|
+
isDone,
|
|
14
|
+
isStrictMode: false,
|
|
15
|
+
});
|
|
16
|
+
const sess = (d, isDone = true) => ({
|
|
17
|
+
date: day(d),
|
|
18
|
+
exercises: [{ exerciseId: "x", records: [set(isDone)] }],
|
|
19
|
+
});
|
|
20
|
+
const ctx = (sessions, level = 1) => ({
|
|
21
|
+
sessions,
|
|
22
|
+
scoredSessions: [],
|
|
23
|
+
exerciseCatalog: {},
|
|
24
|
+
user: { weightKg: 80 },
|
|
25
|
+
now: NOW,
|
|
26
|
+
level,
|
|
27
|
+
});
|
|
28
|
+
(0, vitest_1.describe)("consistency — new vs lapsed user", () => {
|
|
29
|
+
(0, vitest_1.it)("no sessions → getStarted, brightness 0", () => {
|
|
30
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx([]));
|
|
31
|
+
(0, vitest_1.expect)(r.brightness).toBe(0);
|
|
32
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.getStarted");
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.it)("one old session (20d ago), none since → broken", () => {
|
|
35
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx([sess(20)]));
|
|
36
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.broken");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.describe)("consistency — full habit", () => {
|
|
40
|
+
(0, vitest_1.it)("8 well-spaced sessions in 28d, recent → full", () => {
|
|
41
|
+
const days = [1, 4, 8, 11, 15, 18, 22, 25];
|
|
42
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(days.map((d) => sess(d))));
|
|
43
|
+
(0, vitest_1.expect)(r.brightness).toBe(100);
|
|
44
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.full");
|
|
45
|
+
});
|
|
46
|
+
(0, vitest_1.it)("fewer than required → partial + moreSessions gap", () => {
|
|
47
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx([sess(1), sess(4), sess(8), sess(11)]));
|
|
48
|
+
(0, vitest_1.expect)(r.brightness).toBeGreaterThan(0);
|
|
49
|
+
(0, vitest_1.expect)(r.brightness).toBeLessThan(100);
|
|
50
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.moreSessions");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
(0, vitest_1.describe)("consistency — counting rules", () => {
|
|
54
|
+
(0, vitest_1.it)("sessions with no completed set don't count", () => {
|
|
55
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx([sess(1, false), sess(4, false)]));
|
|
56
|
+
(0, vitest_1.expect)(r.brightness).toBe(0);
|
|
57
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.getStarted");
|
|
58
|
+
});
|
|
59
|
+
(0, vitest_1.it)("two sessions same calendar day count once", () => {
|
|
60
|
+
const twoSameDay = [
|
|
61
|
+
{ date: day(2), exercises: [{ exerciseId: "x", records: [set()] }] },
|
|
62
|
+
{
|
|
63
|
+
date: day(2) + 3600000,
|
|
64
|
+
exercises: [{ exerciseId: "y", records: [set()] }],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(twoSameDay));
|
|
68
|
+
// 1 unique day toward 8 → 12.5% * gap (trailing 2d ok) → ~13
|
|
69
|
+
(0, vitest_1.expect)(r.currentState.value).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.describe)("consistency — trailing gap behaviour", () => {
|
|
73
|
+
(0, vitest_1.it)("cooling warning as trailing gap approaches max", () => {
|
|
74
|
+
// 8 sessions but last was 4 days ago (warningAtIdleDays=4, maxGap=5)
|
|
75
|
+
const days = [4, 7, 10, 13, 16, 19, 22, 25];
|
|
76
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(days.map((d) => sess(d))));
|
|
77
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.consistency.gap.cooling");
|
|
78
|
+
});
|
|
79
|
+
(0, vitest_1.it)("trailing gap beyond max decays brightness below count-only", () => {
|
|
80
|
+
// enough sessions for full count, but last 10 days ago → gap penalty
|
|
81
|
+
const days = [10, 12, 14, 16, 18, 20, 22, 24];
|
|
82
|
+
const r = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(days.map((d) => sess(d))));
|
|
83
|
+
(0, vitest_1.expect)(r.brightness).toBeLessThan(100);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
(0, vitest_1.describe)("consistency — level scaling", () => {
|
|
87
|
+
(0, vitest_1.it)("8 sessions fills L1 but not L2 (needs 12)", () => {
|
|
88
|
+
const days = [1, 4, 8, 11, 15, 18, 22, 25];
|
|
89
|
+
const l1 = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(days.map((d) => sess(d)), 1));
|
|
90
|
+
const l2 = (0, starFoundation_1.resolveStar)(consistency_1.consistencyStar, ctx(days.map((d) => sess(d)), 2));
|
|
91
|
+
(0, vitest_1.expect)(l1.brightness).toBe(100);
|
|
92
|
+
(0, vitest_1.expect)(l2.brightness).toBeLessThan(100);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Lower Body Star
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Lower-body strength across squat, lunge, and hinge patterns combined, any
|
|
7
|
+
* equipment. Squats, goblet squats, leg press, lunges, deadlifts, RDLs, hip
|
|
8
|
+
* thrusts, kettlebell swings — all count. Best load across all three lights it.
|
|
9
|
+
* (Knee-vs-hip balance nuance deferred to the future goal layer.)
|
|
10
|
+
*
|
|
11
|
+
* Threshold per level from STRENGTH_THRESHOLDS.lowerBody, resolved by sex.
|
|
12
|
+
* Leg press is NOT special-cased — it is simply a squat-pattern exercise.
|
|
13
|
+
* Bodyweight lower-body work loads via the exercise's weightMultiplier, so home
|
|
14
|
+
* users can reach the threshold honestly.
|
|
15
|
+
*/
|
|
16
|
+
import type { TStarDefinition } from "../types";
|
|
17
|
+
export declare const lowerBodyStar: TStarDefinition;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/stars/lowerBody.ts — Lower Body Star
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Lower Body Star
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Lower-body strength across squat, lunge, and hinge patterns combined, any
|
|
9
|
+
* equipment. Squats, goblet squats, leg press, lunges, deadlifts, RDLs, hip
|
|
10
|
+
* thrusts, kettlebell swings — all count. Best load across all three lights it.
|
|
11
|
+
* (Knee-vs-hip balance nuance deferred to the future goal layer.)
|
|
12
|
+
*
|
|
13
|
+
* Threshold per level from STRENGTH_THRESHOLDS.lowerBody, resolved by sex.
|
|
14
|
+
* Leg press is NOT special-cased — it is simply a squat-pattern exercise.
|
|
15
|
+
* Bodyweight lower-body work loads via the exercise's weightMultiplier, so home
|
|
16
|
+
* users can reach the threshold honestly.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.lowerBodyStar = void 0;
|
|
20
|
+
const strengthStarHelpers_1 = require("../strengthStarHelpers");
|
|
21
|
+
const LOWER_BODY_PATTERNS = ["squat", "lunge", "hinge"];
|
|
22
|
+
exports.lowerBodyStar = {
|
|
23
|
+
id: "lowerBody",
|
|
24
|
+
displayName: { translationKey: "constellation.lowerBody.name" },
|
|
25
|
+
color: "#40b060", // earth green — the foundation everything sits on
|
|
26
|
+
figurePosition: "base",
|
|
27
|
+
objective: { translationKey: "constellation.lowerBody.objective" },
|
|
28
|
+
rationale: { translationKey: "constellation.lowerBody.rationale" },
|
|
29
|
+
evaluate: (ctx) => (0, strengthStarHelpers_1.evaluateStrengthStar)(ctx, "lowerBody", LOWER_BODY_PATTERNS, "constellation.lowerBody"),
|
|
30
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Pull Star
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Pulling strength across all pull-pattern exercises (horizontal + vertical),
|
|
7
|
+
* any equipment. Rows, cable rows, lat pulldowns, pull-ups, chin-ups — all count.
|
|
8
|
+
* Threshold per level from STRENGTH_THRESHOLDS.pull, resolved by sex.
|
|
9
|
+
*/
|
|
10
|
+
import type { TStarDefinition } from "../types";
|
|
11
|
+
export declare const pullStar: TStarDefinition;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/stars/pull.ts — Pull Star
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Pull Star
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Pulling strength across all pull-pattern exercises (horizontal + vertical),
|
|
9
|
+
* any equipment. Rows, cable rows, lat pulldowns, pull-ups, chin-ups — all count.
|
|
10
|
+
* Threshold per level from STRENGTH_THRESHOLDS.pull, resolved by sex.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.pullStar = void 0;
|
|
14
|
+
const strengthStarHelpers_1 = require("../strengthStarHelpers");
|
|
15
|
+
const PULL_PATTERNS = ["pull_horizontal", "pull_vertical"];
|
|
16
|
+
exports.pullStar = {
|
|
17
|
+
id: "pull",
|
|
18
|
+
displayName: { translationKey: "constellation.pull.name" },
|
|
19
|
+
color: "#2060ff", // cool blue — drawing resistance in
|
|
20
|
+
figurePosition: "rightArm",
|
|
21
|
+
objective: { translationKey: "constellation.pull.objective" },
|
|
22
|
+
rationale: { translationKey: "constellation.pull.rationale" },
|
|
23
|
+
evaluate: (ctx) => (0, strengthStarHelpers_1.evaluateStrengthStar)(ctx, "pull", PULL_PATTERNS, "constellation.pull"),
|
|
24
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Push Star
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Pushing strength across all push-pattern exercises (horizontal + vertical),
|
|
7
|
+
* any equipment. Bench, machine press, push-ups, overhead press — all count.
|
|
8
|
+
* Threshold per level from STRENGTH_THRESHOLDS.push, resolved by sex.
|
|
9
|
+
*/
|
|
10
|
+
import type { TStarDefinition } from "../types";
|
|
11
|
+
export declare const pushStar: TStarDefinition;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/stars/push.ts — Push Star
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Push Star
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Pushing strength across all push-pattern exercises (horizontal + vertical),
|
|
9
|
+
* any equipment. Bench, machine press, push-ups, overhead press — all count.
|
|
10
|
+
* Threshold per level from STRENGTH_THRESHOLDS.push, resolved by sex.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.pushStar = void 0;
|
|
14
|
+
const strengthStarHelpers_1 = require("../strengthStarHelpers");
|
|
15
|
+
const PUSH_PATTERNS = ["push_horizontal", "push_vertical"];
|
|
16
|
+
exports.pushStar = {
|
|
17
|
+
id: "push",
|
|
18
|
+
displayName: { translationKey: "constellation.push.name" },
|
|
19
|
+
color: "#ff4820", // warm red — forward force
|
|
20
|
+
figurePosition: "leftArm",
|
|
21
|
+
objective: { translationKey: "constellation.push.objective" },
|
|
22
|
+
rationale: { translationKey: "constellation.push.rationale" },
|
|
23
|
+
evaluate: (ctx) => (0, strengthStarHelpers_1.evaluateStrengthStar)(ctx, "push", PUSH_PATTERNS, "constellation.push"),
|
|
24
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Quality Star (the core)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Execution quality. Brightness reflects how many of the user's recent sessions
|
|
7
|
+
* were executed well. Reads PRE-SCORED sessions from context (the orchestrator
|
|
8
|
+
* scores once); this star never calls the scoring engine itself.
|
|
9
|
+
*
|
|
10
|
+
* Session quality = simple average of its exercises' quality scores (0-100).
|
|
11
|
+
* Star brightness = count-based: of the last N sessions, how many cleared the
|
|
12
|
+
* quality bar, scaled toward a target count. Count-based (not
|
|
13
|
+
* a consecutive streak) so a single off day never zeroes the
|
|
14
|
+
* star — inviting, not punishing.
|
|
15
|
+
*
|
|
16
|
+
* Thresholds come from QUALITY_THRESHOLDS[level].
|
|
17
|
+
*/
|
|
18
|
+
import type { TStarDefinition } from "../types";
|
|
19
|
+
export declare const qualityStar: TStarDefinition;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/stars/quality.ts — Quality Star (the core)
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Quality Star (the core)
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Execution quality. Brightness reflects how many of the user's recent sessions
|
|
9
|
+
* were executed well. Reads PRE-SCORED sessions from context (the orchestrator
|
|
10
|
+
* scores once); this star never calls the scoring engine itself.
|
|
11
|
+
*
|
|
12
|
+
* Session quality = simple average of its exercises' quality scores (0-100).
|
|
13
|
+
* Star brightness = count-based: of the last N sessions, how many cleared the
|
|
14
|
+
* quality bar, scaled toward a target count. Count-based (not
|
|
15
|
+
* a consecutive streak) so a single off day never zeroes the
|
|
16
|
+
* star — inviting, not punishing.
|
|
17
|
+
*
|
|
18
|
+
* Thresholds come from QUALITY_THRESHOLDS[level].
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.qualityStar = void 0;
|
|
22
|
+
const levelThresholds_1 = require("../levelThresholds");
|
|
23
|
+
// --- Session quality --------------------------------------------------------
|
|
24
|
+
/**
|
|
25
|
+
* Average score across a scored session's scorable exercises. Returns null if
|
|
26
|
+
* the session has no scorable exercises (all missing/unscorable).
|
|
27
|
+
*/
|
|
28
|
+
function sessionQuality(session) {
|
|
29
|
+
const scores = [];
|
|
30
|
+
for (const ex of session.exercises) {
|
|
31
|
+
if (typeof ex.score === "number")
|
|
32
|
+
scores.push(ex.score);
|
|
33
|
+
}
|
|
34
|
+
if (scores.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
return scores.reduce((s, v) => s + v, 0) / scores.length;
|
|
37
|
+
}
|
|
38
|
+
// --- Detail builders --------------------------------------------------------
|
|
39
|
+
function clearingMeasure(clearing, target) {
|
|
40
|
+
return {
|
|
41
|
+
value: clearing,
|
|
42
|
+
unit: "sessions",
|
|
43
|
+
display: {
|
|
44
|
+
translationKey: "constellation.quality.measure.clearing",
|
|
45
|
+
params: [Math.min(clearing, target), target],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function buildGap(clearing, considered, target, bar) {
|
|
50
|
+
if (considered === 0) {
|
|
51
|
+
return { translationKey: "constellation.quality.gap.noData" };
|
|
52
|
+
}
|
|
53
|
+
if (clearing >= target) {
|
|
54
|
+
return { translationKey: "constellation.quality.gap.full" };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
translationKey: "constellation.quality.gap.moreQuality",
|
|
58
|
+
params: [target - clearing, bar],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// --- Evaluator --------------------------------------------------------------
|
|
62
|
+
function evaluateQuality(ctx) {
|
|
63
|
+
const { bar, windowSessions, targetClearing } = levelThresholds_1.QUALITY_THRESHOLDS[ctx.level];
|
|
64
|
+
// Most recent N scored sessions (descending by date). Future sessions were
|
|
65
|
+
// already excluded by the orchestrator when scoring.
|
|
66
|
+
const recent = [...ctx.scoredSessions]
|
|
67
|
+
.sort((a, b) => b.date - a.date)
|
|
68
|
+
.slice(0, windowSessions);
|
|
69
|
+
let clearing = 0;
|
|
70
|
+
let considered = 0;
|
|
71
|
+
for (const session of recent) {
|
|
72
|
+
const q = sessionQuality(session);
|
|
73
|
+
if (q === null)
|
|
74
|
+
continue; // unscorable session counts neither way
|
|
75
|
+
considered++;
|
|
76
|
+
if (q >= bar)
|
|
77
|
+
clearing++;
|
|
78
|
+
}
|
|
79
|
+
const brightness = Math.min(100, (clearing / targetClearing) * 100);
|
|
80
|
+
return {
|
|
81
|
+
brightness,
|
|
82
|
+
detail: {
|
|
83
|
+
currentState: clearingMeasure(clearing, targetClearing),
|
|
84
|
+
target: clearingMeasure(targetClearing, targetClearing),
|
|
85
|
+
gap: buildGap(clearing, considered, targetClearing, bar),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// --- Definition -------------------------------------------------------------
|
|
90
|
+
exports.qualityStar = {
|
|
91
|
+
id: "quality",
|
|
92
|
+
displayName: { translationKey: "constellation.quality.name" },
|
|
93
|
+
color: "#00c4b4", // teal — precision, cool focus
|
|
94
|
+
figurePosition: "core",
|
|
95
|
+
objective: { translationKey: "constellation.quality.objective" },
|
|
96
|
+
rationale: { translationKey: "constellation.quality.rationale" },
|
|
97
|
+
evaluate: evaluateQuality,
|
|
98
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// shared/src/utils/constellation/stars/quality.test.ts
|
|
4
|
+
const vitest_1 = require("vitest");
|
|
5
|
+
const starFoundation_1 = require("../starFoundation");
|
|
6
|
+
const quality_1 = require("../stars/quality");
|
|
7
|
+
const NOW = Date.UTC(2026, 5, 1);
|
|
8
|
+
const day = (d) => NOW - d * 86400000;
|
|
9
|
+
/** A scored session whose single exercise has the given score. */
|
|
10
|
+
const scored = (d, score) => ({
|
|
11
|
+
date: day(d),
|
|
12
|
+
exercises: [{ exerciseId: "x", score, muscleScores: {} }],
|
|
13
|
+
});
|
|
14
|
+
const ctx = (scoredSessions, level = 1) => ({
|
|
15
|
+
sessions: [],
|
|
16
|
+
scoredSessions,
|
|
17
|
+
exerciseCatalog: {},
|
|
18
|
+
user: {},
|
|
19
|
+
now: NOW,
|
|
20
|
+
level,
|
|
21
|
+
});
|
|
22
|
+
(0, vitest_1.describe)("quality — count-based brightness (L1 bar 70, target 5)", () => {
|
|
23
|
+
(0, vitest_1.it)("5 of 5 sessions clear → full", () => {
|
|
24
|
+
const s = [
|
|
25
|
+
scored(1, 80),
|
|
26
|
+
scored(3, 75),
|
|
27
|
+
scored(5, 90),
|
|
28
|
+
scored(7, 72),
|
|
29
|
+
scored(9, 85),
|
|
30
|
+
];
|
|
31
|
+
const r = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s));
|
|
32
|
+
(0, vitest_1.expect)(r.brightness).toBe(100);
|
|
33
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.quality.gap.full");
|
|
34
|
+
});
|
|
35
|
+
(0, vitest_1.it)("3 of 5 clear → 60%", () => {
|
|
36
|
+
const s = [
|
|
37
|
+
scored(1, 80),
|
|
38
|
+
scored(3, 60),
|
|
39
|
+
scored(5, 90),
|
|
40
|
+
scored(7, 50),
|
|
41
|
+
scored(9, 75),
|
|
42
|
+
];
|
|
43
|
+
const r = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s));
|
|
44
|
+
(0, vitest_1.expect)(r.brightness).toBeCloseTo(60, 0);
|
|
45
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.quality.gap.moreQuality");
|
|
46
|
+
});
|
|
47
|
+
(0, vitest_1.it)("none clear → 0", () => {
|
|
48
|
+
const s = [scored(1, 50), scored(3, 40)];
|
|
49
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s)).brightness).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
(0, vitest_1.describe)("quality — the bar boundary (>= 70 clears)", () => {
|
|
53
|
+
(0, vitest_1.it)("exactly 70 clears", () => {
|
|
54
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx([scored(1, 70)])).currentState.value).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
(0, vitest_1.it)("69 does not clear", () => {
|
|
57
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx([scored(1, 69)])).currentState.value).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
(0, vitest_1.describe)("quality — session averaging & unscorable handling", () => {
|
|
61
|
+
(0, vitest_1.it)("session score is the average of its exercises", () => {
|
|
62
|
+
const s = [
|
|
63
|
+
{
|
|
64
|
+
date: day(1),
|
|
65
|
+
exercises: [
|
|
66
|
+
{ exerciseId: "a", score: 60, muscleScores: {} },
|
|
67
|
+
{ exerciseId: "b", score: 80, muscleScores: {} }, // avg 70 → clears
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s)).currentState.value).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
(0, vitest_1.it)("sessions with no scorable exercises are skipped (neither clear nor fail)", () => {
|
|
74
|
+
const s = [scored(1, null), scored(3, 80)];
|
|
75
|
+
const r = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s));
|
|
76
|
+
(0, vitest_1.expect)(r.currentState.value).toBe(1); // only the scorable one counts
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)("no data at all → noData gap", () => {
|
|
79
|
+
const r = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx([scored(1, null)]));
|
|
80
|
+
(0, vitest_1.expect)(r.gap.translationKey).toBe("constellation.quality.gap.noData");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
(0, vitest_1.describe)("quality — window & level scaling", () => {
|
|
84
|
+
(0, vitest_1.it)("only the most recent N sessions are considered", () => {
|
|
85
|
+
// L1 window 5: 5 recent good + 3 older bad → still full (bad ones out of window)
|
|
86
|
+
const s = [
|
|
87
|
+
scored(1, 90),
|
|
88
|
+
scored(3, 90),
|
|
89
|
+
scored(5, 90),
|
|
90
|
+
scored(7, 90),
|
|
91
|
+
scored(9, 90),
|
|
92
|
+
scored(20, 10),
|
|
93
|
+
scored(22, 10),
|
|
94
|
+
scored(24, 10),
|
|
95
|
+
];
|
|
96
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s)).brightness).toBe(100);
|
|
97
|
+
});
|
|
98
|
+
(0, vitest_1.it)("L2 demands a higher bar (78) so 75s stop clearing", () => {
|
|
99
|
+
const s = [
|
|
100
|
+
scored(1, 75),
|
|
101
|
+
scored(3, 75),
|
|
102
|
+
scored(5, 75),
|
|
103
|
+
scored(7, 75),
|
|
104
|
+
scored(9, 75),
|
|
105
|
+
scored(11, 75),
|
|
106
|
+
scored(13, 75),
|
|
107
|
+
scored(15, 75),
|
|
108
|
+
];
|
|
109
|
+
const l1 = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s, 1));
|
|
110
|
+
const l2 = (0, starFoundation_1.resolveStar)(quality_1.qualityStar, ctx(s, 2));
|
|
111
|
+
(0, vitest_1.expect)(l1.brightness).toBeGreaterThan(l2.brightness);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Recovery Star (the crown)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Recovery intelligence: does the user let trained muscles fully recover, not
|
|
7
|
+
* just grind through fatigue? Brightness reflects how many RECOVERY CYCLES the
|
|
8
|
+
* user achieved in the trailing window.
|
|
9
|
+
*
|
|
10
|
+
* ZONE-BASED (not all-or-nothing). The body is split into three independent
|
|
11
|
+
* recovery zones — upper, lower, core. A split-routine lifter (e.g. Push/Pull/
|
|
12
|
+
* Legs) almost never has the WHOLE body fresh at once, yet is recovering
|
|
13
|
+
* intelligently zone by zone; an all-muscles-at-once rule would wrongly score
|
|
14
|
+
* them near zero. So we count cycles PER ZONE.
|
|
15
|
+
*
|
|
16
|
+
* A zone "cycle" = a transition where that zone goes fatigued -> recovered
|
|
17
|
+
* (a multi-day rest counts once). Crucially, a zone only earns cycles if the
|
|
18
|
+
* user actually TRAINS it in the window — an untrained zone is "always fresh"
|
|
19
|
+
* and must not hand out free recovery credit. Total cycles = sum across the
|
|
20
|
+
* zones the user trains, capped at the level target.
|
|
21
|
+
*
|
|
22
|
+
* Reads PRE-SCORED sessions from context (orchestrator scores once); builds the
|
|
23
|
+
* enriched history the fatigue model consumes, then replays the model across
|
|
24
|
+
* each day of the window. This star never calls the scoring engine itself.
|
|
25
|
+
*
|
|
26
|
+
* Thresholds come from RECOVERY_THRESHOLDS[level].
|
|
27
|
+
*/
|
|
28
|
+
import type { TStarDefinition } from "../types";
|
|
29
|
+
export declare const recoveryStar: TStarDefinition;
|