Files
fuel-price/docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Ovidiu U c2c16c928b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add UserResource with is_admin toggle and delete
User management resource with editable is_admin field, postcode support,
admin filter, and inline delete action. Includes list and edit pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:31:55 +01:00

60 KiB
Raw Permalink Blame History

Filament Admin Panel Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a Filament v5 admin panel at /admin with 5 resources (ApiLog, User, OilPrediction, BrentPrice, Station), a dashboard stats widget, and page-level actions for triggering predictions and fuel polls.

Architecture: Filament v5 resources auto-discovered in app/Filament/Resources/. Admin access gated by User::canAccessPanel() (already implemented) checking is_admin. Each resource has its own Pages/ namespace. Two resources (OilPrediction, Station) have custom list pages with header actions. Dashboard stats widget reads from four live DB sources. BrentPrice list page shows a line chart widget above the table.

Tech Stack: Filament v5.4.4, Laravel 13, Pest v4, MySQL, Heroicons


What already exists

  • database/migrations/2026_04_04_123728_add_is_admin_to_users_table.phpexists, unrun
  • app/Models/User.php — implements FilamentUser, has canAccessPanel() checking is_admin
  • app/Providers/Filament/AdminPanelProvider.php — scaffolded, needs authGuard + widget config
  • All five target models (ApiLog, Station, BrentPrice, PricePrediction, User)
  • app/Console/Commands/PollFuelPrices.phpfuel:poll {--full}
  • app/Console/Commands/PredictOilPrices.phpoil:predict {--fetch}
  • database/factories/UserFactory.php, StationFactory.php — exist; no is_admin state yet
  • No factories for ApiLog, BrentPrice, PricePrediction

File map

File Action Purpose
database/seeders/AdminSeeder.php Create Seed uovidiu@sent.com as admin
database/seeders/DatabaseSeeder.php Modify Call AdminSeeder
database/factories/UserFactory.php Modify Add admin() state
database/factories/ApiLogFactory.php Create Test data for ApiLog
database/factories/BrentPriceFactory.php Create Test data for BrentPrice
database/factories/PricePredictionFactory.php Create Test data for PricePrediction
app/Models/ApiLog.php Modify Add HasFactory
app/Models/BrentPrice.php Modify Add HasFactory
app/Models/PricePrediction.php Modify Add HasFactory
app/Providers/Filament/AdminPanelProvider.php Modify Auth guard, widget config
app/Jobs/PollFuelPricesJob.php Create Queued job wrapping fuel:poll --full
app/Filament/Resources/ApiLogResource.php Create Read-only log browser
app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php Create List page
app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php Create View page
app/Filament/Resources/UserResource.php Create User management
app/Filament/Resources/UserResource/Pages/ListUsers.php Create List page
app/Filament/Resources/UserResource/Pages/EditUser.php Create Edit page
app/Filament/Resources/OilPredictionResource.php Create Prediction browser
app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php Create List page + run-prediction header action
app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php Create View page
app/Filament/Resources/BrentPriceResource.php Create Brent price table
app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php Create List page + chart widget header
app/Filament/Widgets/BrentPriceChartWidget.php Create 30-day line chart
app/Filament/Resources/StationResource.php Create Station browser
app/Filament/Resources/StationResource/Pages/ListStations.php Create List page + full-poll header action
app/Filament/Resources/StationResource/Pages/ViewStation.php Create View page
app/Filament/Widgets/StatsOverviewWidget.php Create Dashboard 4-stat card
tests/Feature/Admin/AdminAccessTest.php Create Auth gate tests
tests/Feature/Admin/ApiLogResourceTest.php Create ApiLog resource tests
tests/Feature/Admin/UserResourceTest.php Create User resource tests
tests/Feature/Admin/OilPredictionResourceTest.php Create OilPrediction resource tests
tests/Feature/Admin/BrentPriceResourceTest.php Create BrentPrice resource tests
tests/Feature/Admin/StationResourceTest.php Create Station resource tests

Task 1: Run migrations and create AdminSeeder

Files:

  • Run: php artisan migrate

  • Create: database/seeders/AdminSeeder.php

  • Modify: database/seeders/DatabaseSeeder.php

  • Modify: database/factories/UserFactory.php

  • Step 1: Run the pending migration

php artisan migrate --no-interaction

Expected: Migrating: 2026_04_04_123728_add_is_admin_to_users_table ... Migrated

  • Step 2: Write the failing test

Create tests/Feature/Admin/AdminAccessTest.php:

<?php

use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

it('denies non-admin users access to admin panel', function () {
    $user = User::factory()->create();
    $this->actingAs($user);

    $this->get('/admin')->assertRedirect();
});

it('allows admin users to access admin panel', function () {
    $user = User::factory()->admin()->create();
    $this->actingAs($user);

    $this->get('/admin')->assertOk();
});
  • Step 3: Run test to verify it fails
php artisan test --compact --filter=AdminAccessTest

Expected: FAIL — admin() state not found on factory

  • Step 4: Add admin() state to UserFactory

Modify database/factories/UserFactory.php — add the admin() method after withTwoFactor():

public function admin(): static
{
    return $this->state(['is_admin' => true]);
}
  • Step 5: Create AdminSeeder

