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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user