Add multi-week workout programs
A Workout was a single flat session with no way to schedule several across weeks/days as a structured plan (e.g. Push/Pull/Legs over 4 weeks) - every user started from a blank builder each time, and there was no template concept at all. Adds a Program (title, description, week count) that Workouts can optionally belong to via nullable program_id/week_number/day_number columns, reusing all existing workout and session-tracking infrastructure rather than building a parallel system. The program show page lays out the schedule week by week and derives progress (completed vs total) from the user's existing WorkoutSession completions - no new enrollment/progress-tracking concept needed. Deleting a program unschedules its workouts (nullOnDelete) rather than destroying them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Program;
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProgramController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$programs = Auth::user()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Program extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'weeks',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'weeks' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Workout::class);
|
return $this->hasMany(Workout::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function programs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Program::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function favoriteExercises()
|
public function favoriteExercises()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Exercise::class, 'exercise_favorites')->withTimestamps();
|
return $this->belongsToMany(Exercise::class, 'exercise_favorites')->withTimestamps();
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ class Workout extends Model
|
|||||||
'title',
|
'title',
|
||||||
'notes',
|
'notes',
|
||||||
'estimated_minutes',
|
'estimated_minutes',
|
||||||
|
'program_id',
|
||||||
|
'week_number',
|
||||||
|
'day_number',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'estimated_minutes' => 'integer',
|
'estimated_minutes' => 'integer',
|
||||||
|
'week_number' => 'integer',
|
||||||
|
'day_number' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
@@ -28,6 +33,11 @@ class Workout extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function program(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Program::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function items(): HasMany
|
public function items(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkoutExercise::class)->orderBy('position');
|
return $this->hasMany(WorkoutExercise::class)->orderBy('position');
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Program>
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('programs', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('workouts', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'blog' => 'Blog',
|
'blog' => 'Blog',
|
||||||
'build_workout' => 'Build a Workout',
|
'build_workout' => 'Build a Workout',
|
||||||
'dashboard' => 'Dashboard',
|
'dashboard' => 'Dashboard',
|
||||||
|
'programs' => 'Programs',
|
||||||
'admin' => 'Admin',
|
'admin' => 'Admin',
|
||||||
'profile' => 'Profile',
|
'profile' => 'Profile',
|
||||||
'logout' => 'Log Out',
|
'logout' => 'Log Out',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'blog' => 'Blog',
|
'blog' => 'Blog',
|
||||||
'build_workout' => 'Creează un antrenament',
|
'build_workout' => 'Creează un antrenament',
|
||||||
'dashboard' => 'Tabloul de bord',
|
'dashboard' => 'Tabloul de bord',
|
||||||
|
'programs' => 'Programe',
|
||||||
'admin' => 'Admin',
|
'admin' => 'Admin',
|
||||||
'profile' => 'Profil',
|
'profile' => 'Profil',
|
||||||
'logout' => 'Deconectare',
|
'logout' => 'Deconectare',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<a href="{{ route('blog.index', [], false) }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.blog') }}</a>
|
<a href="{{ route('blog.index', [], false) }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.blog') }}</a>
|
||||||
@auth
|
@auth
|
||||||
<a href="{{ route('dashboard') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.dashboard') }}</a>
|
<a href="{{ route('dashboard') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.dashboard') }}</a>
|
||||||
|
<a href="{{ route('programs.index') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.programs') }}</a>
|
||||||
@if(Auth::user()->is_admin)
|
@if(Auth::user()->is_admin)
|
||||||
<a href="{{ route('admin.dashboard') }}" class="text-sm font-medium text-indigo-700 dark:text-indigo-300 hover:text-indigo-900 dark:hover:text-indigo-200">{{ __('app.nav.admin') }}</a>
|
<a href="{{ route('admin.dashboard') }}" class="text-sm font-medium text-indigo-700 dark:text-indigo-300 hover:text-indigo-900 dark:hover:text-indigo-200">{{ __('app.nav.admin') }}</a>
|
||||||
@endif
|
@endif
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
<x-responsive-nav-link :href="route('blog.index', [], false)">{{ __('app.nav.blog') }}</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('blog.index', [], false)">{{ __('app.nav.blog') }}</x-responsive-nav-link>
|
||||||
@auth
|
@auth
|
||||||
<x-responsive-nav-link :href="route('dashboard')">{{ __('app.nav.dashboard') }}</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('dashboard')">{{ __('app.nav.dashboard') }}</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('programs.index')">{{ __('app.nav.programs') }}</x-responsive-nav-link>
|
||||||
@if(Auth::user()->is_admin)
|
@if(Auth::user()->is_admin)
|
||||||
<x-responsive-nav-link :href="route('admin.dashboard')">{{ __('app.nav.admin') }}</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('admin.dashboard')">{{ __('app.nav.admin') }}</x-responsive-nav-link>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">New Program</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Give it a name and how many weeks it runs for. You'll add workouts to specific weeks next.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-2xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||||
|
<ul class="list-disc ms-5 text-sm">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<form method="POST" action="{{ route('programs.store') }}" class="space-y-5 bg-white dark:bg-gray-800 rounded-2xl p-6 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||||
|
<input type="text" name="title" value="{{ old('title') }}" required placeholder="e.g. Push Pull Legs" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Weeks</label>
|
||||||
|
<input type="number" name="weeks" min="1" max="52" value="{{ old('weeks', 4) }}" required class="mt-1 w-32 rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||||
|
<textarea name="description" rows="3" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">{{ old('description') }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Create Program</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">My Programs</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Multi-week training plans built from your workouts.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('programs.create') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">New Program</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
@if($programs->count() === 0)
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">No programs yet. Build a multi-week plan to schedule your workouts across weeks.</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach($programs as $program)
|
||||||
|
<a href="{{ route('programs.show', $program) }}" class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5 hover:shadow-lg transition">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ $program->title }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $program->weeks }} weeks · {{ $program->workouts_count }} workouts</p>
|
||||||
|
@if($program->description)
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">{{ $program->description }}</p>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">{{ $programs->links() }}</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('programs.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← My Programs</a>
|
||||||
|
<h1 class="mt-2 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $program->title }}</h1>
|
||||||
|
<p class="mt-1 text-gray-600 dark:text-gray-300">{{ $program->weeks }} weeks @if($program->description) · {{ $program->description }} @endif</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('programs.destroy', $program) }}" onsubmit="return confirm('Delete this program? Its workouts will stay in your account, just unscheduled.');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-sm text-red-600 hover:text-red-500">Delete Program</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($totalWorkouts > 0)
|
||||||
|
<div class="mt-6 max-w-md">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">Progress</span>
|
||||||
|
<span class="font-semibold text-indigo-600">{{ $completedCount }} / {{ $totalWorkouts }} workouts · {{ $progressPct }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<div class="h-2 bg-gradient-to-r from-emerald-500 to-teal-500" style="width: {{ $progressPct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||||
|
<ul class="list-disc ms-5 text-sm">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
<div class="lg:col-span-8 space-y-6">
|
||||||
|
@for($week = 1; $week <= $program->weeks; $week++)
|
||||||
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Week {{ $week }}</h2>
|
||||||
|
@php $weekWorkouts = $workoutsByWeek->get($week, collect()); @endphp
|
||||||
|
@if($weekWorkouts->isEmpty())
|
||||||
|
<div class="mt-3 rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-4 text-sm text-gray-600 dark:text-gray-300">No workouts scheduled for this week yet.</div>
|
||||||
|
@else
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
@foreach($weekWorkouts as $workout)
|
||||||
|
@php $isDone = $completedWorkoutIds->contains($workout->id); @endphp
|
||||||
|
<li class="flex items-center justify-between rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-semibold text-gray-500">Day {{ $workout->day_number }}</span>
|
||||||
|
<a href="{{ route('workouts.show', $workout) }}" class="block font-medium text-gray-900 dark:text-white hover:text-indigo-600">{{ $workout->title }}</a>
|
||||||
|
</div>
|
||||||
|
@if($isDone)
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-1 text-xs font-semibold">Completed</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2.5 py-1 text-xs font-semibold">Not started</span>
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-4">
|
||||||
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Add a Workout</h2>
|
||||||
|
<form method="POST" action="{{ route('programs.workouts.add', $program) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||||
|
<input type="text" name="title" required placeholder="e.g. Push Day" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Week</label>
|
||||||
|
<input type="number" name="week_number" min="1" max="{{ $program->weeks }}" value="1" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Day</label>
|
||||||
|
<input type="number" name="day_number" min="1" max="14" value="1" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Estimated minutes</label>
|
||||||
|
<input type="number" name="estimated_minutes" min="1" max="600" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Add to Program</button>
|
||||||
|
<p class="text-xs text-gray-500">You'll add exercises to it from the workout's own page after creating it.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -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::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::middleware('auth')->group(function () {
|
||||||
Route::get('/sessions/{session}', [\App\Http\Controllers\WorkoutSessionController::class, 'show'])->name('sessions.show');
|
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');
|
Route::patch('/sessions/{session}/items/{item}', [\App\Http\Controllers\WorkoutSessionController::class, 'toggleItem'])->name('sessions.items.toggle');
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Program;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workout;
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProgramTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guest_is_redirected_to_login(): void
|
||||||
|
{
|
||||||
|
$response = $this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user