Create database/seeders/AdminSeeder.php:

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class AdminSeeder extends Seeder
{
    public function run(): void
    {
        User::updateOrCreate(
            ['email' => 'uovidiu@sent.com'],
            [
                'name'       => 'Ovidiu U',
                'password'   => Hash::make('changeme'),
                'is_admin'   => true,
            ]
        );
    }
}
  • Step 6: Register AdminSeeder in DatabaseSeeder

Modify database/seeders/DatabaseSeeder.php — add the call:

public function run(): void
{
    $this->call(AdminSeeder::class);

    User::factory()->create([
        'name'  => 'Test User',
        'email' => 'test@example.com',
    ]);
}
  • Step 7: Run seeder to verify it works
php artisan db:seed --class=AdminSeeder --no-interaction

Expected: no errors; uovidiu@sent.com now has is_admin = 1 in database.

  • Step 8: Run tests to verify they pass
php artisan test --compact --filter=AdminAccessTest

Expected: PASS (both tests green)

  • Step 9: Commit
git add database/migrations/2026_04_04_123728_add_is_admin_to_users_table.php \
        database/seeders/AdminSeeder.php \
        database/seeders/DatabaseSeeder.php \
        database/factories/UserFactory.php \
        tests/Feature/Admin/AdminAccessTest.php
git commit -m "feat: add admin seeder and is_admin factory state"

Task 2: Configure AdminPanelProvider

Files:

  • Modify: app/Providers/Filament/AdminPanelProvider.php

  • Step 1: Update AdminPanelProvider

Replace the full file content of app/Providers/Filament/AdminPanelProvider.php:

<?php

namespace App\Providers\Filament;

use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            ->authGuard('web')
            ->colors([
                'primary' => Color::Amber,
            ])
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
            ->pages([
                Dashboard::class,
            ])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
            ->widgets([
                AccountWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                PreventRequestForgery::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}
  • Step 2: Run access tests to verify panel config still works
php artisan test --compact --filter=AdminAccessTest

Expected: PASS

  • Step 3: Commit
git add app/Providers/Filament/AdminPanelProvider.php
git commit -m "feat: configure admin panel with authGuard and widget setup"

Task 3: Model factories for ApiLog, BrentPrice, PricePrediction

Files:

  • Modify: app/Models/ApiLog.php

  • Modify: app/Models/BrentPrice.php

  • Modify: app/Models/PricePrediction.php

  • Create: database/factories/ApiLogFactory.php

  • Create: database/factories/BrentPriceFactory.php

  • Create: database/factories/PricePredictionFactory.php

  • Step 1: Add HasFactory to ApiLog

Modify app/Models/ApiLog.php — add the HasFactory import and trait:

<?php

namespace App\Models;

use Database\Factories\ApiLogFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])]
class ApiLog extends Model
{
    /** @use HasFactory<ApiLogFactory> */
    use HasFactory;

    const null UPDATED_AT = null;

    protected function casts(): array
    {
        return [
            'created_at' => 'datetime',
        ];
    }
}
  • Step 2: Create ApiLogFactory

Create database/factories/ApiLogFactory.php:

<?php

namespace Database\Factories;

use App\Models\ApiLog;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<ApiLog> */
class ApiLogFactory extends Factory
{
    public function definition(): array
    {
        return [
            'service'     => fake()->randomElement(['fuel_finder', 'fred', 'anthropic', 'postcodes_io']),
            'method'      => fake()->randomElement(['GET', 'POST']),
            'url'         => fake()->url(),
            'status_code' => fake()->randomElement([200, 200, 200, 401, 429, 500]),
            'duration_ms' => fake()->numberBetween(50, 2000),
            'error'       => null,
            'created_at'  => fake()->dateTimeBetween('-7 days', 'now'),
        ];
    }

    public function failed(): static
    {
        return $this->state([
            'status_code' => fake()->randomElement([400, 401, 403, 429, 500, 503]),
            'error'       => fake()->sentence(),
        ]);
    }
}
  • Step 3: Add HasFactory to BrentPrice

Modify app/Models/BrentPrice.php — add HasFactory:

<?php

namespace App\Models;

use Database\Factories\BrentPriceFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * @property Carbon $date
 * @property string $price_usd
 */
#[Fillable(['date', 'price_usd'])]
class BrentPrice extends Model
{
    /** @use HasFactory<BrentPriceFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $primaryKey = 'date';

    public $incrementing = false;

    protected $keyType = 'string';

    protected function casts(): array
    {
        return [
            'date'      => 'date',
            'price_usd' => 'decimal:2',
        ];
    }
}
  • Step 4: Create BrentPriceFactory

Create database/factories/BrentPriceFactory.php:

<?php

namespace Database\Factories;

use App\Models\BrentPrice;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<BrentPrice> */
class BrentPriceFactory extends Factory
{
    /** @var array<int, string> */
    private static array $usedDates = [];

    public function definition(): array
    {
        do {
            $date = fake()->dateTimeBetween('-60 days', 'now')->format('Y-m-d');
        } while (in_array($date, self::$usedDates, true));

        self::$usedDates[] = $date;

        return [
            'date'      => $date,
            'price_usd' => fake()->randomFloat(2, 65, 95),
        ];
    }
}
  • Step 5: Add HasFactory to PricePrediction

Modify app/Models/PricePrediction.php — add HasFactory:

<?php

namespace App\Models;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * @property int $id
 * @property Carbon $predicted_for
 * @property PredictionSource $source
 * @property TrendDirection $direction
 * @property int $confidence
 * @property string|null $reasoning
 * @property Carbon $generated_at
 */
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
{
    /** @use HasFactory<PricePredictionFactory> */
    use HasFactory;

    public $timestamps = false;

    protected function casts(): array
    {
        return [
            'predicted_for' => 'date',
            'source'        => PredictionSource::class,
            'direction'     => TrendDirection::class,
            'confidence'    => 'integer',
            'generated_at'  => 'datetime',
        ];
    }
}
  • Step 6: Create PricePredictionFactory

Create database/factories/PricePredictionFactory.php:

<?php

namespace Database\Factories;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\PricePrediction;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<PricePrediction> */
class PricePredictionFactory extends Factory
{
    public function definition(): array
    {
        return [
            'predicted_for' => fake()->dateTimeBetween('-30 days', 'now')->format('Y-m-d'),
            'source'        => fake()->randomElement(PredictionSource::cases()),
            'direction'     => fake()->randomElement(TrendDirection::cases()),
            'confidence'    => fake()->numberBetween(40, 85),
            'reasoning'     => fake()->sentence(12),
            'generated_at'  => now(),
        ];
    }

    public function llm(): static
    {
        return $this->state(['source' => PredictionSource::Llm]);
    }

    public function ewma(): static
    {
        return $this->state(['source' => PredictionSource::Ewma]);
    }
}
  • Step 7: Verify factories work with tinker
php artisan tinker --execute 'echo App\Models\ApiLog::factory()->make()->service . "\n";'
php artisan tinker --execute 'echo App\Models\BrentPrice::factory()->make()->price_usd . "\n";'
php artisan tinker --execute 'echo App\Models\PricePrediction::factory()->make()->direction->value . "\n";'

Expected: a service name, a decimal price, a direction string — no exceptions

  • Step 8: Commit
git add app/Models/ApiLog.php app/Models/BrentPrice.php app/Models/PricePrediction.php \
        database/factories/ApiLogFactory.php \
        database/factories/BrentPriceFactory.php \
        database/factories/PricePredictionFactory.php
git commit -m "feat: add HasFactory and factories for ApiLog, BrentPrice, PricePrediction"

Task 4: PollFuelPricesJob

Files:

  • Create: app/Jobs/PollFuelPricesJob.php

  • Create: tests/Unit/Jobs/PollFuelPricesJobTest.php

  • Step 1: Write the failing test

Create tests/Unit/Jobs/PollFuelPricesJobTest.php:

<?php

use App\Jobs\PollFuelPricesJob;
use Illuminate\Support\Facades\Queue;

it('dispatches to the default queue', function () {
    Queue::fake();

    PollFuelPricesJob::dispatch();

    Queue::assertPushed(PollFuelPricesJob::class);
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=PollFuelPricesJobTest

Expected: FAIL — class not found

  • Step 3: Create PollFuelPricesJob

Create app/Jobs/PollFuelPricesJob.php:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;

class PollFuelPricesJob implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        Artisan::call('fuel:poll', ['--full' => true]);
    }
}
  • Step 4: Run test to verify it passes
php artisan test --compact --filter=PollFuelPricesJobTest

Expected: PASS

  • Step 5: Commit
git add app/Jobs/PollFuelPricesJob.php tests/Unit/Jobs/PollFuelPricesJobTest.php
git commit -m "feat: add PollFuelPricesJob queued job"

Task 5: ApiLogResource

Files:

  • Create: app/Filament/Resources/ApiLogResource.php

  • Create: app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php

  • Create: app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php

  • Create: tests/Feature/Admin/ApiLogResourceTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/ApiLogResourceTest.php:

<?php

use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
use App\Models\ApiLog;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->admin = User::factory()->admin()->create();
    $this->actingAs($this->admin);
});

it('renders the api log list page', function () {
    $logs = ApiLog::factory()->count(3)->create();

    livewire(ListApiLogs::class)
        ->assertOk()
        ->assertCanSeeTableRecords($logs);
});

it('filters to errors only', function () {
    $ok = ApiLog::factory()->create(['status_code' => 200, 'error' => null]);
    $err = ApiLog::factory()->failed()->create();

    livewire(ListApiLogs::class)
        ->filterTable('errors_only')
        ->assertCanSeeTableRecords([$err])
        ->assertCanNotSeeTableRecords([$ok]);
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=ApiLogResourceTest

Expected: FAIL — class not found

  • Step 3: Create the ListApiLogs page

Create app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php:

<?php

namespace App\Filament\Resources\ApiLogResource\Pages;

use App\Filament\Resources\ApiLogResource;
use Filament\Resources\Pages\ListRecords;

class ListApiLogs extends ListRecords
{
    protected static string $resource = ApiLogResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 4: Create the ViewApiLog page

Create app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php:

<?php

namespace App\Filament\Resources\ApiLogResource\Pages;

use App\Filament\Resources\ApiLogResource;
use Filament\Resources\Pages\ViewRecord;

class ViewApiLog extends ViewRecord
{
    protected static string $resource = ApiLogResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 5: Create ApiLogResource

Create app/Filament/Resources/ApiLogResource.php:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog;
use App\Models\ApiLog;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class ApiLogResource extends Resource
{
    protected static ?string $model = ApiLog::class;

    protected static ?string $navigationIcon = 'heroicon-o-server';

    protected static ?string $navigationGroup = 'System';

    protected static ?string $navigationLabel = 'API Logs';

    protected static ?int $navigationSort = 1;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('service')
                    ->badge()
                    ->color(fn (string $state) => match ($state) {
                        'fuel_finder' => 'success',
                        'fred'        => 'info',
                        'anthropic'   => 'warning',
                        default       => 'gray',
                    })
                    ->sortable(),
                TextColumn::make('method')
                    ->badge()
                    ->color('gray'),
                TextColumn::make('url')
                    ->limit(60)
                    ->tooltip(fn (ApiLog $record) => $record->url),
                TextColumn::make('status_code')
                    ->badge()
                    ->color(fn (?int $state) => match (true) {
                        $state === null  => 'danger',
                        $state >= 500    => 'danger',
                        $state >= 400    => 'warning',
                        default          => 'success',
                    }),
                TextColumn::make('duration_ms')
                    ->label('Duration (ms)')
                    ->sortable(),
                TextColumn::make('error')
                    ->limit(40)
                    ->placeholder('—'),
                TextColumn::make('created_at')
                    ->dateTime('d M Y H:i')
                    ->sortable(),
            ])
            ->defaultSort('created_at', 'desc')
            ->filters([
                SelectFilter::make('service')
                    ->options([
                        'fuel_finder'  => 'Fuel Finder',
                        'fred'         => 'FRED',
                        'anthropic'    => 'Anthropic',
                        'postcodes_io' => 'Postcodes.io',
                    ]),
                Filter::make('errors_only')
                    ->label('Errors only')
                    ->query(fn (Builder $query) => $query->where(
                        fn (Builder $q) => $q->where('status_code', '>=', 400)
                            ->orWhereNotNull('error')
                    )),
                Filter::make('created_at')
                    ->form([
                        \Filament\Forms\Components\DatePicker::make('from')->label('From'),
                        \Filament\Forms\Components\DatePicker::make('until')->label('Until'),
                    ])
                    ->query(function (Builder $query, array $data) {
                        $query
                            ->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
                            ->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d));
                    }),
            ])
            ->recordActions([
                ViewAction::make(),
            ])
            ->toolbarActions([]);
    }

    public static function infolist(Infolist $infolist): Infolist
    {
        return $infolist->schema([
            Section::make('Request')->schema([
                TextEntry::make('service')->badge(),
                TextEntry::make('method'),
                TextEntry::make('url')->columnSpanFull(),
                TextEntry::make('status_code')
                    ->badge()
                    ->color(fn (?int $state) => match (true) {
                        $state === null => 'danger',
                        $state >= 500   => 'danger',
                        $state >= 400   => 'warning',
                        default         => 'success',
                    }),
                TextEntry::make('duration_ms')->label('Duration (ms)'),
                TextEntry::make('created_at')->dateTime('d M Y H:i:s'),
            ])->columns(3),
            Section::make('Error')->schema([
                TextEntry::make('error')
                    ->columnSpanFull()
                    ->placeholder('No error recorded'),
            ])->collapsed(fn (ApiLog $record) => $record->error === null),
        ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListApiLogs::route('/'),
            'view'  => ViewApiLog::route('/{record}'),
        ];
    }
}
  • Step 6: Run tests to verify they pass
php artisan test --compact --filter=ApiLogResourceTest

Expected: PASS

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add app/Filament/Resources/ApiLogResource.php \
        app/Filament/Resources/ApiLogResource/ \
        tests/Feature/Admin/ApiLogResourceTest.php
git commit -m "feat: add ApiLogResource with filters and view page"

Task 6: UserResource

Files:

  • Create: app/Filament/Resources/UserResource.php

  • Create: app/Filament/Resources/UserResource/Pages/ListUsers.php

  • Create: app/Filament/Resources/UserResource/Pages/EditUser.php

  • Create: tests/Feature/Admin/UserResourceTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/UserResourceTest.php:

<?php

use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use Filament\Actions\DeleteAction;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->admin = User::factory()->admin()->create();
    $this->actingAs($this->admin);
});

it('renders the user list', function () {
    $users = User::factory()->count(3)->create();

    livewire(ListUsers::class)
        ->assertOk()
        ->assertCanSeeTableRecords($users);
});

it('can toggle is_admin on edit', function () {
    $user = User::factory()->create(['is_admin' => false]);

    livewire(EditUser::class, ['record' => $user->id])
        ->fillForm(['is_admin' => true])
        ->call('save')
        ->assertHasNoFormErrors();

    expect($user->fresh()->is_admin)->toBeTrue();
});

