StreakFit — API Laravel 13 OSDD + Octane/FrankenPHP
Build & Deploy / build (push) Successful in 18s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:17 +02:00
co-authored by Claude Opus 4.8
commit af32669931
300 changed files with 21235 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/streaks",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Streaks\\": "src/",
"Functional\\Streaks\\Database\\Seeders\\": "database/seeders/",
"Functional\\Streaks\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Streaks\\Providers\\StreaksServiceProvider"
]
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Streaks\Database\Factories;
use Functional\Streaks\Models\Achievement;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Achievement>
*/
class AchievementFactory extends Factory
{
protected $model = Achievement::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'key' => fake()->randomElement(['first_workout', 'streak_3', 'streak_7', 'streak_30', 'streak_100']),
'earned_at' => fake()->dateTimeBetween('-90 days', 'now'),
'meta' => null,
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace Functional\Streaks\Database\Factories;
use Functional\Streaks\Enums\GoalPeriod;
use Functional\Streaks\Enums\GoalType;
use Functional\Streaks\Models\Goal;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Goal>
*/
class GoalFactory extends Factory
{
protected $model = Goal::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$type = fake()->randomElement(GoalType::cases());
return [
'user_id' => User::factory(),
'type' => $type,
'target' => match ($type) {
GoalType::WeeklySessions => fake()->numberBetween(2, 6),
GoalType::WeeklyMinutes => fake()->numberBetween(60, 300),
GoalType::WeeklyVolume => fake()->numberBetween(1000, 10000),
},
'period' => GoalPeriod::Week,
'is_active' => fake()->boolean(85),
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace Functional\Streaks\Database\Factories;
use Functional\Streaks\Enums\StreakDaySource;
use Functional\Streaks\Models\StreakDay;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<StreakDay>
*/
class StreakDayFactory extends Factory
{
protected $model = StreakDay::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'date' => fake()->unique()->dateTimeBetween('-60 days', 'now')->format('Y-m-d'),
'source' => fake()->randomElement(StreakDaySource::cases()),
'workout_session_id' => null,
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Streaks\Database\Factories;
use Functional\Streaks\Models\Streak;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Streak>
*/
class StreakFactory extends Factory
{
protected $model = Streak::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$current = fake()->numberBetween(0, 60);
return [
'user_id' => User::factory(),
'current_count' => $current,
'longest_count' => max($current, fake()->numberBetween(0, 120)),
'last_active_date' => $current > 0
? fake()->dateTimeBetween('-2 days', 'now')->format('Y-m-d')
: null,
'freezes_available' => fake()->numberBetween(0, 3),
'freeze_last_granted_on' => fake()->optional(0.4)->dateTimeBetween('-30 days', 'now')?->format('Y-m-d'),
];
}
}
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('streaks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained();
$table->unsignedInteger('current_count')->default(0);
$table->unsignedInteger('longest_count')->default(0);
$table->date('last_active_date')->nullable();
$table->unsignedInteger('freezes_available')->default(0);
$table->date('freeze_last_granted_on')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('streaks');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('streak_days', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->date('date');
$table->string('source')->default('workout'); // cast PHP StreakDaySource (workout|manual|freeze|rest)
$table->foreignId('workout_session_id')->nullable()->constrained('workout_sessions');
$table->timestamps();
$table->unique(['user_id', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('streak_days');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('goals', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('type'); // cast PHP GoalType (weekly_sessions|weekly_minutes|weekly_volume)
$table->unsignedInteger('target');
$table->string('period')->default('week'); // cast PHP GoalPeriod
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('goals');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('achievements', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('key'); // ex: first_workout, streak_3, streak_7, streak_30, streak_100
$table->dateTime('earned_at');
$table->json('meta')->nullable();
$table->timestamps();
$table->unique(['user_id', 'key']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('achievements');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Streaks\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class StreaksSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
use Functional\Streaks\Http\Controllers\AchievementsController;
use Functional\Streaks\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'api' middleware group with the 'api'
// prefix by the layer service provider. To version your endpoints
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
// Scopés à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/achievements', [AchievementsController::class, 'index']);
});
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
// Define broadcast channels here, e.g.:
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'web' middleware group by the layer
// service provider. For custom groups (prefix, domain, named groups, ...)
// use Route::middleware(...)->prefix(...)->group(function () { ... });
@@ -0,0 +1,12 @@
<?php
namespace Functional\Streaks\Enums;
/**
* Période sur laquelle porte un objectif. Stocké en base comme string ("no-db-enums") :
* le cast Eloquent fait le lien avec ce PHP enum.
*/
enum GoalPeriod: string
{
case Week = 'week';
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Functional\Streaks\Enums;
/**
* Type d'objectif hebdomadaire suivi par le user. Stocké en base comme string ("no-db-enums") :
* le cast Eloquent fait le lien avec ce PHP enum.
*/
enum GoalType: string
{
case WeeklySessions = 'weekly_sessions';
case WeeklyMinutes = 'weekly_minutes';
case WeeklyVolume = 'weekly_volume';
}
@@ -0,0 +1,15 @@
<?php
namespace Functional\Streaks\Enums;
/**
* Origine d'un jour compté dans le streak. Stocké en base comme string ("no-db-enums") :
* le cast Eloquent fait le lien avec ce PHP enum.
*/
enum StreakDaySource: string
{
case Workout = 'workout';
case Manual = 'manual';
case Freeze = 'freeze';
case Rest = 'rest';
}
@@ -0,0 +1,26 @@
<?php
namespace Functional\Streaks\Http\Controllers;
use Functional\Streaks\Services\DashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Liste complète des badges débloqués par l'utilisateur authentifié.
*/
class AchievementsController
{
public function __construct(
private readonly DashboardService $dashboardService,
) {
}
/** GET /api/achievements */
public function index(Request $request): JsonResponse
{
return response()->json([
'data' => $this->dashboardService->achievementsList($request->user()),
]);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Functional\Streaks\Http\Controllers;
use Functional\Streaks\Services\DashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Vue "prêtes à afficher" du dashboard streak de l'utilisateur authentifié.
* Toute la logique de calcul vit dans DashboardService ce contrôleur ne fait qu'orchestrer.
*/
class DashboardController
{
public function __construct(
private readonly DashboardService $dashboardService,
) {
}
/** GET /api/dashboard */
public function index(Request $request): JsonResponse
{
return response()->json($this->dashboardService->build($request->user()));
}
}
@@ -0,0 +1,29 @@
<?php
namespace Functional\Streaks\Listeners;
use Functional\Streaks\Services\StreakService;
use Functional\Tracking\Events\WorkoutCompleted;
/**
* Synchronise le streak du user à chaque séance d'entraînement terminée (couche tracking).
*/
class UpdateStreakOnWorkoutCompleted
{
public function __construct(
private readonly StreakService $streakService,
) {
}
public function handle(WorkoutCompleted $event): void
{
$session = $event->workoutSession;
$this->streakService->recordActivity(
$session->user,
$session->started_at ?? now(),
'workout',
$session->id,
);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Functional\Streaks\Models;
use Functional\Streaks\Database\Factories\AchievementFactory;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Badge débloqué par un user à un palier donné (ex: streak_7, streak_30, first_workout).
* Unique par (user, key) : un badge n'est débloqué qu'une seule fois.
*
* @property int $user_id
* @property string $key
* @property \Illuminate\Support\Carbon $earned_at
* @property array|null $meta
*/
#[UseFactory(AchievementFactory::class)]
class Achievement extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'key', 'earned_at', 'meta',
];
protected $casts = [
'earned_at' => 'datetime',
'meta' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Functional\Streaks\Models;
use Functional\Streaks\Database\Factories\GoalFactory;
use Functional\Streaks\Enums\GoalPeriod;
use Functional\Streaks\Enums\GoalType;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Objectif hebdomadaire fixé par le user (nombre de séances, minutes ou volume).
*
* @property int $user_id
* @property GoalType $type
* @property int $target
* @property GoalPeriod $period
* @property bool $is_active
*/
#[UseFactory(GoalFactory::class)]
class Goal extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'type', 'target', 'period', 'is_active',
];
protected $casts = [
'type' => GoalType::class,
'target' => 'integer',
'period' => GoalPeriod::class,
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Functional\Streaks\Models;
use Functional\Streaks\Database\Factories\StreakFactory;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Streak courant d'un utilisateur (une ligne par user) : régularité d'entraînement, gamification.
*
* @property int $user_id
* @property int $current_count
* @property int $longest_count
* @property \Illuminate\Support\Carbon|null $last_active_date
* @property int $freezes_available
* @property \Illuminate\Support\Carbon|null $freeze_last_granted_on
*/
#[UseFactory(StreakFactory::class)]
class Streak extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'current_count', 'longest_count', 'last_active_date',
'freezes_available', 'freeze_last_granted_on',
];
protected $casts = [
'current_count' => 'integer',
'longest_count' => 'integer',
'last_active_date' => 'date',
'freezes_available' => 'integer',
'freeze_last_granted_on' => 'date',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Functional\Streaks\Models;
use Functional\Streaks\Database\Factories\StreakDayFactory;
use Functional\Streaks\Enums\StreakDaySource;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Un jour d'activité compté pour le streak (séance réelle, saisie manuelle, freeze ou repos planifié).
* Unique par (user, date) : au plus un jour par user et par date.
*
* @property int $user_id
* @property \Illuminate\Support\Carbon $date
* @property StreakDaySource $source
* @property int|null $workout_session_id
*/
#[UseFactory(StreakDayFactory::class)]
class StreakDay extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'date', 'source', 'workout_session_id',
];
protected $casts = [
'date' => 'date',
'source' => StreakDaySource::class,
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Functional\Streaks\Providers;
use Functional\Streaks\Database\Seeders\StreaksSeeder;
use Functional\Streaks\Listeners\UpdateStreakOnWorkoutCompleted;
use Functional\Streaks\Services\StreakService;
use Functional\Tracking\Events\WorkoutCompleted;
use Illuminate\Support\Facades\Event;
use Xefi\LaravelOSDD\LayerServiceProvider;
class StreaksServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([StreaksSeeder::class]);
}
$this->withRouting(
web: __DIR__ . '/../../routes/web.php',
api: __DIR__ . '/../../routes/api.php',
commands: __DIR__ . '/../../routes/console.php',
channels: __DIR__ . '/../../routes/channels.php',
);
$this->loadSeeders([\Functional\Streaks\Database\Seeders\StreaksSeeder::class]);
// Le streak se met à jour à chaque séance terminée (événement porté par la couche tracking).
Event::listen(WorkoutCompleted::class, UpdateStreakOnWorkoutCompleted::class);
}
public function register(): void
{
$this->app->singleton(StreakService::class);
}
}
@@ -0,0 +1,197 @@
<?php
namespace Functional\Streaks\Services;
use Carbon\CarbonImmutable;
use Functional\Routines\Models\Routine;
use Functional\Streaks\Enums\GoalType;
use Functional\Streaks\Models\Achievement;
use Functional\Streaks\Models\Goal;
use Functional\Streaks\Models\Streak;
use Functional\Streaks\Models\StreakDay;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Users\Models\User;
/**
* Construit le view-model "prêt-à-afficher" du dashboard (GET /api/dashboard) et la liste
* des achievements (GET /api/achievements). Toute la logique de présentation du streak vit ici,
* ni dans le contrôleur, ni dans le front.
*/
class DashboardService
{
private const RECENT_SESSIONS_LIMIT = 5;
private const RECENT_ACHIEVEMENTS_LIMIT = 10;
private const DEFAULT_WEEKLY_GOAL = 3;
private const DASHBOARD_WINDOW_DAYS = 7;
/** Paliers du current streak vers un flame_level 0..5 (index dans ce tableau = niveau). */
private const FLAME_THRESHOLDS = [0, 1, 3, 7, 30, 100];
public function build(User $user): array
{
return [
'streak' => $this->streakOverview($user),
'week' => $this->weekOverview($user),
'recent_sessions' => $this->recentSessions($user),
'achievements' => $this->achievementsList($user, self::RECENT_ACHIEVEMENTS_LIMIT),
'suggestion' => $this->suggestion($user),
];
}
/** Liste complète (ou limitée) des badges de l'utilisateur, avec leur libellé localisé. */
public function achievementsList(User $user, ?int $limit = null): array
{
$query = Achievement::query()->where('user_id', $user->id)->orderByDesc('earned_at');
if ($limit) {
$query->limit($limit);
}
return $query->get()
->map(fn (Achievement $achievement) => $this->formatAchievement($achievement, $user))
->values()
->all();
}
private function streakOverview(User $user): array
{
$streak = Streak::query()->where('user_id', $user->id)->first();
$currentCount = $streak->current_count ?? 0;
return [
'current' => $currentCount,
'longest' => $streak->longest_count ?? 0,
'freezes_available' => $streak->freezes_available ?? 0,
'last_active_date' => optional($streak?->last_active_date)->toDateString(),
'flame_level' => $this->flameLevel($currentCount),
'active_today' => $this->isActiveToday($streak),
];
}
private function weekOverview(User $user): array
{
$today = CarbonImmutable::today();
$windowStart = $today->subDays(self::DASHBOARD_WINDOW_DAYS - 1);
$streakDaysByDate = StreakDay::query()
->where('user_id', $user->id)
->whereBetween('date', [$windowStart->toDateString(), $today->toDateString()])
->get()
->keyBy(fn (StreakDay $streakDay) => $streakDay->date->toDateString());
$days = [];
for ($offset = self::DASHBOARD_WINDOW_DAYS - 1; $offset >= 0; $offset--) {
$date = $today->subDays($offset);
$streakDay = $streakDaysByDate->get($date->toDateString());
$days[] = [
'date' => $date->toDateString(),
'done' => $streakDay !== null,
'source' => $streakDay?->source->value,
];
}
$activeGoal = Goal::query()
->where('user_id', $user->id)
->where('type', GoalType::WeeklySessions)
->where('is_active', true)
->first();
$doneSessions = WorkoutSession::query()
->where('user_id', $user->id)
->where('status', WorkoutSessionStatus::Completed)
->whereBetween('started_at', [$windowStart->startOfDay(), $today->endOfDay()])
->count();
return [
'goal_sessions' => $activeGoal->target ?? self::DEFAULT_WEEKLY_GOAL,
'done_sessions' => $doneSessions,
'days' => $days,
];
}
private function recentSessions(User $user): array
{
return WorkoutSession::query()
->where('user_id', $user->id)
->where('status', WorkoutSessionStatus::Completed)
->withCount('setLogs')
->orderByDesc('started_at')
->limit(self::RECENT_SESSIONS_LIMIT)
->get()
->map(fn (WorkoutSession $session) => [
'id' => $session->id,
'title' => $session->title,
'started_at' => $session->started_at,
'total_volume' => $session->total_volume,
'set_count' => $session->set_logs_count,
])
->values()
->all();
}
/** Proposition de prochaine séance : repos si déjà actif aujourd'hui, sinon une routine du user. */
private function suggestion(User $user): array
{
$locale = $user->locale ?? 'fr';
$streak = Streak::query()->where('user_id', $user->id)->first();
if ($this->isActiveToday($streak)) {
return [
'type' => 'rest',
'label' => __('dashboard.suggestion_rest', [], $locale),
];
}
$routine = Routine::query()->where('user_id', $user->id)->orderBy('name')->first();
if (! $routine) {
return [
'type' => 'rest',
'label' => __('dashboard.suggestion_no_routine', [], $locale),
];
}
return [
'type' => 'routine',
'routine_id' => $routine->id,
'label' => __('dashboard.suggestion_routine', ['name' => $routine->name], $locale),
];
}
private function isActiveToday(?Streak $streak): bool
{
if (! $streak?->last_active_date) {
return false;
}
return $streak->last_active_date->isSameDay(CarbonImmutable::today());
}
private function flameLevel(int $currentStreak): int
{
$level = 0;
foreach (self::FLAME_THRESHOLDS as $index => $threshold) {
if ($currentStreak >= $threshold) {
$level = $index;
}
}
return $level;
}
private function formatAchievement(Achievement $achievement, User $user): array
{
return [
'key' => $achievement->key,
'earned_at' => $achievement->earned_at,
'label' => __("achievements.{$achievement->key}", [], $user->locale ?? 'fr'),
];
}
}
@@ -0,0 +1,108 @@
<?php
namespace Functional\Streaks\Services;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Functional\Streaks\Enums\StreakDaySource;
use Functional\Streaks\Models\Achievement;
use Functional\Streaks\Models\Streak;
use Functional\Streaks\Models\StreakDay;
use Functional\Users\Models\User;
/**
* Cœur de la gamification StreakFit : enregistre une activité et recalcule le streak courant du user.
*/
class StreakService
{
/** Paliers d'achievements débloqués selon le streak courant atteint. */
private const MILESTONES = [
'first_workout' => 1,
'streak_3' => 3,
'streak_7' => 7,
'streak_30' => 30,
'streak_100' => 100,
];
/**
* Enregistre une activité pour une date donnée et recalcule le streak du user.
* Idempotent : rejouer la même date ne crée pas de doublon et ne modifie pas le compteur.
*/
public function recordActivity(
User $user,
CarbonInterface $date,
string $source = 'workout',
?int $workoutSessionId = null,
): Streak {
// Comparaisons de dates uniquement (pas d'heure) : on travaille sur un jour calendaire.
$activityDate = Carbon::parse($date)->startOfDay();
// Un seul streak_day par (user, date) : no-op si déjà enregistré (unique côté migration).
StreakDay::firstOrCreate(
['user_id' => $user->id, 'date' => $activityDate->toDateString()],
['source' => StreakDaySource::from($source), 'workout_session_id' => $workoutSessionId],
);
$streak = Streak::firstOrCreate(['user_id' => $user->id]);
$streak->current_count = $this->nextCurrentCount($streak, $activityDate);
$streak->longest_count = max($streak->longest_count, $streak->current_count);
$streak->last_active_date = $activityDate->toDateString();
$streak->save();
$this->awardMilestones($user, $streak);
return $streak;
}
/**
* Calcule le nouveau compteur courant en comparant last_active_date et la date d'activité,
* au jour près (Y-m-d), pas en datetime.
*/
private function nextCurrentCount(Streak $streak, Carbon $activityDate): int
{
if (! $streak->last_active_date) {
return 1;
}
$lastActiveDate = Carbon::parse($streak->last_active_date)->startOfDay();
$yesterday = $activityDate->copy()->subDay();
// Jour consécutif : le streak continue.
if ($lastActiveDate->isSameDay($yesterday)) {
return $streak->current_count + 1;
}
// Déjà comptée aujourd'hui : rejouer l'événement ne change rien.
if ($lastActiveDate->isSameDay($activityDate)) {
return $streak->current_count;
}
// Trou dans le streak : un freeze ne couvre qu'un unique jour manqué (écart de 2 jours).
$gapInDays = $lastActiveDate->diffInDays($activityDate);
if ($streak->freezes_available > 0 && $gapInDays === 2) {
$streak->freezes_available -= 1;
return $streak->current_count + 1;
}
// Trou trop grand (ou pas de freeze disponible) : le streak repart de zéro.
return 1;
}
/** Débloque les achievements dont le palier est atteint (idempotent via unique user+key). */
private function awardMilestones(User $user, Streak $streak): void
{
foreach (self::MILESTONES as $key => $threshold) {
if ($streak->current_count < $threshold) {
continue;
}
Achievement::firstOrCreate(
['user_id' => $user->id, 'key' => $key],
['earned_at' => now()],
);
}
}
}