diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 057030c..d4e78a5 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\WorkoutSession; +use App\Support\Gamification; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; @@ -55,6 +56,9 @@ class DashboardController extends Controller ? round($weightLogs->last()->weight_kg - $weightLogs->first()->weight_kg, 2) : null; + // Streaks and badges + $gamification = Gamification::forUser($user); + return view('dashboard', compact( 'workoutsCount', 'sessionsCount', @@ -63,7 +67,8 @@ class DashboardController extends Controller 'recentSessions', 'dailyActivity', 'weightLogs', - 'weightChange' + 'weightChange', + 'gamification' )); } } diff --git a/app/Support/Gamification.php b/app/Support/Gamification.php new file mode 100644 index 0000000..20ea3c8 --- /dev/null +++ b/app/Support/Gamification.php @@ -0,0 +1,128 @@ +} + */ + public static function forUser(User $user): array + { + $completedDates = WorkoutSession::where('user_id', $user->id) + ->whereNotNull('completed_at') + ->pluck('completed_at') + ->map(fn ($dt) => $dt->toDateString()) + ->unique() + ->sort() + ->values(); + + $currentStreak = self::currentStreak($completedDates); + $longestStreak = self::longestStreak($completedDates); + + $completedSessions = $completedDates->isNotEmpty() + ? WorkoutSession::where('user_id', $user->id)->whereNotNull('completed_at')->count() + : 0; + + $hasCenturyLift = WorkoutSessionItem::whereHas('session', fn ($q) => $q->where('user_id', $user->id)) + ->where('done', true) + ->where('actual_weight', '>=', 100) + ->exists(); + + $badges = [ + [ + 'key' => 'first_steps', + 'name' => 'First Steps', + 'description' => 'Complete your first workout session.', + 'earned' => $completedSessions >= 1, + ], + [ + 'key' => 'consistent', + 'name' => 'Consistent', + 'description' => 'Complete 10 workout sessions.', + 'earned' => $completedSessions >= 10, + ], + [ + 'key' => 'dedicated', + 'name' => 'Dedicated', + 'description' => 'Complete 50 workout sessions.', + 'earned' => $completedSessions >= 50, + ], + [ + 'key' => 'week_warrior', + 'name' => 'Week Warrior', + 'description' => 'Reach a 7-day training streak.', + 'earned' => $longestStreak >= 7, + ], + [ + 'key' => 'iron_will', + 'name' => 'Iron Will', + 'description' => 'Reach a 30-day training streak.', + 'earned' => $longestStreak >= 30, + ], + [ + 'key' => 'century_club', + 'name' => 'Century Club', + 'description' => 'Log 100kg or more on a completed set.', + 'earned' => $hasCenturyLift, + ], + ]; + + return [ + 'current_streak' => $currentStreak, + 'longest_streak' => $longestStreak, + 'badges' => $badges, + ]; + } + + /** + * @param \Illuminate\Support\Collection $completedDates Sorted, unique Y-m-d strings. + */ + private static function currentStreak($completedDates): int + { + if ($completedDates->isEmpty()) { + return 0; + } + + $dateSet = $completedDates->flip(); + $cursor = Carbon::today(); + + // A missed "today" doesn't break the streak until tomorrow - only walk + // back from yesterday if today has no completed session yet. + if (!$dateSet->has($cursor->toDateString())) { + $cursor = $cursor->copy()->subDay(); + } + + $streak = 0; + while ($dateSet->has($cursor->toDateString())) { + $streak++; + $cursor = $cursor->copy()->subDay(); + } + + return $streak; + } + + /** + * @param \Illuminate\Support\Collection $completedDates Sorted, unique Y-m-d strings. + */ + private static function longestStreak($completedDates): int + { + $longest = 0; + $run = 0; + $previous = null; + + foreach ($completedDates as $dateStr) { + $date = Carbon::parse($dateStr); + $run = ($previous && (int) abs($date->diffInDays($previous)) === 1) ? $run + 1 : 1; + $longest = max($longest, $run); + $previous = $date; + } + + return $longest; + } +} diff --git a/lang/en/app.php b/lang/en/app.php index dcdb373..1f541be 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -228,7 +228,11 @@ return [ 'sessions' => 'Sessions', 'completed' => 'Completed', 'completion_rate' => 'Completion rate', + 'streak' => 'Current streak', ], + 'days' => 'days', + 'best_streak' => 'Best', + 'badges' => 'Badges', 'recent_sessions' => 'Recent Sessions', 'no_sessions' => 'No sessions yet. Start a workout to track your progress.', 'completed' => 'Completed', diff --git a/lang/ro/app.php b/lang/ro/app.php index 5084f64..dd10598 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -228,7 +228,11 @@ return [ 'sessions' => 'Sesiuni', 'completed' => 'Finalizate', 'completion_rate' => 'Rată de finalizare', + 'streak' => 'Serie curentă', ], + 'days' => 'zile', + 'best_streak' => 'Record', + 'badges' => 'Insigne', 'recent_sessions' => 'Sesiuni recente', 'no_sessions' => 'Încă nu există sesiuni. Începe un antrenament pentru a-ți urmări progresul.', 'completed' => 'Finalizată', diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 0a5befc..a1afcd1 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -11,7 +11,14 @@
-
+
+
+
{{ __('app.dashboard.metrics.streak') }}
+
{{ $gamification['current_streak'] }} {{ __('app.dashboard.days') }}
+ @if($gamification['longest_streak'] > $gamification['current_streak']) +
{{ __('app.dashboard.best_streak') }}: {{ $gamification['longest_streak'] }} {{ __('app.dashboard.days') }}
+ @endif +
{{ __('app.dashboard.metrics.workouts') }}
{{ $workoutsCount }}
@@ -134,5 +141,25 @@
@endif
+ +
+