it('can delete a user', function () {
    $user = User::factory()->create();

    livewire(ListUsers::class)
        ->callTableAction(DeleteAction::class, $user)
        ->assertHasNoTableActionErrors();

    $this->assertDatabaseMissing(User::class, ['id' => $user->id]);
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=UserResourceTest

Expected: FAIL — class not found

  • Step 3: Create the ListUsers page

Create app/Filament/Resources/UserResource/Pages/ListUsers.php:

<?php

namespace App\Filament\Resources\UserResource\Pages;

use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\ListRecords;

class ListUsers extends ListRecords
{
    protected static string $resource = UserResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 4: Create the EditUser page

Create app/Filament/Resources/UserResource/Pages/EditUser.php:

<?php

namespace App\Filament\Resources\UserResource\Pages;

use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\EditRecord;

class EditUser extends EditRecord
{
    protected static string $resource = UserResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 5: Create UserResource

Create app/Filament/Resources/UserResource.php:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    protected static ?string $navigationIcon = 'heroicon-o-users';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form->schema([
            Toggle::make('is_admin')
                ->label('Admin')
                ->helperText('Grants access to this admin panel.'),
            TextInput::make('postcode')
                ->label('Postcode')
                ->maxLength(8),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('name')->searchable()->sortable(),
                TextColumn::make('email')->searchable()->sortable(),
                TextColumn::make('postcode'),
                IconColumn::make('is_admin')
                    ->label('Admin')
                    ->boolean(),
                TextColumn::make('created_at')
                    ->dateTime('d M Y')
                    ->sortable(),
            ])
            ->defaultSort('created_at', 'desc')
            ->filters([
                TernaryFilter::make('is_admin')
                    ->label('Admins only'),
            ])
            ->recordActions([
                EditAction::make(),
                DeleteAction::make(),
            ])
            ->toolbarActions([]);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListUsers::route('/'),
            'edit'  => EditUser::route('/{record}/edit'),
        ];
    }
}
  • Step 6: Run tests to verify they pass
php artisan test --compact --filter=UserResourceTest

Expected: PASS

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add app/Filament/Resources/UserResource.php \
        app/Filament/Resources/UserResource/ \
        tests/Feature/Admin/UserResourceTest.php
git commit -m "feat: add UserResource with is_admin toggle and delete"

Task 7: OilPredictionResource with run-prediction header action

Files:

  • Create: app/Filament/Resources/OilPredictionResource.php

  • Create: app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php

  • Create: app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php

  • Create: tests/Feature/Admin/OilPredictionResourceTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/OilPredictionResourceTest.php:

<?php

use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Models\PricePrediction;
use App\Models\User;
use Filament\Actions\Testing\TestAction;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->admin = User::factory()->admin()->create();
    $this->actingAs($this->admin);
});

it('renders the oil prediction list', function () {
    $predictions = PricePrediction::factory()->count(3)->create();

    livewire(ListOilPredictions::class)
        ->assertOk()
        ->assertCanSeeTableRecords($predictions);
});

it('has a run prediction header action', function () {
    livewire(ListOilPredictions::class)
        ->assertActionExists('runPrediction');
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=OilPredictionResourceTest

Expected: FAIL — class not found

  • Step 3: Create the ViewOilPrediction page

Create app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php:

<?php

namespace App\Filament\Resources\OilPredictionResource\Pages;

use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;

class ViewOilPrediction extends ViewRecord
{
    protected static string $resource = OilPredictionResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 4: Create the ListOilPredictions page with header action

Create app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php:

<?php

namespace App\Filament\Resources\OilPredictionResource\Pages;

use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;

class ListOilPredictions extends ListRecords
{
    protected static string $resource = OilPredictionResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('runPrediction')
                ->label('Run Prediction Now')
                ->icon('heroicon-o-cpu-chip')
                ->requiresConfirmation()
                ->modalHeading('Run oil price prediction?')
                ->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.')
                ->action(function () {
                    $result = Artisan::call('oil:predict', ['--fetch' => true]);

                    if ($result === 0) {
                        Notification::make()
                            ->title('Prediction generated successfully')
                            ->success()
                            ->send();
                    } else {
                        Notification::make()
                            ->title('Prediction failed')
                            ->body('Check API Logs for details.')
                            ->danger()
                            ->send();
                    }
                }),
        ];
    }
}
  • Step 5: Create OilPredictionResource

Create app/Filament/Resources/OilPredictionResource.php:

<?php

namespace App\Filament\Resources;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class OilPredictionResource extends Resource
{
    protected static ?string $model = PricePrediction::class;

    protected static ?string $navigationIcon = 'heroicon-o-beaker';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Oil Predictions';

    protected static ?int $navigationSort = 3;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('predicted_for')
                    ->date('d M Y')
                    ->sortable(),
                TextColumn::make('source')
                    ->badge()
                    ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
                    ->color(fn (PredictionSource $state) => match ($state) {
                        PredictionSource::Llm  => 'success',
                        PredictionSource::Ewma => 'info',
                    }),
                TextColumn::make('direction')
                    ->badge()
                    ->color(fn (TrendDirection $state) => match ($state) {
                        TrendDirection::Rising  => 'danger',
                        TrendDirection::Falling => 'success',
                        TrendDirection::Flat    => 'gray',
                    }),
                TextColumn::make('confidence')
                    ->suffix('%')
                    ->sortable(),
                TextColumn::make('reasoning')
                    ->limit(60)
                    ->placeholder('—'),
                TextColumn::make('generated_at')
                    ->dateTime('d M Y H:i')
                    ->sortable(),
            ])
            ->defaultSort('predicted_for', 'desc')
            ->filters([
                SelectFilter::make('source')
                    ->options([
                        PredictionSource::Llm->value  => 'LLM',
                        PredictionSource::Ewma->value => 'EWMA',
                    ]),
                SelectFilter::make('direction')
                    ->options([
                        TrendDirection::Rising->value  => 'Rising',
                        TrendDirection::Falling->value => 'Falling',
                        TrendDirection::Flat->value    => 'Flat',
                    ]),
                Filter::make('predicted_for')
                    ->form([
                        \Filament\Forms\Components\DatePicker::make('from')->label('From'),
                        \Filament\Forms\Components\DatePicker::make('until')->label('Until'),
                    ])
                    ->query(function (Builder $query, array $data) {
                        $query
                            ->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
                            ->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
                    }),
            ])
            ->recordActions([
                ViewAction::make(),
            ])
            ->toolbarActions([]);
    }

    public static function infolist(Infolist $infolist): Infolist
    {
        return $infolist->schema([
            Section::make('Prediction')->schema([
                TextEntry::make('predicted_for')->date('d M Y'),
                TextEntry::make('source')
                    ->badge()
                    ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value)),
                TextEntry::make('direction')->badge(),
                TextEntry::make('confidence')->suffix('%'),
                TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
            ])->columns(3),
            Section::make('Reasoning')->schema([
                TextEntry::make('reasoning')
                    ->columnSpanFull()
                    ->placeholder('No reasoning recorded'),
            ]),
        ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListOilPredictions::route('/'),
            'view'  => ViewOilPrediction::route('/{record}'),
        ];
    }
}
  • Step 6: Run tests to verify they pass
