diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php new file mode 100644 index 0000000..517c0e5 --- /dev/null +++ b/app/Filament/Widgets/StatsOverviewWidget.php @@ -0,0 +1,84 @@ +usersStat(), + $this->stationsStat(), + $this->oilPredictionStat(), + $this->apiErrorsStat(), + ]; + } + + private function usersStat(): Stat + { + return Stat::make('Total users', User::count()) + ->icon('heroicon-o-users') + ->color('primary'); + } + + private function stationsStat(): Stat + { + $count = Station::count(); + $lastSeen = Station::max('last_seen_at'); + $description = $lastSeen + ? 'Last seen '.Carbon::parse($lastSeen)->diffForHumans() + : 'No stations yet'; + + return Stat::make('Stations in DB', number_format($count)) + ->description($description) + ->icon('heroicon-o-map-pin') + ->color('success'); + } + + private function oilPredictionStat(): Stat + { + $prediction = PricePrediction::latest('generated_at')->first(); + + if ($prediction === null) { + return Stat::make('Latest oil prediction', 'None') + ->icon('heroicon-o-beaker') + ->color('gray'); + } + + $ageHours = $prediction->generated_at->diffInHours(now()); + $color = $ageHours > 24 ? 'warning' : 'success'; + $value = ucfirst($prediction->direction->value) + .' · '.$prediction->confidence.'%' + .' · '.strtoupper($prediction->source->value); + + return Stat::make('Latest oil prediction', $value) + ->description('Generated '.$prediction->generated_at->diffForHumans()) + ->icon('heroicon-o-beaker') + ->color($color); + } + + private function apiErrorsStat(): Stat + { + $errors = ApiLog::where('created_at', '>=', now()->subDay()) + ->where(fn ($q) => $q->where('status_code', '>=', 400) + ->orWhereNull('status_code') + ->orWhereNotNull('error')) + ->count(); + + $color = $errors > 0 ? 'danger' : 'success'; + + return Stat::make('API errors (24h)', $errors) + ->icon('heroicon-o-exclamation-triangle') + ->color($color); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index d2f4e34..0c17f7f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,7 @@ namespace App\Providers\Filament; +use App\Filament\Widgets\StatsOverviewWidget; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -39,6 +40,7 @@ class AdminPanelProvider extends PanelProvider ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ AccountWidget::class, + StatsOverviewWidget::class, ]) ->middleware([ EncryptCookies::class, diff --git a/tests/Feature/Admin/StatsOverviewWidgetTest.php b/tests/Feature/Admin/StatsOverviewWidgetTest.php new file mode 100644 index 0000000..8195fb2 --- /dev/null +++ b/tests/Feature/Admin/StatsOverviewWidgetTest.php @@ -0,0 +1,26 @@ +admin = User::factory()->admin()->create(); + $this->actingAs($this->admin); +}); + +it('renders the stats overview widget', function () { + User::factory()->count(3)->create(); + Station::factory()->count(2)->create(); + PricePrediction::factory()->create(['generated_at' => now()->subHours(2)]); + ApiLog::factory()->count(2)->create(['status_code' => 200, 'error' => null, 'created_at' => now()->subMinutes(30)]); + + Livewire::test(StatsOverviewWidget::class) + ->assertOk(); +});