Add streaks and milestone badges

No gamification existed at all - nothing rewarded consistency or gave
users a reason to come back beyond the raw session stats already on
the dashboard. Adds a Gamification helper that derives (not stores)
a current/longest daily streak from completed WorkoutSessions and six
badges (first session, 10 and 50 sessions, 7- and 30-day streaks, a
100kg+ completed lift) from existing session/session-item data - no new
tables, since everything needed is already recorded.

Found and fixed a real Carbon 3 behavior change via testing: diffInDays()
no longer returns an absolute value by default (unlike Carbon 2) - it
returns a signed float. My longest-streak calculation used a strict
=== 1 check against the raw result, which silently always failed
because float(-1) or float(1) never strictly equals int 1. Fixed with
abs() plus an explicit (int) cast. Worth remembering for any other
diffIn*() usage in this codebase going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:44:24 +03:00
co-authored by Claude Sonnet 5
parent e74aa2bda4
commit a9923f72ce
6 changed files with 338 additions and 2 deletions
+28 -1
View File
@@ -11,7 +11,14 @@
</section>
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-gradient-to-br from-amber-500 to-orange-600 p-5 text-white">
<div class="text-sm text-white/80">{{ __('app.dashboard.metrics.streak') }}</div>
<div class="mt-2 text-3xl font-bold">{{ $gamification['current_streak'] }} <span class="text-base font-medium">{{ __('app.dashboard.days') }}</span></div>
@if($gamification['longest_streak'] > $gamification['current_streak'])
<div class="mt-1 text-xs text-white/80">{{ __('app.dashboard.best_streak') }}: {{ $gamification['longest_streak'] }} {{ __('app.dashboard.days') }}</div>
@endif
</div>
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
<div class="text-sm text-gray-500">{{ __('app.dashboard.metrics.workouts') }}</div>
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $workoutsCount }}</div>
@@ -134,5 +141,25 @@
</div>
@endif
</div>
<div class="mt-8 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">{{ __('app.dashboard.badges') }}</h2>
<div class="mt-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
@foreach($gamification['badges'] as $badge)
<div class="rounded-xl p-4 text-center ring-1 {{ $badge['earned'] ? 'ring-amber-300 dark:ring-amber-700 bg-amber-50 dark:bg-amber-900/20' : 'ring-gray-200 dark:ring-gray-700 bg-gray-50 dark:bg-gray-900/40 opacity-60' }}" title="{{ $badge['description'] }}">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full {{ $badge['earned'] ? 'bg-amber-400 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-400' }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
@if($badge['earned'])
<path d="M12 2l2.6 6.2L21 9l-5 4.4L17.4 20 12 16.6 6.6 20 8 13.4 3 9l6.4-.8L12 2z"/>
@else
<path fill-rule="evenodd" d="M12 1a4 4 0 00-4 4v2H7a2 2 0 00-2 2v9a2 2 0 002 2h10a2 2 0 002-2v-9a2 2 0 00-2-2h-1V5a4 4 0 00-4-4zm2 6V5a2 2 0 10-4 0v2h4z" clip-rule="evenodd"/>
@endif
</svg>
</div>
<div class="mt-2 text-xs font-semibold {{ $badge['earned'] ? 'text-amber-800 dark:text-amber-200' : 'text-gray-500' }}">{{ $badge['name'] }}</div>
</div>
@endforeach
</div>
</div>
</section>
</x-app-layout>