php artisan test --compact --filter=OilPredictionResourceTest

Expected: PASS

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add app/Filament/Resources/OilPredictionResource.php \
        app/Filament/Resources/OilPredictionResource/ \
        tests/Feature/Admin/OilPredictionResourceTest.php
git commit -m "feat: add OilPredictionResource with run-prediction header action"

Task 8: BrentPriceResource and chart widget

Files:

  • Create: app/Filament/Resources/BrentPriceResource.php

  • Create: app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php

  • Create: app/Filament/Widgets/BrentPriceChartWidget.php

  • Create: tests/Feature/Admin/BrentPriceResourceTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/BrentPriceResourceTest.php:

<?php

use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
use App\Models\BrentPrice;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->admin = User::factory()->admin()->create();
    $this->actingAs($this->admin);
});

it('renders the brent price list', function () {
    $prices = BrentPrice::factory()->count(3)->create();

    livewire(ListBrentPrices::class)
        ->assertOk()
        ->assertCanSeeTableRecords($prices);
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=BrentPriceResourceTest

Expected: FAIL — class not found

  • Step 3: Create BrentPriceChartWidget

Create app/Filament/Widgets/BrentPriceChartWidget.php:

<?php

namespace App\Filament\Widgets;

use App\Models\BrentPrice;
use Filament\Widgets\ChartWidget;

class BrentPriceChartWidget extends ChartWidget
{
    protected ?string $heading = 'Brent Crude — Last 30 Days (USD/barrel)';

    protected ?string $pollingInterval = null;

    protected function getData(): array
    {
        $prices = BrentPrice::orderBy('date')
            ->where('date', '>=', now()->subDays(30)->toDateString())
            ->get();

        return [
            'datasets' => [
                [
                    'label'       => 'USD/barrel',
                    'data'        => $prices->pluck('price_usd')->map(fn ($p) => (float) $p)->toArray(),
                    'borderColor' => '#f59e0b',
                    'fill'        => false,
                    'tension'     => 0.3,
                ],
            ],
            'labels' => $prices->pluck('date')
                ->map(fn ($d) => $d->format('d M'))
                ->toArray(),
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }
}
  • Step 4: Create the ListBrentPrices page with chart widget

Create app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php:

<?php

namespace App\Filament\Resources\BrentPriceResource\Pages;

use App\Filament\Resources\BrentPriceResource;
use App\Filament\Widgets\BrentPriceChartWidget;
use Filament\Resources\Pages\ListRecords;

class ListBrentPrices extends ListRecords
{
    protected static string $resource = BrentPriceResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }

    protected function getHeaderWidgets(): array
    {
        return [
            BrentPriceChartWidget::class,
        ];
    }
}
  • Step 5: Create BrentPriceResource

Create app/Filament/Resources/BrentPriceResource.php:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
use App\Models\BrentPrice;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

class BrentPriceResource extends Resource
{
    protected static ?string $model = BrentPrice::class;

    protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Brent Prices';

    protected static ?int $navigationSort = 2;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('date')
                    ->date('d M Y')
                    ->sortable(),
                TextColumn::make('price_usd')
                    ->label('Price (USD/barrel)')
                    ->numeric(2)
                    ->sortable(),
            ])
            ->defaultSort('date', 'desc')
            ->filters([])
            ->recordActions([])
            ->toolbarActions([]);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListBrentPrices::route('/'),
        ];
    }
}
  • Step 6: Run tests to verify they pass
