35 - Add dashboard stats for dishes, users, meals and favorites

This commit is contained in:
myrmidex 2026-08-19 15:42:39 +02:00
parent 1e826e7cae
commit 2eccf2c690
4 changed files with 287 additions and 1 deletions

View file

@ -3,6 +3,44 @@
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1> <h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-accent-blue">{{ $stats['dish_count'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Dishes</h3>
</div>
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-primary">{{ $stats['user_count'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Users</h3>
</div>
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-success">{{ $stats['meals_this_month'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Meals this month</h3>
</div>
</div>
<div class="border-2 border-secondary rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold text-accent-blue mb-4">Favorite dishes</h2>
@if ($stats['favorite_dishes']->isEmpty())
<p class="text-gray-100">No users yet.</p>
@else
<ul class="space-y-3">
@foreach ($stats['favorite_dishes'] as $favorite)
<li class="flex justify-between gap-4">
<span class="font-bold text-primary">{{ $favorite['user']->name }}</span>
@if ($favorite['dish'])
<span class="text-gray-100">{{ $favorite['dish']->name }} <span class="text-gray-400">({{ $favorite['count'] }})</span></span>
@else
<span class="text-gray-400">No meals yet</span>
@endif
</li>
@endforeach
</ul>
@endif
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200"> <a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3> <h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3>

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\RegisterController;
use DishPlanner\Dashboard\Services\DashboardStatsService;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
@ -26,7 +27,9 @@
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); $stats = (new DashboardStatsService(auth()->user()))->stats();
return view('dashboard', ['stats' => $stats]);
})->name('dashboard'); })->name('dashboard');
Route::get('/dishes', function () { Route::get('/dishes', function () {

View file

@ -0,0 +1,114 @@
<?php
namespace DishPlanner\Dashboard\Services;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\ScheduledUserDish;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class DashboardStatsService
{
public function __construct(private readonly Planner $planner) {}
/**
* @return array{
* dish_count: int,
* user_count: int,
* meals_this_month: int,
* favorite_dishes: Collection<int, array{user: User, dish: Dish|null, count: int}>
* }
*/
public function stats(): array
{
// PHPStan cannot equate the identical array shapes; see favoriteDishes().
/** @phpstan-ignore return.type */
return [
'dish_count' => $this->dishCount(),
'user_count' => $this->userCount(),
'meals_this_month' => $this->mealsThisMonth(),
'favorite_dishes' => $this->favoriteDishes(),
];
}
public function dishCount(): int
{
return Dish::where('planner_id', $this->planner->id)->count();
}
public function userCount(): int
{
return User::where('planner_id', $this->planner->id)->count();
}
public function mealsThisMonth(): int
{
$start = Carbon::now()->startOfMonth()->toDateString();
$end = Carbon::now()->endOfMonth()->toDateString();
return ScheduledUserDish::query()
->where('is_skipped', false)
->whereNotNull('user_dish_id')
->whereHas('schedule', fn ($query) => $query
->where('planner_id', $this->planner->id)
->where('is_skipped', false)
->whereBetween('date', [$start, $end]))
->count();
}
/**
* @return Collection<int, array{user: User, dish: Dish|null, count: int}>
*/
public function favoriteDishes(): Collection
{
$users = User::where('planner_id', $this->planner->id)
->orderBy('name')
->get();
$favorites = ScheduledUserDish::query()
->with(['user', 'userDish.dish'])
->whereNotNull('user_dish_id')
->whereHas('schedule', fn ($query) => $query->where('planner_id', $this->planner->id))
->get()
->groupBy('user_id')
->map(fn (Collection $items) => $this->favoriteForItems($items));
// Eloquent map() returns a union type PHPStan cannot narrow to the declared shape.
/** @phpstan-ignore return.type */
return $users->map(fn (User $user) => $favorites->get($user->id) ?? [
'user' => $user,
'dish' => null,
'count' => 0,
])->toBase()->values();
}
/**
* @param Collection<int, ScheduledUserDish> $items
* @return array{user: User, dish: Dish|null, count: int}
*/
private function favoriteForItems(Collection $items): array
{
$dishCounts = $items
->groupBy(fn (ScheduledUserDish $item) => $item->userDish->dish_id)
->map(fn (Collection $dishItems) => [
'dish' => $dishItems->firstOrFail()->userDish->dish,
'count' => $dishItems->count(),
]);
$top = $dishCounts->sort(function (array $a, array $b) {
if ($a['count'] === $b['count']) {
return ($a['dish']->name ?? '') <=> ($b['dish']->name ?? '');
}
return $b['count'] <=> $a['count'];
})->first();
return [
'user' => $items->firstOrFail()->user,
'dish' => $top['dish'] ?? null,
'count' => $top['count'] ?? 0,
];
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use App\Models\User;
use App\Models\UserDish;
use DishPlanner\Schedule\Actions\UpdateScheduleAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_renders_with_zero_stats_for_an_empty_planner(): void
{
$planner = Planner::factory()->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewIs('dashboard')
->assertSee('DASHBOARD')
->assertSee('Dishes')
->assertSee('Users')
->assertSee('Meals this month')
->assertSee('No users yet.')
->assertViewHas('stats', fn (array $stats) => $stats['dish_count'] === 0
&& $stats['user_count'] === 0
&& $stats['meals_this_month'] === 0
&& $stats['favorite_dishes']->isEmpty());
}
public function test_dashboard_shows_dish_and_user_counts(): void
{
$planner = Planner::factory()->create();
Dish::factory()->planner($planner)->count(3)->create();
User::factory()->planner($planner)->count(2)->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertSee('Favorite dishes')
->assertSee('No meals yet')
->assertViewHas('stats', fn (array $stats) => $stats['dish_count'] === 3
&& $stats['user_count'] === 2
&& $stats['favorite_dishes']->count() === 2
&& $stats['favorite_dishes']->every(fn (array $favorite) => $favorite['dish'] === null && $favorite['count'] === 0));
}
public function test_dashboard_counts_only_non_skipped_meals_this_month(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$userDish = UserDish::factory()->user($user)->dish($dish)->create();
$date = now()->startOfMonth()->addDays(5);
$scheduled = Schedule::factory()->planner($planner)->date($date)->create();
$skipped = Schedule::factory()->planner($planner)->date($date->copy()->addDay())->create();
ScheduledUserDish::factory()->schedule($scheduled)->user($user)->userDish($userDish)->create();
ScheduledUserDish::factory()->schedule($skipped)->user($user)->userDish($userDish)->skipped()->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewHas('stats', fn (array $stats) => $stats['meals_this_month'] === 1);
}
public function test_dashboard_shows_the_most_scheduled_dish_per_user(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create(['name' => 'Ada']);
$pizza = Dish::factory()->planner($planner)->create(['name' => 'Pizza']);
$tacos = Dish::factory()->planner($planner)->create(['name' => 'Tacos']);
$pizzaDish = UserDish::factory()->user($user)->dish($pizza)->create();
$tacosDish = UserDish::factory()->user($user)->dish($tacos)->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now())->create())
->user($user)
->userDish($pizzaDish)
->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now()->addDay())->create())
->user($user)
->userDish($pizzaDish)
->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now()->addDays(2))->create())
->user($user)
->userDish($tacosDish)
->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertSee('Pizza')
->assertViewHas('stats', function (array $stats) {
$favorite = $stats['favorite_dishes']->first();
return $favorite['user']->name === 'Ada'
&& $favorite['dish']->name === 'Pizza'
&& $favorite['count'] === 2;
});
}
public function test_dashboard_excludes_meals_from_a_skipped_schedule(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$userDish = UserDish::factory()->user($user)->dish($dish)->create();
$date = now()->startOfMonth()->addDays(5);
$schedule = Schedule::factory()->planner($planner)->date($date)->create();
ScheduledUserDish::factory()->schedule($schedule)->user($user)->userDish($userDish)->create();
(new UpdateScheduleAction)->execute($schedule, true);
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewHas('stats', fn (array $stats) => $stats['meals_this_month'] === 0);
}
}