Files
fitness/app/Support/Gamification.php
rootandClaude Sonnet 5 a9923f72ce 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>
2026-08-10 12:44:24 +03:00

129 lines
4.0 KiB
PHP

<?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;
}
}