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