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