From 1cc21ad0c5ad8bef4c30701af681da6d60f9f6e1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 12:10:08 +0300 Subject: [PATCH] Add body weight logging with a trend on the dashboard No way existed to log body weight over time - only the single self-reported value from the signup profile - and the dashboard showed session activity but nothing about physical progress, which is usually the main reason people keep using a fitness app. Adds a WeightLog model (one entry per user per day, re-logging the same day updates it rather than duplicating), a quick-add form and trend summary on the dashboard, and delete support. Found and fixed a real bug along the way: the first implementation used WeightLog::updateOrCreate() with a raw ['logged_at' => $date] match array, which silently failed to find the same-day entry because the `date` cast stores full datetime strings ('2026-01-01 00:00:00') while the match array holds a plain 'Y-m-d' string - so re-logging the same day hit a unique-constraint violation instead of updating. Fixed by using a whereDate() lookup instead, which is comparison-format-safe. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/DashboardController.php | 10 ++- app/Http/Controllers/WeightLogController.php | 42 +++++++++ app/Models/User.php | 5 ++ app/Models/WeightLog.php | 28 ++++++ database/factories/WeightLogFactory.php | 21 +++++ ..._08_10_090532_create_weight_logs_table.php | 32 +++++++ lang/en/app.php | 13 +++ lang/ro/app.php | 13 +++ resources/views/dashboard.blade.php | 46 ++++++++++ routes/web.php | 4 + tests/Feature/WeightLogTest.php | 89 +++++++++++++++++++ 11 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/WeightLogController.php create mode 100644 app/Models/WeightLog.php create mode 100644 database/factories/WeightLogFactory.php create mode 100644 database/migrations/2026_08_10_090532_create_weight_logs_table.php create mode 100644 tests/Feature/WeightLogTest.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 6413e9e..057030c 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -49,13 +49,21 @@ class DashboardController extends Controller ->groupBy(function ($s) { return $s->created_at->format('Y-m-d'); }) ->map->count(); + // Body weight trend + $weightLogs = $user->weightLogs()->orderBy('logged_at')->get(['id', 'weight_kg', 'logged_at']); + $weightChange = $weightLogs->count() >= 2 + ? round($weightLogs->last()->weight_kg - $weightLogs->first()->weight_kg, 2) + : null; + return view('dashboard', compact( 'workoutsCount', 'sessionsCount', 'completedSessions', 'completionRate', 'recentSessions', - 'dailyActivity' + 'dailyActivity', + 'weightLogs', + 'weightChange' )); } } diff --git a/app/Http/Controllers/WeightLogController.php b/app/Http/Controllers/WeightLogController.php new file mode 100644 index 0000000..bcbcb5d --- /dev/null +++ b/app/Http/Controllers/WeightLogController.php @@ -0,0 +1,42 @@ +validate([ + 'weight_kg' => ['required', 'numeric', 'min:20', 'max:400'], + 'logged_at' => ['nullable', 'date', 'before_or_equal:today'], + ]); + + $loggedAt = $data['logged_at'] ?? now()->toDateString(); + + // updateOrCreate's raw match array compares logged_at as a literal string, + // which doesn't reliably match how the `date` cast stores it - use a + // date-aware lookup instead. + $entry = WeightLog::where('user_id', Auth::id())->whereDate('logged_at', $loggedAt)->first() + ?? new WeightLog(['user_id' => Auth::id(), 'logged_at' => $loggedAt]); + $entry->weight_kg = $data['weight_kg']; + $entry->save(); + + return back()->with('status', __('app.weight_log.saved')); + } + + public function destroy(WeightLog $weightLog): RedirectResponse + { + if ($weightLog->user_id !== Auth::id()) { + abort(403); + } + + $weightLog->delete(); + + return back()->with('status', __('app.weight_log.deleted')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e82ca67..67c3e9a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -84,6 +84,11 @@ class User extends Authenticatable return $this->belongsToMany(YogaPose::class, 'yoga_pose_favorites')->withTimestamps(); } + public function weightLogs() + { + return $this->hasMany(WeightLog::class); + } + // Helpers public function hasFavoritedExercise(Exercise $exercise): bool { diff --git a/app/Models/WeightLog.php b/app/Models/WeightLog.php new file mode 100644 index 0000000..e494c94 --- /dev/null +++ b/app/Models/WeightLog.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'logged_at' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/factories/WeightLogFactory.php b/database/factories/WeightLogFactory.php new file mode 100644 index 0000000..e908fac --- /dev/null +++ b/database/factories/WeightLogFactory.php @@ -0,0 +1,21 @@ + + */ +class WeightLogFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'weight_kg' => fake()->randomFloat(1, 55, 110), + 'logged_at' => fake()->dateTimeBetween('-60 days', 'now')->format('Y-m-d'), + ]; + } +} diff --git a/database/migrations/2026_08_10_090532_create_weight_logs_table.php b/database/migrations/2026_08_10_090532_create_weight_logs_table.php new file mode 100644 index 0000000..4fe9249 --- /dev/null +++ b/database/migrations/2026_08_10_090532_create_weight_logs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->decimal('weight_kg', 5, 2); + $table->date('logged_at'); + $table->timestamps(); + + $table->unique(['user_id', 'logged_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('weight_logs'); + } +}; diff --git a/lang/en/app.php b/lang/en/app.php index 07fb39b..cfaed6f 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -231,6 +231,19 @@ return [ 'activity_30d_help' => 'Number of sessions per day (higher is better).', ], + 'weight_log' => [ + 'title' => 'Body Weight', + 'change_since_first' => 'Change since your first entry:', + 'weight_kg' => 'Weight (kg)', + 'date' => 'Date', + 'log' => 'Log', + 'none' => 'No weight entries yet. Log your first one to start tracking your trend.', + 'saved' => 'Weight logged', + 'deleted' => 'Entry deleted', + 'delete' => 'Delete', + 'confirm_delete' => 'Delete this entry?', + ], + 'admin' => [ 'title' => 'Admin Dashboard', 'subtitle' => 'Manage content and see overall site statistics.', diff --git a/lang/ro/app.php b/lang/ro/app.php index 0586de6..c119d78 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -231,6 +231,19 @@ return [ 'activity_30d_help' => 'Număr de sesiuni pe zi (mai mult este mai bine).', ], + 'weight_log' => [ + 'title' => 'Greutate corporală', + 'change_since_first' => 'Schimbare de la prima înregistrare:', + 'weight_kg' => 'Greutate (kg)', + 'date' => 'Data', + 'log' => 'Adaugă', + 'none' => 'Nicio înregistrare încă. Adaugă prima pentru a-ți urmări evoluția.', + 'saved' => 'Greutate înregistrată', + 'deleted' => 'Înregistrare ștearsă', + 'delete' => 'Șterge', + 'confirm_delete' => 'Ștergi această înregistrare?', + ], + 'admin' => [ 'title' => 'Panou de administrare', 'subtitle' => 'Gestionează conținutul și vezi statisticile generale ale site‑ului.', diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8d58be7..0a5befc 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -88,5 +88,51 @@ + +
+
+
+

