@dgpholdings/greatoak-shared 1.2.94 → 1.2.96

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.
@@ -1,10 +1,11 @@
1
+ import { TActivityLevel } from "../types";
1
2
  import { TAiFitnessGoal, TWorkoutIntent } from "./AiExerciseVocabulary";
2
3
  export declare const USER_FITNESS_GOALS: readonly ["lose_weight", "build_muscle", "lean_and_toned", "glutes_and_legs", "get_stronger", "boost_endurance", "athletic_performance", "general_fitness", "posture_and_back", "stress_relief", "rehabilitation", "core_strength", "mobility_flexibility"];
3
4
  export type TUserFitnessGoal = (typeof USER_FITNESS_GOALS)[number];
4
5
  export type TSessionIntent = TWorkoutIntent | "rest";
5
6
  export declare const GOAL_SESSION_INTENTS: Record<TUserFitnessGoal, TWeeklyIntentTemplate>;
6
7
  export declare const GOAL_TO_AI_FITNESS_GOAL: Record<TUserFitnessGoal, TAiFitnessGoal>;
7
- export declare const TRAINING_PHASES: readonly [1, 2, 3, 4];
8
+ export declare const TRAINING_PHASES: readonly ["foundation", "momentum", "push", "peak"];
8
9
  export type TTrainingPhase = (typeof TRAINING_PHASES)[number];
9
10
  export type TPhaseDisplay = {
10
11
  /** i18n key for the phase name — e.g. "goalPhase.foundation.name" */
@@ -50,7 +51,7 @@ export type TIntentDisplay = {
50
51
  */
51
52
  emoji: string;
52
53
  };
53
- export declare const INTENT_DISPLAY: Partial<Record<TSessionIntent, TIntentDisplay>>;
54
+ export declare const INTENT_DISPLAY: Record<Exclude<TSessionIntent, "arms" | "back" | "unstructured">, TIntentDisplay>;
54
55
  export type TGoalDisplay = {
55
56
  /** i18n key for the goal name — e.g. "userGoal.loseWeight.name" */
56
57
  nameKey: string;
@@ -82,17 +83,18 @@ export type TGoalCategoryDisplay = {
82
83
  labelEn: string;
83
84
  };
84
85
  export declare const GOAL_CATEGORY_DISPLAY: Record<TGoalCategory, TGoalCategoryDisplay>;
86
+ export declare const PHASE_ENTRY_CREDIT: Record<TActivityLevel, number>;
87
+ export declare const RECENCY_CREDIT: Record<TActivityLevel, number>;
85
88
  /**
86
- * Derive the current training phase from the week number since goal start.
87
- * Week is 1-indexed (week 1 = first week on the program).
89
+ * Derive training phase from time + volume + recency.
90
+ * All three must pass week count alone is NOT sufficient.
91
+ * A user doing 12 sessions over one year has not built a training base.
88
92
  *
89
- * @example
90
- * derivePhase(1) // → 1 (Foundation)
91
- * derivePhase(3) // → 2 (Momentum)
92
- * derivePhase(7) // → 3 (Push)
93
- * derivePhase(10) // → 4 (Peak)
93
+ * On goal start day (no sessions yet), pass:
94
+ * totalSessions = PHASE_ENTRY_CREDIT[user.fitnessLevel]
95
+ * sessionsInLast4Weeks = RECENCY_CREDIT[user.fitnessLevel]
94
96
  */
95
- export declare function derivePhase(weekNumber: number): TTrainingPhase;
97
+ export declare function derivePhase(weekNumber: number, totalSessions: number, sessionsInLast4Weeks: number): TTrainingPhase;
96
98
  export declare const DAYS_PER_WEEK_OPTIONS: readonly [2, 3, 4, 5];
97
99
  export type TDaysPerWeek = (typeof DAYS_PER_WEEK_OPTIONS)[number];
98
100
  export type TDurationHint = "short" | "medium" | "long";
@@ -105,3 +107,5 @@ export type TWeeklyIntentTemplate = [
105
107
  TSessionIntent,
106
108
  TSessionIntent
107
109
  ];
110
+ export type TGoalPhaseHints = Record<TTrainingPhase, string>;
111
+ export declare const GOAL_PHASE_HINTS: Record<TUserFitnessGoal, TGoalPhaseHints>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DAYS_PER_WEEK_OPTIONS = exports.GOAL_CATEGORY_DISPLAY = exports.GOAL_DISPLAY = exports.INTENT_DISPLAY = exports.PHASE_DISPLAY = exports.TRAINING_PHASES = exports.GOAL_TO_AI_FITNESS_GOAL = exports.GOAL_SESSION_INTENTS = exports.USER_FITNESS_GOALS = void 0;
3
+ exports.GOAL_PHASE_HINTS = exports.DAYS_PER_WEEK_OPTIONS = exports.RECENCY_CREDIT = exports.PHASE_ENTRY_CREDIT = exports.GOAL_CATEGORY_DISPLAY = exports.GOAL_DISPLAY = exports.INTENT_DISPLAY = exports.PHASE_DISPLAY = exports.TRAINING_PHASES = exports.GOAL_TO_AI_FITNESS_GOAL = exports.GOAL_SESSION_INTENTS = exports.USER_FITNESS_GOALS = void 0;
4
4
  exports.derivePhase = derivePhase;
5
5
  exports.USER_FITNESS_GOALS = [
6
6
  // Appearance
@@ -126,9 +126,15 @@ exports.GOAL_TO_AI_FITNESS_GOAL = {
126
126
  core_strength: "core_strength",
127
127
  mobility_flexibility: "mobility",
128
128
  };
129
- exports.TRAINING_PHASES = [1, 2, 3, 4];
129
+ // Foundation Momentum Push → Peak
130
+ exports.TRAINING_PHASES = [
131
+ "foundation",
132
+ "momentum",
133
+ "push",
134
+ "peak",
135
+ ];
130
136
  exports.PHASE_DISPLAY = {
131
- 1: {
137
+ foundation: {
132
138
  nameKey: "goalPhase.foundation.name",
133
139
  nameEn: "Foundation",
134
140
  subtitleKey: "goalPhase.foundation.subtitle",
@@ -140,7 +146,7 @@ exports.PHASE_DISPLAY = {
140
146
  weekRange: [1, 2],
141
147
  intensityPercent: 40,
142
148
  },
143
- 2: {
149
+ momentum: {
144
150
  nameKey: "goalPhase.momentum.name",
145
151
  nameEn: "Momentum",
146
152
  subtitleKey: "goalPhase.momentum.subtitle",
@@ -152,7 +158,7 @@ exports.PHASE_DISPLAY = {
152
158
  weekRange: [3, 5],
153
159
  intensityPercent: 60,
154
160
  },
155
- 3: {
161
+ push: {
156
162
  nameKey: "goalPhase.push.name",
157
163
  nameEn: "Push",
158
164
  subtitleKey: "goalPhase.push.subtitle",
@@ -164,7 +170,7 @@ exports.PHASE_DISPLAY = {
164
170
  weekRange: [6, 8],
165
171
  intensityPercent: 80,
166
172
  },
167
- 4: {
173
+ peak: {
168
174
  nameKey: "goalPhase.peak.name",
169
175
  nameEn: "Peak",
170
176
  subtitleKey: "goalPhase.peak.subtitle",
@@ -421,23 +427,169 @@ exports.GOAL_CATEGORY_DISPLAY = {
421
427
  // derivePhase — pure helper
422
428
  // Shared between Lambda and client — lives here so neither duplicates it
423
429
  // ---------------------------------------------------------------------------
430
+ exports.PHASE_ENTRY_CREDIT = {
431
+ sedentary: 0,
432
+ "lightly-active": 0,
433
+ "moderately-active": 6, // enters Momentum directly
434
+ "very-active": 12, // enters Push directly
435
+ };
436
+ exports.RECENCY_CREDIT = {
437
+ sedentary: 0,
438
+ "lightly-active": 0,
439
+ "moderately-active": 4,
440
+ "very-active": 8,
441
+ };
424
442
  /**
425
- * Derive the current training phase from the week number since goal start.
426
- * Week is 1-indexed (week 1 = first week on the program).
443
+ * Derive training phase from time + volume + recency.
444
+ * All three must pass week count alone is NOT sufficient.
445
+ * A user doing 12 sessions over one year has not built a training base.
427
446
  *
428
- * @example
429
- * derivePhase(1) // → 1 (Foundation)
430
- * derivePhase(3) // → 2 (Momentum)
431
- * derivePhase(7) // → 3 (Push)
432
- * derivePhase(10) // → 4 (Peak)
447
+ * On goal start day (no sessions yet), pass:
448
+ * totalSessions = PHASE_ENTRY_CREDIT[user.fitnessLevel]
449
+ * sessionsInLast4Weeks = RECENCY_CREDIT[user.fitnessLevel]
433
450
  */
434
- function derivePhase(weekNumber) {
435
- if (weekNumber <= 2)
436
- return 1;
437
- if (weekNumber <= 5)
438
- return 2;
439
- if (weekNumber <= 8)
440
- return 3;
441
- return 4;
451
+ function derivePhase(weekNumber, totalSessions, sessionsInLast4Weeks) {
452
+ // Foundation needs minimum 2 weeks, 6 sessions, 1 session/week pace
453
+ if (weekNumber < 2 || totalSessions < 6 || sessionsInLast4Weeks < 4)
454
+ return "foundation";
455
+ // Momentum → needs 6 weeks, 12 sessions, ~1.5 sessions/week pace
456
+ if (weekNumber < 6 || totalSessions < 12 || sessionsInLast4Weeks < 6)
457
+ return "momentum";
458
+ // Push → needs 9 weeks, 20 sessions, ~2 sessions/week pace
459
+ if (weekNumber < 9 || totalSessions < 20 || sessionsInLast4Weeks < 8)
460
+ return "push";
461
+ return "peak";
442
462
  }
443
463
  exports.DAYS_PER_WEEK_OPTIONS = [2, 3, 4, 5];
464
+ exports.GOAL_PHASE_HINTS = {
465
+ lose_weight: {
466
+ // "Build the cardio habit"
467
+ foundation: "goalPhaseHint.loseWeight.foundation.buildCardioHabit",
468
+ // "Intensity picks up"
469
+ momentum: "goalPhaseHint.loseWeight.momentum.intensityPicksUp",
470
+ // "Fat burning at its best"
471
+ push: "goalPhaseHint.loseWeight.push.fatBurningAtBest",
472
+ // "Final push to your goal"
473
+ peak: "goalPhaseHint.loseWeight.peak.finalPushToGoal",
474
+ },
475
+ build_muscle: {
476
+ // "Learn the fundamental movements"
477
+ foundation: "goalPhaseHint.buildMuscle.foundation.learnFundamentalMovements",
478
+ // "Volume increases, splits begin"
479
+ momentum: "goalPhaseHint.buildMuscle.momentum.volumeIncreaseSplitsBegin",
480
+ // "Real muscle growth happens here"
481
+ push: "goalPhaseHint.buildMuscle.push.realMuscleGrowthHappensHere",
482
+ // "Push to your peak output"
483
+ peak: "goalPhaseHint.buildMuscle.peak.pushToPeakOutput",
484
+ },
485
+ lean_and_toned: {
486
+ // "Start moving consistently"
487
+ foundation: "goalPhaseHint.leanAndToned.foundation.startMovingConsistently",
488
+ // "Splits begin, cardio added"
489
+ momentum: "goalPhaseHint.leanAndToned.momentum.splitsBeginCardioAdded",
490
+ // "Definition starts to show"
491
+ push: "goalPhaseHint.leanAndToned.push.definitionStartsToShow",
492
+ // "Your best shape yet"
493
+ peak: "goalPhaseHint.leanAndToned.peak.yourBestShapeYet",
494
+ },
495
+ glutes_and_legs: {
496
+ // "Learn hip-hinge and squat patterns"
497
+ foundation: "goalPhaseHint.glutesAndLegs.foundation.learnHipHingeSquatPatterns",
498
+ // "Volume builds in lower body"
499
+ momentum: "goalPhaseHint.glutesAndLegs.momentum.volumeBuildsLowerBody",
500
+ // "Legs up to 3 times per week"
501
+ push: "goalPhaseHint.glutesAndLegs.push.legsThreeTimesPerWeek",
502
+ // "Maximum lower body output"
503
+ peak: "goalPhaseHint.glutesAndLegs.peak.maximumLowerBodyOutput",
504
+ },
505
+ get_stronger: {
506
+ // "Master the big compound lifts"
507
+ foundation: "goalPhaseHint.getStronger.foundation.masterBigCompoundLifts",
508
+ // "Load increases week on week"
509
+ momentum: "goalPhaseHint.getStronger.momentum.loadIncreasesWeekOnWeek",
510
+ // "Heavy compound focus"
511
+ push: "goalPhaseHint.getStronger.push.heavyCompoundFocus",
512
+ // "Strength peak — test your limits"
513
+ peak: "goalPhaseHint.getStronger.peak.strengthPeakTestYourLimits",
514
+ },
515
+ boost_endurance: {
516
+ // "Build base aerobic fitness"
517
+ foundation: "goalPhaseHint.boostEndurance.foundation.buildBaseAerobicFitness",
518
+ // "Duration and distance increase"
519
+ momentum: "goalPhaseHint.boostEndurance.momentum.durationDistanceIncrease",
520
+ // "High volume cardio sessions"
521
+ push: "goalPhaseHint.boostEndurance.push.highVolumeCardioSessions",
522
+ // "Peak cardiovascular output"
523
+ peak: "goalPhaseHint.boostEndurance.peak.peakCardiovascularOutput",
524
+ },
525
+ athletic_performance: {
526
+ // "Movement quality comes first"
527
+ foundation: "goalPhaseHint.athleticPerformance.foundation.movementQualityFirst",
528
+ // "Power and speed sessions begin"
529
+ momentum: "goalPhaseHint.athleticPerformance.momentum.powerSpeedSessionsBegin",
530
+ // "Sport specific intensity"
531
+ push: "goalPhaseHint.athleticPerformance.push.sportSpecificIntensity",
532
+ // "Peak athletic output"
533
+ peak: "goalPhaseHint.athleticPerformance.peak.peakAthleticOutput",
534
+ },
535
+ general_fitness: {
536
+ // "Build the habit"
537
+ foundation: "goalPhaseHint.generalFitness.foundation.buildTheHabit",
538
+ // "Add more variety"
539
+ momentum: "goalPhaseHint.generalFitness.momentum.addMoreVariety",
540
+ // "Consistent challenge"
541
+ push: "goalPhaseHint.generalFitness.push.consistentChallenge",
542
+ // "Sustained healthy output"
543
+ peak: "goalPhaseHint.generalFitness.peak.sustainedHealthyOutput",
544
+ },
545
+ posture_and_back: {
546
+ // "Activate neglected muscles"
547
+ foundation: "goalPhaseHint.postureAndBack.foundation.activateNeglectedMuscles",
548
+ // "Posterior chain strengthens"
549
+ momentum: "goalPhaseHint.postureAndBack.momentum.posteriorChainStrengthens",
550
+ // "Pull heavy split begins"
551
+ push: "goalPhaseHint.postureAndBack.push.pullHeavySplitBegins",
552
+ // "Posture transformation complete"
553
+ peak: "goalPhaseHint.postureAndBack.peak.postureTransformationComplete",
554
+ },
555
+ stress_relief: {
556
+ // "Low pressure movement"
557
+ foundation: "goalPhaseHint.stressRelief.foundation.lowPressureMovement",
558
+ // "Rhythm and routine develop"
559
+ momentum: "goalPhaseHint.stressRelief.momentum.rhythmAndRoutineDevelop",
560
+ // "Endorphin peak"
561
+ push: "goalPhaseHint.stressRelief.push.endorphinPeak",
562
+ // "Sustained stress release"
563
+ peak: "goalPhaseHint.stressRelief.peak.sustainedStressRelease",
564
+ },
565
+ rehabilitation: {
566
+ // "Safe mobility work only"
567
+ foundation: "goalPhaseHint.rehabilitation.foundation.safeMobilityWorkOnly",
568
+ // "Strength carefully reintroduced"
569
+ momentum: "goalPhaseHint.rehabilitation.momentum.strengthCarefullyReintroduced",
570
+ // "Full movement patterns restored"
571
+ push: "goalPhaseHint.rehabilitation.push.fullMovementPatternsRestored",
572
+ // "Back to full function"
573
+ peak: "goalPhaseHint.rehabilitation.peak.backToFullFunction",
574
+ },
575
+ core_strength: {
576
+ // "Core activation patterns"
577
+ foundation: "goalPhaseHint.coreStrength.foundation.coreActivationPatterns",
578
+ // "Volume and difficulty rise"
579
+ momentum: "goalPhaseHint.coreStrength.momentum.volumeAndDifficultyRise",
580
+ // "Heavy compound core work"
581
+ push: "goalPhaseHint.coreStrength.push.heavyCompoundCoreWork",
582
+ // "Maximum core output"
583
+ peak: "goalPhaseHint.coreStrength.peak.maximumCoreOutput",
584
+ },
585
+ mobility_flexibility: {
586
+ // "Daily mobility habit"
587
+ foundation: "goalPhaseHint.mobilityFlexibility.foundation.dailyMobilityHabit",
588
+ // "Range of motion grows"
589
+ momentum: "goalPhaseHint.mobilityFlexibility.momentum.rangeOfMotionGrows",
590
+ // "Full body flexibility"
591
+ push: "goalPhaseHint.mobilityFlexibility.push.fullBodyFlexibility",
592
+ // "Elite movement quality"
593
+ peak: "goalPhaseHint.mobilityFlexibility.peak.eliteMovementQuality",
594
+ },
595
+ };
@@ -1,2 +1,3 @@
1
1
  export * from "./BodyCategories";
2
2
  export * from "./AiExerciseVocabulary";
3
+ export * from "./goalJourney";
@@ -16,3 +16,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./BodyCategories"), exports);
18
18
  __exportStar(require("./AiExerciseVocabulary"), exports);
19
+ __exportStar(require("./goalJourney"), exports);
@@ -1,5 +1,5 @@
1
1
  import { TAiEnergyLevel, TAiUserInjury, TQuickStartEquipmentGroupExplicit } from "../constants/AiExerciseVocabulary";
2
- import { TTrainingPhase, TUserFitnessGoal } from "./goalFeature";
2
+ import { TTrainingPhase, TUserFitnessGoal } from "../constants/goalJourney";
3
3
  import { TTemplate } from "./TApiTemplateData";
4
4
  export type TApiAiQuickStartWorkoutReq = {
5
5
  /**
@@ -0,0 +1,76 @@
1
+ import { TWorkoutIntent } from "../constants";
2
+ import { TDaysPerWeek, TSessionIntent, TTrainingPhase, TUserFitnessGoal } from "../constants/goalJourney";
3
+ export type TGoalProfile = {
4
+ activeGoal: TUserFitnessGoal;
5
+ goalStartDate: string;
6
+ daysPerWeek: TDaysPerWeek;
7
+ phaseCompletions: {
8
+ phase: TTrainingPhase;
9
+ completedAt: string;
10
+ celebrationShown: boolean;
11
+ }[];
12
+ previousGoals: {
13
+ goal: TUserFitnessGoal;
14
+ startDate: string;
15
+ endDate: string;
16
+ weeksCompleted: number;
17
+ reasonEnded: "completed" | "changed";
18
+ }[];
19
+ };
20
+ export type TGoalContext = {
21
+ goal: TUserFitnessGoal;
22
+ daysPerWeek: TDaysPerWeek;
23
+ currentPhase: TTrainingPhase;
24
+ currentWeek: number;
25
+ weeklyStructure: TSessionIntent[];
26
+ lastSessionDate: string;
27
+ lastSessionIntent: TSessionIntent;
28
+ sessionsCompletedThisWeek: number;
29
+ totalSessionsSinceGoalStart: number;
30
+ sessionsInLast4Weeks: number;
31
+ };
32
+ export type TIntentRecommendation = {
33
+ intent: TWorkoutIntent;
34
+ confidence: number;
35
+ rationale: string;
36
+ decisionPath: "global_recovery" | "returning_user" | "gap_bridge" | "structure_follow" | "muscle_override";
37
+ intentScores: Partial<Record<TWorkoutIntent, number>>;
38
+ };
39
+ export type TGoalSessionSummary = {
40
+ sessionId: string;
41
+ createdAt: string;
42
+ intentId: TWorkoutIntent | undefined;
43
+ exercises: {
44
+ exerciseId: string;
45
+ thumbnailUrl: string;
46
+ name: string;
47
+ }[];
48
+ };
49
+ export type TApiGoalContextRes = {
50
+ status: 200;
51
+ goalProfile: TGoalProfile;
52
+ goalContext: TGoalContext;
53
+ recommendation: TIntentRecommendation;
54
+ recentSessions: TGoalSessionSummary[];
55
+ weeklySessionCounts: {
56
+ weekNumber: number;
57
+ weekStartDate: string;
58
+ count: number;
59
+ }[];
60
+ newPhaseUnlocked?: TTrainingPhase;
61
+ } | {
62
+ status: 400 | 401 | 404 | 500;
63
+ message: string;
64
+ };
65
+ export type TApiChangeGoalReq = {
66
+ userId: string;
67
+ newGoal: TUserFitnessGoal;
68
+ daysPerWeek: TDaysPerWeek;
69
+ };
70
+ export type TApiChangeGoalRes = {
71
+ status: 200;
72
+ goalProfile: TGoalProfile;
73
+ } | {
74
+ status: 401 | 500;
75
+ message: string;
76
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,7 +1,7 @@
1
1
  import type { TAiFitnessGoal, TAiUserInjury } from "../constants/AiExerciseVocabulary";
2
2
  import { TConstellationUserLevel } from "../utils";
3
3
  import { TGdprData } from "./commonTypes";
4
- import { TGoalProfile } from "./goalFeature";
4
+ import { TGoalProfile } from "./TApiGoalContextRes";
5
5
  import { TUserPreferences } from "./TUserPreferences";
6
6
  export type TAuthType = "email" | "apple" | "anonymous" | "token";
7
7
  export type TGender = "male" | "female" | "unmentioned";
@@ -16,4 +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";
19
+ export type * from "./TApiGoalContextRes";
@@ -1,18 +1,2 @@
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
- };
16
2
  Object.defineProperty(exports, "__esModule", { value: true });
17
- // Features
18
- __exportStar(require("./goalFeature"), exports);
@@ -0,0 +1,22 @@
1
+ import { TMuscleFatigueResult } from "..";
2
+ import { TSessionIntent } from "../../constants/goalJourney";
3
+ import { TGoalContext, TGoalProfile, TIntentRecommendation } from "../../types/TApiGoalContextRes";
4
+ export declare function resolveGoalContext(goalProfile: TGoalProfile, recentSessions: {
5
+ intentId: TSessionIntent | undefined;
6
+ createdAt: string;
7
+ }[], now?: Date): TGoalContext | null;
8
+ /**
9
+ * Recommend the next session intent.
10
+ *
11
+ * Decision pipeline (strict priority):
12
+ * 1. GLOBAL RECOVERY avgFatigue > 72 → "mobility"
13
+ * 2. RETURNING USER gap >= 7 days → first non-rest slot
14
+ * 3. GAP BRIDGE gap 3-7 days → most overdue slot
15
+ * 4. STRUCTURE FOLLOW gap < 3 days → next slot (circular)
16
+ * 5. MUSCLE OVERRIDE cluster fatigue > 65 → highest-readiness alt
17
+ *
18
+ * Output is always a valid TWorkoutIntent.
19
+ * Never outputs: "rest", "arms", "back", "unstructured".
20
+ * rationale is an i18n key — resolve via t() on the FE.
21
+ */
22
+ export declare function recommendNextIntent(fatigue: TMuscleFatigueResult, goalContext: TGoalContext): TIntentRecommendation;
@@ -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("../../constants/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
+ }
@@ -14,4 +14,5 @@ export * from "./exerciseRecord/workoutMath";
14
14
  export * from "./exerciseRecord/recordValidator";
15
15
  export * from "./metricConversions";
16
16
  export * from "./constellation";
17
+ export * from "./goalFeature/utilGoal";
17
18
  export type * from "./constellation/types";
@@ -52,3 +52,4 @@ __exportStar(require("./exerciseRecord/workoutMath"), exports);
52
52
  __exportStar(require("./exerciseRecord/recordValidator"), exports);
53
53
  __exportStar(require("./metricConversions"), exports);
54
54
  __exportStar(require("./constellation"), exports);
55
+ __exportStar(require("./goalFeature/utilGoal"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dgpholdings/greatoak-shared",
3
- "version": "1.2.94",
3
+ "version": "1.2.96",
4
4
  "description": "Shared TypeScript types and utilities for @dgpholdings projects",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",