Files
fuel-price/docs/superpowers/specs/2026-04-07-fuelfinder-subcomponent-split-design.md
2026-04-07 21:27:56 +01:00

212 lines
6.8 KiB
Markdown

# FuelFinder Sub-Component Split
**Date:** 2026-04-07
**Status:** Approved
**Scope:** Split the monolithic `FuelFinder` Livewire component into focused sub-components communicating via browser events.
---
## Motivation
`FuelFinder` currently owns search state, API calls, map data, station results, and prediction — all in one component. Every search re-renders the entire page including the map container. Splitting into focused components allows the map, station list, and recommendation to update independently without re-mounting Leaflet or triggering unnecessary re-renders.
---
## Architecture
```
FuelFinder (shell) — layout only, no state
├── <livewire:fuel.search /> — search form, filters, geolocation, API calls
├── <livewire:fuel.map /> — relays stations-found to Alpine/Leaflet
├── <livewire:fuel.station-list /> — renders station cards
├── <livewire:fuel.recommendation /> — renders prediction card
└── <x-fuel.forecast /> — Blade component, unchanged
```
---
## Components
### FuelFinder (shell)
**Class:** `App\Livewire\Public\FuelFinder`
**View:** `resources/views/livewire/public/fuel-finder.blade.php`
Retains `#[Layout('layouts.shell')]`. Holds no state and no methods. The view contains only the page chrome (header, main scroll area, bottom nav) and the four `<livewire:fuel.*>` tags.
---
### fuel.search
**Class:** `App\Livewire\Public\Fuel\Search`
**View:** `resources/views/livewire/public/fuel/search.blade.php`
Owns all search state and logic migrated from `FuelFinder`:
**Properties:**
- `search: string` — postcode/town input
- `fuelType: string` — default `'petrol'`
- `radius: int` — default `5`
- `sort: string` — default `'reliable'`
- `apiError: ?string`
- `hasSearched: bool`
**Methods:**
- `findStations()` — validates, calls `/api/stations`, calls `/api/prediction`, dispatches `stations-found`
- `updatedFuelType()`, `updatedRadius()`, `updatedSort()` — re-run `findStations()` if `hasSearched`
**Dispatches:**
```php
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius, // needed by Map for Leaflet zoom level
);
```
The view contains the search input, geolocation Alpine component, filter selects, and error display — extracted from the current `fuel-finder.blade.php`.
---
### fuel.map
**Class:** `App\Livewire\Public\Fuel\Map`
**View:** `resources/views/livewire/public/fuel/map.blade.php`
Thin relay. Listens for `stations-found` on the PHP side and re-dispatches as a browser event for Alpine/Leaflet to consume. No re-render required.
```php
#[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);
}
```
The view contains the `<x-fuel.station-map>` component wrapped in `wire:ignore`. The Alpine `stationMap` function in `station-map.js` is updated to listen for the `map-update` browser event instead of `$watch`-ing an entangled Livewire property.
**station-map.js change:**
```js
init() {
// ...existing Leaflet init...
window.addEventListener('map-update', (e) => {
this.results = e.detail.results;
this.meta = e.detail.meta;
this.radius = e.detail.radius;
this._plotMarkers();
});
}
```
`station-map.blade.php` props change from `@entangle(...)` to static defaults — `Map` holds no state:
```blade
<div x-data="stationMap([], {}, 5)" class="h-56 w-full ..."></div>
```
---
### fuel.station-list
**Class:** `App\Livewire\Public\Fuel\StationList`
**View:** `resources/views/livewire/public/fuel/station-list.blade.php`
Listens for `stations-found`, stores results, re-renders station cards.
**Properties:**
- `results: array`
- `meta: array`
- `hasSearched: bool`
**Methods:**
```php
#[On('stations-found')]
public function handle(array $results, array $meta): void
{
$this->results = $results;
$this->meta = $meta;
$this->hasSearched = true;
}
```
The view contains the "Stations Nearby" heading, result count, `@forelse` station card loop, and the empty-state message — extracted from `fuel-finder.blade.php`.
---
### fuel.recommendation
**Class:** `App\Livewire\Public\Fuel\Recommendation`
**View:** `resources/views/livewire/public/fuel/recommendation.blade.php`
Listens for `stations-found`, stores prediction, re-renders.
**Properties:**
- `prediction: ?array`
**Methods:**
```php
#[On('stations-found')]
public function handle(array $results, array $meta, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
```
The view renders `<x-fuel.recommendation :prediction="$prediction" />` when prediction is set. Currently commented out in the parent view — this component makes it easy to uncomment when the scoring engine is wired.
---
## Event Contract
**Event name:** `stations-found`
**Payload:**
```php
[
'results' => array, // station records from /api/stations
'meta' => array, // lat, lng, count from API response
'prediction' => ?array, // from /api/prediction, nullable
'radius' => int, // user-selected radius in miles, for Leaflet zoom
]
```
All listener components must accept `?array $prediction = null` and `int $radius = 5` as defaults to avoid errors when optional fields are absent.
---
## File Changes
**New files:**
- `app/Livewire/Public/Fuel/Search.php`
- `app/Livewire/Public/Fuel/Map.php`
- `app/Livewire/Public/Fuel/StationList.php`
- `app/Livewire/Public/Fuel/Recommendation.php`
- `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`
**Modified files:**
- `app/Livewire/Public/FuelFinder.php` — strip all state/methods, keep layout attribute
- `resources/views/livewire/public/fuel-finder.blade.php` — replace inline sections with `<livewire:fuel.*>` tags
- `resources/js/maps/station-map.js` — replace `$watch` with `map-update` event listener
- `resources/views/components/fuel/station-map.blade.php` — remove `@entangle` props
**Moved tests:**
- `FuelFinderTest` search/API logic tests → `SearchTest`
**New tests:**
- `SearchTest` — validates, dispatches `stations-found`, handles API errors
- `StationListTest` — responds to `stations-found`, renders cards, handles empty state
- `RecommendationTest` — responds to `stations-found`, renders when prediction set, hidden when null
---
## What Does Not Change
- `x-fuel.forecast` — remains a Blade component
- `station-map.js` map logic — only the data-binding mechanism changes
- All `fuel/` Blade components (`station-card`, `recommendation`, `forecast`, etc.)
- The visual layout in `fuel-finder.blade.php`
- Route definition