diff --git a/docs/superpowers/plans/2026-04-05-station-search-page.md b/docs/superpowers/plans/2026-04-05-station-search-page.md new file mode 100644 index 0000000..15a79f6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-station-search-page.md @@ -0,0 +1,489 @@ +# Station Search Page 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 public Livewire page at `/stations` where users search for nearby petrol stations by location, fuel type, and radius. + +**Architecture:** A classic two-file Livewire component (`StationSearch`) renders a search form and handles submission by calling `/api/stations` server-side via Laravel's `Http` facade. The API key stays on the server. Results are displayed as a list below the form. + +**Tech Stack:** Laravel 11, Livewire 4 (classic), Flux UI v2, Tailwind CSS v4, Pest v4 + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Modify | `config/services.php` | Add `fuelalert.api_key` entry | +| Modify | `.env.example` | Document `FUELALERT_API_KEY` | +| Modify | `routes/web.php` | Add public `GET /stations` route | +| Create | `app/Livewire/Public/StationSearch.php` | Component: properties, validation, `findStations()` | +| Create | `resources/views/livewire/public/station-search.blade.php` | Form, loading state, meta bar, results list, error/empty states | +| Create | `tests/Feature/Livewire/StationSearchTest.php` | All feature tests for the component | + +--- + +## Task 1: Config, env, and route + +**Files:** +- Modify: `config/services.php` +- Modify: `.env.example` +- Modify: `routes/web.php` + +- [ ] **Step 1: Add fuelalert config to services.php** + +In `config/services.php`, add after the `fred` block: + +```php +'fuelalert' => [ + 'api_key' => env('FUELALERT_API_KEY'), +], +``` + +- [ ] **Step 2: Document env key in .env.example** + +Append to `.env.example`: + +``` +FUELALERT_API_KEY= +``` + +- [ ] **Step 3: Register the public route** + +In `routes/web.php`, add before `require __DIR__.'/settings.php';`: + +```php +use App\Livewire\Public\StationSearch; + +Route::get('/stations', StationSearch::class)->name('stations.search'); +``` + +Also add the import at the top of the `use` block with the other imports. + +- [ ] **Step 4: Commit** + +```bash +git add config/services.php .env.example routes/web.php +git commit -m "feat: add fuelalert config and public /stations route" +``` + +--- + +## Task 2: Write failing tests + +**Files:** +- Create: `tests/Feature/Livewire/StationSearchTest.php` + +- [ ] **Step 1: Create the test file** + +```bash +php artisan make:test --pest Livewire/StationSearchTest +``` + +- [ ] **Step 2: Replace the generated file contents** + +```php +assertStatus(200) + ->assertSeeHtml('name="search"') + ->assertSeeHtml('name="fuelType"') + ->assertSeeHtml('name="radius"'); +}); + +it('validates search is required', function () { + Livewire::test(StationSearch::class) + ->call('findStations') + ->assertHasErrors(['search' => 'required']); +}); + +it('validates fuelType is required', function () { + Livewire::test(StationSearch::class) + ->set('search', 'SW1A 1AA') + ->call('findStations') + ->assertHasErrors(['fuelType' => 'required']); +}); + +it('populates results and meta on successful search', function () { + Http::fake([ + '*/api/stations*' => Http::response([ + 'data' => [ + [ + 'station_id' => 'abc123', + 'name' => 'BP Garage', + 'brand' => 'BP', + 'is_supermarket' => false, + 'address' => '1 High Street, London', + 'postcode' => 'SW1A 1AA', + 'lat' => 51.5074, + 'lng' => -0.1278, + 'distance_km' => 1.5, + 'fuel_type' => 'e10', + 'price_pence' => 14390, + 'price' => 143.9, + 'price_updated_at' => '2026-04-05T08:00:00.000Z', + ], + ], + 'meta' => [ + 'count' => 1, + 'fuel_type' => 'e10', + 'radius_km' => 8.05, + 'lowest_pence' => 14390, + 'highest_pence' => 14390, + 'cheapest_price_pence' => 14390, + 'avg_pence' => 14390.0, + ], + ], 200), + ]); + + Livewire::test(StationSearch::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->set('radius', 5) + ->call('findStations') + ->assertSet('apiError', null) + ->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage') + ->assertSet('meta', fn (array $m) => $m['count'] === 1); +}); + +it('sets apiError from 422 postcode validation response', function () { + Http::fake([ + '*/api/stations*' => Http::response([ + 'errors' => ['postcode' => ['Postcode not found.']], + ], 422), + ]); + + Livewire::test(StationSearch::class) + ->set('search', 'ZZ99 9ZZ') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('results', []) + ->assertSet('apiError', 'Postcode not found.'); +}); + +it('sets generic apiError on server error', function () { + Http::fake([ + '*/api/stations*' => Http::response([], 500), + ]); + + Livewire::test(StationSearch::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('results', []) + ->assertSet('apiError', 'Unable to fetch stations. Please try again.'); +}); + +it('converts radius from miles to km in the outgoing API request', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + ]); + + Livewire::test(StationSearch::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->set('radius', 5) + ->call('findStations'); + + Http::assertSent(function ($request) { + $data = $request->data(); + return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01; + }); +}); + +it('resets results and error before each new search', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + ]); + + Livewire::test(StationSearch::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->set('results', [['name' => 'Old Result']]) + ->set('apiError', 'Old error') + ->call('findStations') + ->assertSet('apiError', null) + ->assertSet('results', []); +}); +``` + +- [ ] **Step 3: Run tests — verify they all fail** + +```bash +php artisan test --compact tests/Feature/Livewire/StationSearchTest.php +``` + +Expected: all fail with `Class "App\Livewire\Public\StationSearch" not found` or similar. + +- [ ] **Step 4: Commit the test file** + +```bash +git add tests/Feature/Livewire/StationSearchTest.php +git commit -m "test: add failing tests for StationSearch Livewire component" +``` + +--- + +## Task 3: Implement the component class + +**Files:** +- Create: `app/Livewire/Public/StationSearch.php` + +- [ ] **Step 1: Create the directory and component** + +```bash +php artisan make:livewire Public/StationSearch --no-interaction +``` + +- [ ] **Step 2: Replace the generated class with the full implementation** + +```php +validate(); + + $this->results = []; + $this->meta = []; + $this->apiError = null; + + $radiusKm = round($this->radius * 1.60934, 2); + + $response = Http::timeout(10) + ->withHeaders(['X-Api-Key' => config('services.fuelalert.api_key')]) + ->get(url('/api/stations'), [ + 'postcode' => $this->search, + 'fuel_type' => $this->fuelType, + 'radius' => $radiusKm, + 'sort' => 'price', + ]); + + if ($response->status() === 422) { + $errors = $response->json('errors', []); + $this->apiError = collect($errors)->flatten()->first() + ?? $response->json('message', 'Validation error.'); + + return; + } + + if (! $response->successful()) { + $this->apiError = 'Unable to fetch stations. Please try again.'; + + return; + } + + $this->results = $response->json('data', []); + $this->meta = $response->json('meta', []); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.public.station-search'); + } +} +``` + +- [ ] **Step 3: Run tests — most should pass, view tests may still fail** + +```bash +php artisan test --compact tests/Feature/Livewire/StationSearchTest.php +``` + +Expected: 6–7 pass, the `renders the station search form` test may fail until the view exists. + +- [ ] **Step 4: Format** + +```bash +vendor/bin/pint app/Livewire/Public/StationSearch.php --format agent +``` + +--- + +## Task 4: Implement the view + +**Files:** +- Modify: `resources/views/livewire/public/station-search.blade.php` (generated in Task 3, now replace contents) + +- [ ] **Step 1: Replace the generated view** + +```blade +
+ Find Cheap Fuel Near You + Search by postcode, town or city + +
+
+
+ + @error('search') +

