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/exercises",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Exercises\\": "src/",
"Functional\\Exercises\\Database\\Seeders\\": "database/seeders/",
"Functional\\Exercises\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Exercises\\Providers\\ExercisesServiceProvider"
]
}
}
}
@@ -0,0 +1,9 @@
<?php
return [
// Base URL publique des médias (RustFS/S3). Ex: https://s3.nfteam.ovh/streakfit
'media_base_url' => env('MEDIA_BASE_URL', 'https://s3.nfteam.ovh/streakfit'),
// Préfixe des objets média dans le bucket.
'media_prefix' => env('EXERCISES_MEDIA_PREFIX', 'exercises'),
];
@@ -0,0 +1,39 @@
<?php
namespace Functional\Exercises\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Exercise>
*/
class ExerciseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->words(3, true);
$ext = fake()->unique()->numerify('####');
return [
'external_id' => $ext,
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name . '-' . $ext),
'category' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'body_part' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'equipment' => fake()->randomElement(['body weight', 'dumbbell', 'barbell', 'cable', 'kettlebell']),
'target' => fake()->randomElement(['abs', 'pectorals', 'lats', 'quads', 'delts']),
'muscle_group' => fake()->word(),
'secondary_muscles' => fake()->randomElements(['hip flexors', 'lower back', 'triceps', 'glutes'], 2),
'instructions' => ['en' => [fake()->sentence()], 'fr' => [fake()->sentence()]],
'image_path' => 'exercises/' . $ext . '.jpg',
'gif_path' => 'exercises/' . $ext . '.gif',
'attribution' => '© Gym visual — https://gymvisual.com/',
];
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('exercises', function (Blueprint $table) {
$table->id();
$table->string('external_id')->unique(); // "0001" du dataset
$table->string('name');
$table->string('slug')->index();
$table->string('category')->nullable()->index(); // waist, chest...
$table->string('body_part')->nullable()->index(); // upper arms, back...
$table->string('equipment')->nullable()->index(); // dumbbell, body weight...
$table->string('target')->nullable()->index(); // muscle cible principal
$table->string('muscle_group')->nullable(); // synergistes primaires
$table->json('secondary_muscles')->nullable(); // ["deltoid", ...]
$table->json('instructions')->nullable(); // { en:[...], fr:[...] }
$table->string('image_path')->nullable(); // clé S3 / chemin média
$table->string('gif_path')->nullable();
$table->string('attribution')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('exercises');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Exercises\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ExercisesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
use Functional\Exercises\Http\Controllers\ExercisesController;
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.
// Catalogue public, lecture seule — aucun middleware d'authentification.
// /facets doit être déclaré avant /{exercise} pour ne pas être capturé par le binding.
Route::get('/exercises/facets', [ExercisesController::class, 'facets']);
Route::get('/exercises', [ExercisesController::class, 'index']);
Route::get('/exercises/{exercise}', [ExercisesController::class, 'show']);
+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,115 @@
<?php
namespace Functional\Exercises\Console\Commands;
use Functional\Exercises\Models\Exercise;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Importe le dataset d'exercices (fork hasaneyldrm/exercises-dataset).
* JSON : upsert par external_id. --media : pousse aussi images+gifs vers RustFS/S3.
*/
class ImportExercisesCommand extends Command
{
protected $signature = 'exercises:import
{--path= : racine du dataset (contient data/exercises.json, images/, videos/)}
{--media : uploade aussi les images/gifs vers le disque S3 "media"}
{--langs=en,fr : langues d instructions à conserver (pt-BR retombe sur en)}
{--fresh : vide la table avant import}';
protected $description = "Importe le dataset d'exercices (JSON) et, avec --media, les médias vers RustFS/S3.";
public function handle(): int
{
$root = rtrim($this->option('path') ?: storage_path('app/dataset'), '/');
$json = is_file("$root/data/exercises.json") ? "$root/data/exercises.json" : "$root/exercises.json";
if (! is_file($json)) {
$this->error("exercises.json introuvable sous {$root}");
return self::FAILURE;
}
$langs = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('langs')))));
$records = json_decode((string) file_get_contents($json), true);
if (! is_array($records)) {
$this->error('JSON invalide');
return self::FAILURE;
}
if ($this->option('fresh')) {
Exercise::query()->delete();
$this->warn('Table exercises vidée.');
}
$media = (bool) $this->option('media');
$prefix = trim((string) config('exercises.media_prefix', 'exercises'), '/');
$disk = $media ? Storage::disk('media') : null;
$bar = $this->output->createProgressBar(count($records));
$bar->start();
$imported = 0;
$uploaded = 0;
foreach ($records as $r) {
$ext = (string) ($r['id'] ?? '');
if ($ext === '') {
$bar->advance();
continue;
}
// Instructions : on garde les étapes (arrays) des langues demandées.
$steps = $r['instruction_steps'] ?? [];
$instructions = [];
foreach ($langs as $l) {
if (! empty($steps[$l])) {
$instructions[$l] = array_values($steps[$l]);
}
}
$imgKey = ! empty($r['image']) ? "{$prefix}/{$ext}.jpg" : null;
$gifKey = ! empty($r['gif_url']) ? "{$prefix}/{$ext}.gif" : null;
if ($media && $disk) {
foreach ([[$r['image'] ?? null, $imgKey], [$r['gif_url'] ?? null, $gifKey]] as [$rel, $key]) {
if ($rel && $key && is_file("{$root}/{$rel}") && ! $disk->exists($key)) {
$disk->put($key, (string) file_get_contents("{$root}/{$rel}"), 'public');
$uploaded++;
}
}
}
Exercise::updateOrCreate(
['external_id' => $ext],
[
'name' => $r['name'] ?? $ext,
'slug' => Str::slug(($r['name'] ?? $ext) . '-' . $ext),
'category' => $r['category'] ?? null,
'body_part' => $r['body_part'] ?? null,
'equipment' => $r['equipment'] ?? null,
'target' => $r['target'] ?? null,
'muscle_group' => $r['muscle_group'] ?? null,
'secondary_muscles' => $r['secondary_muscles'] ?? [],
'instructions' => $instructions,
'image_path' => $imgKey,
'gif_path' => $gifKey,
'attribution' => $r['attribution'] ?? null,
]
);
$imported++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Importé/à jour : {$imported} exercices" . ($media ? " (+ {$uploaded} médias S3)" : ''));
return self::SUCCESS;
}
}
@@ -0,0 +1,74 @@
<?php
namespace Functional\Exercises\Http\Controllers;
use Functional\Exercises\Http\Resources\ExerciseListResource;
use Functional\Exercises\Http\Resources\ExerciseResource;
use Functional\Exercises\Models\Exercise;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Catalogue d'exercices : lecture seule et public, aucun scoping par utilisateur.
*/
class ExercisesController
{
private const DEFAULT_LOCALE = 'fr';
private const DEFAULT_PER_PAGE = 20;
/** GET /api/exercises — liste filtrable (search/body_part/equipment/target/category) et paginée. */
public function index(Request $request): AnonymousResourceCollection
{
$exercises = Exercise::query()
->search($request->query('search'))
->when($request->query('body_part'), fn ($query, $bodyPart) => $query->where('body_part', $bodyPart))
->when($request->query('equipment'), fn ($query, $equipment) => $query->where('equipment', $equipment))
->when($request->query('target'), fn ($query, $target) => $query->where('target', $target))
->when($request->query('category'), fn ($query, $category) => $query->where('category', $category))
->orderBy('name')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return ExerciseListResource::collection($exercises);
}
/** GET /api/exercises/{exercise} — détail avec instructions dans la locale demandée. */
public function show(Request $request, Exercise $exercise): ExerciseResource
{
return new ExerciseResource($exercise, $this->resolveLocale($request));
}
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
public function facets(): JsonResponse
{
return response()->json([
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
]);
}
/** Résout la locale demandée : paramètre `locale`, sinon header Accept-Language, sinon 'fr'. */
private function resolveLocale(Request $request): string
{
$locale = $request->query('locale');
if ($locale) {
return (string) $locale;
}
$acceptLanguage = $request->header('Accept-Language');
if (! $acceptLanguage) {
return self::DEFAULT_LOCALE;
}
// Le header peut lister plusieurs langues pondérées ("fr-FR,fr;q=0.9,en;q=0.8") : on ne garde que la première.
$firstLanguage = strtok($acceptLanguage, ',');
$languageTag = strtok($firstLanguage, ';');
return $languageTag !== false ? $languageTag : self::DEFAULT_LOCALE;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
*/
class ExerciseListResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'body_part' => $this->body_part,
'equipment' => $this->equipment,
'target' => $this->target,
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Détail complet d'un exercice (GET /api/exercises/{id}), instructions localisées.
*/
class ExerciseResource extends JsonResource
{
public function __construct($resource, private readonly string $locale)
{
parent::__construct($resource);
}
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'category' => $this->category,
'equipment' => $this->equipment,
'target' => $this->target,
'muscle_group' => $this->muscle_group,
'secondary_muscles' => $this->secondary_muscles ?? [],
'instructions' => $this->resource->instructionsFor($this->locale),
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
'attribution' => $this->attribution,
];
}
}
@@ -0,0 +1,101 @@
<?php
namespace Functional\Exercises\Models;
use Functional\Exercises\Database\Factories\ExerciseFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Catalogue d'exercices (lecture seule, alimenté par l'import du dataset).
*
* @property string $external_id
* @property string $name
* @property array|null $secondary_muscles
* @property array|null $instructions
*/
#[UseFactory(ExerciseFactory::class)]
class Exercise extends Model
{
use HasFactory;
protected $fillable = [
'external_id', 'name', 'slug', 'category', 'body_part', 'equipment',
'target', 'muscle_group', 'secondary_muscles', 'instructions',
'image_path', 'gif_path', 'attribution',
];
protected $casts = [
'secondary_muscles' => 'array',
'instructions' => 'array',
];
protected $appends = ['image_url', 'gif_url'];
/** URL publique du thumbnail (média servi depuis RustFS/S3). */
public function imageUrl(): ?string
{
return $this->mediaUrl($this->image_path);
}
public function gifUrl(): ?string
{
return $this->mediaUrl($this->gif_path);
}
public function getImageUrlAttribute(): ?string
{
return $this->imageUrl();
}
public function getGifUrlAttribute(): ?string
{
return $this->gifUrl();
}
private function mediaUrl(?string $path): ?string
{
if (! $path) {
return null;
}
return rtrim((string) config('exercises.media_base_url'), '/') . '/' . ltrim($path, '/');
}
/**
* Instructions dans la locale demandée, avec repli.
* pt-BR n'existe pas dans le dataset repli en/fr.
*/
public function instructionsFor(string $locale): array
{
$data = $this->instructions ?? [];
$chain = [$locale, substr($locale, 0, 2), 'en', 'fr'];
foreach ($chain as $lang) {
if (! empty($data[$lang])) {
return (array) $data[$lang];
}
}
return [];
}
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
public function scopeSearch(Builder $query, ?string $term): Builder
{
if (! $term) {
return $query;
}
$like = '%' . str_replace('%', '\%', $term) . '%';
return $query->where(function (Builder $q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('target', 'like', $like)
->orWhere('equipment', 'like', $like)
->orWhere('body_part', 'like', $like);
});
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Exercises\Providers;
use Functional\Exercises\Console\Commands\ImportExercisesCommand;
use Functional\Exercises\Database\Seeders\ExercisesSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class ExercisesServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([ExercisesSeeder::class]);
$this->commands([ImportExercisesCommand::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\Exercises\Database\Seeders\ExercisesSeeder::class]);
}
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/programs",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Programs\\": "src/",
"Functional\\Programs\\Database\\Seeders\\": "database/seeders/",
"Functional\\Programs\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Programs\\Providers\\ProgramsServiceProvider"
]
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Functional\Programs\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Program>
*/
class ProgramFactory extends Factory
{
protected $model = Program::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->randomElement([
'Prise de masse 8 semaines', 'Sèche été', 'Préparation force',
'Remise en forme progressive', 'Cross-training intensif', 'Retour de blessure',
]) . ' ' . fake()->numberBetween(1, 9);
return [
'user_id' => User::factory(),
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->numberBetween(1000, 9999),
'description' => fake()->boolean(70) ? fake()->sentence(15) : null,
'duration_weeks' => fake()->numberBetween(4, 16),
'days_per_week' => fake()->optional(0.8)->numberBetween(2, 6),
'is_public' => fake()->boolean(25),
];
}
/** Programme partagé publiquement (visible du catalogue communautaire). */
public function public(): static
{
return $this->state(fn (array $attributes) => ['is_public' => true]);
}
/** Programme privé, réservé à son propriétaire. */
public function private(): static
{
return $this->state(fn (array $attributes) => ['is_public' => false]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Functional\Programs\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Programs\Models\ProgramSlot;
use Functional\Routines\Models\Routine;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProgramSlot>
*/
class ProgramSlotFactory extends Factory
{
protected $model = ProgramSlot::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'program_id' => Program::factory(),
'week' => fake()->numberBetween(1, 8),
'weekday' => fake()->numberBetween(1, 7),
'routine_id' => fake()->boolean(85) ? Routine::factory() : null,
'label' => fake()->optional(0.3)->randomElement(['Repos actif', 'Séance test', 'Deload']),
];
}
/** Case de repos, sans routine associée. */
public function restDay(): static
{
return $this->state(fn (array $attributes) => [
'routine_id' => null,
'label' => 'Repos',
]);
}
}
@@ -0,0 +1,34 @@
<?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('programs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('slug')->index();
$table->text('description')->nullable();
$table->unsignedInteger('duration_weeks');
$table->unsignedTinyInteger('days_per_week')->nullable();
$table->boolean('is_public')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('programs');
}
};
@@ -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
{
// Une case du planning : une semaine/jour donnée du programme, optionnellement liée à une routine.
Schema::create('program_slots', function (Blueprint $table) {
$table->id();
$table->foreignId('program_id')->constrained();
$table->unsignedInteger('week'); // 1..duration_weeks
$table->unsignedTinyInteger('weekday'); // 1 (lundi) .. 7 (dimanche)
$table->foreignId('routine_id')->nullable()->constrained();
$table->string('label')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('program_slots');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Programs\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ProgramsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Functional\Programs\Http\Controllers\ProgramsController;
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::apiResource('programs', ProgramsController::class);
});
+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,77 @@
<?php
namespace Functional\Programs\Http\Controllers;
use Functional\Programs\Http\Requests\StoreProgramRequest;
use Functional\Programs\Http\Requests\UpdateProgramRequest;
use Functional\Programs\Http\Resources\ProgramResource;
use Functional\Programs\Models\Program;
use Functional\Programs\Services\ProgramService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response as HttpResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD des programmes de l'utilisateur authentifié, strictement scopés par auth()->id().
*/
class ProgramsController
{
public function __construct(
private readonly ProgramService $programService,
) {
}
/** GET /api/programs */
public function index(): AnonymousResourceCollection
{
$programs = Program::query()
->where('user_id', auth()->id())
->with('programSlots')
->orderBy('name')
->paginate();
return ProgramResource::collection($programs);
}
/** POST /api/programs */
public function store(StoreProgramRequest $request): JsonResponse
{
$program = $this->programService->create($request->user(), $request->validated());
return (new ProgramResource($program))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/** GET /api/programs/{program} */
public function show(int $program): ProgramResource
{
return new ProgramResource($this->findForUser($program));
}
/** PUT /api/programs/{program} */
public function update(UpdateProgramRequest $request, int $program): ProgramResource
{
$updated = $this->programService->update($this->findForUser($program), $request->validated());
return new ProgramResource($updated);
}
/** DELETE /api/programs/{program} */
public function destroy(int $program): HttpResponse
{
$this->findForUser($program)->delete();
return response()->noContent();
}
/** Charge le programme (avec son planning) en le scopant strictement à l'utilisateur courant. */
private function findForUser(int $programId): Program
{
return Program::query()
->where('user_id', auth()->id())
->with('programSlots')
->findOrFail($programId);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Functional\Programs\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validation de la création d'un programme, avec la liste (optionnelle) de ses créneaux (slots).
*/
class StoreProgramRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'duration_weeks' => ['required', 'integer', 'min:1'],
'days_per_week' => ['nullable', 'integer', 'between:1,7'],
'is_public' => ['sometimes', 'boolean'],
'slots' => ['sometimes', 'array'],
'slots.*.week' => ['required', 'integer', 'min:1'],
'slots.*.weekday' => ['required', 'integer', 'between:1,7'],
'slots.*.routine_id' => [
'nullable',
'integer',
Rule::exists('routines', 'id')->where(fn ($query) => $query->where('user_id', $this->user()?->id)),
],
'slots.*.label' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Functional\Programs\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validation de la mise à jour d'un programme. Les slots, si fournis, remplacent
* intégralement le planning existant.
*/
class UpdateProgramRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'duration_weeks' => ['sometimes', 'integer', 'min:1'],
'days_per_week' => ['nullable', 'integer', 'between:1,7'],
'is_public' => ['sometimes', 'boolean'],
'slots' => ['sometimes', 'array'],
'slots.*.week' => ['required', 'integer', 'min:1'],
'slots.*.weekday' => ['required', 'integer', 'between:1,7'],
'slots.*.routine_id' => [
'nullable',
'integer',
Rule::exists('routines', 'id')->where(fn ($query) => $query->where('user_id', $this->user()?->id)),
],
'slots.*.label' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Programs\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Un programme, avec son planning de créneaux (chargé via programSlots).
*/
class ProgramResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'duration_weeks' => $this->duration_weeks,
'days_per_week' => $this->days_per_week,
'slots' => ProgramSlotResource::collection($this->whenLoaded('programSlots')),
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace Functional\Programs\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une case du planning d'un programme (semaine/jour, routine optionnelle).
*/
class ProgramSlotResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'week' => $this->week,
'weekday' => $this->weekday,
'routine_id' => $this->routine_id,
'label' => $this->label,
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace Functional\Programs\Models;
use Functional\Programs\Database\Factories\ProgramFactory;
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;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Un programme = planning de routines sur plusieurs semaines, possédé par un utilisateur.
*
* @property string $name
* @property string $slug
* @property string|null $description
* @property int $duration_weeks
* @property int|null $days_per_week
* @property bool $is_public
*/
#[UseFactory(ProgramFactory::class)]
class Program extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'name', 'slug', 'description', 'duration_weeks', 'days_per_week', 'is_public',
];
protected $casts = [
'duration_weeks' => 'integer',
'days_per_week' => 'integer',
'is_public' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function programSlots(): HasMany
{
return $this->hasMany(ProgramSlot::class);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Functional\Programs\Models;
use Functional\Programs\Database\Factories\ProgramSlotFactory;
use Functional\Routines\Models\Routine;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Une case du planning d'un programme : semaine/jour, optionnellement liée à une routine.
*
* @property int $week
* @property int $weekday
* @property string|null $label
*/
#[UseFactory(ProgramSlotFactory::class)]
class ProgramSlot extends Model
{
use HasFactory;
protected $fillable = [
'program_id', 'week', 'weekday', 'routine_id', 'label',
];
protected $casts = [
'week' => 'integer',
'weekday' => 'integer',
];
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Programs\Providers;
use Functional\Programs\Database\Seeders\ProgramsSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class ProgramsServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([ProgramsSeeder::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\Programs\Database\Seeders\ProgramsSeeder::class]);
}
public function register(): void
{
//
}
}
@@ -0,0 +1,65 @@
<?php
namespace Functional\Programs\Services;
use Functional\Programs\Models\Program;
use Functional\Users\Models\User;
use Illuminate\Support\Str;
/**
* Création/mise à jour d'un programme et synchronisation de son planning (program_slots).
*/
class ProgramService
{
/** Champs propres au programme (les slots sont synchronisés à part). */
private const PROGRAM_FIELDS = ['name', 'description', 'duration_weeks', 'days_per_week', 'is_public'];
public function create(User $user, array $data): Program
{
$program = new Program($this->programAttributes($data));
$program->user_id = $user->id;
$program->slug = Str::slug($data['name']);
$program->save();
$this->syncSlots($program, $data['slots'] ?? []);
return $program->load('programSlots');
}
public function update(Program $program, array $data): Program
{
$program->fill($this->programAttributes($data));
if (array_key_exists('name', $data)) {
$program->slug = Str::slug($data['name']);
}
$program->save();
if (array_key_exists('slots', $data)) {
$this->syncSlots($program, $data['slots']);
}
return $program->load('programSlots');
}
private function programAttributes(array $data): array
{
return array_intersect_key($data, array_flip(self::PROGRAM_FIELDS));
}
/** Remplace intégralement le planning par la liste fournie (purge puis recrée). */
private function syncSlots(Program $program, array $slots): void
{
$program->programSlots()->delete();
foreach ($slots as $slot) {
$program->programSlots()->create([
'week' => $slot['week'],
'weekday' => $slot['weekday'],
'routine_id' => $slot['routine_id'] ?? null,
'label' => $slot['label'] ?? null,
]);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/routines",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Routines\\": "src/",
"Functional\\Routines\\Database\\Seeders\\": "database/seeders/",
"Functional\\Routines\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Routines\\Providers\\RoutinesServiceProvider"
]
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Routines\Models\Routine;
use Functional\Routines\Models\RoutineExercise;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<RoutineExercise>
*/
class RoutineExerciseFactory extends Factory
{
protected $model = RoutineExercise::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'routine_id' => Routine::factory(),
'exercise_id' => Exercise::factory(),
'position' => fake()->numberBetween(1, 10),
'sets' => fake()->numberBetween(3, 5),
'target_reps' => fake()->randomElement(['6-8', '8-12', '10-15', '12-20', 'AMRAP']),
'rest_seconds' => fake()->optional(0.8)->randomElement([45, 60, 90, 120, 180]),
'tempo' => fake()->optional(0.4)->randomElement(['2-0-2', '3-1-1', '4-0-1']),
'target_weight' => fake()->optional(0.6)->randomFloat(2, 10, 120),
'notes' => fake()->optional(0.3)->sentence(8),
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Functional\Routines\Database\Factories;
use Functional\Routines\Models\Routine;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Routine>
*/
class RoutineFactory extends Factory
{
protected $model = Routine::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->randomElement([
'Full body débutant', 'Push day', 'Pull day', 'Leg day', 'Upper body',
'Circuit HIIT', 'Force 5x5', 'Hypertrophie bras', 'Core & mobilité',
]) . ' ' . fake()->numberBetween(1, 9);
return [
'user_id' => User::factory(),
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->numberBetween(1000, 9999),
'description' => fake()->boolean(70) ? fake()->sentence(12) : null,
'is_public' => fake()->boolean(30),
'estimated_minutes' => fake()->optional(0.8)->numberBetween(20, 90),
];
}
/** Routine partagée publiquement (visible du catalogue communautaire). */
public function public(): static
{
return $this->state(fn (array $attributes) => ['is_public' => true]);
}
/** Routine privée, réservée à son propriétaire. */
public function private(): static
{
return $this->state(fn (array $attributes) => ['is_public' => false]);
}
}
@@ -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('routines', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('slug')->index();
$table->text('description')->nullable();
$table->boolean('is_public')->default(false);
$table->unsignedInteger('estimated_minutes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('routines');
}
};
@@ -0,0 +1,37 @@
<?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
{
// Pivot enrichi : un exercice positionné dans une routine, avec ses paramètres d'entraînement.
Schema::create('routine_exercises', function (Blueprint $table) {
$table->id();
$table->foreignId('routine_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->unsignedInteger('position');
$table->unsignedInteger('sets');
$table->string('target_reps'); // ex "8-12"
$table->unsignedInteger('rest_seconds')->nullable();
$table->string('tempo')->nullable();
$table->decimal('target_weight', 8, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('routine_exercises');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Routines\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class RoutinesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Functional\Routines\Http\Controllers\RoutinesController;
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ées à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('routines', RoutinesController::class);
});
+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,77 @@
<?php
namespace Functional\Routines\Http\Controllers;
use Functional\Routines\Http\Requests\StoreRoutineRequest;
use Functional\Routines\Http\Requests\UpdateRoutineRequest;
use Functional\Routines\Http\Resources\RoutineResource;
use Functional\Routines\Models\Routine;
use Functional\Routines\Services\RoutineService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response as HttpResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD des routines de l'utilisateur authentifié, strictement scopées par auth()->id().
*/
class RoutinesController
{
public function __construct(
private readonly RoutineService $routineService,
) {
}
/** GET /api/routines */
public function index(): AnonymousResourceCollection
{
$routines = Routine::query()
->where('user_id', auth()->id())
->with('routineExercises.exercise')
->orderBy('name')
->paginate();
return RoutineResource::collection($routines);
}
/** POST /api/routines */
public function store(StoreRoutineRequest $request): JsonResponse
{
$routine = $this->routineService->create($request->user(), $request->validated());
return (new RoutineResource($routine))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/** GET /api/routines/{routine} */
public function show(int $routine): RoutineResource
{
return new RoutineResource($this->findForUser($routine));
}
/** PUT /api/routines/{routine} */
public function update(UpdateRoutineRequest $request, int $routine): RoutineResource
{
$updated = $this->routineService->update($this->findForUser($routine), $request->validated());
return new RoutineResource($updated);
}
/** DELETE /api/routines/{routine} */
public function destroy(int $routine): HttpResponse
{
$this->findForUser($routine)->delete();
return response()->noContent();
}
/** Charge la routine (avec ses exercices) en la scopant strictement à l'utilisateur courant. */
private function findForUser(int $routineId): Routine
{
return Routine::query()
->where('user_id', auth()->id())
->with('routineExercises.exercise')
->findOrFail($routineId);
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la création d'une routine, avec la liste (optionnelle) de ses exercices.
*/
class StoreRoutineRequest extends FormRequest
{
public function authorize(): bool
{
// Le scoping par utilisateur est géré par le contrôleur (auth:sanctum + user_id courant).
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_public' => ['sometimes', 'boolean'],
'estimated_minutes' => ['nullable', 'integer', 'min:1'],
'exercises' => ['sometimes', 'array'],
'exercises.*.exercise_id' => ['required', 'integer', 'exists:exercises,id'],
'exercises.*.position' => ['nullable', 'integer', 'min:0'],
'exercises.*.sets' => ['required', 'integer', 'min:1'],
'exercises.*.target_reps' => ['required', 'string', 'max:50'],
'exercises.*.rest_seconds' => ['nullable', 'integer', 'min:0'],
'exercises.*.tempo' => ['nullable', 'string', 'max:50'],
'exercises.*.target_weight' => ['nullable', 'numeric', 'min:0'],
'exercises.*.notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la mise à jour d'une routine. Les exercices, si fournis, remplacent
* intégralement le pivot routine_exercises existant.
*/
class UpdateRoutineRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_public' => ['sometimes', 'boolean'],
'estimated_minutes' => ['nullable', 'integer', 'min:1'],
'exercises' => ['sometimes', 'array'],
'exercises.*.exercise_id' => ['required', 'integer', 'exists:exercises,id'],
'exercises.*.position' => ['nullable', 'integer', 'min:0'],
'exercises.*.sets' => ['required', 'integer', 'min:1'],
'exercises.*.target_reps' => ['required', 'string', 'max:50'],
'exercises.*.rest_seconds' => ['nullable', 'integer', 'min:0'],
'exercises.*.tempo' => ['nullable', 'string', 'max:50'],
'exercises.*.target_weight' => ['nullable', 'numeric', 'min:0'],
'exercises.*.notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Routines\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une ligne du pivot routine_exercises, avec un sous-objet exercice minimal prêt-à-afficher.
*/
class RoutineExerciseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'exercise_id' => $this->exercise_id,
'position' => $this->position,
'sets' => $this->sets,
'target_reps' => $this->target_reps,
'rest_seconds' => $this->rest_seconds,
'tempo' => $this->tempo,
'target_weight' => $this->target_weight,
'notes' => $this->notes,
'exercise' => [
'id' => $this->exercise->id,
'name' => $this->exercise->name,
'image_url' => $this->exercise->image_url,
'target' => $this->exercise->target,
],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Routines\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une routine, avec ses exercices ordonnés (chargés via routineExercises.exercise).
*/
class RoutineResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'is_public' => $this->is_public,
'estimated_minutes' => $this->estimated_minutes,
'exercises' => RoutineExerciseResource::collection($this->whenLoaded('routineExercises')),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace Functional\Routines\Models;
use Functional\Routines\Database\Factories\RoutineFactory;
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;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Une routine = liste ordonnée d'exercices avec séries/reps/repos, possédée par un utilisateur.
*
* @property string $name
* @property string $slug
* @property string|null $description
* @property bool $is_public
* @property int|null $estimated_minutes
*/
#[UseFactory(RoutineFactory::class)]
class Routine extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'name', 'slug', 'description', 'is_public', 'estimated_minutes',
];
protected $casts = [
'is_public' => 'boolean',
'estimated_minutes' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function routineExercises(): HasMany
{
return $this->hasMany(RoutineExercise::class)->orderBy('position');
}
}
@@ -0,0 +1,49 @@
<?php
namespace Functional\Routines\Models;
use Functional\Exercises\Models\Exercise;
use Functional\Routines\Database\Factories\RoutineExerciseFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Pivot enrichi : un exercice positionné dans une routine, avec ses paramètres d'entraînement.
*
* @property int $position
* @property int $sets
* @property string $target_reps
* @property int|null $rest_seconds
* @property string|null $tempo
* @property float|null $target_weight
* @property string|null $notes
*/
#[UseFactory(RoutineExerciseFactory::class)]
class RoutineExercise extends Model
{
use HasFactory;
protected $fillable = [
'routine_id', 'exercise_id', 'position', 'sets', 'target_reps',
'rest_seconds', 'tempo', 'target_weight', 'notes',
];
protected $casts = [
'position' => 'integer',
'sets' => 'integer',
'rest_seconds' => 'integer',
'target_weight' => 'decimal:2',
];
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}
public function exercise(): BelongsTo
{
return $this->belongsTo(Exercise::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Routines\Providers;
use Functional\Routines\Database\Seeders\RoutinesSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class RoutinesServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([RoutinesSeeder::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\Routines\Database\Seeders\RoutinesSeeder::class]);
}
public function register(): void
{
//
}
}
@@ -0,0 +1,69 @@
<?php
namespace Functional\Routines\Services;
use Functional\Routines\Models\Routine;
use Functional\Users\Models\User;
use Illuminate\Support\Str;
/**
* Création/mise à jour d'une routine et synchronisation de son pivot routine_exercises.
*/
class RoutineService
{
/** Champs propres à la routine (les exercices sont synchronisés à part). */
private const ROUTINE_FIELDS = ['name', 'description', 'is_public', 'estimated_minutes'];
public function create(User $user, array $data): Routine
{
$routine = new Routine($this->routineAttributes($data));
$routine->user_id = $user->id;
$routine->slug = Str::slug($data['name']);
$routine->save();
$this->syncExercises($routine, $data['exercises'] ?? []);
return $routine->load('routineExercises.exercise');
}
public function update(Routine $routine, array $data): Routine
{
$routine->fill($this->routineAttributes($data));
if (array_key_exists('name', $data)) {
$routine->slug = Str::slug($data['name']);
}
$routine->save();
if (array_key_exists('exercises', $data)) {
$this->syncExercises($routine, $data['exercises']);
}
return $routine->load('routineExercises.exercise');
}
private function routineAttributes(array $data): array
{
return array_intersect_key($data, array_flip(self::ROUTINE_FIELDS));
}
/** Remplace intégralement le pivot par la liste fournie (stratégie simple : purge puis recrée, dans l'ordre donné). */
private function syncExercises(Routine $routine, array $exercises): void
{
$routine->routineExercises()->delete();
foreach ($exercises as $index => $exercise) {
$routine->routineExercises()->create([
'exercise_id' => $exercise['exercise_id'],
'position' => $exercise['position'] ?? $index,
'sets' => $exercise['sets'],
'target_reps' => $exercise['target_reps'],
'rest_seconds' => $exercise['rest_seconds'] ?? null,
'tempo' => $exercise['tempo'] ?? null,
'target_weight' => $exercise['target_weight'] ?? null,
'notes' => $exercise['notes'] ?? null,
]);
}
}
}
+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()],
);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/tracking",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Tracking\\": "src/",
"Functional\\Tracking\\Database\\Seeders\\": "database/seeders/",
"Functional\\Tracking\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Tracking\\Providers\\TrackingServiceProvider"
]
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Tracking\Models\BodyMetric;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BodyMetric>
*/
class BodyMetricFactory extends Factory
{
protected $model = BodyMetric::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'measured_on' => fake()->unique()->dateTimeBetween('-6 months', 'now')->format('Y-m-d'),
'weight' => fake()->randomFloat(2, 55, 110),
'body_fat' => fake()->randomFloat(2, 8, 30),
'notes' => null,
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Enums\PersonalRecordType;
use Functional\Tracking\Models\PersonalRecord;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PersonalRecord>
*/
class PersonalRecordFactory extends Factory
{
protected $model = PersonalRecord::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'exercise_id' => Exercise::factory(),
'type' => fake()->randomElement(PersonalRecordType::cases()),
'value' => fake()->randomFloat(2, 20, 250),
'unit' => 'kg',
'achieved_at' => fake()->dateTimeBetween('-1 year', 'now'),
'workout_session_id' => null,
];
}
/** Record de charge maximale à une répétition. */
public function oneRepMax(): static
{
return $this->state(fn (array $attributes) => [
'type' => PersonalRecordType::OneRepMax,
'unit' => 'kg',
]);
}
/** Record de temps (ex : gainage, sprint chronométré). */
public function bestTime(): static
{
return $this->state(fn (array $attributes) => [
'type' => PersonalRecordType::BestTime,
'value' => fake()->randomFloat(2, 30, 600),
'unit' => 'sec',
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Models\SetLog;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SetLog>
*/
class SetLogFactory extends Factory
{
protected $model = SetLog::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workout_session_id' => WorkoutSession::factory(),
'exercise_id' => Exercise::factory(),
'set_number' => fake()->numberBetween(1, 5),
'reps' => fake()->numberBetween(4, 15),
'weight' => fake()->randomFloat(2, 5, 150),
'rpe' => fake()->randomFloat(1, 6, 10),
'duration_seconds' => null,
'distance_meters' => null,
'is_warmup' => false,
'is_completed' => true,
'logged_at' => fake()->dateTimeBetween('-2 months', 'now'),
];
}
/** Série d'échauffement, plus légère. */
public function warmup(): static
{
return $this->state(fn (array $attributes) => [
'is_warmup' => true,
'weight' => fake()->randomFloat(2, 5, 40),
]);
}
/** Série démarrée mais non terminée par l'utilisateur. */
public function incomplete(): static
{
return $this->state(fn (array $attributes) => [
'is_completed' => false,
]);
}
/** Série cardio (course, rameur...) : durée/distance plutôt que reps/poids. */
public function cardio(): static
{
return $this->state(fn (array $attributes) => [
'reps' => null,
'weight' => null,
'rpe' => null,
'duration_seconds' => fake()->numberBetween(60, 3600),
'distance_meters' => fake()->randomFloat(2, 200, 10000),
]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Routines\Models\Routine;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WorkoutSession>
*/
class WorkoutSessionFactory extends Factory
{
protected $model = WorkoutSession::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'routine_id' => null,
'program_id' => null,
'title' => null,
'started_at' => fake()->dateTimeBetween('-2 months', 'now'),
'ended_at' => null,
'status' => WorkoutSessionStatus::Active,
'notes' => null,
'total_volume' => null,
];
}
/** Séance terminée normalement, avec un volume total calculé. */
public function completed(): static
{
return $this->state(function (array $attributes) {
$startedAt = $attributes['started_at'] ?? fake()->dateTimeBetween('-2 months', '-1 day');
return [
'started_at' => $startedAt,
'ended_at' => fake()->dateTimeBetween($startedAt, 'now'),
'status' => WorkoutSessionStatus::Completed,
'total_volume' => fake()->randomFloat(2, 500, 8000),
];
});
}
/** Séance interrompue avant la fin. */
public function abandoned(): static
{
return $this->state(function (array $attributes) {
$startedAt = $attributes['started_at'] ?? fake()->dateTimeBetween('-2 months', '-1 day');
return [
'started_at' => $startedAt,
'ended_at' => fake()->dateTimeBetween($startedAt, 'now'),
'status' => WorkoutSessionStatus::Abandoned,
];
});
}
/** Rattache la séance à une routine existante. */
public function forRoutine(Routine $routine): static
{
return $this->state(fn (array $attributes) => [
'routine_id' => $routine->id,
]);
}
/** Rattache la séance à un programme existant. */
public function forProgram(Program $program): static
{
return $this->state(fn (array $attributes) => [
'program_id' => $program->id,
]);
}
}
@@ -0,0 +1,31 @@
<?php
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('workout_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('routine_id')->nullable()->constrained(); // séance issue d'une routine (optionnel)
$table->foreignId('program_id')->nullable()->constrained(); // séance issue d'un programme (optionnel)
$table->string('title')->nullable();
$table->dateTime('started_at');
$table->dateTime('ended_at')->nullable();
$table->string('status')->default(WorkoutSessionStatus::Active->value)->index();
$table->text('notes')->nullable();
$table->decimal('total_volume', 10, 2)->nullable(); // somme reps*poids calculée à la complétion
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('workout_sessions');
}
};
@@ -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
{
public function up(): void
{
Schema::create('set_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('workout_session_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->unsignedInteger('set_number');
$table->unsignedInteger('reps')->nullable();
$table->decimal('weight', 8, 2)->nullable();
$table->decimal('rpe', 3, 1)->nullable(); // Rate of Perceived Exertion (0-10)
$table->unsignedInteger('duration_seconds')->nullable(); // séries cardio/isométriques
$table->decimal('distance_meters', 8, 2)->nullable(); // séries cardio (course, rameur...)
$table->boolean('is_warmup')->default(false);
$table->boolean('is_completed')->default(true);
$table->dateTime('logged_at');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('set_logs');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('personal_records', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->string('type')->index();
$table->decimal('value', 10, 2);
$table->string('unit'); // "kg", "lb", "sec"...
$table->dateTime('achieved_at');
$table->foreignId('workout_session_id')->nullable()->constrained(); // séance à l'origine du record (si connue)
$table->timestamps();
$table->unique(['user_id', 'exercise_id', 'type']);
});
}
public function down(): void
{
Schema::dropIfExists('personal_records');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('body_metrics', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->date('measured_on');
$table->decimal('weight', 6, 2)->nullable();
$table->decimal('body_fat', 5, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['user_id', 'measured_on']);
});
}
public function down(): void
{
Schema::dropIfExists('body_metrics');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Tracking\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class TrackingSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
use Functional\Tracking\Http\Controllers\BodyMetricsController;
use Functional\Tracking\Http\Controllers\HistoryController;
use Functional\Tracking\Http\Controllers\SessionsController;
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ées à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::post('/sessions', [SessionsController::class, 'store']);
Route::get('/sessions/{session}', [SessionsController::class, 'show']);
Route::post('/sessions/{session}/sets', [SessionsController::class, 'storeSet']);
Route::post('/sessions/{session}/complete', [SessionsController::class, 'complete']);
Route::get('/history', [HistoryController::class, 'index']);
Route::get('/metrics', [BodyMetricsController::class, 'index']);
Route::post('/metrics', [BodyMetricsController::class, 'store']);
});
+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,16 @@
<?php
namespace Functional\Tracking\Enums;
/**
* Type de record personnel suivi pour un exercice donné.
* Stocké en base comme string ("no-db-enums") : le cast Eloquent fait le lien avec ce PHP enum.
*/
enum PersonalRecordType: string
{
case OneRepMax = '1rm';
case MaxWeight = 'max_weight';
case MaxReps = 'max_reps';
case MaxVolume = 'max_volume';
case BestTime = 'best_time';
}
@@ -0,0 +1,23 @@
<?php
namespace Functional\Tracking\Enums;
/**
* Statut d'une séance d'entraînement.
* Stocké en base comme string ("no-db-enums") : le cast Eloquent fait le lien avec ce PHP enum.
*/
enum WorkoutSessionStatus: string
{
case Active = 'active';
case Completed = 'completed';
case Abandoned = 'abandoned';
/** Une séance terminée (avec succès ou non) ne peut plus être modifiée. */
public function isTerminal(): bool
{
return match ($this) {
self::Completed, self::Abandoned => true,
self::Active => false,
};
}
}
@@ -0,0 +1,22 @@
<?php
namespace Functional\Tracking\Events;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Émis quand une WorkoutSession passe au statut "completed".
* Écouté par functional/streaks pour mettre à jour le streak de l'utilisateur (non géré ici).
*/
class WorkoutCompleted
{
use Dispatchable;
use SerializesModels;
public function __construct(
public readonly WorkoutSession $workoutSession,
) {
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Tracking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
/**
* Levée quand on tente de logger un set ou de compléter une séance déjà terminée
* (status completed ou abandoned).
*/
class WorkoutSessionAlreadyClosedException extends RuntimeException
{
public function __construct(
public readonly int $workoutSessionId,
) {
parent::__construct("Workout session {$workoutSessionId} is already closed.");
}
/** Rendu HTTP au format Laravel standard ({message}), en 422 (règle métier violée). */
public function render(Request $request): JsonResponse
{
$locale = $request->user()?->locale ?? 'fr';
return response()->json(
['message' => __('tracking.session_already_closed', [], $locale)],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Functional\Tracking\Http\Controllers;
use Functional\Tracking\Http\Requests\StoreBodyMetricRequest;
use Functional\Tracking\Http\Resources\BodyMetricResource;
use Functional\Tracking\Models\BodyMetric;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
/**
* Mensurations (poids, masse grasse) de l'utilisateur authentifié.
*/
class BodyMetricsController
{
private const DEFAULT_PER_PAGE = 20;
/** GET /api/metrics */
public function index(Request $request): AnonymousResourceCollection
{
$metrics = BodyMetric::query()
->where('user_id', auth()->id())
->orderByDesc('measured_on')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return BodyMetricResource::collection($metrics);
}
/**
* POST /api/metrics idempotent par (user_id, measured_on) : une mensuration existante
* pour cette date est mise à jour plutôt que dupliquée (contrainte unique en base).
*/
public function store(StoreBodyMetricRequest $request): JsonResponse
{
$data = $request->validated();
$metric = BodyMetric::query()->updateOrCreate(
['user_id' => auth()->id(), 'measured_on' => $data['measured_on']],
['weight' => $data['weight'] ?? null, 'body_fat' => $data['body_fat'] ?? null, 'notes' => $data['notes'] ?? null],
);
return (new BodyMetricResource($metric))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Tracking\Http\Controllers;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Http\Resources\WorkoutSessionResource;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Historique des séances terminées de l'utilisateur authentifié.
*/
class HistoryController
{
private const DEFAULT_PER_PAGE = 20;
/** GET /api/history */
public function index(Request $request): AnonymousResourceCollection
{
$sessions = WorkoutSession::query()
->where('user_id', auth()->id())
->where('status', WorkoutSessionStatus::Completed)
->withCount('setLogs')
->orderByDesc('started_at')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return WorkoutSessionResource::collection($sessions);
}
}

Some files were not shown because too many files have changed in this diff Show More