Compare commits
3 commits
1e826e7cae
...
cfad3dcb1b
| Author | SHA1 | Date | |
|---|---|---|---|
| cfad3dcb1b | |||
| e0e3bb0936 | |||
| 2eccf2c690 |
6 changed files with 301 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,44 @@
|
|||
<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">
|
||||
<h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
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