Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
+33
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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.'));
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user