{{ __('app.weight_log.title') }}

+ @if($weightChange !== null) +

+ {{ __('app.weight_log.change_since_first') }} + + {{ $weightChange > 0 ? '+' : '' }}{{ $weightChange }} kg + +

+ @endif +
+
+ @csrf +
+ + +
+
+ + +
+ +
+
+ + @if($weightLogs->isEmpty()) +
{{ __('app.weight_log.none') }}
+ @else +
+ @foreach($weightLogs->reverse()->take(10) as $log) +
+ {{ rtrim(rtrim(number_format($log->weight_kg, 1), '0'), '.') }} kg + {{ $log->logged_at->format('M j') }} +
+ @csrf + @method('DELETE') + +
+
+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index a71262b..2e0f23b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,10 @@ Route::middleware('auth')->group(function () { // Exercise alias (per-user rename) Route::post('/exercises/{exercise}/alias', [\App\Http\Controllers\ExerciseAliasController::class, 'store'])->name('exercises.alias'); + + // Body weight log + Route::post('/weight-logs', [\App\Http\Controllers\WeightLogController::class, 'store'])->name('weight-logs.store'); + Route::delete('/weight-logs/{weightLog}', [\App\Http\Controllers\WeightLogController::class, 'destroy'])->name('weight-logs.destroy'); }); Route::prefix('/exercises')->group(function () { diff --git a/tests/Feature/WeightLogTest.php b/tests/Feature/WeightLogTest.php new file mode 100644 index 0000000..63c743e --- /dev/null +++ b/tests/Feature/WeightLogTest.php @@ -0,0 +1,89 @@ +post(route('weight-logs.store'), ['weight_kg' => 80]); + + $response->assertRedirect(route('login')); + } + + public function test_user_can_log_weight_for_today(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('weight-logs.store'), [ + 'weight_kg' => 82.5, + ]); + + $response->assertRedirect(); + $entry = WeightLog::where('user_id', $user->id)->first(); + $this->assertNotNull($entry); + $this->assertEquals(82.5, $entry->weight_kg); + $this->assertEquals(now()->toDateString(), $entry->logged_at->toDateString()); + } + + public function test_logging_twice_for_the_same_date_updates_the_entry(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('weight-logs.store'), [ + 'weight_kg' => 80, + 'logged_at' => '2026-01-01', + ]); + $this->actingAs($user)->post(route('weight-logs.store'), [ + 'weight_kg' => 81, + 'logged_at' => '2026-01-01', + ]); + + $this->assertEquals(1, WeightLog::where('user_id', $user->id)->count()); + $entry = WeightLog::where('user_id', $user->id)->first(); + $this->assertEquals(81, $entry->weight_kg); + $this->assertEquals('2026-01-01', $entry->logged_at->toDateString()); + } + + public function test_future_date_is_rejected(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('weight-logs.store'), [ + 'weight_kg' => 80, + 'logged_at' => now()->addDay()->toDateString(), + ]); + + $response->assertSessionHasErrors('logged_at'); + } + + public function test_owner_can_delete_their_entry(): void + { + $user = User::factory()->create(); + $log = WeightLog::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->delete(route('weight-logs.destroy', $log)); + + $response->assertRedirect(); + $this->assertDatabaseMissing('weight_logs', ['id' => $log->id]); + } + + public function test_other_user_cannot_delete_someone_elses_entry(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $log = WeightLog::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->delete(route('weight-logs.destroy', $log)); + + $response->assertForbidden(); + $this->assertDatabaseHas('weight_logs', ['id' => $log->id]); + } +}