Compare commits

...

3 commits

Author SHA1 Message Date
cfad3dcb1b 61 - Authenticate composer dist downloads and drop the dead registry cache
All checks were successful
CI / ci-image (push) Successful in 25m46s
CI / ci (push) Successful in 22m39s
2026-08-19 15:49:09 +02:00
e0e3bb0936 Merge branch '35-enhance-dashboard-page' into release/v0.9.0 2026-08-19 15:43:22 +02:00
2eccf2c690 35 - Add dashboard stats for dishes, users, meals and favorites 2026-08-19 15:42:39 +02:00
6 changed files with 301 additions and 7 deletions

View file

@ -59,8 +59,8 @@ jobs:
file: docker/build/Dockerfile.ci
push: true
tags: forge.lvl0.xyz/lvl0/dishplanner-ci:${{ steps.meta.outputs.tag }}
cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/dishplanner-ci:buildcache
cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/dishplanner-ci:buildcache,mode=max
secrets: |
gh_pat=${{ secrets.GH_PAT }}
ci:
needs: ci-image

View file

@ -37,13 +37,21 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Bake the project's PHP dependencies (dev included) into the image so CI
# restores them with a local copy instead of paying a per-run composer install
# over the network. --prefer-source clones via git instead of fetching dist
# archives, avoiding the codeload.github.com rate limits the runner hits under
# --prefer-dist.
# over the network.
#
# The token lifts GitHub's API rate limit from 60 to 5000 requests/hour, which
# is what forced --prefer-source before; dist archives need no special handling.
#
# --no-scripts skips `php artisan package:discover` (the app isn't present
# here). CI runs `composer install` after restoring vendor, which regenerates
# bootstrap/cache.
WORKDIR /opt/deps
COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-progress --prefer-source --no-scripts
RUN --mount=type=secret,id=gh_pat \
if [ -s /run/secrets/gh_pat ]; then \
composer config --global github-oauth.github.com "$(cat /run/secrets/gh_pat)" || exit 1; \
fi; \
composer install --no-interaction --no-progress --no-scripts; \
STATUS=$?; \
composer config --global --unset github-oauth.github.com >/dev/null 2>&1 || true; \
exit $STATUS

View file

@ -2,6 +2,44 @@
<div class="px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
<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">
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">

View file

@ -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 () {

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);
}
}