35 - Add dashboard stats for dishes, users, meals and favorites
This commit is contained in:
parent
1e826e7cae
commit
2eccf2c690
4 changed files with 287 additions and 1 deletions
|
|
@ -2,6 +2,44 @@
|
||||||
<div class="px-4 sm:px-6 lg:px-8">
|
<div class="px-4 sm:px-6 lg:px-8">
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
114
src/DishPlanner/Dashboard/Services/DashboardStatsService.php
Normal file
114
src/DishPlanner/Dashboard/Services/DashboardStatsService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
131
tests/Feature/DashboardTest.php
Normal file
131
tests/Feature/DashboardTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue