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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:17 +02:00
co-authored by Claude Opus 4.8
commit af32669931
300 changed files with 21235 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/tracking",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Tracking\\": "src/",
"Functional\\Tracking\\Database\\Seeders\\": "database/seeders/",
"Functional\\Tracking\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Tracking\\Providers\\TrackingServiceProvider"
]
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Tracking\Models\BodyMetric;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BodyMetric>
*/
class BodyMetricFactory extends Factory
{
protected $model = BodyMetric::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'measured_on' => fake()->unique()->dateTimeBetween('-6 months', 'now')->format('Y-m-d'),
'weight' => fake()->randomFloat(2, 55, 110),
'body_fat' => fake()->randomFloat(2, 8, 30),
'notes' => null,
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Enums\PersonalRecordType;
use Functional\Tracking\Models\PersonalRecord;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PersonalRecord>
*/
class PersonalRecordFactory extends Factory
{
protected $model = PersonalRecord::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'exercise_id' => Exercise::factory(),
'type' => fake()->randomElement(PersonalRecordType::cases()),
'value' => fake()->randomFloat(2, 20, 250),
'unit' => 'kg',
'achieved_at' => fake()->dateTimeBetween('-1 year', 'now'),
'workout_session_id' => null,
];
}
/** Record de charge maximale à une répétition. */
public function oneRepMax(): static
{
return $this->state(fn (array $attributes) => [
'type' => PersonalRecordType::OneRepMax,
'unit' => 'kg',
]);
}
/** Record de temps (ex : gainage, sprint chronométré). */
public function bestTime(): static
{
return $this->state(fn (array $attributes) => [
'type' => PersonalRecordType::BestTime,
'value' => fake()->randomFloat(2, 30, 600),
'unit' => 'sec',
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Models\SetLog;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SetLog>
*/
class SetLogFactory extends Factory
{
protected $model = SetLog::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workout_session_id' => WorkoutSession::factory(),
'exercise_id' => Exercise::factory(),
'set_number' => fake()->numberBetween(1, 5),
'reps' => fake()->numberBetween(4, 15),
'weight' => fake()->randomFloat(2, 5, 150),
'rpe' => fake()->randomFloat(1, 6, 10),
'duration_seconds' => null,
'distance_meters' => null,
'is_warmup' => false,
'is_completed' => true,
'logged_at' => fake()->dateTimeBetween('-2 months', 'now'),
];
}
/** Série d'échauffement, plus légère. */
public function warmup(): static
{
return $this->state(fn (array $attributes) => [
'is_warmup' => true,
'weight' => fake()->randomFloat(2, 5, 40),
]);
}
/** Série démarrée mais non terminée par l'utilisateur. */
public function incomplete(): static
{
return $this->state(fn (array $attributes) => [
'is_completed' => false,
]);
}
/** Série cardio (course, rameur...) : durée/distance plutôt que reps/poids. */
public function cardio(): static
{
return $this->state(fn (array $attributes) => [
'reps' => null,
'weight' => null,
'rpe' => null,
'duration_seconds' => fake()->numberBetween(60, 3600),
'distance_meters' => fake()->randomFloat(2, 200, 10000),
]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Functional\Tracking\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Routines\Models\Routine;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WorkoutSession>
*/
class WorkoutSessionFactory extends Factory
{
protected $model = WorkoutSession::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'routine_id' => null,
'program_id' => null,
'title' => null,
'started_at' => fake()->dateTimeBetween('-2 months', 'now'),
'ended_at' => null,
'status' => WorkoutSessionStatus::Active,
'notes' => null,
'total_volume' => null,
];
}
/** Séance terminée normalement, avec un volume total calculé. */
public function completed(): static
{
return $this->state(function (array $attributes) {
$startedAt = $attributes['started_at'] ?? fake()->dateTimeBetween('-2 months', '-1 day');
return [
'started_at' => $startedAt,
'ended_at' => fake()->dateTimeBetween($startedAt, 'now'),
'status' => WorkoutSessionStatus::Completed,
'total_volume' => fake()->randomFloat(2, 500, 8000),
];
});
}
/** Séance interrompue avant la fin. */
public function abandoned(): static
{
return $this->state(function (array $attributes) {
$startedAt = $attributes['started_at'] ?? fake()->dateTimeBetween('-2 months', '-1 day');
return [
'started_at' => $startedAt,
'ended_at' => fake()->dateTimeBetween($startedAt, 'now'),
'status' => WorkoutSessionStatus::Abandoned,
];
});
}
/** Rattache la séance à une routine existante. */
public function forRoutine(Routine $routine): static
{
return $this->state(fn (array $attributes) => [
'routine_id' => $routine->id,
]);
}
/** Rattache la séance à un programme existant. */
public function forProgram(Program $program): static
{
return $this->state(fn (array $attributes) => [
'program_id' => $program->id,
]);
}
}
@@ -0,0 +1,31 @@
<?php
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('workout_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('routine_id')->nullable()->constrained(); // séance issue d'une routine (optionnel)
$table->foreignId('program_id')->nullable()->constrained(); // séance issue d'un programme (optionnel)
$table->string('title')->nullable();
$table->dateTime('started_at');
$table->dateTime('ended_at')->nullable();
$table->string('status')->default(WorkoutSessionStatus::Active->value)->index();
$table->text('notes')->nullable();
$table->decimal('total_volume', 10, 2)->nullable(); // somme reps*poids calculée à la complétion
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('workout_sessions');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('set_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('workout_session_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->unsignedInteger('set_number');
$table->unsignedInteger('reps')->nullable();
$table->decimal('weight', 8, 2)->nullable();
$table->decimal('rpe', 3, 1)->nullable(); // Rate of Perceived Exertion (0-10)
$table->unsignedInteger('duration_seconds')->nullable(); // séries cardio/isométriques
$table->decimal('distance_meters', 8, 2)->nullable(); // séries cardio (course, rameur...)
$table->boolean('is_warmup')->default(false);
$table->boolean('is_completed')->default(true);
$table->dateTime('logged_at');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('set_logs');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('personal_records', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->string('type')->index();
$table->decimal('value', 10, 2);
$table->string('unit'); // "kg", "lb", "sec"...
$table->dateTime('achieved_at');
$table->foreignId('workout_session_id')->nullable()->constrained(); // séance à l'origine du record (si connue)
$table->timestamps();
$table->unique(['user_id', 'exercise_id', 'type']);
});
}
public function down(): void
{
Schema::dropIfExists('personal_records');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('body_metrics', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->date('measured_on');
$table->decimal('weight', 6, 2)->nullable();
$table->decimal('body_fat', 5, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['user_id', 'measured_on']);
});
}
public function down(): void
{
Schema::dropIfExists('body_metrics');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Tracking\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class TrackingSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
use Functional\Tracking\Http\Controllers\BodyMetricsController;
use Functional\Tracking\Http\Controllers\HistoryController;
use Functional\Tracking\Http\Controllers\SessionsController;
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'api' middleware group with the 'api'
// prefix by the layer service provider. To version your endpoints
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
// Scopées à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::post('/sessions', [SessionsController::class, 'store']);
Route::get('/sessions/{session}', [SessionsController::class, 'show']);
Route::post('/sessions/{session}/sets', [SessionsController::class, 'storeSet']);
Route::post('/sessions/{session}/complete', [SessionsController::class, 'complete']);
Route::get('/history', [HistoryController::class, 'index']);
Route::get('/metrics', [BodyMetricsController::class, 'index']);
Route::post('/metrics', [BodyMetricsController::class, 'store']);
});
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
// Define broadcast channels here, e.g.:
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'web' middleware group by the layer
// service provider. For custom groups (prefix, domain, named groups, ...)
// use Route::middleware(...)->prefix(...)->group(function () { ... });
@@ -0,0 +1,16 @@
<?php
namespace Functional\Tracking\Enums;
/**
* Type de record personnel suivi pour un exercice donné.
* Stocké en base comme string ("no-db-enums") : le cast Eloquent fait le lien avec ce PHP enum.
*/
enum PersonalRecordType: string
{
case OneRepMax = '1rm';
case MaxWeight = 'max_weight';
case MaxReps = 'max_reps';
case MaxVolume = 'max_volume';
case BestTime = 'best_time';
}
@@ -0,0 +1,23 @@
<?php
namespace Functional\Tracking\Enums;
/**
* Statut d'une séance d'entraînement.
* Stocké en base comme string ("no-db-enums") : le cast Eloquent fait le lien avec ce PHP enum.
*/
enum WorkoutSessionStatus: string
{
case Active = 'active';
case Completed = 'completed';
case Abandoned = 'abandoned';
/** Une séance terminée (avec succès ou non) ne peut plus être modifiée. */
public function isTerminal(): bool
{
return match ($this) {
self::Completed, self::Abandoned => true,
self::Active => false,
};
}
}
@@ -0,0 +1,22 @@
<?php
namespace Functional\Tracking\Events;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Émis quand une WorkoutSession passe au statut "completed".
* Écouté par functional/streaks pour mettre à jour le streak de l'utilisateur (non géré ici).
*/
class WorkoutCompleted
{
use Dispatchable;
use SerializesModels;
public function __construct(
public readonly WorkoutSession $workoutSession,
) {
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Tracking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
/**
* Levée quand on tente de logger un set ou de compléter une séance déjà terminée
* (status completed ou abandoned).
*/
class WorkoutSessionAlreadyClosedException extends RuntimeException
{
public function __construct(
public readonly int $workoutSessionId,
) {
parent::__construct("Workout session {$workoutSessionId} is already closed.");
}
/** Rendu HTTP au format Laravel standard ({message}), en 422 (règle métier violée). */
public function render(Request $request): JsonResponse
{
$locale = $request->user()?->locale ?? 'fr';
return response()->json(
['message' => __('tracking.session_already_closed', [], $locale)],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Functional\Tracking\Http\Controllers;
use Functional\Tracking\Http\Requests\StoreBodyMetricRequest;
use Functional\Tracking\Http\Resources\BodyMetricResource;
use Functional\Tracking\Models\BodyMetric;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
/**
* Mensurations (poids, masse grasse) de l'utilisateur authentifié.
*/
class BodyMetricsController
{
private const DEFAULT_PER_PAGE = 20;
/** GET /api/metrics */
public function index(Request $request): AnonymousResourceCollection
{
$metrics = BodyMetric::query()
->where('user_id', auth()->id())
->orderByDesc('measured_on')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return BodyMetricResource::collection($metrics);
}
/**
* POST /api/metrics idempotent par (user_id, measured_on) : une mensuration existante
* pour cette date est mise à jour plutôt que dupliquée (contrainte unique en base).
*/
public function store(StoreBodyMetricRequest $request): JsonResponse
{
$data = $request->validated();
$metric = BodyMetric::query()->updateOrCreate(
['user_id' => auth()->id(), 'measured_on' => $data['measured_on']],
['weight' => $data['weight'] ?? null, 'body_fat' => $data['body_fat'] ?? null, 'notes' => $data['notes'] ?? null],
);
return (new BodyMetricResource($metric))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Tracking\Http\Controllers;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Http\Resources\WorkoutSessionResource;
use Functional\Tracking\Models\WorkoutSession;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Historique des séances terminées de l'utilisateur authentifié.
*/
class HistoryController
{
private const DEFAULT_PER_PAGE = 20;
/** GET /api/history */
public function index(Request $request): AnonymousResourceCollection
{
$sessions = WorkoutSession::query()
->where('user_id', auth()->id())
->where('status', WorkoutSessionStatus::Completed)
->withCount('setLogs')
->orderByDesc('started_at')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return WorkoutSessionResource::collection($sessions);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Functional\Tracking\Http\Controllers;
use Functional\Streaks\Models\Achievement;
use Functional\Streaks\Models\Streak;
use Functional\Tracking\Http\Requests\CompleteWorkoutSessionRequest;
use Functional\Tracking\Http\Requests\StoreSetLogRequest;
use Functional\Tracking\Http\Requests\StoreWorkoutSessionRequest;
use Functional\Tracking\Http\Resources\SetLogResource;
use Functional\Tracking\Http\Resources\WorkoutSessionResource;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Tracking\Services\WorkoutSessionService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Séances d'entraînement de l'utilisateur authentifié (démarrage, consultation, log de sets, complétion).
*/
class SessionsController
{
public function __construct(
private readonly WorkoutSessionService $workoutSessionService,
) {
}
/** POST /api/sessions — démarre une séance (status=active). */
public function store(StoreWorkoutSessionRequest $request): JsonResponse
{
$session = $this->workoutSessionService->start($request->user(), $request->validated());
return (new WorkoutSessionResource($session))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/** GET /api/sessions/{session} — séance + setLogs. */
public function show(int $session): WorkoutSessionResource
{
return new WorkoutSessionResource($this->findForUser($session)->load('setLogs'));
}
/** POST /api/sessions/{session}/sets — log un set. */
public function storeSet(StoreSetLogRequest $request, int $session): JsonResponse
{
$setLog = $this->workoutSessionService->logSet($this->findForUser($session), $request->validated());
return (new SetLogResource($setLog))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/**
* POST /api/sessions/{session}/complete termine la séance (déclenche WorkoutCompleted streak).
* Renvoie la séance, le streak recalculé et les achievements nouvellement débloqués.
*/
public function complete(CompleteWorkoutSessionRequest $request, int $session): JsonResponse
{
$user = $request->user();
$previouslyEarnedKeys = Achievement::query()->where('user_id', $user->id)->pluck('key');
$workoutSession = $this->workoutSessionService->complete($this->findForUser($session), $request->validated());
$streak = Streak::query()->where('user_id', $user->id)->first();
$locale = $user->locale ?? 'fr';
$unlockedAchievements = Achievement::query()
->where('user_id', $user->id)
->whereNotIn('key', $previouslyEarnedKeys)
->get()
->map(fn (Achievement $achievement) => [
'key' => $achievement->key,
'earned_at' => $achievement->earned_at,
'label' => __("achievements.{$achievement->key}", [], $locale),
])
->values();
return response()->json([
'session' => new WorkoutSessionResource($workoutSession),
'streak' => [
'current' => $streak?->current_count ?? 0,
'longest' => $streak?->longest_count ?? 0,
'freezes_available' => $streak?->freezes_available ?? 0,
'last_active_date' => optional($streak?->last_active_date)->toDateString(),
],
'unlocked_achievements' => $unlockedAchievements,
]);
}
/** Charge la séance en la scopant strictement à l'utilisateur courant. */
private function findForUser(int $sessionId): WorkoutSession
{
return WorkoutSession::query()
->where('user_id', auth()->id())
->findOrFail($sessionId);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Functional\Tracking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la complétion d'une séance (POST /api/sessions/{session}/complete).
*/
class CompleteWorkoutSessionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ended_at' => ['nullable', 'date'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Functional\Tracking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la création d'une mensuration (POST /api/metrics).
* Idempotent par (user_id, measured_on) : une mensuration existante pour cette date est mise à jour.
*/
class StoreBodyMetricRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'measured_on' => ['required', 'date'],
'weight' => ['nullable', 'numeric', 'min:0'],
'body_fat' => ['nullable', 'numeric', 'min:0', 'max:100'],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Tracking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation du log d'une série (POST /api/sessions/{session}/sets).
*/
class StoreSetLogRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'exercise_id' => ['required', 'integer', 'exists:exercises,id'],
'set_number' => ['required', 'integer', 'min:1'],
'reps' => ['nullable', 'integer', 'min:0'],
'weight' => ['nullable', 'numeric', 'min:0'],
'rpe' => ['nullable', 'numeric', 'between:0,10'],
'duration_seconds' => ['nullable', 'integer', 'min:0'],
'distance_meters' => ['nullable', 'numeric', 'min:0'],
'is_warmup' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace Functional\Tracking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validation du démarrage d'une séance (POST /api/sessions).
*/
class StoreWorkoutSessionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'routine_id' => [
'nullable',
'integer',
Rule::exists('routines', 'id')->where(fn ($query) => $query->where('user_id', $this->user()?->id)),
],
'title' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace Functional\Tracking\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une mensuration ponctuelle de l'utilisateur.
*/
class BodyMetricResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'measured_on' => optional($this->measured_on)->toDateString(),
'weight' => $this->weight,
'body_fat' => $this->body_fat,
'notes' => $this->notes,
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace Functional\Tracking\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une série loggée au sein d'une séance.
*/
class SetLogResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'exercise_id' => $this->exercise_id,
'set_number' => $this->set_number,
'reps' => $this->reps,
'weight' => $this->weight,
'rpe' => $this->rpe,
'duration_seconds' => $this->duration_seconds,
'distance_meters' => $this->distance_meters,
'is_warmup' => $this->is_warmup,
'is_completed' => $this->is_completed,
'logged_at' => $this->logged_at,
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Functional\Tracking\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une séance d'entraînement, avec ses séries (setLogs) quand elles sont chargées.
*/
class WorkoutSessionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'routine_id' => $this->routine_id,
'status' => $this->status->value,
'started_at' => $this->started_at,
'ended_at' => $this->ended_at,
'total_volume' => $this->total_volume,
'notes' => $this->notes,
'set_count' => $this->when($this->set_logs_count !== null, $this->set_logs_count),
'set_logs' => SetLogResource::collection($this->whenLoaded('setLogs')),
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace Functional\Tracking\Models;
use Functional\Tracking\Database\Factories\BodyMetricFactory;
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;
/**
* Mensuration ponctuelle d'un utilisateur (poids, masse grasse) à une date donnée.
*/
#[UseFactory(BodyMetricFactory::class)]
class BodyMetric extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'measured_on', 'weight', 'body_fat', 'notes',
];
protected $casts = [
'measured_on' => 'date',
'weight' => 'decimal:2',
'body_fat' => 'decimal:2',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Functional\Tracking\Models;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Database\Factories\PersonalRecordFactory;
use Functional\Tracking\Enums\PersonalRecordType;
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;
/**
* Record personnel d'un utilisateur pour un exercice et un type de mesure donnés.
*
* @property PersonalRecordType $type
*/
#[UseFactory(PersonalRecordFactory::class)]
class PersonalRecord extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'exercise_id', 'type', 'value', 'unit', 'achieved_at', 'workout_session_id',
];
protected $casts = [
'type' => PersonalRecordType::class,
'value' => 'decimal:2',
'achieved_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function exercise(): BelongsTo
{
return $this->belongsTo(Exercise::class);
}
public function workoutSession(): BelongsTo
{
return $this->belongsTo(WorkoutSession::class);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Functional\Tracking\Models;
use Functional\Exercises\Models\Exercise;
use Functional\Tracking\Database\Factories\SetLogFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Une série loggée au sein d'une séance, pour un exercice donné.
*/
#[UseFactory(SetLogFactory::class)]
class SetLog extends Model
{
use HasFactory;
protected $fillable = [
'workout_session_id', 'exercise_id', 'set_number', 'reps', 'weight', 'rpe',
'duration_seconds', 'distance_meters', 'is_warmup', 'is_completed', 'logged_at',
];
protected $casts = [
'set_number' => 'integer',
'reps' => 'integer',
'weight' => 'decimal:2',
'rpe' => 'decimal:1',
'duration_seconds' => 'integer',
'distance_meters' => 'decimal:2',
'is_warmup' => 'boolean',
'is_completed' => 'boolean',
'logged_at' => 'datetime',
];
public function workoutSession(): BelongsTo
{
return $this->belongsTo(WorkoutSession::class);
}
public function exercise(): BelongsTo
{
return $this->belongsTo(Exercise::class);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Functional\Tracking\Models;
use Functional\Programs\Models\Program;
use Functional\Routines\Models\Routine;
use Functional\Tracking\Database\Factories\WorkoutSessionFactory;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Events\WorkoutCompleted;
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;
/**
* Séance d'entraînement réalisée par un utilisateur (ad hoc, ou issue d'une routine/d'un programme).
*
* @property WorkoutSessionStatus $status
*/
#[UseFactory(WorkoutSessionFactory::class)]
class WorkoutSession extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'routine_id', 'program_id', 'title', 'started_at', 'ended_at',
'status', 'notes', 'total_volume',
];
protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'status' => WorkoutSessionStatus::class,
'total_volume' => 'decimal:2',
];
/**
* Émet WorkoutCompleted quand une séance passe à "completed"
* (à la création directe en completed, ou lors d'un changement de statut).
* C'est le déclencheur écouté par la couche streaks.
*/
protected static function booted(): void
{
$fire = function (WorkoutSession $session) {
if ($session->status === WorkoutSessionStatus::Completed) {
WorkoutCompleted::dispatch($session);
}
};
static::created($fire);
static::updated(function (WorkoutSession $session) use ($fire) {
if ($session->wasChanged('status')) {
$fire($session);
}
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function setLogs(): HasMany
{
return $this->hasMany(SetLog::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Tracking\Providers;
use Functional\Tracking\Database\Seeders\TrackingSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class TrackingServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([TrackingSeeder::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\Tracking\Database\Seeders\TrackingSeeder::class]);
}
public function register(): void
{
//
}
}
@@ -0,0 +1,72 @@
<?php
namespace Functional\Tracking\Services;
use Carbon\Carbon;
use Functional\Tracking\Enums\WorkoutSessionStatus;
use Functional\Tracking\Exceptions\WorkoutSessionAlreadyClosedException;
use Functional\Tracking\Models\SetLog;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Users\Models\User;
/**
* Cycle de vie d'une séance d'entraînement : démarrage, log de sets, complétion.
* La complétion sauvegarde le statut "completed", ce qui déclenche WorkoutCompleted
* (écouté par la couche streaks pour recalculer le streak de l'utilisateur).
*/
class WorkoutSessionService
{
public function start(User $user, array $data): WorkoutSession
{
return WorkoutSession::create([
'user_id' => $user->id,
'routine_id' => $data['routine_id'] ?? null,
'title' => $data['title'] ?? null,
'started_at' => now(),
'status' => WorkoutSessionStatus::Active,
]);
}
public function logSet(WorkoutSession $session, array $data): SetLog
{
if ($session->status->isTerminal()) {
throw new WorkoutSessionAlreadyClosedException($session->id);
}
return $session->setLogs()->create([
'exercise_id' => $data['exercise_id'],
'set_number' => $data['set_number'],
'reps' => $data['reps'] ?? null,
'weight' => $data['weight'] ?? null,
'rpe' => $data['rpe'] ?? null,
'duration_seconds' => $data['duration_seconds'] ?? null,
'distance_meters' => $data['distance_meters'] ?? null,
'is_warmup' => $data['is_warmup'] ?? false,
'is_completed' => true,
'logged_at' => now(),
]);
}
public function complete(WorkoutSession $session, array $data): WorkoutSession
{
if ($session->status->isTerminal()) {
throw new WorkoutSessionAlreadyClosedException($session->id);
}
$session->ended_at = isset($data['ended_at']) ? Carbon::parse($data['ended_at']) : now();
$session->total_volume = $this->calculateTotalVolume($session);
$session->status = WorkoutSessionStatus::Completed;
$session->save(); // déclenche WorkoutCompleted (recalcul du streak, couche streaks)
return $session->fresh('setLogs');
}
/** Volume total = somme(reps * poids) des séries effectives (hors échauffement). */
private function calculateTotalVolume(WorkoutSession $session): float
{
return (float) $session->setLogs()
->where('is_warmup', false)
->get()
->sum(fn (SetLog $setLog) => (float) ($setLog->reps ?? 0) * (float) ($setLog->weight ?? 0));
}
}