diff --git a/app/Http/Controllers/ProgramController.php b/app/Http/Controllers/ProgramController.php new file mode 100644 index 0000000..4b0fe91 --- /dev/null +++ b/app/Http/Controllers/ProgramController.php @@ -0,0 +1,102 @@ +programs()->withCount('workouts')->latest()->paginate(10); + + return view('programs.index', compact('programs')); + } + + public function create(): View + { + return view('programs.create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'weeks' => ['required', 'integer', 'min:1', 'max:52'], + ]); + + $program = Auth::user()->programs()->create($data); + + return redirect()->route('programs.show', $program)->with('status', 'Program created'); + } + + public function show(Program $program): View + { + $this->authorizeProgram($program); + $program->load('workouts'); + + $completedWorkoutIds = WorkoutSession::query() + ->where('user_id', Auth::id()) + ->whereIn('workout_id', $program->workouts->pluck('id')) + ->whereNotNull('completed_at') + ->pluck('workout_id') + ->unique(); + + $workoutsByWeek = $program->workouts->groupBy('week_number'); + + $totalWorkouts = $program->workouts->count(); + $completedCount = $completedWorkoutIds->count(); + $progressPct = $totalWorkouts > 0 ? (int) round(($completedCount / $totalWorkouts) * 100) : 0; + + return view('programs.show', [ + 'program' => $program, + 'workoutsByWeek' => $workoutsByWeek, + 'completedWorkoutIds' => $completedWorkoutIds, + 'progressPct' => $progressPct, + 'completedCount' => $completedCount, + 'totalWorkouts' => $totalWorkouts, + ]); + } + + public function destroy(Program $program): RedirectResponse + { + $this->authorizeProgram($program); + $program->delete(); + + return redirect()->route('programs.index')->with('status', 'Program deleted'); + } + + public function addWorkout(Request $request, Program $program): RedirectResponse + { + $this->authorizeProgram($program); + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'week_number' => ['required', 'integer', 'min:1', 'max:' . $program->weeks], + 'day_number' => ['required', 'integer', 'min:1', 'max:14'], + 'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:600'], + ]); + + $program->workouts()->create([ + 'user_id' => Auth::id(), + 'title' => $data['title'], + 'estimated_minutes' => $data['estimated_minutes'] ?? null, + 'week_number' => $data['week_number'], + 'day_number' => $data['day_number'], + ]); + + return back()->with('status', 'Workout added to program'); + } + + protected function authorizeProgram(Program $program): void + { + if ($program->user_id !== Auth::id()) { + abort(403); + } + } +} diff --git a/app/Models/Program.php b/app/Models/Program.php new file mode 100644 index 0000000..5261091 --- /dev/null +++ b/app/Models/Program.php @@ -0,0 +1,34 @@ + 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function workouts(): HasMany + { + return $this->hasMany(Workout::class)->orderBy('week_number')->orderBy('day_number'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 67c3e9a..9b3f514 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -69,6 +69,11 @@ class User extends Authenticatable return $this->hasMany(Workout::class); } + public function programs() + { + return $this->hasMany(Program::class); + } + public function favoriteExercises() { return $this->belongsToMany(Exercise::class, 'exercise_favorites')->withTimestamps(); diff --git a/app/Models/Workout.php b/app/Models/Workout.php index e5d821b..ec79d2a 100644 --- a/app/Models/Workout.php +++ b/app/Models/Workout.php @@ -17,10 +17,15 @@ class Workout extends Model 'title', 'notes', 'estimated_minutes', + 'program_id', + 'week_number', + 'day_number', ]; protected $casts = [ 'estimated_minutes' => 'integer', + 'week_number' => 'integer', + 'day_number' => 'integer', ]; public function user(): BelongsTo @@ -28,6 +33,11 @@ class Workout extends Model return $this->belongsTo(User::class); } + public function program(): BelongsTo + { + return $this->belongsTo(Program::class); + } + public function items(): HasMany { return $this->hasMany(WorkoutExercise::class)->orderBy('position'); diff --git a/database/factories/ProgramFactory.php b/database/factories/ProgramFactory.php new file mode 100644 index 0000000..c9c4f23 --- /dev/null +++ b/database/factories/ProgramFactory.php @@ -0,0 +1,22 @@ + + */ +class ProgramFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'title' => ucfirst(fake()->words(3, true)) . ' Program', + 'description' => fake()->sentence(), + 'weeks' => fake()->numberBetween(3, 8), + ]; + } +} diff --git a/database/migrations/2026_08_10_091735_create_programs_table.php b/database/migrations/2026_08_10_091735_create_programs_table.php new file mode 100644 index 0000000..719f9ad --- /dev/null +++ b/database/migrations/2026_08_10_091735_create_programs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->unsignedTinyInteger('weeks')->default(4); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('programs'); + } +}; diff --git a/database/migrations/2026_08_10_091736_add_program_scheduling_to_workouts_table.php b/database/migrations/2026_08_10_091736_add_program_scheduling_to_workouts_table.php new file mode 100644 index 0000000..6308476 --- /dev/null +++ b/database/migrations/2026_08_10_091736_add_program_scheduling_to_workouts_table.php @@ -0,0 +1,31 @@ +foreignId('program_id')->nullable()->after('user_id')->constrained()->nullOnDelete(); + $table->unsignedTinyInteger('week_number')->nullable()->after('program_id'); + $table->unsignedTinyInteger('day_number')->nullable()->after('week_number'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workouts', function (Blueprint $table) { + $table->dropConstrainedForeignId('program_id'); + $table->dropColumn(['week_number', 'day_number']); + }); + } +}; diff --git a/lang/en/app.php b/lang/en/app.php index 279c431..1c69435 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -8,6 +8,7 @@ return [ 'blog' => 'Blog', 'build_workout' => 'Build a Workout', 'dashboard' => 'Dashboard', + 'programs' => 'Programs', 'admin' => 'Admin', 'profile' => 'Profile', 'logout' => 'Log Out', diff --git a/lang/ro/app.php b/lang/ro/app.php index e3a834b..4e8de8a 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -8,6 +8,7 @@ return [ 'blog' => 'Blog', 'build_workout' => 'Creează un antrenament', 'dashboard' => 'Tabloul de bord', + 'programs' => 'Programe', 'admin' => 'Admin', 'profile' => 'Profil', 'logout' => 'Deconectare', diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 32cfb38..2b8b039 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -19,6 +19,7 @@ {{ __('app.nav.blog') }} @auth {{ __('app.nav.dashboard') }} + {{ __('app.nav.programs') }} @if(Auth::user()->is_admin) {{ __('app.nav.admin') }} @endif @@ -98,6 +99,7 @@ {{ __('app.nav.blog') }} @auth {{ __('app.nav.dashboard') }} + {{ __('app.nav.programs') }} @if(Auth::user()->is_admin) {{ __('app.nav.admin') }} @endif diff --git a/resources/views/programs/create.blade.php b/resources/views/programs/create.blade.php new file mode 100644 index 0000000..1ec69ec --- /dev/null +++ b/resources/views/programs/create.blade.php @@ -0,0 +1,36 @@ + +
+
+

New Program

+

Give it a name and how many weeks it runs for. You'll add workouts to specific weeks next.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/programs/index.blade.php b/resources/views/programs/index.blade.php new file mode 100644 index 0000000..504d3eb --- /dev/null +++ b/resources/views/programs/index.blade.php @@ -0,0 +1,35 @@ + +
+
+
+
+

My Programs

+

Multi-week training plans built from your workouts.

+
+ New Program +
+
+
+ +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + @if($programs->count() === 0) +
No programs yet. Build a multi-week plan to schedule your workouts across weeks.
+ @else + +
{{ $programs->links() }}
+ @endif +
+
diff --git a/resources/views/programs/show.blade.php b/resources/views/programs/show.blade.php new file mode 100644 index 0000000..b2f10f1 --- /dev/null +++ b/resources/views/programs/show.blade.php @@ -0,0 +1,105 @@ + +
+
+
+
+ ← My Programs +

{{ $program->title }}

+

{{ $program->weeks }} weeks @if($program->description) · {{ $program->description }} @endif

+
+
+ @csrf + @method('DELETE') + +
+
+ + @if($totalWorkouts > 0) +
+
+ Progress + {{ $completedCount }} / {{ $totalWorkouts }} workouts · {{ $progressPct }}% +
+
+
+
+
+ @endif +
+
+ +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+ @for($week = 1; $week <= $program->weeks; $week++) +
+

Week {{ $week }}

+ @php $weekWorkouts = $workoutsByWeek->get($week, collect()); @endphp + @if($weekWorkouts->isEmpty()) +
No workouts scheduled for this week yet.
+ @else +
    + @foreach($weekWorkouts as $workout) + @php $isDone = $completedWorkoutIds->contains($workout->id); @endphp +
  • +
    + Day {{ $workout->day_number }} + {{ $workout->title }} +
    + @if($isDone) + Completed + @else + Not started + @endif +
  • + @endforeach +
+ @endif +
+ @endfor +
+ +
+
+

Add a Workout

+
+ @csrf +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +

You'll add exercises to it from the workout's own page after creating it.

+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 2e0f23b..ffbb21d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -60,6 +60,15 @@ Route::middleware('auth')->prefix('/workouts')->group(function () { Route::post('/{workout}/sessions', [\App\Http\Controllers\WorkoutSessionController::class, 'store'])->name('sessions.store'); }); +Route::middleware('auth')->prefix('/programs')->group(function () { + Route::get('/', [\App\Http\Controllers\ProgramController::class, 'index'])->name('programs.index'); + Route::get('/create', [\App\Http\Controllers\ProgramController::class, 'create'])->name('programs.create'); + Route::post('/', [\App\Http\Controllers\ProgramController::class, 'store'])->name('programs.store'); + Route::get('/{program}', [\App\Http\Controllers\ProgramController::class, 'show'])->name('programs.show'); + Route::delete('/{program}', [\App\Http\Controllers\ProgramController::class, 'destroy'])->name('programs.destroy'); + Route::post('/{program}/workouts', [\App\Http\Controllers\ProgramController::class, 'addWorkout'])->name('programs.workouts.add'); +}); + Route::middleware('auth')->group(function () { Route::get('/sessions/{session}', [\App\Http\Controllers\WorkoutSessionController::class, 'show'])->name('sessions.show'); Route::patch('/sessions/{session}/items/{item}', [\App\Http\Controllers\WorkoutSessionController::class, 'toggleItem'])->name('sessions.items.toggle'); diff --git a/tests/Feature/ProgramTest.php b/tests/Feature/ProgramTest.php new file mode 100644 index 0000000..2d12539 --- /dev/null +++ b/tests/Feature/ProgramTest.php @@ -0,0 +1,140 @@ +get(route('programs.index')); + + $response->assertRedirect(route('login')); + } + + public function test_user_can_create_a_program(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('programs.store'), [ + 'title' => 'Push Pull Legs', + 'weeks' => 6, + ]); + + $this->assertDatabaseHas('programs', ['user_id' => $user->id, 'title' => 'Push Pull Legs', 'weeks' => 6]); + $program = Program::where('title', 'Push Pull Legs')->firstOrFail(); + $response->assertRedirect(route('programs.show', $program)); + } + + public function test_owner_can_view_their_program(): void + { + $user = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('programs.show', $program)); + + $response->assertOk(); + $response->assertSee($program->title); + } + + public function test_other_user_cannot_view_program(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->get(route('programs.show', $program)); + + $response->assertForbidden(); + } + + public function test_owner_can_add_a_workout_to_a_program_week(): void + { + $user = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $user->id, 'weeks' => 4]); + + $response = $this->actingAs($user)->post(route('programs.workouts.add', $program), [ + 'title' => 'Push Day', + 'week_number' => 2, + 'day_number' => 1, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('workouts', [ + 'program_id' => $program->id, + 'user_id' => $user->id, + 'title' => 'Push Day', + 'week_number' => 2, + 'day_number' => 1, + ]); + } + + public function test_week_number_beyond_program_length_is_rejected(): void + { + $user = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $user->id, 'weeks' => 4]); + + $response = $this->actingAs($user)->post(route('programs.workouts.add', $program), [ + 'title' => 'Push Day', + 'week_number' => 5, + 'day_number' => 1, + ]); + + $response->assertSessionHasErrors('week_number'); + } + + public function test_other_user_cannot_add_a_workout_to_someone_elses_program(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->post(route('programs.workouts.add', $program), [ + 'title' => 'Push Day', + 'week_number' => 1, + 'day_number' => 1, + ]); + + $response->assertForbidden(); + } + + public function test_show_reports_progress_from_completed_sessions(): void + { + $user = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $user->id, 'weeks' => 2]); + $workout1 = Workout::factory()->create(['user_id' => $user->id, 'program_id' => $program->id, 'week_number' => 1, 'day_number' => 1]); + $workout2 = Workout::factory()->create(['user_id' => $user->id, 'program_id' => $program->id, 'week_number' => 1, 'day_number' => 2]); + + WorkoutSession::factory()->create([ + 'user_id' => $user->id, + 'workout_id' => $workout1->id, + 'completed_at' => now(), + ]); + + $response = $this->actingAs($user)->get(route('programs.show', $program)); + + $response->assertOk(); + $response->assertSee('1 / 2 workouts', false); + } + + public function test_owner_can_delete_a_program_without_deleting_its_workouts(): void + { + $user = User::factory()->create(); + $program = Program::factory()->create(['user_id' => $user->id]); + $workout = Workout::factory()->create(['user_id' => $user->id, 'program_id' => $program->id, 'week_number' => 1, 'day_number' => 1]); + + $response = $this->actingAs($user)->delete(route('programs.destroy', $program)); + + $response->assertRedirect(route('programs.index')); + $this->assertDatabaseMissing('programs', ['id' => $program->id]); + $this->assertDatabaseHas('workouts', ['id' => $workout->id]); + } +}