From fa8bf46f8ad8b8a008fd21cc2ffdb61da73b61d8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 12:15:14 +0300 Subject: [PATCH] Add per-exercise personal record tracking Session tracking only ever stored a done boolean - no record of what weight or reps were actually performed, so there was no way to derive real progress data even though the dashboard implied "progress tracking" was a thing. Workout templates only hold a static planned value, not a history of performance. Adds actual_weight/actual_reps to workout_session_items, captured inline when checking off a set in a session (defaults to the workout's planned weight/reps, editable before confirming). The exercise detail page now shows the user's personal best (heaviest completed set, tie broken by reps) computed from their completed session history across all workouts containing that exercise. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/ExerciseController.php | 15 ++++ .../Controllers/WorkoutSessionController.php | 13 ++++ app/Models/WorkoutSessionItem.php | 4 ++ .../factories/WorkoutSessionItemFactory.php | 24 +++++++ ...ormance_to_workout_session_items_table.php | 29 ++++++++ lang/en/app.php | 6 +- lang/ro/app.php | 6 +- resources/views/exercises/show.blade.php | 16 +++++ resources/views/workouts/session.blade.php | 24 +++++++ tests/Feature/ExerciseTest.php | 71 +++++++++++++++++++ tests/Feature/WorkoutSessionTest.php | 42 +++++++++++ 11 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 database/factories/WorkoutSessionItemFactory.php create mode 100644 database/migrations/2026_08_10_091029_add_actual_performance_to_workout_session_items_table.php diff --git a/app/Http/Controllers/ExerciseController.php b/app/Http/Controllers/ExerciseController.php index dd77ff6..549ac87 100644 --- a/app/Http/Controllers/ExerciseController.php +++ b/app/Http/Controllers/ExerciseController.php @@ -6,6 +6,7 @@ use App\Models\BodyPart; use App\Models\Equipment; use App\Models\Exercise; use App\Models\Muscle; +use App\Models\WorkoutSessionItem; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -104,9 +105,23 @@ class ExerciseController extends Controller $similarExercises = $similarQuery->get(); + $personalBest = null; + if (Auth::check()) { + $personalBest = WorkoutSessionItem::query() + ->with('session') + ->whereHas('workoutExercise', fn ($q) => $q->where('exercise_id', $exercise->id)) + ->whereHas('session', fn ($q) => $q->where('user_id', Auth::id())) + ->where('done', true) + ->whereNotNull('actual_weight') + ->orderByDesc('actual_weight') + ->orderByDesc('actual_reps') + ->first(); + } + return view('exercises.show', [ 'exercise' => $exercise, 'similarExercises' => $similarExercises, + 'personalBest' => $personalBest, ]); } } diff --git a/app/Http/Controllers/WorkoutSessionController.php b/app/Http/Controllers/WorkoutSessionController.php index deb26b3..bbfef5c 100644 --- a/app/Http/Controllers/WorkoutSessionController.php +++ b/app/Http/Controllers/WorkoutSessionController.php @@ -58,6 +58,11 @@ class WorkoutSessionController extends Controller abort(404); } + $data = $request->validate([ + 'actual_weight' => ['nullable', 'numeric', 'min:0', 'max:9999.99'], + 'actual_reps' => ['nullable', 'integer', 'min:0', 'max:1000'], + ]); + $si = WorkoutSessionItem::firstOrCreate([ 'session_id' => $session->id, 'workout_exercise_id' => $item->id, @@ -66,6 +71,14 @@ class WorkoutSessionController extends Controller ]); $si->done = !$si->done; + if ($si->done) { + if (array_key_exists('actual_weight', $data) && $data['actual_weight'] !== null) { + $si->actual_weight = $data['actual_weight']; + } + if (array_key_exists('actual_reps', $data) && $data['actual_reps'] !== null) { + $si->actual_reps = $data['actual_reps']; + } + } $si->save(); return back(); diff --git a/app/Models/WorkoutSessionItem.php b/app/Models/WorkoutSessionItem.php index 4382b6a..9bf6f7d 100644 --- a/app/Models/WorkoutSessionItem.php +++ b/app/Models/WorkoutSessionItem.php @@ -16,12 +16,16 @@ class WorkoutSessionItem extends Model 'done', 'exercise_ms', 'rest_ms', + 'actual_weight', + 'actual_reps', ]; protected $casts = [ 'done' => 'boolean', 'exercise_ms' => 'integer', 'rest_ms' => 'integer', + 'actual_weight' => 'decimal:2', + 'actual_reps' => 'integer', ]; public function session(): BelongsTo diff --git a/database/factories/WorkoutSessionItemFactory.php b/database/factories/WorkoutSessionItemFactory.php new file mode 100644 index 0000000..b1e0ea7 --- /dev/null +++ b/database/factories/WorkoutSessionItemFactory.php @@ -0,0 +1,24 @@ + + */ +class WorkoutSessionItemFactory extends Factory +{ + public function definition(): array + { + return [ + 'session_id' => WorkoutSession::factory(), + 'workout_exercise_id' => WorkoutExercise::factory(), + 'done' => false, + 'actual_weight' => null, + 'actual_reps' => null, + ]; + } +} diff --git a/database/migrations/2026_08_10_091029_add_actual_performance_to_workout_session_items_table.php b/database/migrations/2026_08_10_091029_add_actual_performance_to_workout_session_items_table.php new file mode 100644 index 0000000..7da23c2 --- /dev/null +++ b/database/migrations/2026_08_10_091029_add_actual_performance_to_workout_session_items_table.php @@ -0,0 +1,29 @@ +decimal('actual_weight', 6, 2)->nullable()->after('done'); + $table->unsignedSmallInteger('actual_reps')->nullable()->after('actual_weight'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workout_session_items', function (Blueprint $table) { + $table->dropColumn(['actual_weight', 'actual_reps']); + }); + } +}; diff --git a/lang/en/app.php b/lang/en/app.php index cfaed6f..279c431 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -192,6 +192,8 @@ return [ 'rename_reset' => 'Reset', 'renamed_success' => 'Your custom name has been saved.', 'rename_cleared' => 'Custom name cleared.', + 'personal_best' => 'Your Personal Best', + 'no_personal_best' => 'No completed sets logged yet. Track weight and reps when you check off a set in a session.', ], 'sessions' => [ @@ -211,7 +213,9 @@ return [ 'rest_timer' => 'Rest timer', 'start_rest' => 'Start', 'pause_rest' => 'Pause', - 'reset_rest' => 'Reset' + 'reset_rest' => 'Reset', + 'weight_lifted' => 'Weight', + 'reps_done' => 'Reps', ], 'dashboard' => [ diff --git a/lang/ro/app.php b/lang/ro/app.php index c119d78..e3a834b 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -192,6 +192,8 @@ return [ 'rename_reset' => 'Resetează', 'renamed_success' => 'Numele personalizat a fost salvat.', 'rename_cleared' => 'Numele personalizat a fost eliminat.', + 'personal_best' => 'Recordul tău personal', + 'no_personal_best' => 'Niciun set finalizat înregistrat încă. Înregistrează greutatea și repetările când bifezi un set într-o sesiune.', ], 'sessions' => [ @@ -211,7 +213,9 @@ return [ 'rest_timer' => 'Cronometru pauză', 'start_rest' => 'Start', 'pause_rest' => 'Pauză', - 'reset_rest' => 'Reset' + 'reset_rest' => 'Reset', + 'weight_lifted' => 'Greutate', + 'reps_done' => 'Repetări', ], 'dashboard' => [ diff --git a/resources/views/exercises/show.blade.php b/resources/views/exercises/show.blade.php index 41dcac3..5120815 100644 --- a/resources/views/exercises/show.blade.php +++ b/resources/views/exercises/show.blade.php @@ -79,6 +79,22 @@
+ @auth +
+

{{ __('app.exercises.personal_best') }}

+ @if($personalBest) +
+ {{ rtrim(rtrim(number_format($personalBest->actual_weight, 1), '0'), '.') }} kg + @if($personalBest->actual_reps) + × {{ $personalBest->actual_reps }} {{ __('app.sessions.reps') }} + @endif +
+

{{ optional($personalBest->session)->completed_at?->format('M j, Y') ?? $personalBest->updated_at->format('M j, Y') }}

+ @else +

{{ __('app.exercises.no_personal_best') }}

+ @endif +
+ @endauth

Exercise Info

diff --git a/resources/views/workouts/session.blade.php b/resources/views/workouts/session.blade.php index b1f3d84..8885f6d 100644 --- a/resources/views/workouts/session.blade.php +++ b/resources/views/workouts/session.blade.php @@ -29,6 +29,8 @@
@csrf @method('PATCH') + +