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

40 KiB
Raw Permalink Blame History

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

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

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
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

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:

<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
php artisan test --compact --filter="SearchTest" --timeout=10

Expected: all 13 tests pass.

  • Step 7: Run Pint
vendor/bin/pint app/Livewire/Public/Fuel/Search.php --format agent
  • Step 8: Commit
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

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

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
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

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:

<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
php artisan test --compact --filter="StationListTest" --timeout=10

Expected: all 4 tests pass.

  • Step 7: Run Pint
vendor/bin/pint app/Livewire/Public/Fuel/StationList.php --format agent
  • Step 8: Commit
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

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

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
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

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:

<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
php artisan test --compact --filter="RecommendationTest" --timeout=10

Expected: all 4 tests pass.

  • Step 7: Run Pint
vendor/bin/pint app/Livewire/Public/Fuel/Recommendation.php --format agent
  • Step 8: Commit
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

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

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
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

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:

<div class="mb-4" wire:ignore>
    <x-fuel.station-map />
</div>
  • Step 6: Run PHP tests to confirm they pass
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():

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():

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:

<div
    x-data="stationMap([], {}, 5)"
    class="h-56 w-full overflow-hidden border-y border-border md:h-96"
></div>
  • Step 9: Run Pint
vendor/bin/pint app/Livewire/Public/Fuel/Map.php --format agent
  • Step 10: Commit
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

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:

<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

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
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
vendor/bin/pint app/Livewire/Public/FuelFinder.php --format agent
  • Step 6: Commit
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"