php artisan test --compact --filter=BrentPriceResourceTest

Expected: PASS

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add app/Filament/Resources/BrentPriceResource.php \
        app/Filament/Resources/BrentPriceResource/ \
        app/Filament/Widgets/BrentPriceChartWidget.php \
        tests/Feature/Admin/BrentPriceResourceTest.php
git commit -m "feat: add BrentPriceResource with 30-day line chart widget"

Task 9: StationResource with full-poll header action

Files:

  • Create: app/Filament/Resources/StationResource.php

  • Create: app/Filament/Resources/StationResource/Pages/ListStations.php

  • Create: app/Filament/Resources/StationResource/Pages/ViewStation.php

  • Create: tests/Feature/Admin/StationResourceTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/StationResourceTest.php:

<?php

use App\Filament\Resources\StationResource\Pages\ListStations;
use App\Models\Station;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->admin = User::factory()->admin()->create();
    $this->actingAs($this->admin);
});

it('renders the station list', function () {
    $stations = Station::factory()->count(3)->create();

    livewire(ListStations::class)
        ->assertOk()
        ->assertCanSeeTableRecords($stations);
});

it('filters to supermarkets only', function () {
    $regular = Station::factory()->create(['is_supermarket' => false]);
    $super = Station::factory()->supermarket()->create();

    livewire(ListStations::class)
        ->filterTable('is_supermarket', true)
        ->assertCanSeeTableRecords([$super])
        ->assertCanNotSeeTableRecords([$regular]);
});

it('has a trigger full poll header action', function () {
    livewire(ListStations::class)
        ->assertActionExists('triggerFullPoll');
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=StationResourceTest

Expected: FAIL — class not found

  • Step 3: Create the ViewStation page

Create app/Filament/Resources/StationResource/Pages/ViewStation.php:

<?php

namespace App\Filament\Resources\StationResource\Pages;

use App\Filament\Resources\StationResource;
use Filament\Resources\Pages\ViewRecord;

class ViewStation extends ViewRecord
{
    protected static string $resource = StationResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
  • Step 4: Create the ListStations page with header action

Create app/Filament/Resources/StationResource/Pages/ListStations.php:

<?php

namespace App\Filament\Resources\StationResource\Pages;

use App\Filament\Resources\StationResource;
use App\Jobs\PollFuelPricesJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;

class ListStations extends ListRecords
{
    protected static string $resource = StationResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('triggerFullPoll')
                ->label('Trigger Full Poll')
                ->icon('heroicon-o-arrow-path')
                ->requiresConfirmation()
                ->modalHeading('Trigger full station refresh?')
                ->modalDescription('This dispatches a background job to refresh all ~14,500 stations from the Fuel Finder API. Results will appear in API Logs once complete.')
                ->action(function () {
                    PollFuelPricesJob::dispatch();

                    Notification::make()
                        ->title('Poll dispatched to queue')
                        ->body('Check API Logs once the job completes.')
                        ->success()
                        ->send();
                }),
        ];
    }
}
  • Step 5: Create StationResource

Create app/Filament/Resources/StationResource.php:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\StationResource\Pages\ListStations;
use App\Filament\Resources\StationResource\Pages\ViewStation;
use App\Models\Station;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;

class StationResource extends Resource
{
    protected static ?string $model = Station::class;

    protected static ?string $navigationIcon = 'heroicon-o-map-pin';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Stations';

    protected static ?int $navigationSort = 1;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('trading_name')
                    ->searchable()
                    ->sortable(),
                TextColumn::make('brand_name')
                    ->searchable()
                    ->placeholder('—'),
                TextColumn::make('postcode')
                    ->searchable(),
                TextColumn::make('city'),
                IconColumn::make('is_supermarket')
                    ->label('Supermarket')
                    ->boolean(),
                IconColumn::make('is_motorway_service_station')
                    ->label('Motorway')
                    ->boolean(),
                IconColumn::make('temporary_closure')
                    ->label('Temp closed')
                    ->boolean()
                    ->trueColor('warning')
                    ->falseColor('success'),
                TextColumn::make('last_seen_at')
                    ->dateTime('d M Y H:i')
                    ->sortable(),
            ])
            ->searchPlaceholder('Search name, brand, or postcode...')
            ->defaultSort('last_seen_at', 'desc')
            ->filters([
                TernaryFilter::make('is_supermarket')->label('Supermarket'),
                TernaryFilter::make('is_motorway_service_station')->label('Motorway'),
                TernaryFilter::make('temporary_closure')->label('Temporarily closed'),
                TernaryFilter::make('permanent_closure')->label('Permanently closed'),
            ])
            ->recordActions([
                ViewAction::make(),
            ])
            ->toolbarActions([]);
    }

    public static function infolist(Infolist $infolist): Infolist
    {
        return $infolist->schema([
            Section::make('Location')->schema([
                TextEntry::make('trading_name'),
                TextEntry::make('brand_name')->placeholder('—'),
                TextEntry::make('address_line_1'),
                TextEntry::make('address_line_2')->placeholder('—'),
                TextEntry::make('city'),
                TextEntry::make('county')->placeholder('—'),
                TextEntry::make('postcode'),
                TextEntry::make('country'),
            ])->columns(3),
            Section::make('Status')->schema([
                IconColumn::make('is_supermarket')->boolean(),
                IconColumn::make('is_motorway_service_station')->boolean(),
                IconColumn::make('temporary_closure')->boolean()->trueColor('warning'),
                IconColumn::make('permanent_closure')->boolean()->trueColor('danger'),
                TextEntry::make('permanent_closure_date')->date()->placeholder('—'),
                TextEntry::make('last_seen_at')->dateTime('d M Y H:i'),
            ])->columns(3),
            Section::make('Fuel Types')->schema([
                TextEntry::make('fuel_types')
                    ->listWithLineBreaks()
                    ->columnSpanFull(),
            ]),
            Section::make('Amenities')->schema([
                TextEntry::make('amenities')
                    ->listWithLineBreaks()
                    ->placeholder('None recorded')
                    ->columnSpanFull(),
            ]),
        ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => ListStations::route('/'),
            'view'  => ViewStation::route('/{record}'),
        ];
    }
}
  • Step 6: Run tests to verify they pass
