@dgpholdings/greatoak-shared 1.2.91 → 1.2.93

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.
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveGoalContext = resolveGoalContext;
4
+ exports.recommendNextIntent = recommendNextIntent;
5
+ const goalJourney_1 = require("./goalJourney");
6
+ function resolveGoalContext(goalProfile, recentSessions, now = new Date()) {
7
+ var _a, _b;
8
+ if (!(goalProfile === null || goalProfile === void 0 ? void 0 : goalProfile.activeGoal) || !(goalProfile === null || goalProfile === void 0 ? void 0 : goalProfile.goalStartDate))
9
+ return null;
10
+ const goalStart = new Date(goalProfile.goalStartDate);
11
+ const msPerDay = 86400000;
12
+ const daysSince = (now.getTime() - goalStart.getTime()) / msPerDay;
13
+ const currentWeek = Math.max(1, Math.ceil(daysSince / 7));
14
+ // Sessions this calendar week (Monday 00:00 UTC)
15
+ const monday = new Date(now);
16
+ monday.setUTCDate(now.getUTCDate() - ((now.getUTCDay() + 6) % 7));
17
+ monday.setUTCHours(0, 0, 0, 0);
18
+ const last4WeeksCutoff = new Date(now.getTime() - 28 * msPerDay);
19
+ const goalStartTime = goalStart.getTime();
20
+ const sessionsSinceGoalStart = recentSessions.filter((s) => new Date(s.createdAt).getTime() >= goalStartTime);
21
+ const totalSince = sessionsSinceGoalStart.length;
22
+ const sessionsIn4Weeks = sessionsSinceGoalStart.filter((s) => new Date(s.createdAt) >= last4WeeksCutoff).length;
23
+ const sessionsThisWeek = sessionsSinceGoalStart.filter((s) => new Date(s.createdAt) >= monday).length;
24
+ const currentPhase = (0, goalJourney_1.derivePhase)(currentWeek, totalSince, sessionsIn4Weeks);
25
+ const weeklyStructure = [...goalJourney_1.GOAL_SESSION_INTENTS[goalProfile.activeGoal]];
26
+ // Edge case: no sessions yet — use first non-rest slot as entry point
27
+ if (recentSessions.length === 0) {
28
+ const firstActive = (_a = weeklyStructure.find((s) => s !== "rest")) !== null && _a !== void 0 ? _a : "full-body";
29
+ return {
30
+ goal: goalProfile.activeGoal,
31
+ daysPerWeek: goalProfile.daysPerWeek,
32
+ currentPhase,
33
+ currentWeek,
34
+ weeklyStructure,
35
+ lastSessionDate: goalProfile.goalStartDate,
36
+ lastSessionIntent: firstActive,
37
+ sessionsCompletedThisWeek: 0,
38
+ totalSessionsSinceGoalStart: 0,
39
+ sessionsInLast4Weeks: 0,
40
+ };
41
+ }
42
+ const lastSession = recentSessions[0]; // sorted desc by createdAt
43
+ return {
44
+ goal: goalProfile.activeGoal,
45
+ daysPerWeek: goalProfile.daysPerWeek,
46
+ currentPhase,
47
+ currentWeek,
48
+ weeklyStructure,
49
+ lastSessionDate: lastSession.createdAt,
50
+ lastSessionIntent: (_b = lastSession.intentId) !== null && _b !== void 0 ? _b : "unstructured",
51
+ sessionsCompletedThisWeek: sessionsThisWeek,
52
+ totalSessionsSinceGoalStart: totalSince,
53
+ sessionsInLast4Weeks: sessionsIn4Weeks,
54
+ };
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Muscle → intent cluster
58
+ // Keyed by keyof typeof EBodyParts — TypeScript enforces valid muscle keys
59
+ // ---------------------------------------------------------------------------
60
+ const MUSCLE_TO_INTENTS = {
61
+ "serratus-anterior": ["push", "upper"],
62
+ "tricep-brachii-long": ["push", "upper"],
63
+ "tricep-brachii-lateral": ["push", "upper"],
64
+ "latissimus-dorsi": ["pull", "upper"],
65
+ rhomboids: ["pull", "upper"],
66
+ trapezius: ["pull", "upper"],
67
+ "bicep-long-outer": ["pull", "upper"],
68
+ "bicep-short-inner": ["pull", "upper"],
69
+ "deltoids-posterior": ["pull", "upper"],
70
+ "fore-arm-inner": ["pull", "upper"],
71
+ "fore-arm-outer": ["pull", "upper"],
72
+ "lower-back": ["core"],
73
+ quadriceps: ["legs", "lower", "cardio", "fatburn"],
74
+ hamstrings: ["legs", "lower", "cardio", "fatburn"],
75
+ "glutes-maximus": ["legs", "lower", "cardio", "fatburn"], // burpees, jump squats
76
+ "glutes-medius": ["legs", "lower", "cardio", "fatburn"],
77
+ "medius-upper": ["legs", "lower", "cardio", "fatburn"],
78
+ "maximus-lower": ["legs", "lower", "cardio", "fatburn"],
79
+ adductors: ["legs", "lower", "cardio", "fatburn"],
80
+ "calf-inner": ["legs", "lower", "cardio"],
81
+ "calf-outer": ["legs", "lower", "cardio"],
82
+ calves: ["legs", "lower", "cardio"],
83
+ "pectoralis-major": ["push", "upper", "fatburn"],
84
+ "deltoids-anterior": ["push", "upper", "fatburn"],
85
+ "deltoids-middle": ["push", "upper", "fatburn"],
86
+ "abs-upper": ["core", "fatburn"],
87
+ "abs-lower": ["core", "fatburn"],
88
+ obliques: ["core", "fatburn"],
89
+ };
90
+ const MUSCLE_SCORED_INTENTS = [
91
+ "push",
92
+ "pull",
93
+ "legs",
94
+ "lower",
95
+ "upper",
96
+ "core",
97
+ "cardio",
98
+ "fatburn",
99
+ ];
100
+ // ---------------------------------------------------------------------------
101
+ // Goal → intent readiness bias
102
+ // Modest nudge — never overrides the structural decision in steps 1-3
103
+ // ---------------------------------------------------------------------------
104
+ const GOAL_INTENT_BIAS = {
105
+ strength: { push: 10, pull: 10, legs: 8 },
106
+ hypertrophy: { push: 10, pull: 10, legs: 10 },
107
+ fat_loss: { cardio: 20, fatburn: 18, "full-body": 10 },
108
+ endurance: { cardio: 25, lower: 8 },
109
+ mobility: { mobility: 30, core: 8 },
110
+ rehabilitation: { mobility: 25, core: 12 },
111
+ sport_performance: { legs: 12, core: 10, cardio: 8 },
112
+ general_fitness: { "full-body": 15, cardio: 8 },
113
+ core_strength: { core: 25, pull: 6, legs: 6 },
114
+ };
115
+ const INTENT_RATIONALE = {
116
+ push: "intentRationale.push",
117
+ pull: "intentRationale.pull",
118
+ legs: "intentRationale.legs",
119
+ lower: "intentRationale.lower",
120
+ upper: "intentRationale.upper",
121
+ core: "intentRationale.core",
122
+ cardio: "intentRationale.cardio",
123
+ strength: "intentRationale.strength",
124
+ fatburn: "intentRationale.fatburn",
125
+ mobility: "intentRationale.mobility",
126
+ "full-body": "intentRationale.fullBody",
127
+ };
128
+ const GLOBAL_RECOVERY_THRESHOLD = 72;
129
+ const CLUSTER_FATIGUE_HARD_CAP = 65;
130
+ const RETURNING_USER_GAP_DAYS = 7;
131
+ const GAP_BRIDGE_THRESHOLD_DAYS = 3;
132
+ const HIGH_CONFIDENCE_GAP = 15;
133
+ function daysSince(isoDate, now = new Date()) {
134
+ return (now.getTime() - new Date(isoDate).getTime()) / 86400000;
135
+ }
136
+ function globalAvgFatigue(fatigue) {
137
+ const values = Object.values(fatigue).map((v) => v.fatigue);
138
+ if (values.length === 0)
139
+ return 0;
140
+ return values.reduce((a, b) => a + b, 0) / values.length;
141
+ }
142
+ function clusterAvgFatigue(intent, fatigue) {
143
+ const muscles = Object.entries(MUSCLE_TO_INTENTS)
144
+ .filter(([, intents]) => intents.includes(intent))
145
+ .map(([muscle]) => muscle);
146
+ if (muscles.length === 0)
147
+ return 0;
148
+ const scores = muscles.map((m) => { var _a, _b; return (_b = (_a = fatigue[m]) === null || _a === void 0 ? void 0 : _a.fatigue) !== null && _b !== void 0 ? _b : 0; });
149
+ return scores.reduce((a, b) => a + b, 0) / scores.length;
150
+ }
151
+ function buildReadinessScores(fatigue, goalContext) {
152
+ var _a, _b, _c, _d, _e, _f;
153
+ const bias = (_a = GOAL_INTENT_BIAS[goalJourney_1.GOAL_TO_AI_FITNESS_GOAL[goalContext.goal]]) !== null && _a !== void 0 ? _a : {};
154
+ const scores = {};
155
+ for (const intent of MUSCLE_SCORED_INTENTS) {
156
+ scores[intent] =
157
+ 100 - clusterAvgFatigue(intent, fatigue) + ((_b = bias[intent]) !== null && _b !== void 0 ? _b : 0);
158
+ }
159
+ scores["full-body"] = 50 + ((_c = bias["full-body"]) !== null && _c !== void 0 ? _c : 0);
160
+ scores["strength"] = 50 + ((_d = bias["strength"]) !== null && _d !== void 0 ? _d : 0);
161
+ scores["fatburn"] = 50 + ((_e = bias["fatburn"]) !== null && _e !== void 0 ? _e : 0);
162
+ scores["mobility"] = 60 + ((_f = bias["mobility"]) !== null && _f !== void 0 ? _f : 0);
163
+ return scores;
164
+ }
165
+ function topByScore(scores) {
166
+ return Object.entries(scores).sort(([, a], [, b]) => b - a)[0][0];
167
+ }
168
+ function computeConfidence(scores) {
169
+ var _a;
170
+ const sorted = Object.values(scores).sort((a, b) => b - a);
171
+ const gap = sorted[0] - ((_a = sorted[1]) !== null && _a !== void 0 ? _a : 0);
172
+ if (gap >= HIGH_CONFIDENCE_GAP)
173
+ return Math.min(0.95, 0.7 + gap / 100);
174
+ return Math.max(0.4, 0.5 + gap / 100);
175
+ }
176
+ function validateOrFallback(desired, scores, fatigue) {
177
+ if (desired === "rest") {
178
+ return { intent: topByScore(scores), wasOverridden: true };
179
+ }
180
+ const desiredRec = desired;
181
+ if (MUSCLE_SCORED_INTENTS.includes(desiredRec)) {
182
+ if (clusterAvgFatigue(desiredRec, fatigue) <= CLUSTER_FATIGUE_HARD_CAP) {
183
+ return { intent: desiredRec, wasOverridden: false };
184
+ }
185
+ return { intent: topByScore(scores), wasOverridden: true };
186
+ }
187
+ return { intent: desiredRec, wasOverridden: false };
188
+ }
189
+ /**
190
+ * Find the next non-rest slot in weeklyStructure after afterIntent.
191
+ * Circular — wraps around. Used by both gap bridge and structure follow.
192
+ * Returns "full-body" if structure is entirely rest days (defensive).
193
+ */
194
+ function nextNonRestSlot(weeklyStructure, afterIntent) {
195
+ const lastIdx = weeklyStructure.findIndex((s) => s === afterIntent && s !== "rest");
196
+ const start = lastIdx === -1 ? 0 : (lastIdx + 1) % weeklyStructure.length;
197
+ for (let i = 0; i < weeklyStructure.length; i++) {
198
+ const slot = weeklyStructure[(start + i) % weeklyStructure.length];
199
+ if (slot !== "rest")
200
+ return slot;
201
+ }
202
+ return "full-body";
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Public API
206
+ // ---------------------------------------------------------------------------
207
+ /**
208
+ * Recommend the next session intent.
209
+ *
210
+ * Decision pipeline (strict priority):
211
+ * 1. GLOBAL RECOVERY avgFatigue > 72 → "mobility"
212
+ * 2. RETURNING USER gap >= 7 days → first non-rest slot
213
+ * 3. GAP BRIDGE gap 3-7 days → most overdue slot
214
+ * 4. STRUCTURE FOLLOW gap < 3 days → next slot (circular)
215
+ * 5. MUSCLE OVERRIDE cluster fatigue > 65 → highest-readiness alt
216
+ *
217
+ * Output is always a valid TWorkoutIntent.
218
+ * Never outputs: "rest", "arms", "back", "unstructured".
219
+ * rationale is an i18n key — resolve via t() on the FE.
220
+ */
221
+ function recommendNextIntent(fatigue, goalContext) {
222
+ var _a;
223
+ const { weeklyStructure, lastSessionDate, lastSessionIntent } = goalContext;
224
+ const gap = daysSince(lastSessionDate);
225
+ const avgFat = globalAvgFatigue(fatigue);
226
+ const scores = buildReadinessScores(fatigue, goalContext);
227
+ // ── 1. GLOBAL RECOVERY ───────────────────────────────────────────────────
228
+ if (avgFat >= GLOBAL_RECOVERY_THRESHOLD) {
229
+ return {
230
+ intent: "mobility",
231
+ confidence: 0.92,
232
+ rationale: INTENT_RATIONALE["mobility"],
233
+ decisionPath: "global_recovery",
234
+ intentScores: scores,
235
+ };
236
+ }
237
+ // ── 2. RETURNING USER ────────────────────────────────────────────────────
238
+ if (gap >= RETURNING_USER_GAP_DAYS) {
239
+ const reEntry = (_a = weeklyStructure.find((s) => s !== "rest")) !== null && _a !== void 0 ? _a : "full-body";
240
+ const { intent } = validateOrFallback(reEntry, scores, fatigue);
241
+ return {
242
+ intent,
243
+ confidence: 0.82,
244
+ rationale: INTENT_RATIONALE[intent],
245
+ decisionPath: "returning_user",
246
+ intentScores: scores,
247
+ };
248
+ }
249
+ // ── 3. GAP BRIDGE ────────────────────────────────────────────────────────
250
+ if (gap >= GAP_BRIDGE_THRESHOLD_DAYS) {
251
+ const missed = nextNonRestSlot(weeklyStructure, lastSessionIntent);
252
+ const { intent, wasOverridden } = validateOrFallback(missed, scores, fatigue);
253
+ return {
254
+ intent,
255
+ confidence: computeConfidence(scores),
256
+ rationale: INTENT_RATIONALE[intent],
257
+ decisionPath: wasOverridden ? "muscle_override" : "gap_bridge",
258
+ intentScores: scores,
259
+ };
260
+ }
261
+ // ── 4. STRUCTURE FOLLOW + 5. MUSCLE OVERRIDE ─────────────────────────────
262
+ const next = nextNonRestSlot(weeklyStructure, lastSessionIntent);
263
+ const { intent, wasOverridden } = validateOrFallback(next, scores, fatigue);
264
+ return {
265
+ intent,
266
+ confidence: computeConfidence(scores),
267
+ rationale: INTENT_RATIONALE[intent],
268
+ decisionPath: wasOverridden ? "muscle_override" : "structure_follow",
269
+ intentScores: scores,
270
+ };
271
+ }
@@ -16,3 +16,4 @@ export type * from "./TApiAiExerciseAnalysis";
16
16
  export type * from "./TApiAdminAiHelp";
17
17
  export type * from "./TApiAiQuickStartWorkout";
18
18
  export type * from "./TApiClientConstellation";
19
+ export * from "./goalFeature";
@@ -1,2 +1,18 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
2
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Features
18
+ __exportStar(require("./goalFeature"), exports);
@@ -1,18 +1,4 @@
1
- /**
2
- * ============================================================================
3
- * FITFRIX EXERCISE SCORING SYSTEM — Helpers
4
- * ============================================================================
5
- *
6
- * Pure utility functions with no business logic. These handle:
7
- * - Safe parsing of string values from TRecord
8
- * - Duration parsing ("MM:SS" → seconds)
9
- * - Clamping and validation
10
- * - Effort fraction conversion (RPE/RIR → 0–1)
11
- * - User context extraction with fallbacks
12
- *
13
- * All functions are deterministic and side-effect free.
14
- */
15
- import { TGender } from "../../types";
1
+ import { TActivityLevel, TGender } from "../../types";
16
2
  import type { IUserContext } from "./types";
17
3
  /**
18
4
  * Safely parse a string to a float.
@@ -112,12 +98,11 @@ export declare function getEffortFraction(record: {
112
98
  * @param user - The raw TUserMetric object
113
99
  */
114
100
  export declare function extractUserContext(user: {
115
- weightKg?: number;
116
- heightCm?: number;
117
- gender?: TGender;
118
- dob?: Date | string;
119
- fitnessLevel?: import("../../types").TActivityLevel;
120
- fitnessGoals?: import("../../constants/AiExerciseVocabulary").TAiFitnessGoal[];
101
+ weightKg: number;
102
+ heightCm: number;
103
+ gender: TGender;
104
+ dob: Date | string;
105
+ fitnessLevel: TActivityLevel;
121
106
  bodyFatPercentage?: number;
122
107
  }): IUserContext;
123
108
  /**
@@ -199,7 +199,7 @@ function getEffortFraction(record) {
199
199
  * @param user - The raw TUserMetric object
200
200
  */
201
201
  function extractUserContext(user) {
202
- var _a, _b, _c;
202
+ var _a, _b;
203
203
  const weightKg = user.weightKg && user.weightKg > 20 && user.weightKg < 300
204
204
  ? user.weightKg
205
205
  : constants_1.DEFAULT_USER_WEIGHT_KG;
@@ -213,8 +213,7 @@ function extractUserContext(user) {
213
213
  gender: user.gender || "unmentioned",
214
214
  age,
215
215
  fitnessLevel: (_a = user.fitnessLevel) !== null && _a !== void 0 ? _a : "moderately-active",
216
- fitnessGoals: (_b = user.fitnessGoals) !== null && _b !== void 0 ? _b : ["general_fitness"],
217
- bodyFatPercentage: (_c = user.bodyFatPercentage) !== null && _c !== void 0 ? _c : 20,
216
+ bodyFatPercentage: (_b = user.bodyFatPercentage) !== null && _b !== void 0 ? _b : 20,
218
217
  };
219
218
  }
220
219
  /**
@@ -39,7 +39,6 @@ const calculateExerciseScoreV2 = (param) => {
39
39
  const { exercise, record, user, historicalContext } = param;
40
40
  const userContext = (0, helpers_1.extractUserContext)(user);
41
41
  const parsedSets = (0, parseRecords_1.parseRecords)(record, exercise.timingGuardrails, historicalContext);
42
- // ── Pillar 2: Muscle Fatigue ─────────────────────────────────────────────
43
42
  const muscleScores = (0, calculateMuscleFatigue_1.calculateMuscleFatigue)(parsedSets, {
44
43
  primaryMuscles: exercise.primaryMuscles,
45
44
  secondaryMuscles: exercise.secondaryMuscles,
@@ -51,11 +50,9 @@ const calculateExerciseScoreV2 = (param) => {
51
50
  scoringSpecialHandling: exercise.scoringSpecialHandling,
52
51
  isUnilateral: exercise.isUnilateral,
53
52
  }, userContext, exercise.timingGuardrails, historicalContext);
54
- // ── Pillar 3: Quality Score ──────────────────────────────────────────────
55
53
  // userContext is intentionally not passed — quality scoring is goal-agnostic.
56
54
  // Goal-specific logic lives in the gate system and quick plan generator.
57
55
  const { score, qualityBreakdown, restDisciplineActive } = (0, calculateQualityScore_1.calculateQualityScore)(parsedSets, record, exercise.timingGuardrails, historicalContext);
58
- // ── Result (Option A flat shape) ─────────────────────────────────────────
59
56
  return {
60
57
  score,
61
58
  qualityBreakdown,
@@ -8,7 +8,6 @@
8
8
  * scoring pillars (Calories, Muscle Fatigue, Quality).
9
9
  */
10
10
  import { TActivityLevel, TGender, TRecord } from "../../types";
11
- import type { TAiFitnessGoal } from "../../constants/AiExerciseVocabulary";
12
11
  /**
13
12
  * A single scored session with real per-muscle fatigue values.
14
13
  * muscleScores = {} for pre-P3-1 sessions (backward compat fallback).
@@ -141,6 +140,5 @@ export interface IUserContext {
141
140
  gender: TGender;
142
141
  age: number;
143
142
  fitnessLevel: TActivityLevel;
144
- fitnessGoals: TAiFitnessGoal[];
145
143
  bodyFatPercentage: number;
146
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.91",
3
+ "version": "1.2.93",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",