{{ $message }}

+ @enderror +
+ +
+ + Select fuel type + Petrol (E10) + Super Unleaded (E5) + Diesel + Premium Diesel + B10 Biodiesel + HVO + + @error('fuelType') +

{{ $message }}

+ @enderror +
+ +
+ + 1 mile + 2 miles + 5 miles + 10 miles + 20 miles + +
+ +
+ + Search + Searching… + +
+
+
+ + @if ($apiError) +
+ {{ $apiError }} +
+ @endif + + @if (! empty($meta)) +
+ @if (! empty($results)) +

+ {{ $meta['count'] }} {{ Str::plural('station', $meta['count']) }} found + · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p + · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p +

+ +
+ @foreach ($results as $station) +
+
+
+

+ {{ $station['name'] }} +

+ @if ($station['is_supermarket']) + Supermarket + @endif +
+

+ {{ $station['address'] }}, {{ $station['postcode'] }} +

+

+ {{ number_format($station['distance_km'] * 0.621371, 1) }} miles away +

+
+ +
+

+ {{ $station['price'] }}p +

+

+ {{ \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() }} +

+
+
+ @endforeach +
+ @else +

+ No stations found within {{ $radius }} {{ Str::plural('mile', $radius) }} of "{{ $search }}". +

+ @endif +
+ @endif +
+``` + +- [ ] **Step 2: Run all tests — all should pass** + +```bash +php artisan test --compact tests/Feature/Livewire/StationSearchTest.php +``` + +Expected: all 7 tests pass. + +- [ ] **Step 3: Format both files** + +```bash +vendor/bin/pint app/Livewire/Public/StationSearch.php resources/views/livewire/public/station-search.blade.php --format agent +``` + +- [ ] **Step 4: Confirm page loads in browser** + +Visit `https://fuel-price.test/stations` — form should render with all three fields and a Search button. + +- [ ] **Step 5: Commit** + +```bash +git add app/Livewire/Public/StationSearch.php resources/views/livewire/public/station-search.blade.php +git commit -m "feat: implement StationSearch Livewire component and view" +``` + +--- + +## Self-Review Checklist + +- [x] Config + env key — Task 1 +- [x] Route `GET /stations` — Task 1 +- [x] Form fields: search, fuelType, radius — Task 3 + 4 +- [x] Validation (required) — Task 3, tested in Task 2 +- [x] `findStations()` makes server-side HTTP call with API key — Task 3 +- [x] Miles → km conversion (× 1.60934, rounded to 2dp) — Task 3, tested in Task 2 +- [x] 422 error handling → `$apiError` — Task 3, tested in Task 2 +- [x] Non-2xx error → generic `$apiError` — Task 3, tested in Task 2 +- [x] Results list: name, brand/supermarket badge, address, distance in miles, price, updated-at — Task 4 +- [x] Meta bar: count, cheapest, average — Task 4 +- [x] Empty state message — Task 4 +- [x] Loading state on submit button — Task 4 +- [x] `wire:loading` disables button during request — Task 4