{{ __('app.dashboard.badges') }}

+
+ @foreach($gamification['badges'] as $badge) +
+
+ + @if($badge['earned']) + + @else + + @endif + +
+
{{ $badge['name'] }}
+
+ @endforeach +
+
diff --git a/tests/Feature/GamificationTest.php b/tests/Feature/GamificationTest.php new file mode 100644 index 0000000..09c9103 --- /dev/null +++ b/tests/Feature/GamificationTest.php @@ -0,0 +1,168 @@ +create([ + 'user_id' => $user->id, + 'workout_id' => $workout->id, + 'completed_at' => $completedAt, + ]); + } + + public function test_user_with_no_sessions_has_no_streak_or_badges(): void + { + $user = User::factory()->create(); + + $result = Gamification::forUser($user); + + $this->assertEquals(0, $result['current_streak']); + $this->assertEquals(0, $result['longest_streak']); + $this->assertTrue(collect($result['badges'])->every(fn ($b) => $b['earned'] === false)); + } + + public function test_a_session_completed_today_counts_as_a_one_day_streak(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $this->completeSession($user, $workout, now()->toDateString()); + + $result = Gamification::forUser($user); + + $this->assertEquals(1, $result['current_streak']); + } + + public function test_consecutive_days_extend_the_current_streak(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $this->completeSession($user, $workout, now()->toDateString()); + $this->completeSession($user, $workout, now()->subDay()->toDateString()); + $this->completeSession($user, $workout, now()->subDays(2)->toDateString()); + + $result = Gamification::forUser($user); + + $this->assertEquals(3, $result['current_streak']); + } + + public function test_a_gap_breaks_the_current_streak_but_longest_streak_remembers_it(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + // A 3-day streak, a week ago, with a gap since. + $this->completeSession($user, $workout, now()->subDays(10)->toDateString()); + $this->completeSession($user, $workout, now()->subDays(9)->toDateString()); + $this->completeSession($user, $workout, now()->subDays(8)->toDateString()); + // Today, isolated. + $this->completeSession($user, $workout, now()->toDateString()); + + $result = Gamification::forUser($user); + + $this->assertEquals(1, $result['current_streak']); + $this->assertEquals(3, $result['longest_streak']); + } + + public function test_missing_only_today_does_not_break_the_streak_yet(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $this->completeSession($user, $workout, now()->subDay()->toDateString()); + $this->completeSession($user, $workout, now()->subDays(2)->toDateString()); + + $result = Gamification::forUser($user); + + $this->assertEquals(2, $result['current_streak']); + } + + public function test_first_steps_badge_earned_after_one_completed_session(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $this->completeSession($user, $workout, now()->toDateString()); + + $badges = collect(Gamification::forUser($user)['badges'])->keyBy('key'); + + $this->assertTrue($badges['first_steps']['earned']); + $this->assertFalse($badges['consistent']['earned']); + } + + public function test_consistent_badge_earned_after_ten_completed_sessions(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + for ($i = 0; $i < 10; $i++) { + WorkoutSession::factory()->create([ + 'user_id' => $user->id, + 'workout_id' => $workout->id, + 'completed_at' => now()->subDays($i * 3)->toDateString(), + ]); + } + + $badges = collect(Gamification::forUser($user)['badges'])->keyBy('key'); + + $this->assertTrue($badges['consistent']['earned']); + } + + public function test_century_club_badge_earned_from_a_completed_hundred_kilo_set(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item = WorkoutExercise::factory()->create(['workout_id' => $workout->id]); + $session = WorkoutSession::factory()->create(['user_id' => $user->id, 'workout_id' => $workout->id]); + WorkoutSessionItem::factory()->create([ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + 'done' => true, + 'actual_weight' => 100, + ]); + + $badges = collect(Gamification::forUser($user)['badges'])->keyBy('key'); + + $this->assertTrue($badges['century_club']['earned']); + } + + public function test_century_club_badge_not_earned_from_an_incomplete_set(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item = WorkoutExercise::factory()->create(['workout_id' => $workout->id]); + $session = WorkoutSession::factory()->create(['user_id' => $user->id, 'workout_id' => $workout->id]); + WorkoutSessionItem::factory()->create([ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + 'done' => false, + 'actual_weight' => 150, + ]); + + $badges = collect(Gamification::forUser($user)['badges'])->keyBy('key'); + + $this->assertFalse($badges['century_club']['earned']); + } + + public function test_dashboard_renders_streak_and_badges(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $this->completeSession($user, $workout, now()->toDateString()); + + $response = $this->actingAs($user)->get(route('dashboard')); + + $response->assertOk(); + $response->assertSee('First Steps'); + $response->assertSee('1', false); + } +}