Files
fuel-price/docs/superpowers/plans/2026-04-07-fuelfinder-subcomponent-split.md

1250 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FuelFinder Sub-Component Split 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:** Split the monolithic `FuelFinder` Livewire component into four focused sub-components (`Search`, `Map`, `StationList`, `Recommendation`) communicating via a `stations-found` browser event, so each part re-renders independently without re-mounting Leaflet.
**Architecture:** `FuelFinder` becomes a layout-only shell. `Search` owns all API calls and dispatches `stations-found` with results, meta, prediction, and radius. `Map` relays the event to Alpine/Leaflet via `map-update`. `StationList` and `Recommendation` each listen for `stations-found` and re-render their slice of the UI. `Forecast` is unchanged.
**Tech Stack:** Laravel 13, Livewire 4, Alpine.js, Leaflet.js, Pest 4
---
## File Map
**New PHP classes:**
- `app/Livewire/Public/Fuel/Search.php` — search state, API calls, dispatcher
- `app/Livewire/Public/Fuel/Map.php` — event relay, no state
- `app/Livewire/Public/Fuel/StationList.php` — station card list
- `app/Livewire/Public/Fuel/Recommendation.php` — prediction card
**New Blade views:**
- `resources/views/livewire/public/fuel/search.blade.php`
- `resources/views/livewire/public/fuel/map.blade.php`
- `resources/views/livewire/public/fuel/station-list.blade.php`
- `resources/views/livewire/public/fuel/recommendation.blade.php`
**New tests:**
- `tests/Feature/Livewire/Fuel/SearchTest.php`
- `tests/Feature/Livewire/Fuel/StationListTest.php`
- `tests/Feature/Livewire/Fuel/RecommendationTest.php`
- `tests/Feature/Livewire/Fuel/MapTest.php`
**Modified files:**
- `app/Livewire/Public/FuelFinder.php` — strip all state and methods
- `resources/views/livewire/public/fuel-finder.blade.php` — replace sections with `<livewire:fuel.*>` tags
- `resources/js/maps/station-map.js` — replace `$watch` with `map-update` window event listener
- `resources/views/components/fuel/station-map.blade.php` — remove `@entangle` props
- `tests/Feature/Livewire/FuelFinderTest.php` — strip to render-only test
---
## Task 1: fuel.search component
**Files:**
- Create: `app/Livewire/Public/Fuel/Search.php`
- Create: `resources/views/livewire/public/fuel/search.blade.php`
- Create: `tests/Feature/Livewire/Fuel/SearchTest.php`
- [ ] **Step 1: Scaffold the component and test**
```bash
php artisan make:livewire Public/Fuel/Search --no-interaction
php artisan make:test Feature/Livewire/Fuel/SearchTest --pest --no-interaction
```
- [ ] **Step 2: Write the failing tests**
Replace the contents of `tests/Feature/Livewire/Fuel/SearchTest.php`:
```php
<?php
use App\Livewire\Public\Fuel\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the search component', function () {
Livewire::test(Search::class)
->assertStatus(200)
->assertSeeHtml('name="search"');
});
it('has default property values', function () {
Livewire::test(Search::class)
->assertSet('search', '')
->assertSet('fuelType', 'petrol')
->assertSet('radius', 5)
->assertSet('sort', 'reliable')
->assertSet('apiError', null)
->assertSet('hasSearched', false);
});
it('validates search is required', function () {
Livewire::test(Search::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('dispatches stations-found with results, meta, prediction and radius 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',
'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',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0],
], 200),
'*/api/prediction*' => Http::response([
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('apiError', null)
->assertDispatched('stations-found', fn ($event, $params) =>
count($params['results']) === 1
&& $params['results'][0]['name'] === 'BP Garage'
&& $params['meta']['count'] === 1
&& $params['prediction']['action'] === 'fill_now'
&& $params['radius'] === 5
);
});
it('sets apiError from 422 station response and does not dispatch stations-found', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(Search::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', false)
->assertSet('apiError', 'Postcode not found.')
->assertNotDispatched('stations-found');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing stations request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api/stations')) {
return false;
}
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
});
});
it('resets apiError before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null);
});
it('does not call findStations on updatedFuelType if not yet searched', function () {
Http::fake();
Livewire::test(Search::class)
->set('fuelType', 'diesel');
Http::assertNothingSent();
});
it('re-runs findStations on updatedFuelType when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('fuelType', 'diesel');
Http::assertSentCount(2);
});
it('re-runs findStations on updatedRadius when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('radius', 10);
Http::assertSentCount(2);
});
it('re-runs findStations on updatedSort when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('sort', 'price');
Http::assertSentCount(2);
});
it('prediction is null in stations-found payload when prediction api fails', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertDispatched('stations-found', fn ($event, $params) =>
$params['prediction'] === null
);
});
```
- [ ] **Step 3: Run tests to confirm they fail**
```bash
php artisan test --compact --filter="SearchTest" --timeout=10
```
Expected: all fail with `Class "App\Livewire\Public\Fuel\Search" not found` or similar.
- [ ] **Step 4: Implement Search.php**
Replace the contents of `app/Livewire/Public/Fuel/Search.php`:
```php
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
final class Search extends Component
{
#[Validate('required|string', message: 'Please enter a postcode, town or city.')]
public string $search = '';
#[Validate('required|string', message: 'Please select a fuel type.')]
public string $fuelType = 'petrol';
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
#[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
public string $sort = 'reliable';
public ?string $apiError = null;
public bool $hasSearched = false;
public function updatedFuelType(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedRadius(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedSort(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function findStations(): void
{
$this->validate();
$this->apiError = null;
$this->hasSearched = false;
$radiusKm = round($this->radius * 1.60934, 2);
try {
$response = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/stations'), [
'postcode' => $this->search,
'fuel_type' => $this->fuelType,
'radius' => $radiusKm,
'sort' => $this->sort,
]);
} catch (ConnectionException) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
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;
}
$results = $response->json('data', []);
$meta = $response->json('meta', []);
$this->hasSearched = true;
$prediction = null;
try {
$predictionResponse = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/prediction'));
if ($predictionResponse->successful()) {
$prediction = $predictionResponse->json();
}
} catch (ConnectionException) {
// Prediction failure is silent — stations are more important
}
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius,
);
}
public function render(): View
{
return view('livewire.public.fuel.search');
}
}
```
- [ ] **Step 5: Implement search.blade.php**
Replace the contents of `resources/views/livewire/public/fuel/search.blade.php`:
```blade
<div class="space-y-3 px-5 pb-4 pt-5">
<form wire:submit="findStations">
<div
x-data="{
query: @js($search),
locatingUser: false,
_usedIpFallback: false,
async _postcodeFromLatLng(lat, lng) {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
const data = await res.json();
return data?.result?.[0]?.postcode ?? null;
},
async locateUser() {
this.locatingUser = true;
this._usedIpFallback = false;
try {
let lat, lng;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
);
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch (e) {
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
lat = d.latitude;
lng = d.longitude;
this._usedIpFallback = true;
}
const postcode = await this._postcodeFromLatLng(lat, lng);
if (postcode) {
this.query = postcode;
this.$wire.set('search', postcode);
this.$wire.findStations();
}
} catch (e) {
// silent
} finally {
this.locatingUser = false;
}
}
}"
class="relative mb-3"
>
<iconify-icon
icon="lucide:map-pin"
class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-xl text-text-muted"
></iconify-icon>
<input
wire:model="search"
x-model="query"
x-ref="searchInput"
type="text"
name="search"
@focus="_usedIpFallback = false"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-border bg-surface pl-12 pr-36 text-base font-semibold text-text-base focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-text-muted hover:text-text-base"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-surface-subtle px-3 py-1.5 text-sm font-semibold text-text-base disabled:opacity-40"
>
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="animate-spin text-sm"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
<button
type="submit"
wire:loading.attr="disabled"
class="text-text-muted disabled:opacity-60"
>
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="animate-spin text-xl"></iconify-icon>
</button>
</div>
{{-- IP fallback nudge --}}
<div
x-show="_usedIpFallback"
x-cloak
x-transition:enter="transition ease-out duration-300 delay-500"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="absolute left-0 right-0 top-full z-40 mt-2"
>
<div
@click="$refs.searchInput.focus(); _usedIpFallback = false"
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
>
<p class="text-center text-xs text-amber-800">
<span class="font-medium">Showing approximate location.</span>
<span class="underline">Enter your postcode above</span> for exact results.
</p>
</div>
</div>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="shrink-0"><x-fuel.type-select wire:model.live="fuelType" /></div>
<div class="shrink-0"><x-fuel.sort-select wire:model.live="sort" /></div>
<div class="shrink-0"><x-fuel.radius-select wire:model.live="radius" /></div>
</div>
</form>
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</div>
```
- [ ] **Step 6: Run tests to confirm they pass**
```bash
php artisan test --compact --filter="SearchTest" --timeout=10
```
Expected: all 13 tests pass.
- [ ] **Step 7: Run Pint**
```bash
vendor/bin/pint app/Livewire/Public/Fuel/Search.php --format agent
```
- [ ] **Step 8: Commit**
```bash
git add app/Livewire/Public/Fuel/Search.php resources/views/livewire/public/fuel/search.blade.php tests/Feature/Livewire/Fuel/SearchTest.php
git commit -m "feat: extract fuel.search Livewire component with stations-found dispatch"
```
---
## Task 2: fuel.station-list component
**Files:**
- Create: `app/Livewire/Public/Fuel/StationList.php`
- Create: `resources/views/livewire/public/fuel/station-list.blade.php`
- Create: `tests/Feature/Livewire/Fuel/StationListTest.php`
- [ ] **Step 1: Scaffold**
```bash
php artisan make:livewire Public/Fuel/StationList --no-interaction
php artisan make:test Feature/Livewire/Fuel/StationListTest --pest --no-interaction
```
- [ ] **Step 2: Write the failing tests**
Replace contents of `tests/Feature/Livewire/Fuel/StationListTest.php`:
```php
<?php
use App\Livewire\Public\Fuel\StationList;
use Livewire\Livewire;
it('renders empty state before any search', function () {
Livewire::test(StationList::class)
->assertStatus(200)
->assertSet('hasSearched', false)
->assertDontSee('Stations Nearby');
});
it('shows station cards after stations-found event', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'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',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('Stations Nearby')
->assertSee('BP Garage')
->assertSee('1 Result');
});
it('shows empty state message when stations-found has no results', function () {
Livewire::test(StationList::class)
->set('search', 'ZZ99 9ZZ')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('No stations found');
});
it('updates results when stations-found fires again', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'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',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
->assertSee('BP Garage')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertDontSee('BP Garage');
});
```
- [ ] **Step 3: Run tests to confirm they fail**
```bash
php artisan test --compact --filter="StationListTest" --timeout=10
```
Expected: all fail — class not found or component has no `handle` method yet.
- [ ] **Step 4: Implement StationList.php**
Replace contents of `app/Livewire/Public/Fuel/StationList.php`:
```php
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class StationList extends Component
{
public array $results = [];
public array $meta = [];
public bool $hasSearched = false;
public string $search = '';
public int $radius = 5;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->results = $results;
$this->meta = $meta;
$this->radius = $radius;
$this->hasSearched = true;
}
public function render(): View
{
return view('livewire.public.fuel.station-list');
}
}
```
- [ ] **Step 5: Implement station-list.blade.php**
Replace contents of `resources/views/livewire/public/fuel/station-list.blade.php`:
```blade
<div>
@if ($hasSearched)
<div class="px-5 pb-5">
@if (! empty($meta))
<div class="mb-3 flex items-center justify-between">
<h3 class="text-base font-bold text-text-base">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-text-muted">
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
</span>
</div>
@endif
@forelse ($results as $station)
<div class="mb-2">
<x-fuel.station-card :station="$station" />
</div>
@empty
<p class="text-sm text-text-muted">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
</p>
@endforelse
</div>
@endif
</div>
```
- [ ] **Step 6: Run tests to confirm they pass**
```bash
php artisan test --compact --filter="StationListTest" --timeout=10
```
Expected: all 4 tests pass.
- [ ] **Step 7: Run Pint**
```bash
vendor/bin/pint app/Livewire/Public/Fuel/StationList.php --format agent
```
- [ ] **Step 8: Commit**
```bash
git add app/Livewire/Public/Fuel/StationList.php resources/views/livewire/public/fuel/station-list.blade.php tests/Feature/Livewire/Fuel/StationListTest.php
git commit -m "feat: extract fuel.station-list Livewire component"
```
---
## Task 3: fuel.recommendation component
**Files:**
- Create: `app/Livewire/Public/Fuel/Recommendation.php`
- Create: `resources/views/livewire/public/fuel/recommendation.blade.php`
- Create: `tests/Feature/Livewire/Fuel/RecommendationTest.php`
- [ ] **Step 1: Scaffold**
```bash
php artisan make:livewire Public/Fuel/Recommendation --no-interaction
php artisan make:test Feature/Livewire/Fuel/RecommendationTest --pest --no-interaction
```
- [ ] **Step 2: Write the failing tests**
Replace contents of `tests/Feature/Livewire/Fuel/RecommendationTest.php`:
```php
<?php
use App\Livewire\Public\Fuel\Recommendation;
use Livewire\Livewire;
it('renders nothing before stations-found fires', function () {
Livewire::test(Recommendation::class)
->assertStatus(200)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('shows recommendation card when stations-found includes a prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices are rising sharply.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSet('prediction', $prediction)
->assertSee('Recommendation')
->assertSee('Fill up now');
});
it('shows nothing when stations-found has null prediction', function () {
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('clears previous prediction when new stations-found fires with null prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSee('Recommendation')
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertDontSee('Recommendation');
});
```
- [ ] **Step 3: Run tests to confirm they fail**
```bash
php artisan test --compact --filter="RecommendationTest" --timeout=10
```
Expected: all fail — class not found or no `handle` method.
- [ ] **Step 4: Implement Recommendation.php**
Replace contents of `app/Livewire/Public/Fuel/Recommendation.php`:
```php
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Recommendation extends Component
{
public ?array $prediction = null;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
public function render(): View
{
return view('livewire.public.fuel.recommendation');
}
}
```
- [ ] **Step 5: Implement recommendation.blade.php**
Replace contents of `resources/views/livewire/public/fuel/recommendation.blade.php`:
```blade
<div>
@if ($prediction)
<div class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</div>
@endif
</div>
```
- [ ] **Step 6: Run tests to confirm they pass**
```bash
php artisan test --compact --filter="RecommendationTest" --timeout=10
```
Expected: all 4 tests pass.
- [ ] **Step 7: Run Pint**
```bash
vendor/bin/pint app/Livewire/Public/Fuel/Recommendation.php --format agent
```
- [ ] **Step 8: Commit**
```bash
git add app/Livewire/Public/Fuel/Recommendation.php resources/views/livewire/public/fuel/recommendation.blade.php tests/Feature/Livewire/Fuel/RecommendationTest.php
git commit -m "feat: extract fuel.recommendation Livewire component"
```
---
## Task 4: fuel.map component + JS update
**Files:**
- Create: `app/Livewire/Public/Fuel/Map.php`
- Create: `resources/views/livewire/public/fuel/map.blade.php`
- Create: `tests/Feature/Livewire/Fuel/MapTest.php`
- Modify: `resources/js/maps/station-map.js`
- Modify: `resources/views/components/fuel/station-map.blade.php`
- [ ] **Step 1: Scaffold**
```bash
php artisan make:livewire Public/Fuel/Map --no-interaction
php artisan make:test Feature/Livewire/Fuel/MapTest --pest --no-interaction
```
- [ ] **Step 2: Write the failing tests**
Replace contents of `tests/Feature/Livewire/Fuel/MapTest.php`:
```php
<?php
use App\Livewire\Public\Fuel\Map;
use Livewire\Livewire;
it('renders the map component', function () {
Livewire::test(Map::class)
->assertStatus(200);
});
it('dispatches map-update browser event when stations-found is received', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [['name' => 'BP Garage']],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
radius: 5,
prediction: null
)
->assertDispatched('map-update');
});
it('passes radius in map-update payload', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
radius: 10,
prediction: null
)
->assertDispatched('map-update', fn ($event, $params) =>
$params['radius'] === 10
);
});
```
- [ ] **Step 3: Run tests to confirm they fail**
```bash
php artisan test --compact --filter="MapTest" --timeout=10
```
Expected: all fail — class not found.
- [ ] **Step 4: Implement Map.php**
Replace contents of `app/Livewire/Public/Fuel/Map.php`:
```php
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Map extends Component
{
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
}
public function render(): View
{
return view('livewire.public.fuel.map');
}
}
```
- [ ] **Step 5: Implement map.blade.php**
Replace contents of `resources/views/livewire/public/fuel/map.blade.php`:
```blade
<div class="mb-4" wire:ignore>
<x-fuel.station-map />
</div>
```
- [ ] **Step 6: Run PHP tests to confirm they pass**
```bash
php artisan test --compact --filter="MapTest" --timeout=10
```
Expected: all 3 tests pass.
- [ ] **Step 7: Update station-map.js**
Replace the `init()` method in `resources/js/maps/station-map.js` (lines 5974). The old `init()`:
```js
init() {
injectUserMarkerStyles();
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(this._map);
if (this.results && this.results.length > 0) {
this._plotMarkers();
}
this.$watch('results', () => this._plotMarkers());
this.locateUser();
},
```
New `init()`:
```js
init() {
injectUserMarkerStyles();
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(this._map);
window.addEventListener('map-update', (e) => {
this.results = e.detail.results;
this.meta = e.detail.meta;
this.radius = e.detail.radius;
this._plotMarkers();
});
this.locateUser();
},
```
- [ ] **Step 8: Update station-map.blade.php**
Replace the full contents of `resources/views/components/fuel/station-map.blade.php`:
```blade
<div
x-data="stationMap([], {}, 5)"
class="h-56 w-full overflow-hidden border-y border-border md:h-96"
></div>
```
- [ ] **Step 9: Run Pint**
```bash
vendor/bin/pint app/Livewire/Public/Fuel/Map.php --format agent
```
- [ ] **Step 10: Commit**
```bash
git add app/Livewire/Public/Fuel/Map.php resources/views/livewire/public/fuel/map.blade.php tests/Feature/Livewire/Fuel/MapTest.php resources/js/maps/station-map.js resources/views/components/fuel/station-map.blade.php
git commit -m "feat: extract fuel.map component and wire Leaflet to map-update browser event"
```
---
## Task 5: Strip FuelFinder to shell
**Files:**
- Modify: `app/Livewire/Public/FuelFinder.php`
- Modify: `resources/views/livewire/public/fuel-finder.blade.php`
- Modify: `tests/Feature/Livewire/FuelFinderTest.php`
- [ ] **Step 1: Update FuelFinder.php**
Replace the full contents of `app/Livewire/Public/FuelFinder.php`:
```php
<?php
namespace App\Livewire\Public;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.shell')]
final class FuelFinder extends Component
{
public function render(): View
{
return view('livewire.public.fuel-finder');
}
}
```
- [ ] **Step 2: Update fuel-finder.blade.php**
Replace the full contents of `resources/views/livewire/public/fuel-finder.blade.php`:
```blade
<div class="flex h-dvh flex-col bg-surface-page">
{{-- HEADER --}}
<header class="shrink-0 z-50 bg-surface border-b border-border shadow-sm
flex items-center justify-between px-5 pb-4 md:px-8"
style="padding-top: max(1rem, env(safe-area-inset-top))">
<a href="{{ route('home') }}" class="flex items-center gap-2.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary shadow-md md:h-10 md:w-10">
<iconify-icon icon="lucide:fuel" class="text-lg text-white md:text-xl"></iconify-icon>
</div>
<span class="text-xl font-black tracking-tighter text-primary md:text-2xl">FuelAlert</span>
</a>
<nav class="hidden items-center gap-8 md:flex">
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Prices</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Alerts</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Trends</a>
</nav>
@auth
<a href="{{ route('dashboard') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@else
<a href="{{ route('login') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@endauth
</header>
{{-- MAIN --}}
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="md:mx-auto md:max-w-3xl">
<livewire:fuel.search />
<livewire:fuel.recommendation />
<livewire:fuel.map />
<livewire:fuel.station-list />
<section class="px-5 pb-8">
<x-fuel.forecast />
</section>
</div>
</main>
{{-- BOTTOM TAB BAR --}}
@php
$tabs = [
['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'],
['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null],
['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null],
['label' => 'Trends', 'icon' => 'lucide:trending-up', 'route' => null],
];
@endphp
<nav class="shrink-0 border-t border-border bg-surface md:hidden"
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom))">
<div class="flex pt-3">
@foreach ($tabs as $tab)
@php $active = $tab['route'] && request()->routeIs($tab['route']); @endphp
<div class="flex flex-1 flex-col items-center gap-1">
<iconify-icon
icon="{{ $tab['icon'] }}"
class="text-xl {{ $active ? 'text-primary' : 'text-text-muted' }}"
></iconify-icon>
<span class="text-[10px] font-bold uppercase tracking-wide {{ $active ? 'text-primary' : 'text-text-muted' }}">
{{ $tab['label'] }}
</span>
</div>
@endforeach
</div>
</nav>
</div>
```
- [ ] **Step 3: Update FuelFinderTest.php**
Replace the full contents of `tests/Feature/Livewire/FuelFinderTest.php`:
```php
<?php
use App\Livewire\Public\FuelFinder;
use Livewire\Livewire;
it('renders the fuel finder shell', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200);
});
```
- [ ] **Step 4: Run the full test suite**
```bash
php artisan test --compact --timeout=10
```
Expected: 1 failure in `StatsOverviewWidgetTest` (pre-existing, unrelated to this work). All other tests pass, including the new `SearchTest`, `StationListTest`, `RecommendationTest`, and `MapTest`.
- [ ] **Step 5: Run Pint**
```bash
vendor/bin/pint app/Livewire/Public/FuelFinder.php --format agent
```
- [ ] **Step 6: Commit**
```bash
git add app/Livewire/Public/FuelFinder.php resources/views/livewire/public/fuel-finder.blade.php tests/Feature/Livewire/FuelFinderTest.php
git commit -m "feat: strip FuelFinder to layout shell, wire sub-components"
```