Add budget progress gauge inside mobile category buttons

Each depense/epargne category button on the mobile quick-entry form
now shows a fill bar plus percentage of its monthly budget already
tracked, so the user sees at a glance which categories are close to
their limit while picking one. Categories without a budget defined
in Plan show no gauge (avoids a misleading 0%). Capped at 100% —
overspend detail already lives on the dashboard's "Manque" column.

3 new Pest tests for the percentage calculation (normal, capped,
no-budget cases). 61/61 tests pass, Pint clean, verified on mobile
against production data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-07-26 12:48:29 +02:00
co-authored by Claude Sonnet 5
parent 5861fc49fc
commit 613220f43a
3 changed files with 113 additions and 5 deletions
+45 -1
View File
@@ -6,6 +6,7 @@ namespace App\Livewire\Suivi;
use App\Enums\CategoryType; use App\Enums\CategoryType;
use App\Models\BudgetLine; use App\Models\BudgetLine;
use App\Models\Category;
use App\Models\RecurringTransaction; use App\Models\RecurringTransaction;
use App\Models\Transaction; use App\Models\Transaction;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
@@ -108,6 +109,32 @@ class Index extends Component
]); ]);
} }
/**
* Budget planifié pour cette catégorie sur le mois en cours, en résolvant
* l'override du mois s'il existe sinon le montant par défaut de l'année.
*/
private function budgetForCurrentMonth(Category $category, int $year, int $month): ?float
{
$lines = $category->budgetLines()->where('year', $year)->get();
$override = $lines->firstWhere('month', $month);
if ($override) {
return (float) $override->amount;
}
$default = $lines->firstWhere('month', null);
return $default ? (float) $default->amount : null;
}
private function trackedForCategoryThisMonth(Category $category, int $year, int $month): float
{
return (float) $category->transactions()
->whereYear('date_effective', $year)
->whereMonth('date_effective', $month)
->sum('amount');
}
public function render() public function render()
{ {
$household = auth()->user()->currentHousehold; $household = auth()->user()->currentHousehold;
@@ -159,10 +186,27 @@ class Index extends Component
->sortBy('day_of_month') ->sortBy('day_of_month')
->values(); ->values();
$categories = $household->categories()->orderBy('type')->orderBy('position')->get();
$categoryProgress = $categories
->whereIn('type', [CategoryType::Depense, CategoryType::Epargne])
->mapWithKeys(function (Category $category) use ($year, $month) {
$budget = $this->budgetForCurrentMonth($category, $year, $month);
if ($budget === null || $budget <= 0) {
return [$category->id => null];
}
$tracked = $this->trackedForCategoryThisMonth($category, $year, $month);
return [$category->id => min(100, round($tracked / $budget * 100))];
});
return view('livewire.suivi.index', [ return view('livewire.suivi.index', [
'transactions' => $transactions, 'transactions' => $transactions,
'running' => $running, 'running' => $running,
'categories' => $household->categories()->orderBy('type')->orderBy('position')->get(), 'categories' => $categories,
'categoryProgress' => $categoryProgress,
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(), 'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
'nbrTotal' => $transactions->count(), 'nbrTotal' => $transactions->count(),
'lastEntry' => $transactions->first(), 'lastEntry' => $transactions->first(),
+20 -4
View File
@@ -55,14 +55,30 @@
<p class="text-xs text-text-muted mb-1.5">{{ $meta['label'] }}</p> <p class="text-xs text-text-muted mb-1.5">{{ $meta['label'] }}</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@foreach ($typeCategories as $category) @foreach ($typeCategories as $category)
@php
$isActive = (string) $categoryId === (string) $category->id;
$progress = $categoryProgress[$category->id] ?? null;
@endphp
<button <button
type="button" type="button"
wire:click="$set('categoryId', {{ $category->id }})" wire:click="$set('categoryId', {{ $category->id }})"
aria-pressed="{{ (string) $categoryId === (string) $category->id ? 'true' : 'false' }}" aria-pressed="{{ $isActive ? 'true' : 'false' }}"
class="min-h-[52px] px-3 py-2 rounded-lg border text-sm font-medium text-left transition focus:outline-none focus-visible:ring-2 {{ $meta['ring'] }} class="relative min-h-[52px] px-3 py-2 rounded-lg border text-sm font-medium text-left transition overflow-hidden focus:outline-none focus-visible:ring-2 {{ $meta['ring'] }}
{{ (string) $categoryId === (string) $category->id ? $meta['active'] : 'bg-surface border-border-strong text-text' }}" {{ $isActive ? $meta['active'] : 'bg-surface border-border-strong text-text' }}"
> >
{{ $category->name }} @if ($progress !== null)
<span
aria-hidden="true"
class="absolute inset-y-0 left-0 {{ $isActive ? 'bg-white/25' : 'bg-text/10' }}"
style="width: {{ $progress }}%"
></span>
@endif
<span class="relative flex items-center justify-between gap-2">
<span>{{ $category->name }}</span>
@if ($progress !== null)
<span class="text-xs font-normal {{ $isActive ? 'text-white/80' : 'text-text-muted' }}">{{ $progress }}%</span>
@endif
</span>
</button> </button>
@endforeach @endforeach
</div> </div>
+48
View File
@@ -184,6 +184,54 @@ it('shows the recurrence again the following month after being confirmed', funct
expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id); expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id);
}); });
it('computes the percentage of budget already tracked for a category this month', function (): void {
$this->travelTo(now()->setDate(2026, 3, 10));
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1000]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $user->id,
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 600,
]);
$component = Livewire::actingAs($user)->test(Index::class);
expect($component->viewData('categoryProgress')[$category->id])->toBe(60.0);
});
it('caps the category progress percentage at 100', function (): void {
$this->travelTo(now()->setDate(2026, 3, 10));
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1000]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $user->id,
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1500,
]);
$component = Livewire::actingAs($user)->test(Index::class);
expect($component->viewData('categoryProgress')[$category->id])->toBe(100);
});
it('returns no progress for a category without a budget defined', function (): void {
$this->travelTo(now()->setDate(2026, 3, 10));
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$component = Livewire::actingAs($user)->test(Index::class);
expect($component->viewData('categoryProgress')[$category->id])->toBeNull();
});
it('does not show another household transactions', function (): void { it('does not show another household transactions', function (): void {
$ownerA = User::factory()->create(); $ownerA = User::factory()->create();
$ownerB = User::factory()->create(); $ownerB = User::factory()->create();