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:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\WorkoutSession;
|
use App\Models\WorkoutSession;
|
||||||
|
use App\Support\Gamification;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
@@ -55,6 +56,9 @@ class DashboardController extends Controller
|
|||||||
? round($weightLogs->last()->weight_kg - $weightLogs->first()->weight_kg, 2)
|
? round($weightLogs->last()->weight_kg - $weightLogs->first()->weight_kg, 2)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Streaks and badges
|
||||||
|
$gamification = Gamification::forUser($user);
|
||||||
|
|
||||||
return view('dashboard', compact(
|
return view('dashboard', compact(
|
||||||
'workoutsCount',
|
'workoutsCount',
|
||||||
'sessionsCount',
|
'sessionsCount',
|
||||||
@@ -63,7 +67,8 @@ class DashboardController extends Controller
|
|||||||
'recentSessions',
|
'recentSessions',
|
||||||
'dailyActivity',
|
'dailyActivity',
|
||||||
'weightLogs',
|
'weightLogs',
|
||||||
'weightChange'
|
'weightChange',
|
||||||
|
'gamification'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use App\Models\WorkoutSessionItem;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class Gamification
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{current_streak: int, longest_streak: int, badges: array<int, array{key: string, name: string, description: string, earned: bool}>}
|
||||||
|
*/
|
||||||
|
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<int, string> $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<int, string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,7 +228,11 @@ return [
|
|||||||
'sessions' => 'Sessions',
|
'sessions' => 'Sessions',
|
||||||
'completed' => 'Completed',
|
'completed' => 'Completed',
|
||||||
'completion_rate' => 'Completion rate',
|
'completion_rate' => 'Completion rate',
|
||||||
|
'streak' => 'Current streak',
|
||||||
],
|
],
|
||||||
|
'days' => 'days',
|
||||||
|
'best_streak' => 'Best',
|
||||||
|
'badges' => 'Badges',
|
||||||
'recent_sessions' => 'Recent Sessions',
|
'recent_sessions' => 'Recent Sessions',
|
||||||
'no_sessions' => 'No sessions yet. Start a workout to track your progress.',
|
'no_sessions' => 'No sessions yet. Start a workout to track your progress.',
|
||||||
'completed' => 'Completed',
|
'completed' => 'Completed',
|
||||||
|
|||||||
@@ -228,7 +228,11 @@ return [
|
|||||||
'sessions' => 'Sesiuni',
|
'sessions' => 'Sesiuni',
|
||||||
'completed' => 'Finalizate',
|
'completed' => 'Finalizate',
|
||||||
'completion_rate' => 'Rată de finalizare',
|
'completion_rate' => 'Rată de finalizare',
|
||||||
|
'streak' => 'Serie curentă',
|
||||||
],
|
],
|
||||||
|
'days' => 'zile',
|
||||||
|
'best_streak' => 'Record',
|
||||||
|
'badges' => 'Insigne',
|
||||||
'recent_sessions' => 'Sesiuni recente',
|
'recent_sessions' => 'Sesiuni recente',
|
||||||
'no_sessions' => 'Încă nu există sesiuni. Începe un antrenament pentru a-ți urmări progresul.',
|
'no_sessions' => 'Încă nu există sesiuni. Începe un antrenament pentru a-ți urmări progresul.',
|
||||||
'completed' => 'Finalizată',
|
'completed' => 'Finalizată',
|
||||||
|
|||||||
@@ -11,7 +11,14 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
<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="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="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>
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $workoutsCount }}</div>
|
||||||
@@ -134,5 +141,25 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workout;
|
||||||
|
use App\Models\WorkoutExercise;
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use App\Models\WorkoutSessionItem;
|
||||||
|
use App\Support\Gamification;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GamificationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function completeSession(User $user, Workout $workout, string $completedAt): WorkoutSession
|
||||||
|
{
|
||||||
|
return WorkoutSession::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user