diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 3724df0..5cec696 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -2,6 +2,44 @@

DASHBOARD

+ +
+
+

{{ $stats['dish_count'] }}

+

Dishes

+
+ +
+

{{ $stats['user_count'] }}

+

Users

+
+ +
+

{{ $stats['meals_this_month'] }}

+

Meals this month

+
+
+ +
+

Favorite dishes

+ + @if ($stats['favorite_dishes']->isEmpty()) +

No users yet.

+ @else +
    + @foreach ($stats['favorite_dishes'] as $favorite) +
  • + {{ $favorite['user']->name }} + @if ($favorite['dish']) + {{ $favorite['dish']->name }} ({{ $favorite['count'] }}) + @else + No meals yet + @endif +
  • + @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index a4031ae..3e47e5c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\RegisterController; +use DishPlanner\Dashboard\Services\DashboardStatsService; use Illuminate\Support\Facades\Route; Route::get('/', function () { @@ -26,7 +27,9 @@ Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::get('/dashboard', function () { - return view('dashboard'); + $stats = (new DashboardStatsService(auth()->user()))->stats(); + + return view('dashboard', ['stats' => $stats]); })->name('dashboard'); Route::get('/dishes', function () { diff --git a/src/DishPlanner/Dashboard/Services/DashboardStatsService.php b/src/DishPlanner/Dashboard/Services/DashboardStatsService.php new file mode 100644 index 0000000..d7b6996 --- /dev/null +++ b/src/DishPlanner/Dashboard/Services/DashboardStatsService.php @@ -0,0 +1,114 @@ + + * } + */ + 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 + */ + 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 $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, + ]; + } +} diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php new file mode 100644 index 0000000..6b84f5e --- /dev/null +++ b/tests/Feature/DashboardTest.php @@ -0,0 +1,131 @@ +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); + } +}