Skip to content

Commit feb12f7

Browse files
authored
Merge pull request #58 from wulfland/copilot/make-mesocycle-primary-training
Make mesocycles the primary training experience
2 parents eae5211 + ada193f commit feb12f7

8 files changed

Lines changed: 363 additions & 104 deletions

File tree

e2e/onboarding.spec.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,21 @@ test.describe('User Onboarding', () => {
5252
await page.click('input[value="imperial"]');
5353
await page.click('button:has-text("Continue")');
5454

55-
// Step 3: Training Split (optional)
55+
// Step 3: Training Split (required)
5656
await expect(page.locator('text=Choose Your Training Split')).toBeVisible();
5757
// Click the label containing the Upper/Lower option to select it
5858
await page.click('label:has(input[value="upper_lower"])');
5959
await page.click('button:has-text("Continue")');
6060

61-
// Step 4: First Exercise (optional)
61+
// Step 4: Mesocycle Setup (required)
62+
await expect(page.locator('text=Set Up Your First Mesocycle')).toBeVisible();
63+
await page.click('button:has-text("Create Mesocycle & Continue")');
64+
65+
// Step 5: First Exercise (optional)
6266
await expect(page.locator('text=Add Your First Exercise')).toBeVisible();
6367
await page.click('button:has-text("Continue")');
6468

65-
// Step 5: Quick Tour
69+
// Step 6: Quick Tour
6670
await expect(page.locator('text=Quick Tour')).toBeVisible();
6771
await expect(page.locator('text=Log Workouts')).toBeVisible();
6872

@@ -114,9 +118,13 @@ test.describe('User Onboarding', () => {
114118
await expect(page.locator('text=Set Up Your Profile')).toBeVisible();
115119
await page.click('button:has-text("Continue")');
116120

117-
// Skip training split
121+
// Training split (required - has a default selection)
118122
await expect(page.locator('text=Choose Your Training Split')).toBeVisible();
119-
await page.click('button:has-text("Skip for now")');
123+
await page.click('button:has-text("Continue")');
124+
125+
// Mesocycle setup (required)
126+
await expect(page.locator('text=Set Up Your First Mesocycle')).toBeVisible();
127+
await page.click('button:has-text("Create Mesocycle & Continue")');
120128

121129
// Skip first exercise
122130
await expect(page.locator('text=Add Your First Exercise')).toBeVisible();
@@ -162,15 +170,15 @@ test.describe('User Onboarding', () => {
162170
await expect(page.locator('h1.logo-text')).toBeVisible({ timeout: 10000 });
163171

164172
// Check progress indicator on first step
165-
await expect(page.locator('text=Step 1 of 5')).toBeVisible();
173+
await expect(page.locator('text=Step 1 of 6')).toBeVisible();
166174

167175
// Go to next step
168176
await page.click('button:has-text("Get Started")');
169-
await expect(page.locator('text=Step 2 of 5')).toBeVisible();
177+
await expect(page.locator('text=Step 2 of 6')).toBeVisible();
170178

171179
// Check progress dots
172180
const progressDots = page.locator('.progress-dot');
173-
await expect(progressDots).toHaveCount(5);
181+
await expect(progressDots).toHaveCount(6);
174182
});
175183

176184
test('should persist onboarding completion', async ({ page }) => {

src/App.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@ import {
1616
useUserProfiles,
1717
createUserProfile,
1818
updateUserProfile,
19+
createMesocycle,
1920
} from './hooks/useDatabase';
2021
import { seedStarterExercises } from './lib/seedData';
2122
import { seedSampleMesocycle } from './lib/seedMesocycle';
23+
import { generateSplitDays } from './lib/generateSplitDays';
2224
import { db } from './db';
2325
import type { BeforeInstallPromptEvent } from './types/global';
2426
import type { Exercise } from './types/models';
2527
import './App.css';
2628

27-
type Page = 'workout' | 'exercises' | 'mesocycles' | 'progress' | 'settings';
29+
type Page = 'mesocycles' | 'workout' | 'exercises' | 'progress' | 'settings';
2830

2931
function App() {
3032
const exercises = useExercises();
@@ -33,7 +35,7 @@ function App() {
3335
useState<BeforeInstallPromptEvent | null>(null);
3436
const [isOnline, setIsOnline] = useState(navigator.onLine);
3537
const [isLoading, setIsLoading] = useState(true);
36-
const [currentPage, setCurrentPage] = useState<Page>('workout');
38+
const [currentPage, setCurrentPage] = useState<Page>('mesocycles');
3739
const [showOnboarding, setShowOnboarding] = useState(false);
3840

3941
// Check if we should show onboarding
@@ -229,6 +231,45 @@ function App() {
229231
});
230232
}
231233

234+
// Create mesocycle if requested
235+
if (data.createMesocycle && data.trainingSplit && data.mesocycleWeeks) {
236+
try {
237+
const startDate = new Date();
238+
const endDate = new Date();
239+
endDate.setDate(endDate.getDate() + data.mesocycleWeeks * 7);
240+
241+
const splitDays = generateSplitDays(data.trainingSplit);
242+
243+
await createMesocycle({
244+
name: data.mesocycleName || 'My First Mesocycle',
245+
startDate,
246+
endDate,
247+
durationWeeks: data.mesocycleWeeks,
248+
currentWeek: 1,
249+
deloadWeek: data.mesocycleWeeks, // Last week is deload
250+
trainingSplit: data.trainingSplit,
251+
splitDays,
252+
status: 'active',
253+
notes: 'Created during onboarding',
254+
});
255+
} catch (err) {
256+
// Handle the case where an active mesocycle already exists gracefully
257+
if (
258+
err instanceof Error &&
259+
err.message.includes(
260+
'Cannot create active mesocycle: another mesocycle is already active'
261+
)
262+
) {
263+
console.warn(
264+
'Skipping mesocycle creation during onboarding because an active mesocycle already exists.'
265+
);
266+
} else {
267+
// Re-throw unexpected errors to be handled by the outer catch
268+
throw err;
269+
}
270+
}
271+
}
272+
232273
setShowOnboarding(false);
233274
} catch (error) {
234275
console.error('Failed to complete onboarding:', error);
@@ -306,10 +347,12 @@ function App() {
306347
)}
307348
</div>
308349

309-
{currentPage === 'workout' && <WorkoutSession />}
310-
311350
{currentPage === 'mesocycles' && <MesocycleDashboard />}
312351

352+
{currentPage === 'workout' && (
353+
<WorkoutSession onNavigate={setCurrentPage} />
354+
)}
355+
313356
{currentPage === 'exercises' && (
314357
<ExerciseList
315358
exercises={exercises || []}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Mesocycle Setup Step in Onboarding
3+
* Introduces mesocycle concept and guides users to create their first mesocycle
4+
*/
5+
6+
import { useState } from 'react';
7+
import type { OnboardingData } from './Onboarding';
8+
9+
interface MesocycleSetupStepProps {
10+
initialData: OnboardingData;
11+
onNext: (data: Partial<OnboardingData>) => void;
12+
onBack: () => void;
13+
}
14+
15+
export default function MesocycleSetupStep({
16+
initialData,
17+
onNext,
18+
onBack,
19+
}: MesocycleSetupStepProps) {
20+
const [mesocycleName, setMesocycleName] = useState('My First Mesocycle');
21+
const [weeks, setWeeks] = useState(4);
22+
23+
const handleSubmit = (e: React.FormEvent) => {
24+
e.preventDefault();
25+
onNext({
26+
mesocycleName,
27+
mesocycleWeeks: weeks,
28+
createMesocycle: true,
29+
});
30+
};
31+
32+
return (
33+
<div className="onboarding-step">
34+
<div className="step-header">
35+
<h2>Set Up Your First Mesocycle</h2>
36+
<p className="step-description">
37+
Mesocycles are the foundation of evidence-based training
38+
</p>
39+
</div>
40+
41+
<div className="step-content">
42+
<div className="info-box">
43+
<h3>💪 What is a Mesocycle?</h3>
44+
<p>
45+
A mesocycle is a 4-6 week training block designed to maximize muscle
46+
growth through:
47+
</p>
48+
<ul>
49+
<li>
50+
<strong>Progressive Overload:</strong> Gradually increasing volume
51+
over weeks
52+
</li>
53+
<li>
54+
<strong>Planned Deloads:</strong> Strategic recovery weeks to
55+
prevent overtraining
56+
</li>
57+
<li>
58+
<strong>Volume Management:</strong> Tracking your training volume
59+
through MEV, MAV, and MRV
60+
</li>
61+
<li>
62+
<strong>Auto-regulation:</strong> Adjusting based on your recovery
63+
and feedback
64+
</li>
65+
</ul>
66+
</div>
67+
68+
<form onSubmit={handleSubmit} className="step-form">
69+
<div className="form-group">
70+
<label htmlFor="mesocycle-name">
71+
Mesocycle Name
72+
<span className="field-hint">
73+
Give your training block a memorable name
74+
</span>
75+
</label>
76+
<input
77+
id="mesocycle-name"
78+
type="text"
79+
value={mesocycleName}
80+
onChange={(e) => setMesocycleName(e.target.value)}
81+
placeholder="e.g., Spring 2026 Mass Phase"
82+
required
83+
/>
84+
</div>
85+
86+
<div className="form-group">
87+
<label htmlFor="weeks">
88+
Number of Weeks
89+
<span className="field-hint">
90+
Most mesocycles are 4-6 weeks (including deload)
91+
</span>
92+
</label>
93+
<select
94+
id="weeks"
95+
value={weeks}
96+
onChange={(e) => setWeeks(Number(e.target.value))}
97+
>
98+
<option value={4}>4 Weeks</option>
99+
<option value={5}>5 Weeks</option>
100+
<option value={6}>6 Weeks</option>
101+
</select>
102+
</div>
103+
104+
<div className="info-box info-box-secondary">
105+
<p>
106+
<strong>📋 Note:</strong> You'll configure exercises and volume
107+
progression after completing onboarding. For now, we'll create a
108+
basic structure using your{' '}
109+
{initialData.trainingSplit ? 'selected' : 'default'} training
110+
split.
111+
</p>
112+
</div>
113+
114+
<div className="step-actions">
115+
<button
116+
type="button"
117+
onClick={onBack}
118+
className="btn btn-secondary"
119+
>
120+
Back
121+
</button>
122+
<button type="submit" className="btn btn-primary">
123+
Create Mesocycle & Continue
124+
</button>
125+
</div>
126+
</form>
127+
</div>
128+
</div>
129+
);
130+
}

src/components/onboarding/Onboarding.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useState } from 'react';
77
import WelcomeStep from './WelcomeStep';
88
import ProfileSetupStep from './ProfileSetupStep';
99
import TrainingSplitStep from './TrainingSplitStep';
10+
import MesocycleSetupStep from './MesocycleSetupStep';
1011
import FirstExerciseStep from './FirstExerciseStep';
1112
import QuickTourStep from './QuickTourStep';
1213
import ProgressIndicator from './ProgressIndicator';
@@ -22,6 +23,9 @@ export interface OnboardingData {
2223
| 'full_body'
2324
| 'bro_split'
2425
| 'custom';
26+
mesocycleName?: string;
27+
mesocycleWeeks?: number;
28+
createMesocycle?: boolean;
2529
skipFirstExercise?: boolean;
2630
skipTour?: boolean;
2731
}
@@ -42,7 +46,8 @@ export default function Onboarding({ onComplete, onSkip }: OnboardingProps) {
4246
const steps = [
4347
{ id: 'welcome', title: 'Welcome', optional: false },
4448
{ id: 'profile', title: 'Profile', optional: false },
45-
{ id: 'split', title: 'Training Split', optional: true },
49+
{ id: 'split', title: 'Training Split', optional: false },
50+
{ id: 'mesocycle', title: 'Mesocycle', optional: false },
4651
{ id: 'exercise', title: 'First Exercise', optional: true },
4752
{ id: 'tour', title: 'Quick Tour', optional: true },
4853
];
@@ -94,18 +99,25 @@ export default function Onboarding({ onComplete, onSkip }: OnboardingProps) {
9499
experienceLevel={onboardingData.experienceLevel}
95100
onNext={handleNext}
96101
onBack={handleBack}
97-
onSkip={handleSkipStep}
98102
/>
99103
);
100104
case 3:
105+
return (
106+
<MesocycleSetupStep
107+
initialData={onboardingData}
108+
onNext={handleNext}
109+
onBack={handleBack}
110+
/>
111+
);
112+
case 4:
101113
return (
102114
<FirstExerciseStep
103115
onNext={handleNext}
104116
onBack={handleBack}
105117
onSkip={handleSkipStep}
106118
/>
107119
);
108-
case 4:
120+
case 5:
109121
return (
110122
<QuickTourStep
111123
onComplete={() => onComplete(onboardingData)}

src/components/onboarding/TrainingSplitStep.tsx

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface TrainingSplitStepProps {
1010
experienceLevel: 'beginner' | 'intermediate' | 'advanced';
1111
onNext: (data: Partial<OnboardingData>) => void;
1212
onBack: () => void;
13-
onSkip: () => void;
13+
onSkip?: () => void; // Make optional since we won't use it
1414
}
1515

1616
type TrainingSplit =
@@ -68,21 +68,18 @@ export default function TrainingSplitStep({
6868
experienceLevel,
6969
onNext,
7070
onBack,
71-
onSkip,
7271
}: TrainingSplitStepProps) {
73-
const [selectedSplit, setSelectedSplit] = useState<TrainingSplit | undefined>(
74-
initialSplit
72+
// Default to full_body for beginners, upper_lower for intermediate/advanced
73+
const defaultSplit =
74+
experienceLevel === 'beginner' ? 'full_body' : 'upper_lower';
75+
const [selectedSplit, setSelectedSplit] = useState<TrainingSplit>(
76+
initialSplit || defaultSplit
7577
);
7678

7779
const handleContinue = () => {
7880
onNext({ trainingSplit: selectedSplit });
7981
};
8082

81-
const handleSkip = () => {
82-
onNext({ trainingSplit: undefined });
83-
onSkip();
84-
};
85-
8683
return (
8784
<div className="onboarding-step training-split-step">
8885
<div className="step-content">
@@ -140,19 +137,13 @@ export default function TrainingSplitStep({
140137
<button type="button" className="btn btn-secondary" onClick={onBack}>
141138
Back
142139
</button>
143-
<div className="action-group">
144-
<button type="button" className="btn btn-text" onClick={handleSkip}>
145-
Skip for now
146-
</button>
147-
<button
148-
type="button"
149-
className="btn btn-primary"
150-
onClick={handleContinue}
151-
disabled={!selectedSplit}
152-
>
153-
Continue
154-
</button>
155-
</div>
140+
<button
141+
type="button"
142+
className="btn btn-primary"
143+
onClick={handleContinue}
144+
>
145+
Continue
146+
</button>
156147
</div>
157148
</div>
158149
</div>

0 commit comments

Comments
 (0)