From c2c16c928b55c65f9a1c7e6f65c72251a662848b Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Sat, 4 Apr 2026 18:31:55 +0100 Subject: [PATCH] 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 --- .claude/rules/scoring.md | 65 +- app/Filament/NavigationGroup.php | 35 + app/Filament/Resources/ApiLogResource.php | 5 +- app/Filament/Resources/BrentPriceResource.php | 5 +- .../Resources/OilPredictionResource.php | 5 +- app/Filament/Resources/StationResource.php | 14 +- app/Filament/Resources/UserResource.php | 3 +- app/Services/StationTaggingService.php | 18 +- .../plans/2026-04-04-filament-admin-panel.md | 2044 +++++++++++++++++ .../Services/StationTaggingServiceTest.php | 19 +- 10 files changed, 2172 insertions(+), 41 deletions(-) create mode 100644 app/Filament/NavigationGroup.php create mode 100644 docs/superpowers/plans/2026-04-04-filament-admin-panel.md diff --git a/.claude/rules/scoring.md b/.claude/rules/scoring.md index f79fd65..0cc1345 100644 --- a/.claude/rules/scoring.md +++ b/.claude/rules/scoring.md @@ -6,15 +6,20 @@ Produces a "fill up now or wait?" recommendation per user based on their local station history. Output is one of: `fill_up`, `wait`, `no_signal`. Never guess — stay silent (no_signal) when signals conflict or data is insufficient. -## The 4 signals (in priority order) +## The 5 signals (in priority order) ### Signal 1 — Local price trend (HIGHEST WEIGHT) - Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng) - Use last 14 days of history for `e10` (or user's preferred fuel type) -- Calculate 3-day rolling average vs 7-day rolling average -- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal -- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal -- **Flat**: difference < 0.5p → neutral, no signal +- **Use linear regression, not rolling averages:** + - Run least-squares regression on `(recorded_at, price_pence)` pairs + - Calculate slope (pence/day) and R² (goodness of fit, 0–1) + - Only use the regression result if R² ≥ 0.5 — below that, data is too noisy + - Use adaptive lookback: try 5 days first (best signal on sharp moves), fall back to 14 days if R² < 0.5 +- **Falling**: slope ≤ -0.3p/day AND R² ≥ 0.5 → wait signal, points scale with slope magnitude +- **Rising**: slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up signal +- **Flat / noisy**: |slope| < 0.3 OR R² < 0.5 → no signal from this source +- Store slope, R², lookback_days, and data_points in signal output - Weight: 40 points max ### Signal 2 — Supermarket anchor effect (HIGH WEIGHT) @@ -22,6 +27,7 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi - Check if supermarket cut price in last 48 hours (> 1p drop) - Check if nearest non-supermarket stations have NOT yet followed - If supermarket cut AND independents haven't moved → strong wait signal +- Also check the inverse: if supermarket RAISED and independents haven't → mild fill_up - Weight: 35 points max ### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data) @@ -39,6 +45,16 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi - Points awarded proportionally to confidence: `(confidence / 100) * 10` - Weight: 10 points max +### Signal 5 — Price stickiness (CONFIDENCE MODIFIER) +- Per station: calculate average hold duration (days between price changes) from history +- Requires 30+ days of history to activate +- Use as a confidence modifier, not a directional signal: + - avg hold < 2 days → reduce overall confidence by 5 points (volatile, hard to predict) + - avg hold 2–4 days → neutral, no adjustment + - avg hold > 5 days → increase overall confidence by 5 points (predictable, sticky) +- Store avg_hold_days and data_points in signal output +- Applied after all other signals are summed (±5 points) + ## Confidence thresholds - Score 70–100: strong signal → fire recommendation + notification @@ -51,29 +67,50 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi ```php [ - 'recommendation' => 'wait', // fill_up | wait | no_signal - 'confidence' => 78, // 0-100 - 'signals' => [ - 'trend' => ['direction' => 'falling', 'points' => 32], + 'recommendation' => 'wait', + 'confidence' => 78, + 'signals' => [ + 'trend' => [ + 'direction' => 'falling', + 'slope' => -1.07, // pence per day + 'r_squared' => 0.96, + 'lookback_days' => 5, + 'data_points' => 5, + 'points' => 32, + ], 'supermarket' => ['triggered' => true, 'points' => 35], 'day_pattern' => ['triggered' => false, 'points' => 0], 'brent' => ['direction' => 'flat', 'points' => 0], + 'stickiness' => ['avg_hold_days' => 2.8, 'modifier' => 0], ], - 'local_avg_pence' => 14380, // 143.80p - 'trend_delta' => -2.3, // pence change over 7 days + 'local_avg_pence' => 14380, // 143.80p + 'trend_delta' => -2.3, // pence change over lookback period ] ``` ## Human-readable reason strings Always generate a plain-English reason for the recommendation: -- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours." -- "Prices are rising in your area — filling up today avoids paying more later." +- "Prices near you have been falling at 1.1p/day for 5 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours." +- "Prices are rising sharply in your area (+7.5p expected this week) — filling up today avoids paying more later." - "No clear pattern this week — fill up at the cheapest station near you now." Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications. +## Data quality — anomaly rejection + +The Fuel Finder API contains dirty data (live example: 1369.0p/litre in national index). +Reject a price record before storing or scoring if: +- `price_pence > 25000` (over 250p/litre — physically implausible for UK pump prices) +- `price_pence < 10000` (under 100p/litre — almost certainly a decimal entry error) +- Price changed by more than 20p in a single update from the same station + (flag for review, do not use in scoring) + +Log rejected records to an `anomalous_prices` table for monitoring. +Never let a dirty data point skew the regression slope or collapse R². + ## Accuracy self-tracking After 3 days, check if `wait` recommendation was correct (prices did fall further). -Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area." +Store outcome in `scoring_results` for future display: +"This signal has been right X% of the time in your area." \ No newline at end of file diff --git a/app/Filament/NavigationGroup.php b/app/Filament/NavigationGroup.php new file mode 100644 index 0000000..1342975 --- /dev/null +++ b/app/Filament/NavigationGroup.php @@ -0,0 +1,35 @@ + 'Users', + self::Data => 'Data', + self::System => 'System', + }; + } + + public function getIcon(): string|BackedEnum|Htmlable|null + { + return match ($this) { + self::Users => 'heroicon-o-users', + self::Data => 'heroicon-o-circle-stack', + self::System => 'heroicon-o-cog-6-tooth', + }; + } +} diff --git a/app/Filament/Resources/ApiLogResource.php b/app/Filament/Resources/ApiLogResource.php index 01f6fe4..824e517 100644 --- a/app/Filament/Resources/ApiLogResource.php +++ b/app/Filament/Resources/ApiLogResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\NavigationGroup; use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs; use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog; use App\Models\ApiLog; @@ -21,9 +22,7 @@ class ApiLogResource extends Resource { protected static ?string $model = ApiLog::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-server'; - - protected static string|\UnitEnum|null $navigationGroup = 'System'; + protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System; protected static ?string $navigationLabel = 'API Logs'; diff --git a/app/Filament/Resources/BrentPriceResource.php b/app/Filament/Resources/BrentPriceResource.php index d321e05..08fe5e7 100644 --- a/app/Filament/Resources/BrentPriceResource.php +++ b/app/Filament/Resources/BrentPriceResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\NavigationGroup; use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices; use App\Models\BrentPrice; use Filament\Resources\Resource; @@ -12,9 +13,7 @@ class BrentPriceResource extends Resource { protected static ?string $model = BrentPrice::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-currency-dollar'; - - protected static string|\UnitEnum|null $navigationGroup = 'Data'; + protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data; protected static ?string $navigationLabel = 'Brent Prices'; diff --git a/app/Filament/Resources/OilPredictionResource.php b/app/Filament/Resources/OilPredictionResource.php index ef66e60..851312d 100644 --- a/app/Filament/Resources/OilPredictionResource.php +++ b/app/Filament/Resources/OilPredictionResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; use App\Enums\PredictionSource; use App\Enums\TrendDirection; +use App\Filament\NavigationGroup; use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions; use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction; use App\Models\PricePrediction; @@ -23,9 +24,7 @@ class OilPredictionResource extends Resource { protected static ?string $model = PricePrediction::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-beaker'; - - protected static string|\UnitEnum|null $navigationGroup = 'Data'; + protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data; protected static ?string $navigationLabel = 'Oil Predictions'; diff --git a/app/Filament/Resources/StationResource.php b/app/Filament/Resources/StationResource.php index 055e4e8..245842c 100644 --- a/app/Filament/Resources/StationResource.php +++ b/app/Filament/Resources/StationResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\NavigationGroup; use App\Filament\Resources\StationResource\Pages\ListStations; use App\Filament\Resources\StationResource\Pages\ViewStation; use App\Models\Station; @@ -20,9 +21,7 @@ class StationResource extends Resource { protected static ?string $model = Station::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-map-pin'; - - protected static string|\UnitEnum|null $navigationGroup = 'Data'; + protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data; protected static ?string $navigationLabel = 'Stations'; @@ -32,10 +31,13 @@ class StationResource extends Resource { return $table ->columns([ - TextColumn::make('trading_name')->searchable()->sortable(), + TextColumn::make('trading_name')->searchable()->sortable() + ->limit(25) + ->tooltip(fn (Station $record) => strlen($record->trading_name) > 45 ? $record->trading_name : null), TextColumn::make('brand_name')->searchable()->placeholder('—'), - TextColumn::make('postcode')->searchable(), - TextColumn::make('city'), + TextColumn::make('city') + ->description(fn (Station $record) => $record->postcode) + ->searchable(['city', 'postcode']), IconColumn::make('is_supermarket')->label('Supermarket')->boolean(), IconColumn::make('is_motorway_service_station')->label('Motorway')->boolean(), IconColumn::make('temporary_closure') diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 1dafa19..ce53cd2 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\NavigationGroup; use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Resources\UserResource\Pages\ListUsers; use App\Models\User; @@ -20,7 +21,7 @@ class UserResource extends Resource { protected static ?string $model = User::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users'; + protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users; protected static ?int $navigationSort = 1; diff --git a/app/Services/StationTaggingService.php b/app/Services/StationTaggingService.php index 0bed126..a8d8a77 100644 --- a/app/Services/StationTaggingService.php +++ b/app/Services/StationTaggingService.php @@ -8,23 +8,25 @@ class StationTaggingService { /** @var array brand keyword → normalised brand name */ private const SUPERMARKET_BRANDS = [ - 'tesco' => 'Tesco', - 'asda' => 'Asda', + 'tesco' => 'Tesco', + 'asda' => 'Asda', 'morrisons' => 'Morrisons', 'sainsbury' => 'Sainsbury\'s', - 'aldi' => 'Aldi', - 'lidl' => 'Lidl', - 'costco' => 'Costco', + 'aldi' => 'Aldi', + 'lidl' => 'Lidl', + 'costco' => 'Costco', ]; public function tag(Station $station): void { - $name = strtolower($station->trading_name); + $tradingName = strtolower($station->trading_name); + $brandName = strtolower($station->brand_name ?? ''); foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) { - if (str_contains($name, $keyword)) { + if (str_contains($tradingName, $keyword) || str_contains($brandName, $keyword)) { $station->is_supermarket = true; - $station->brand_name = $normalisedBrand; + $station->brand_name = $normalisedBrand; + return; } } diff --git a/docs/superpowers/plans/2026-04-04-filament-admin-panel.md b/docs/superpowers/plans/2026-04-04-filament-admin-panel.md new file mode 100644 index 0000000..a8e7976 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-filament-admin-panel.md @@ -0,0 +1,2044 @@ +# 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.php` — **exists, 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.php` — `fuel:poll {--full}` +- `app/Console/Commands/PredictOilPrices.php` — `oil: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** + +```bash +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 +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** + +```bash +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()`: + +```php +public function admin(): static +{ + return $this->state(['is_admin' => true]); +} +``` + +- [ ] **Step 5: Create AdminSeeder** + +Create `database/seeders/AdminSeeder.php`: + +```php + '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: + +```php +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** + +```bash +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** + +```bash +php artisan test --compact --filter=AdminAccessTest +``` +Expected: PASS (both tests green) + +- [ ] **Step 9: Commit** + +```bash +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 +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** + +```bash +php artisan test --compact --filter=AdminAccessTest +``` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +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 + */ + 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 + */ +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 + */ + 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 + */ +class BrentPriceFactory extends Factory +{ + /** @var array */ + 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 + */ + 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 + */ +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** + +```bash +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** + +```bash +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 + true]); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +php artisan test --compact --filter=PollFuelPricesJobTest +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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 +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** + +```bash +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 +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** + +```bash +php artisan test --compact --filter=ApiLogResourceTest +``` +Expected: PASS + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +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 +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** + +```bash +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 +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** + +```bash +php artisan test --compact --filter=UserResourceTest +``` +Expected: PASS + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +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 +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** + +```bash +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 +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 +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** + +```bash +php artisan test --compact --filter=OilPredictionResourceTest +``` +Expected: PASS + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +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 +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** + +```bash +php artisan test --compact --filter=BrentPriceResourceTest +``` +Expected: FAIL — class not found + +- [ ] **Step 3: Create BrentPriceChartWidget** + +Create `app/Filament/Widgets/BrentPriceChartWidget.php`: + +```php +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 +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** + +```bash +php artisan test --compact --filter=BrentPriceResourceTest +``` +Expected: PASS + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +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 +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** + +```bash +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 +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 +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** + +```bash +php artisan test --compact --filter=StationResourceTest +``` +Expected: PASS + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +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 +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** + +```bash +php artisan test --compact --filter=StatsOverviewWidgetTest +``` +Expected: FAIL — class not found + +- [ ] **Step 3: Create StatsOverviewWidget** + +Create `app/Filament/Widgets/StatsOverviewWidget.php`: + +```php +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`: + +```php +use App\Filament\Widgets\StatsOverviewWidget; +// ... existing imports ... + +->widgets([ + AccountWidget::class, + StatsOverviewWidget::class, +]) +``` + +- [ ] **Step 5: Run all admin tests to verify nothing broke** + +```bash +php artisan test --compact tests/Feature/Admin/ +``` +Expected: all green + +- [ ] **Step 6: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 7: Commit** + +```bash +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 1–2 | ✓ | +| `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 5–9 (`$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. diff --git a/tests/Unit/Services/StationTaggingServiceTest.php b/tests/Unit/Services/StationTaggingServiceTest.php index bf42024..a30353c 100644 --- a/tests/Unit/Services/StationTaggingServiceTest.php +++ b/tests/Unit/Services/StationTaggingServiceTest.php @@ -4,7 +4,7 @@ use App\Models\Station; use App\Services\StationTaggingService; beforeEach(function (): void { - $this->service = new StationTaggingService(); + $this->service = new StationTaggingService; }); it('marks tesco station as supermarket and normalises brand', function (): void { @@ -54,10 +54,23 @@ it('handles case insensitive matching', function (): void { ->and($station->brand_name)->toBe('Morrisons'); }); +it('marks station as supermarket when brand_name matches even if trading_name does not', function (): void { + $station = new Station([ + 'trading_name' => 'PETERBOROUGH EXTRA - PETROL FILLING STATION', + 'brand_name' => 'TESCO', + 'is_supermarket' => false, + ]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeTrue() + ->and($station->brand_name)->toBe('Tesco'); +}); + it('does not overwrite brand_name for non-supermarket stations', function (): void { $station = new Station([ - 'trading_name' => 'Shell Garage', - 'brand_name' => 'Shell', + 'trading_name' => 'Shell Garage', + 'brand_name' => 'Shell', 'is_supermarket' => false, ]);