feat: history and calculate needs for user
CI / 🧪 Tests Laravel (push) Failing after 2m18s
CI / 🐳 Build & Push Images (push) Has been skipped

This commit is contained in:
2026-08-14 12:45:37 +02:00
parent ea5a5737ec
commit 69372a49ed
51 changed files with 2226 additions and 305 deletions
@@ -0,0 +1,79 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\WeeklyReviewStatus;
use App\Models\NutritionPlan;
use App\Models\WeeklyReview;
use Illuminate\Support\Facades\DB;
class AcceptWeeklyReviewAdjustment
{
public function execute(WeeklyReview $weeklyReview): NutritionPlan
{
return DB::transaction(function () use ($weeklyReview): NutritionPlan {
$lockedReview = WeeklyReview::query()->lockForUpdate()->findOrFail($weeklyReview->getKey());
abort_if($lockedReview->accepted_at !== null, 409, 'WEEKLY_ADJUSTMENT_ALREADY_ACCEPTED');
abort_unless(
$lockedReview->status === WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED
&& $lockedReview->recommended_calorie_adjustment !== 0,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$user = $lockedReview->user()->lockForUpdate()->firstOrFail();
$currentPlan = $user->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail();
abort_unless(
$lockedReview->nutrition_plan_id === $currentPlan->getKey(),
409,
'WEEKLY_ADJUSTMENT_STALE',
);
$now = now();
$targetCalories = max(1200, $currentPlan->calories + $lockedReview->recommended_calorie_adjustment);
$proteins = (int) round($currentPlan->proteins);
$fats = (int) round($currentPlan->fats);
$carbs = max(0, (int) round(($targetCalories - ($proteins * 4) - ($fats * 9)) / 4));
$calories = ($proteins * 4) + ($carbs * 4) + ($fats * 9);
abort_if(
$calories === $currentPlan->calories,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$currentPlan->update([
'is_active' => false,
'effective_until' => $now,
]);
$nextPlan = $user->nutritionPlans()->create([
'source' => NutritionPlanSource::WEEKLY_ADJUSTMENT,
'formula_version' => $currentPlan->formula_version,
'calories' => $calories,
'proteins' => $proteins,
'carbs' => $carbs,
'fats' => $fats,
'resting_metabolism' => $currentPlan->resting_metabolism,
'maintenance_calories' => $currentPlan->maintenance_calories,
'calorie_adjustment_percent' => ($calories - $currentPlan->maintenance_calories) / max(1, $currentPlan->maintenance_calories),
'calculation_details' => [
...$currentPlan->calculation_details,
'weeklyReviewId' => $lockedReview->getKey(),
'weeklyAdjustmentCalories' => $lockedReview->recommended_calorie_adjustment,
],
'effective_from' => $now,
]);
$lockedReview->update([
'accepted_at' => $now,
'accepted_nutrition_plan_id' => $nextPlan->getKey(),
]);
return $nextPlan;
}, attempts: 3);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Models\User;
class CompleteNutritionOnboarding
{
public function __construct(private RecalculateNutritionPlan $recalculateNutritionPlan) {}
/**
* @param array<string, mixed> $data
*/
public function execute(User $user, array $data): User
{
if ($user->hasCompletedNutritionOnboarding()) {
return $user->loadMissing(['activeNutritionPlan', 'latestWeightEntry']);
}
return $this->recalculateNutritionPlan->execute(
user: $user,
data: $data,
source: NutritionPlanSource::ONBOARDING,
completeOnboarding: true,
);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Models\User;
use App\Services\NutritionPlanCalculator;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
class RecalculateNutritionPlan
{
public function __construct(private NutritionPlanCalculator $calculator) {}
/**
* @param array<string, mixed> $data
*/
public function execute(
User $user,
array $data,
NutritionPlanSource $source,
bool $completeOnboarding = false,
): User {
return DB::transaction(function () use ($user, $data, $source, $completeOnboarding): User {
$lockedUser = User::query()->lockForUpdate()->findOrFail($user->getKey());
$now = now();
if ($completeOnboarding && $lockedUser->hasCompletedNutritionOnboarding()) {
return $lockedUser->load(['activeNutritionPlan', 'latestWeightEntry']);
}
$lockedUser->update([
'height' => $data['heightCm'],
'target_weight' => $data['targetWeightKg'] ?? null,
'date_of_birth' => $data['dateOfBirth'],
'sex' => $data['sex'],
'physical_activity_level' => $data['physicalActivityLevel'],
'weight_goal' => $data['weightGoal'],
'pace_preference' => $data['pacePreference'],
...($completeOnboarding ? [
'onboarding_completed_at' => $now,
'nutrition_estimate_accepted_at' => $now,
] : []),
]);
$weightKg = (float) $data['weightKg'];
$latestWeight = $lockedUser->weightEntries()->latest('measured_at')->first();
if ($latestWeight === null || abs($latestWeight->weight_kg - $weightKg) >= 0.01) {
$lockedUser->weightEntries()->create([
'weight_kg' => $weightKg,
'measured_at' => $now,
]);
}
$lockedUser->nutritionPlans()
->where('is_active', true)
->update([
'is_active' => false,
'effective_until' => $now,
]);
$calculation = $this->calculator->calculate(
weightKg: $weightKg,
heightCm: (int) $data['heightCm'],
dateOfBirth: CarbonImmutable::parse($data['dateOfBirth']),
sex: UserSex::from($data['sex']),
activityLevel: PhysicalActivityLevel::from($data['physicalActivityLevel']),
weightGoal: WeightGoal::from($data['weightGoal']),
pacePreference: PacePreference::from($data['pacePreference']),
);
$lockedUser->nutritionPlans()->create([
...$calculation,
'source' => $source,
'is_active' => true,
'effective_from' => $now,
]);
return $lockedUser->fresh()->load(['activeNutritionPlan', 'latestWeightEntry']);
}, attempts: 3);
}
}