@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,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// shared/src/utils/constellation/evaluateConstellation.test.ts
|
|
4
|
+
const evaluateConstellation_1 = require("./evaluateConstellation");
|
|
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) => ({ type: "weight-reps", kg, reps, isDone: true, isStrictMode: false });
|
|
9
|
+
const catalog = {
|
|
10
|
+
bench: {
|
|
11
|
+
exerciseId: "bench",
|
|
12
|
+
name: "Bench",
|
|
13
|
+
primaryMuscles: ["pectoralis-major"],
|
|
14
|
+
secondaryMuscles: [],
|
|
15
|
+
difficultyLevel: 3,
|
|
16
|
+
movementPattern: "push_horizontal",
|
|
17
|
+
status: "active",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const baseInput = (over = {}) => ({
|
|
21
|
+
sessions: [
|
|
22
|
+
{
|
|
23
|
+
date: day(2),
|
|
24
|
+
exercises: [{ exerciseId: "bench", records: [wr("60", "5")] }],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
exerciseCatalog: catalog,
|
|
28
|
+
user: { weightKg: 80, gender: "male" },
|
|
29
|
+
now: NOW,
|
|
30
|
+
...over,
|
|
31
|
+
});
|
|
32
|
+
(0, vitest_1.describe)("orchestrator — now guard", () => {
|
|
33
|
+
(0, vitest_1.it)("throws on NaN now", () => {
|
|
34
|
+
(0, vitest_1.expect)(() => (0, evaluateConstellation_1.evaluateConstellation)(baseInput({ now: NaN }))).toThrow();
|
|
35
|
+
});
|
|
36
|
+
(0, vitest_1.it)("throws on Infinity now", () => {
|
|
37
|
+
(0, vitest_1.expect)(() => (0, evaluateConstellation_1.evaluateConstellation)(baseInput({ now: Infinity }))).toThrow();
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.it)("accepts a finite now", () => {
|
|
40
|
+
(0, vitest_1.expect)((0, evaluateConstellation_1.evaluateConstellation)(baseInput()).stars).toHaveLength(6);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
(0, vitest_1.describe)("orchestrator — shape & aggregate", () => {
|
|
44
|
+
(0, vitest_1.it)("returns six stars and a figure", () => {
|
|
45
|
+
const s = (0, evaluateConstellation_1.evaluateConstellation)(baseInput());
|
|
46
|
+
(0, vitest_1.expect)(s.stars).toHaveLength(6);
|
|
47
|
+
(0, vitest_1.expect)(s.figure.auraIntensity).toBeGreaterThanOrEqual(0);
|
|
48
|
+
(0, vitest_1.expect)(s.figure.auraIntensity).toBeLessThanOrEqual(100);
|
|
49
|
+
});
|
|
50
|
+
(0, vitest_1.it)("auraIntensity equals the mean star brightness", () => {
|
|
51
|
+
const s = (0, evaluateConstellation_1.evaluateConstellation)(baseInput());
|
|
52
|
+
const mean = s.stars.reduce((a, x) => a + x.brightness, 0) / s.stars.length;
|
|
53
|
+
(0, vitest_1.expect)(s.figure.auraIntensity).toBeCloseTo(Math.round(mean * 10) / 10, 1);
|
|
54
|
+
});
|
|
55
|
+
(0, vitest_1.it)("fullStarCount matches tier-3 stars", () => {
|
|
56
|
+
const s = (0, evaluateConstellation_1.evaluateConstellation)(baseInput());
|
|
57
|
+
const full = s.stars.filter((x) => x.tier === 3).length;
|
|
58
|
+
(0, vitest_1.expect)(s.figure.fullStarCount).toBe(full);
|
|
59
|
+
});
|
|
60
|
+
(0, vitest_1.it)("evaluatedAt is the ISO of now", () => {
|
|
61
|
+
const s = (0, evaluateConstellation_1.evaluateConstellation)(baseInput());
|
|
62
|
+
(0, vitest_1.expect)(s.evaluatedAt).toBe(new Date(NOW).toISOString());
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.describe)("orchestrator — level clamping", () => {
|
|
66
|
+
(0, vitest_1.it)("undefined level → 1", () => (0, vitest_1.expect)((0, evaluateConstellation_1.evaluateConstellation)(baseInput()).currentLevel).toBe(1));
|
|
67
|
+
(0, vitest_1.it)("out-of-range level → clamped to 3", () => (0, vitest_1.expect)((0, evaluateConstellation_1.evaluateConstellation)(baseInput({ currentLevel: 99 })).currentLevel).toBe(3));
|
|
68
|
+
(0, vitest_1.it)("level 2 echoed", () => (0, vitest_1.expect)((0, evaluateConstellation_1.evaluateConstellation)(baseInput({ currentLevel: 2 })).currentLevel).toBe(2));
|
|
69
|
+
});
|
|
70
|
+
(0, vitest_1.describe)("orchestrator — future sessions excluded from scoring", () => {
|
|
71
|
+
(0, vitest_1.it)("a session dated after now does not light push", () => {
|
|
72
|
+
const future = (0, evaluateConstellation_1.evaluateConstellation)(baseInput({
|
|
73
|
+
sessions: [
|
|
74
|
+
{
|
|
75
|
+
date: NOW + 86400000,
|
|
76
|
+
exercises: [{ exerciseId: "bench", records: [wr("100", "5")] }],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
}));
|
|
80
|
+
(0, vitest_1.expect)(future.stars.find((s) => s.id === "push").brightness).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
(0, vitest_1.it)("a present session does light push (control)", () => {
|
|
83
|
+
(0, vitest_1.expect)((0, evaluateConstellation_1.evaluateConstellation)(baseInput()).stars.find((s) => s.id === "push")
|
|
84
|
+
.brightness).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
(0, vitest_1.describe)("orchestrator — empty input is safe", () => {
|
|
88
|
+
(0, vitest_1.it)("no sessions → all stars resolve, none throw", () => {
|
|
89
|
+
const s = (0, evaluateConstellation_1.evaluateConstellation)(baseInput({ sessions: [] }));
|
|
90
|
+
(0, vitest_1.expect)(s.stars).toHaveLength(6);
|
|
91
|
+
(0, vitest_1.expect)(s.stars.every((x) => Number.isFinite(x.brightness))).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Public API
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* The constellation module's surface. Consumers (the Lambda, tests) import from
|
|
7
|
+
* here, not from internal files.
|
|
8
|
+
*
|
|
9
|
+
* import { evaluateConstellation } from ".../constellation";
|
|
10
|
+
* const state = evaluateConstellation({ sessions, exerciseCatalog, user, now });
|
|
11
|
+
*
|
|
12
|
+
* Everything else (individual stars, helpers, the normalised-load math) is an
|
|
13
|
+
* implementation detail and intentionally not re-exported.
|
|
14
|
+
*/
|
|
15
|
+
export { evaluateConstellation } from "./evaluateConstellation";
|
|
16
|
+
export type { TConstellationInput, TConstellationState, TConstellationFigure, TResolvedStar, TStarId, TStarTier, TFigurePosition, TStarMeasure, TLocalizedText, TSessionRecord, } from "./types";
|
|
17
|
+
export { TIER_THRESHOLDS, deriveTier } from "./starFoundation";
|
|
18
|
+
export { MAX_LEVEL as CONSTELLATION_USER_MAX_LEVEL, clampLevel as constellationClampLevel, type TLevel as TConstellationUserLevel, } from "./levelThresholds";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/index.ts — Public API
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Public API
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* The constellation module's surface. Consumers (the Lambda, tests) import from
|
|
9
|
+
* here, not from internal files.
|
|
10
|
+
*
|
|
11
|
+
* import { evaluateConstellation } from ".../constellation";
|
|
12
|
+
* const state = evaluateConstellation({ sessions, exerciseCatalog, user, now });
|
|
13
|
+
*
|
|
14
|
+
* Everything else (individual stars, helpers, the normalised-load math) is an
|
|
15
|
+
* implementation detail and intentionally not re-exported.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.constellationClampLevel = exports.CONSTELLATION_USER_MAX_LEVEL = exports.deriveTier = exports.TIER_THRESHOLDS = exports.evaluateConstellation = void 0;
|
|
19
|
+
// The single entry point.
|
|
20
|
+
var evaluateConstellation_1 = require("./evaluateConstellation");
|
|
21
|
+
Object.defineProperty(exports, "evaluateConstellation", { enumerable: true, get: function () { return evaluateConstellation_1.evaluateConstellation; } });
|
|
22
|
+
// Tier thresholds are exported so the app can label tiers consistently with
|
|
23
|
+
// the server (dormant / faint / rising / full) without hard-coding the cutoffs.
|
|
24
|
+
var starFoundation_1 = require("./starFoundation");
|
|
25
|
+
Object.defineProperty(exports, "TIER_THRESHOLDS", { enumerable: true, get: function () { return starFoundation_1.TIER_THRESHOLDS; } });
|
|
26
|
+
Object.defineProperty(exports, "deriveTier", { enumerable: true, get: function () { return starFoundation_1.deriveTier; } });
|
|
27
|
+
var levelThresholds_1 = require("./levelThresholds");
|
|
28
|
+
Object.defineProperty(exports, "CONSTELLATION_USER_MAX_LEVEL", { enumerable: true, get: function () { return levelThresholds_1.MAX_LEVEL; } });
|
|
29
|
+
Object.defineProperty(exports, "constellationClampLevel", { enumerable: true, get: function () { return levelThresholds_1.clampLevel; } });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Level Threshold Ladder
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for what "full" means at each level, for every
|
|
7
|
+
* star. Stars read their row by the user's current level; nothing about a
|
|
8
|
+
* threshold is hard-coded inside star logic.
|
|
9
|
+
*
|
|
10
|
+
* Design intent (coach's calibration):
|
|
11
|
+
* Level 1 = committed beginner (reachable in ~2-3 months of honest work)
|
|
12
|
+
* Level 2 = solid intermediate (~6-12 months)
|
|
13
|
+
* Level 3 = advanced (multi-year, genuinely strong)
|
|
14
|
+
*
|
|
15
|
+
* Strength thresholds are estimated-1RM as a ratio of bodyweight, by sex.
|
|
16
|
+
* `unmentioned` gender is resolved to `male` (the higher bar) elsewhere, so a
|
|
17
|
+
* star is never too-easily lit for an unspecified profile.
|
|
18
|
+
*
|
|
19
|
+
* Calibration note (v1.1): these are first-pass values to be tuned against real
|
|
20
|
+
* brightness distributions post-launch. L1 pull was lowered from 0.65 -> 0.50
|
|
21
|
+
* because 0.65x BW pull excluded the large beginner segment that cannot yet do
|
|
22
|
+
* bodyweight pull-ups; L1 push eased 0.75 -> 0.60 so the first milestone lands
|
|
23
|
+
* in ~6-8 weeks rather than feeling intermediate. Lower body held at 1.0x — the
|
|
24
|
+
* bodyweight-load path lets home users reach it via pistol squats/lunges.
|
|
25
|
+
*
|
|
26
|
+
* Adding a level later = adding a key to each record. Star code does not change.
|
|
27
|
+
* The future goal layer can multiply these per-goal without touching this file.
|
|
28
|
+
*/
|
|
29
|
+
export type TLevel = 1 | 2 | 3;
|
|
30
|
+
export declare const MAX_LEVEL: TLevel;
|
|
31
|
+
export interface ISexThreshold {
|
|
32
|
+
male: number;
|
|
33
|
+
female: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Bodyweight-ratio thresholds for a full strength star, per level.
|
|
37
|
+
*
|
|
38
|
+
* Push (bench/press family):
|
|
39
|
+
* L1 0.60 — a committed beginner pressing meaningful load (~6-8 weeks)
|
|
40
|
+
* L2 1.00 — pressing ~bodyweight (classic intermediate bench)
|
|
41
|
+
* L3 1.40 — advanced pressing strength
|
|
42
|
+
* Pull (row/pulldown/pull-up family):
|
|
43
|
+
* L1 0.50 — reachable beginner pulling (rows / pulldowns), pre-pull-up
|
|
44
|
+
* L2 0.85 — strong rows / clean bodyweight pull-ups
|
|
45
|
+
* L3 1.20 — advanced (heavy weighted pull-ups)
|
|
46
|
+
* Lower (squat/hinge/lunge family):
|
|
47
|
+
* L1 1.00 — beginner squatting ~bodyweight (reachable via bodyweight work too)
|
|
48
|
+
* L2 1.50 — intermediate
|
|
49
|
+
* L3 2.00 — advanced (a 2x bodyweight squat is a real milestone)
|
|
50
|
+
*/
|
|
51
|
+
export declare const STRENGTH_THRESHOLDS: Record<"push" | "pull" | "lowerBody", Record<TLevel, ISexThreshold>>;
|
|
52
|
+
export interface IConsistencyThreshold {
|
|
53
|
+
/** Training days required within the window for full brightness. */
|
|
54
|
+
requiredSessions: number;
|
|
55
|
+
/** Trailing window length in days. */
|
|
56
|
+
windowDays: number;
|
|
57
|
+
/** Maximum idle gap (days) allowed before the no-gap condition breaks. */
|
|
58
|
+
maxGapDays: number;
|
|
59
|
+
/** Trailing idle days at which the "cooling" warning begins. */
|
|
60
|
+
warningAtIdleDays: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* L1 ~2x/week habit; L2 ~3x/week; L3 ~4x/week. Window stays 28 days so it always
|
|
64
|
+
* reads "this month's habit".
|
|
65
|
+
*
|
|
66
|
+
* L3 maxGap relaxed 3 -> 4 (v1.1): a 3-day cap punished legitimate splits and
|
|
67
|
+
* planned deloads. 4 still keeps higher-level gap discipline tighter than L1/L2
|
|
68
|
+
* without penalising smart programming.
|
|
69
|
+
*/
|
|
70
|
+
export declare const CONSISTENCY_THRESHOLDS: Record<TLevel, IConsistencyThreshold>;
|
|
71
|
+
export interface IQualityThreshold {
|
|
72
|
+
/** A session "clears" at or above this score (0-100). */
|
|
73
|
+
bar: number;
|
|
74
|
+
/** Number of most-recent sessions considered. */
|
|
75
|
+
windowSessions: number;
|
|
76
|
+
/** How many of the window must clear for full brightness. */
|
|
77
|
+
targetClearing: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* L1 forgiving bar (70) over a short window; higher levels demand both a higher
|
|
81
|
+
* bar and a longer clean streak.
|
|
82
|
+
*/
|
|
83
|
+
export declare const QUALITY_THRESHOLDS: Record<TLevel, IQualityThreshold>;
|
|
84
|
+
export interface IRecoveryThreshold {
|
|
85
|
+
/** Recovery cycles needed for full brightness (summed across trained zones). */
|
|
86
|
+
targetCycles: number;
|
|
87
|
+
/** Trailing window length in days. */
|
|
88
|
+
windowDays: number;
|
|
89
|
+
/** A muscle is "recovered" below this fatigue value (0-100). */
|
|
90
|
+
recoveredBelow: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cycles are counted PER trained zone (upper/lower/core), so targets reflect
|
|
94
|
+
* summed zone cycles. Recovery is meaningful only alongside training volume — a
|
|
95
|
+
* no-train user earns nothing (untrained zones give no credit).
|
|
96
|
+
*/
|
|
97
|
+
export declare const RECOVERY_THRESHOLDS: Record<TLevel, IRecoveryThreshold>;
|
|
98
|
+
/** Clamp any incoming level into the valid 1..MAX_LEVEL range. */
|
|
99
|
+
export declare function clampLevel(level: number | undefined): TLevel;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/levelThresholds.ts — Per-level threshold ladder
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Level Threshold Ladder
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* The single source of truth for what "full" means at each level, for every
|
|
9
|
+
* star. Stars read their row by the user's current level; nothing about a
|
|
10
|
+
* threshold is hard-coded inside star logic.
|
|
11
|
+
*
|
|
12
|
+
* Design intent (coach's calibration):
|
|
13
|
+
* Level 1 = committed beginner (reachable in ~2-3 months of honest work)
|
|
14
|
+
* Level 2 = solid intermediate (~6-12 months)
|
|
15
|
+
* Level 3 = advanced (multi-year, genuinely strong)
|
|
16
|
+
*
|
|
17
|
+
* Strength thresholds are estimated-1RM as a ratio of bodyweight, by sex.
|
|
18
|
+
* `unmentioned` gender is resolved to `male` (the higher bar) elsewhere, so a
|
|
19
|
+
* star is never too-easily lit for an unspecified profile.
|
|
20
|
+
*
|
|
21
|
+
* Calibration note (v1.1): these are first-pass values to be tuned against real
|
|
22
|
+
* brightness distributions post-launch. L1 pull was lowered from 0.65 -> 0.50
|
|
23
|
+
* because 0.65x BW pull excluded the large beginner segment that cannot yet do
|
|
24
|
+
* bodyweight pull-ups; L1 push eased 0.75 -> 0.60 so the first milestone lands
|
|
25
|
+
* in ~6-8 weeks rather than feeling intermediate. Lower body held at 1.0x — the
|
|
26
|
+
* bodyweight-load path lets home users reach it via pistol squats/lunges.
|
|
27
|
+
*
|
|
28
|
+
* Adding a level later = adding a key to each record. Star code does not change.
|
|
29
|
+
* The future goal layer can multiply these per-goal without touching this file.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.RECOVERY_THRESHOLDS = exports.QUALITY_THRESHOLDS = exports.CONSISTENCY_THRESHOLDS = exports.STRENGTH_THRESHOLDS = exports.MAX_LEVEL = void 0;
|
|
33
|
+
exports.clampLevel = clampLevel;
|
|
34
|
+
exports.MAX_LEVEL = 3;
|
|
35
|
+
/**
|
|
36
|
+
* Bodyweight-ratio thresholds for a full strength star, per level.
|
|
37
|
+
*
|
|
38
|
+
* Push (bench/press family):
|
|
39
|
+
* L1 0.60 — a committed beginner pressing meaningful load (~6-8 weeks)
|
|
40
|
+
* L2 1.00 — pressing ~bodyweight (classic intermediate bench)
|
|
41
|
+
* L3 1.40 — advanced pressing strength
|
|
42
|
+
* Pull (row/pulldown/pull-up family):
|
|
43
|
+
* L1 0.50 — reachable beginner pulling (rows / pulldowns), pre-pull-up
|
|
44
|
+
* L2 0.85 — strong rows / clean bodyweight pull-ups
|
|
45
|
+
* L3 1.20 — advanced (heavy weighted pull-ups)
|
|
46
|
+
* Lower (squat/hinge/lunge family):
|
|
47
|
+
* L1 1.00 — beginner squatting ~bodyweight (reachable via bodyweight work too)
|
|
48
|
+
* L2 1.50 — intermediate
|
|
49
|
+
* L3 2.00 — advanced (a 2x bodyweight squat is a real milestone)
|
|
50
|
+
*/
|
|
51
|
+
exports.STRENGTH_THRESHOLDS = {
|
|
52
|
+
push: {
|
|
53
|
+
1: { male: 0.6, female: 0.45 },
|
|
54
|
+
2: { male: 1.0, female: 0.7 },
|
|
55
|
+
3: { male: 1.4, female: 0.95 },
|
|
56
|
+
},
|
|
57
|
+
pull: {
|
|
58
|
+
1: { male: 0.5, female: 0.38 },
|
|
59
|
+
2: { male: 0.85, female: 0.6 },
|
|
60
|
+
3: { male: 1.2, female: 0.85 },
|
|
61
|
+
},
|
|
62
|
+
lowerBody: {
|
|
63
|
+
1: { male: 1.0, female: 0.75 },
|
|
64
|
+
2: { male: 1.5, female: 1.1 },
|
|
65
|
+
3: { male: 2.0, female: 1.5 },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* L1 ~2x/week habit; L2 ~3x/week; L3 ~4x/week. Window stays 28 days so it always
|
|
70
|
+
* reads "this month's habit".
|
|
71
|
+
*
|
|
72
|
+
* L3 maxGap relaxed 3 -> 4 (v1.1): a 3-day cap punished legitimate splits and
|
|
73
|
+
* planned deloads. 4 still keeps higher-level gap discipline tighter than L1/L2
|
|
74
|
+
* without penalising smart programming.
|
|
75
|
+
*/
|
|
76
|
+
exports.CONSISTENCY_THRESHOLDS = {
|
|
77
|
+
1: {
|
|
78
|
+
requiredSessions: 8,
|
|
79
|
+
windowDays: 28,
|
|
80
|
+
maxGapDays: 5,
|
|
81
|
+
warningAtIdleDays: 4,
|
|
82
|
+
},
|
|
83
|
+
2: {
|
|
84
|
+
requiredSessions: 12,
|
|
85
|
+
windowDays: 28,
|
|
86
|
+
maxGapDays: 4,
|
|
87
|
+
warningAtIdleDays: 3,
|
|
88
|
+
},
|
|
89
|
+
3: {
|
|
90
|
+
requiredSessions: 16,
|
|
91
|
+
windowDays: 28,
|
|
92
|
+
maxGapDays: 4,
|
|
93
|
+
warningAtIdleDays: 3,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* L1 forgiving bar (70) over a short window; higher levels demand both a higher
|
|
98
|
+
* bar and a longer clean streak.
|
|
99
|
+
*/
|
|
100
|
+
exports.QUALITY_THRESHOLDS = {
|
|
101
|
+
1: { bar: 70, windowSessions: 5, targetClearing: 5 },
|
|
102
|
+
2: { bar: 78, windowSessions: 8, targetClearing: 7 },
|
|
103
|
+
3: { bar: 85, windowSessions: 10, targetClearing: 9 },
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Cycles are counted PER trained zone (upper/lower/core), so targets reflect
|
|
107
|
+
* summed zone cycles. Recovery is meaningful only alongside training volume — a
|
|
108
|
+
* no-train user earns nothing (untrained zones give no credit).
|
|
109
|
+
*/
|
|
110
|
+
exports.RECOVERY_THRESHOLDS = {
|
|
111
|
+
1: { targetCycles: 3, windowDays: 56, recoveredBelow: 25 },
|
|
112
|
+
2: { targetCycles: 5, windowDays: 56, recoveredBelow: 25 },
|
|
113
|
+
3: { targetCycles: 7, windowDays: 56, recoveredBelow: 25 },
|
|
114
|
+
};
|
|
115
|
+
// --- Level helper -----------------------------------------------------------
|
|
116
|
+
/** Clamp any incoming level into the valid 1..MAX_LEVEL range. */
|
|
117
|
+
function clampLevel(level) {
|
|
118
|
+
if (!level || level < 1)
|
|
119
|
+
return 1;
|
|
120
|
+
if (level > exports.MAX_LEVEL)
|
|
121
|
+
return exports.MAX_LEVEL;
|
|
122
|
+
return Math.floor(level);
|
|
123
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Star Foundation
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Shared pure glue. Types come from ./types. Behaviour only:
|
|
7
|
+
* - deriveTier: continuous brightness -> discrete band (0-3)
|
|
8
|
+
* - resolveStar: definition + context -> render-ready TResolvedStar
|
|
9
|
+
*/
|
|
10
|
+
import type { TStarTier, TStarDefinition, TStarContext, TResolvedStar } from "./types";
|
|
11
|
+
/** Brightness lower-bounds per tier. Tunable in one place. */
|
|
12
|
+
export declare const TIER_THRESHOLDS: {
|
|
13
|
+
readonly faint: 1;
|
|
14
|
+
readonly rising: 34;
|
|
15
|
+
readonly full: 100;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* 0 dormant (<1) - 1 faint (1-33) - 2 rising (34-99) - 3 full (>=100).
|
|
19
|
+
*/
|
|
20
|
+
export declare function deriveTier(brightness: number): TStarTier;
|
|
21
|
+
/**
|
|
22
|
+
* Run a star definition through its evaluator into the render-ready shape.
|
|
23
|
+
* Brightness clamped 0-100 here so evaluators don't each have to.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveStar(def: TStarDefinition, ctx: TStarContext): TResolvedStar;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// utils/constellation/starFoundation.ts — Star Foundation
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX CONSTELLATION — Star Foundation
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* Shared pure glue. Types come from ./types. Behaviour only:
|
|
9
|
+
* - deriveTier: continuous brightness -> discrete band (0-3)
|
|
10
|
+
* - resolveStar: definition + context -> render-ready TResolvedStar
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.TIER_THRESHOLDS = void 0;
|
|
14
|
+
exports.deriveTier = deriveTier;
|
|
15
|
+
exports.resolveStar = resolveStar;
|
|
16
|
+
/** Brightness lower-bounds per tier. Tunable in one place. */
|
|
17
|
+
exports.TIER_THRESHOLDS = {
|
|
18
|
+
faint: 1,
|
|
19
|
+
rising: 34,
|
|
20
|
+
full: 100,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* 0 dormant (<1) - 1 faint (1-33) - 2 rising (34-99) - 3 full (>=100).
|
|
24
|
+
*/
|
|
25
|
+
function deriveTier(brightness) {
|
|
26
|
+
if (brightness >= exports.TIER_THRESHOLDS.full)
|
|
27
|
+
return 3;
|
|
28
|
+
if (brightness >= exports.TIER_THRESHOLDS.rising)
|
|
29
|
+
return 2;
|
|
30
|
+
if (brightness >= exports.TIER_THRESHOLDS.faint)
|
|
31
|
+
return 1;
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Run a star definition through its evaluator into the render-ready shape.
|
|
36
|
+
* Brightness clamped 0-100 here so evaluators don't each have to.
|
|
37
|
+
*/
|
|
38
|
+
function resolveStar(def, ctx) {
|
|
39
|
+
const { brightness, detail } = def.evaluate(ctx);
|
|
40
|
+
const clamped = Math.max(0, Math.min(100, brightness));
|
|
41
|
+
return {
|
|
42
|
+
id: def.id,
|
|
43
|
+
displayName: def.displayName,
|
|
44
|
+
color: def.color,
|
|
45
|
+
figurePosition: def.figurePosition,
|
|
46
|
+
brightness: clamped,
|
|
47
|
+
tier: deriveTier(clamped),
|
|
48
|
+
objective: def.objective,
|
|
49
|
+
rationale: def.rationale,
|
|
50
|
+
currentState: detail.currentState,
|
|
51
|
+
target: detail.target,
|
|
52
|
+
gap: detail.gap,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// shared/src/utils/constellation/starFoundation.test.ts
|
|
4
|
+
const starFoundation_1 = require("./starFoundation");
|
|
5
|
+
const levelThresholds_1 = require("./levelThresholds");
|
|
6
|
+
const vitest_1 = require("vitest");
|
|
7
|
+
(0, vitest_1.describe)("deriveTier — brightness band boundaries", () => {
|
|
8
|
+
(0, vitest_1.it)("0 brightness → tier 0 (dormant)", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(0)).toBe(0));
|
|
9
|
+
(0, vitest_1.it)("just below faint (0.9) → tier 0", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(0.9)).toBe(0));
|
|
10
|
+
(0, vitest_1.it)("exactly faint (1) → tier 1", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(1)).toBe(1));
|
|
11
|
+
(0, vitest_1.it)("mid faint (33) → tier 1", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(33)).toBe(1));
|
|
12
|
+
(0, vitest_1.it)("exactly rising (34) → tier 2", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(34)).toBe(2));
|
|
13
|
+
(0, vitest_1.it)("just below full (99) → tier 2", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(99)).toBe(2));
|
|
14
|
+
(0, vitest_1.it)("exactly full (100) → tier 3", () => (0, vitest_1.expect)((0, starFoundation_1.deriveTier)(100)).toBe(3));
|
|
15
|
+
(0, vitest_1.it)("thresholds are the documented values", () => {
|
|
16
|
+
(0, vitest_1.expect)(starFoundation_1.TIER_THRESHOLDS.faint).toBe(1);
|
|
17
|
+
(0, vitest_1.expect)(starFoundation_1.TIER_THRESHOLDS.rising).toBe(34);
|
|
18
|
+
(0, vitest_1.expect)(starFoundation_1.TIER_THRESHOLDS.full).toBe(100);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
(0, vitest_1.describe)("resolveStar — clamps brightness and packages output", () => {
|
|
22
|
+
const ctx = {};
|
|
23
|
+
const make = (brightness) => ({
|
|
24
|
+
id: "push",
|
|
25
|
+
displayName: { translationKey: "constellation.push.name" },
|
|
26
|
+
color: "#fff",
|
|
27
|
+
figurePosition: "leftArm",
|
|
28
|
+
objective: { translationKey: "constellation.push.objective" },
|
|
29
|
+
rationale: { translationKey: "constellation.push.rationale" },
|
|
30
|
+
evaluate: () => ({
|
|
31
|
+
brightness,
|
|
32
|
+
detail: {
|
|
33
|
+
currentState: {
|
|
34
|
+
value: 0,
|
|
35
|
+
unit: "bw_ratio",
|
|
36
|
+
display: { translationKey: "x" },
|
|
37
|
+
},
|
|
38
|
+
target: {
|
|
39
|
+
value: 1,
|
|
40
|
+
unit: "bw_ratio",
|
|
41
|
+
display: { translationKey: "x" },
|
|
42
|
+
},
|
|
43
|
+
gap: { translationKey: "g" },
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
(0, vitest_1.it)("clamps brightness > 100 down to 100", () => {
|
|
48
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(make(150), ctx).brightness).toBe(100);
|
|
49
|
+
});
|
|
50
|
+
(0, vitest_1.it)("clamps negative brightness up to 0", () => {
|
|
51
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(make(-20), ctx).brightness).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
(0, vitest_1.it)("derives tier from the clamped value", () => {
|
|
54
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(make(150), ctx).tier).toBe(3);
|
|
55
|
+
(0, vitest_1.expect)((0, starFoundation_1.resolveStar)(make(-20), ctx).tier).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.it)("passes through identity + i18n fields unchanged", () => {
|
|
58
|
+
const r = (0, starFoundation_1.resolveStar)(make(50), ctx);
|
|
59
|
+
(0, vitest_1.expect)(r.id).toBe("push");
|
|
60
|
+
(0, vitest_1.expect)(r.figurePosition).toBe("leftArm");
|
|
61
|
+
(0, vitest_1.expect)(r.displayName.translationKey).toBe("constellation.push.name");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.describe)("clampLevel — valid level range", () => {
|
|
65
|
+
(0, vitest_1.it)("undefined → 1", () => (0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(undefined)).toBe(1));
|
|
66
|
+
(0, vitest_1.it)("0 → 1", () => (0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(0)).toBe(1));
|
|
67
|
+
(0, vitest_1.it)("negative → 1", () => (0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(-3)).toBe(1));
|
|
68
|
+
(0, vitest_1.it)("1/2/3 pass through", () => {
|
|
69
|
+
(0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(1)).toBe(1);
|
|
70
|
+
(0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(2)).toBe(2);
|
|
71
|
+
(0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(3)).toBe(3);
|
|
72
|
+
});
|
|
73
|
+
(0, vitest_1.it)("above max → 3", () => (0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(99)).toBe(3));
|
|
74
|
+
(0, vitest_1.it)("fractional floored", () => (0, vitest_1.expect)((0, levelThresholds_1.clampLevel)(2.7)).toBe(2));
|
|
75
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX CONSTELLATION — Consistency Star (the heart)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* The live "showing up" star. Brightness reflects the user's CURRENT training
|
|
7
|
+
* habit, recomputed every call from recent session dates. The one star meant to
|
|
8
|
+
* be maintained, not achieved — it brightens with a steady habit and dims when
|
|
9
|
+
* the user goes quiet.
|
|
10
|
+
*
|
|
11
|
+
* Full brightness requires BOTH:
|
|
12
|
+
* - requiredSessions training days within the trailing window, AND
|
|
13
|
+
* - the trailing idle gap (last session -> now) within maxGapDays.
|
|
14
|
+
*
|
|
15
|
+
* Brightness is graded below full so progress is always visible:
|
|
16
|
+
* - countComponent — progress toward the required session count
|
|
17
|
+
* - gapComponent — a live penalty as the TRAILING gap exceeds maxGapDays
|
|
18
|
+
*
|
|
19
|
+
* Note: brightness uses the TRAILING gap, not interior gaps. An old gap the user
|
|
20
|
+
* has since recovered from should not permanently dim a live habit signal.
|
|
21
|
+
* Interior gaps still inform the gap MESSAGE for context.
|
|
22
|
+
*
|
|
23
|
+
* Counting rule: a session counts as a training day only if it has >=1 completed
|
|
24
|
+
* set. Multiple sessions on one calendar day = one training day.
|
|
25
|
+
*
|
|
26
|
+
* Thresholds come from CONSISTENCY_THRESHOLDS[level].
|
|
27
|
+
*/
|
|
28
|
+
import type { TStarDefinition } from "../types";
|
|
29
|
+
export declare const consistencyStar: TStarDefinition;
|