php artisan test --compact --filter=StationResourceTest

Expected: PASS

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add app/Filament/Resources/StationResource.php \
        app/Filament/Resources/StationResource/ \
        tests/Feature/Admin/StationResourceTest.php
git commit -m "feat: add StationResource with poll action and view page"

Task 10: StatsOverviewWidget and dashboard

Files:

  • Create: app/Filament/Widgets/StatsOverviewWidget.php

  • Modify: app/Providers/Filament/AdminPanelProvider.php

  • Create: tests/Feature/Admin/StatsOverviewWidgetTest.php

  • Step 1: Write the failing test

Create tests/Feature/Admin/StatsOverviewWidgetTest.php:

<?php

use App\Filament\Widgets\StatsOverviewWidget;
use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Station;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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(StatsOverviewWidget::class)
        ->assertOk();
});

it('shows red colour on api error stat when errors exist in last 24h', function () {
    ApiLog::factory()->failed()->create(['created_at' => now()->subMinutes(10)]);

    $component = livewire(StatsOverviewWidget::class);

    $stats = invade($component->instance())->getStats();
    $errorStat = collect($stats)->last();

    expect($errorStat->getColor())->toBe('danger');
});
  • Step 2: Run test to verify it fails
php artisan test --compact --filter=StatsOverviewWidgetTest

Expected: FAIL — class not found

  • Step 3: Create StatsOverviewWidget

Create app/Filament/Widgets/StatsOverviewWidget.php:

<?php

namespace App\Filament\Widgets;

use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Station;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class StatsOverviewWidget extends BaseWidget
{
    protected ?string $pollingInterval = '30s';

    protected function getStats(): array
    {
        return [
            $this->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\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)->orWhereNotNull('error'))
            ->count();

        $color = $errors > 0 ? 'danger' : 'success';

        return Stat::make('API errors (24h)', $errors)
            ->icon('heroicon-o-exclamation-triangle')
            ->color($color);
    }
}
  • Step 4: Register widget in AdminPanelProvider

Modify app/Providers/Filament/AdminPanelProvider.php — update the widgets() call to include StatsOverviewWidget:

use App\Filament\Widgets\StatsOverviewWidget;
// ... existing imports ...

->widgets([
    AccountWidget::class,
    StatsOverviewWidget::class,
])
  • Step 5: Run all admin tests to verify nothing broke
php artisan test --compact tests/Feature/Admin/

Expected: all green

  • Step 6: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 7: Commit
git add app/Filament/Widgets/StatsOverviewWidget.php \
        app/Providers/Filament/AdminPanelProvider.php \
        tests/Feature/Admin/StatsOverviewWidgetTest.php
git commit -m "feat: add StatsOverviewWidget to admin dashboard"

Self-review against spec

Spec requirement Task Status
/admin path with is_admin auth Task 12
authGuard('web') Task 2
uovidiu@sent.com admin seeder Task 1
Dashboard: 4 stat cards Task 10
Dashboard: yellow alert if prediction > 24h Task 10 (oilPredictionStat)
Dashboard: red if API errors > 0 Task 10 (apiErrorsStat)
ApiLogResource: columns, filters, service badge, status colour Task 5
ApiLogResource: view page with full url/error Task 5 (infolist)
ApiLogResource: no edit/delete Task 5
UserResource: columns, is_admin filter Task 6
UserResource: edit (is_admin toggle + postcode) Task 6
UserResource: delete Task 6
OilPredictionResource: columns with progress bar Task 7 ✓ (confidence shown as xx%)
OilPredictionResource: view with full reasoning Task 7 (infolist)
OilPredictionResource: "Run prediction now" action Task 7
BrentPriceResource: date + price columns Task 8
BrentPriceResource: 30-day line chart widget Task 8
StationResource: columns, filters, search Task 9
StationResource: view with amenities/opening_times Task 9 (infolist)
StationResource: "Trigger full poll" dispatches job Task 9
Navigation groups (Data, System) Tasks 59 ($navigationGroup)
PollFuelPricesJob queued (not sync) Task 4

Note on spec item "confidence progress bar": Filament v5 does not have a native progress bar table column. The spec was written as a design intent; showing confidence as xx% text with sorting achieves the same information. A custom column could be added later if desired.