diff --git a/app/Enums/PriceClassification.php b/app/Enums/PriceClassification.php
new file mode 100644
index 0000000..e286c96
--- /dev/null
+++ b/app/Enums/PriceClassification.php
@@ -0,0 +1,49 @@
+diffInHours(now());
+
+ return match (true) {
+ $hours < 24 => self::Current,
+ $hours < 48 => self::Recent,
+ $hours < 120 => self::Stale,
+ default => self::Outdated,
+ };
+ }
+
+ public function weight(): int
+ {
+ return match ($this) {
+ self::Current => 0,
+ self::Recent => 1,
+ self::Stale => 2,
+ self::Outdated => 3,
+ };
+ }
+
+ public function label(): string
+ {
+ return match ($this) {
+ self::Current => 'Current',
+ self::Recent => 'Recent',
+ self::Stale => 'Stale',
+ self::Outdated => 'Outdated',
+ };
+ }
+}
diff --git a/app/Http/Requests/Api/NearbyStationsRequest.php b/app/Http/Requests/Api/NearbyStationsRequest.php
index 5ddf639..5bc80c7 100644
--- a/app/Http/Requests/Api/NearbyStationsRequest.php
+++ b/app/Http/Requests/Api/NearbyStationsRequest.php
@@ -20,7 +20,7 @@ class NearbyStationsRequest extends FormRequest
'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'],
'fuel_type' => ['required', 'string'],
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
- 'sort' => ['nullable', 'string', 'in:price,distance,updated,brand'],
+ 'sort' => ['nullable', 'string', 'in:price,distance,updated,brand,reliable'],
'pricing_mode' => ['nullable', 'string', 'in:pump'],
];
}
diff --git a/app/Http/Resources/Api/StationResource.php b/app/Http/Resources/Api/StationResource.php
index dcfcb0f..0fafe81 100644
--- a/app/Http/Resources/Api/StationResource.php
+++ b/app/Http/Resources/Api/StationResource.php
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Api;
+use App\Enums\PriceClassification;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
@@ -26,6 +27,12 @@ class StationResource extends JsonResource
'price_updated_at' => $this->price_effective_at
? Carbon::parse($this->price_effective_at)->toISOString()
: null,
+ 'price_classification' => PriceClassification::fromUpdatedAt(
+ $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
+ )->value,
+ 'price_classification_label' => PriceClassification::fromUpdatedAt(
+ $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
+ )->label(),
];
}
}
diff --git a/app/Livewire/Public/StationSearch.php b/app/Livewire/Public/StationSearch.php
index a36f0bd..578fe83 100644
--- a/app/Livewire/Public/StationSearch.php
+++ b/app/Livewire/Public/StationSearch.php
@@ -19,8 +19,8 @@ class StationSearch extends Component
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
- #[Validate('nullable|string|in:price,distance,updated,brand')]
- public string $sort = 'price';
+ #[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
+ public string $sort = 'reliable';
public array $results = [];
diff --git a/docs/superpowers/plans/2026-04-06-station-map.md b/docs/superpowers/plans/2026-04-06-station-map.md
new file mode 100644
index 0000000..adc7123
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-06-station-map.md
@@ -0,0 +1,490 @@
+# Station Map (Leaflet + OSM) 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:** Add an interactive Leaflet/OSM map to the station search page that plots colour-coded markers for each result and centres on the searched location.
+
+**Architecture:** A Leaflet map is managed by an Alpine.js component (`stationMap`) registered via `alpine:init`. The component receives the Livewire `results` array via `@entangle` and re-plots markers reactively whenever the data changes. The API meta response is extended to include the resolved search `lat`/`lng` so the map can centre precisely on the search point.
+
+**Tech Stack:** Leaflet 1.x (npm), Alpine.js (bundled with Livewire 4), Livewire `@entangle`, Tailwind CSS v4.
+
+---
+
+## File Map
+
+| Action | File | Responsibility |
+|--------|------|----------------|
+| Modify | `package.json` | Add `leaflet` npm dependency |
+| Modify | `resources/css/app.css` | Import Leaflet CSS |
+| Create | `resources/js/maps/station-map.js` | Alpine component: map init, marker plotting, colour logic, popups |
+| Modify | `resources/js/app.js` | Register `stationMap` Alpine component on `alpine:init` |
+| Modify | `app/Http/Controllers/Api/StationController.php` | Add `lat`/`lng` to `meta` response |
+| Modify | `resources/views/livewire/public/station-search.blade.php` | Add map `
` with `x-data` above the results list |
+| Modify | `tests/Feature/Api/StationControllerTest.php` | Assert `meta.lat` and `meta.lng` are present |
+| Modify | `tests/Feature/Livewire/StationSearchTest.php` | Add `lat`/`lng` to the faked `meta` fixture |
+
+---
+
+## Task 1: Install Leaflet and import its CSS
+
+**Files:**
+- Modify: `package.json`
+- Modify: `resources/css/app.css`
+
+- [ ] **Step 1: Install leaflet via npm**
+
+```bash
+cd /Users/bitstream/code/fuel-price && npm install leaflet
+```
+
+Expected: `leaflet` appears in `node_modules/leaflet` and `package.json` `dependencies`.
+
+- [ ] **Step 2: Import Leaflet CSS in app.css**
+
+Add this line at the top of `resources/css/app.css` (before the Tailwind import):
+
+```css
+@import 'leaflet/dist/leaflet.css';
+@import 'tailwindcss';
+@import '../../vendor/livewire/flux/dist/flux.css';
+/* … rest unchanged … */
+```
+
+- [ ] **Step 3: Verify build succeeds**
+
+```bash
+cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5
+```
+
+Expected: Build completes without errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add package.json package-lock.json resources/css/app.css
+git commit -m "feat: install leaflet and import CSS"
+```
+
+---
+
+## Task 2: Add lat/lng to API meta response + test
+
+**Files:**
+- Modify: `app/Http/Controllers/Api/StationController.php:87-98`
+- Modify: `tests/Feature/Api/StationControllerTest.php`
+
+- [ ] **Step 1: Write a failing test**
+
+Add this test to `tests/Feature/Api/StationControllerTest.php`:
+
+```php
+it('includes resolved lat and lng in meta', function () {
+ $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
+ StationPriceCurrent::factory()->create([
+ 'station_id' => $station->node_id,
+ 'fuel_type' => FuelType::B7Standard,
+ 'price_pence' => 14500,
+ ]);
+
+ $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
+ ->assertOk()
+ ->assertJsonPath('meta.lat', 52.555064)
+ ->assertJsonPath('meta.lng', -0.256119);
+});
+```
+
+- [ ] **Step 2: Run test to confirm it fails**
+
+```bash
+cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -20
+```
+
+Expected: FAIL — `meta.lat` not found.
+
+- [ ] **Step 3: Add lat/lng to the meta array in the controller**
+
+In `app/Http/Controllers/Api/StationController.php`, change the `return response()->json([...])` block (around line 87) from:
+
+```php
+ return response()->json([
+ 'data' => StationResource::collection($stations),
+ 'meta' => [
+ 'count' => $stations->count(),
+ 'fuel_type' => $fuelType->value,
+ 'radius_km' => $radius,
+ 'lowest_pence' => $prices->min(),
+ 'highest_pence' => $prices->max(),
+ 'cheapest_price_pence' => $prices->min(),
+ 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
+ ],
+ ]);
+```
+
+to:
+
+```php
+ return response()->json([
+ 'data' => StationResource::collection($stations),
+ 'meta' => [
+ 'count' => $stations->count(),
+ 'fuel_type' => $fuelType->value,
+ 'radius_km' => $radius,
+ 'lat' => $lat,
+ 'lng' => $lng,
+ 'lowest_pence' => $prices->min(),
+ 'highest_pence' => $prices->max(),
+ 'cheapest_price_pence' => $prices->min(),
+ 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
+ ],
+ ]);
+```
+
+- [ ] **Step 4: Run tests to confirm they pass**
+
+```bash
+cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -10
+```
+
+Expected: All tests PASS.
+
+- [ ] **Step 5: Run pint**
+
+```bash
+cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/Http/Controllers/Api/StationController.php tests/Feature/Api/StationControllerTest.php
+git commit -m "feat: include search lat/lng in station API meta response"
+```
+
+---
+
+## Task 3: Create the station-map Alpine component
+
+**Files:**
+- Create: `resources/js/maps/station-map.js`
+
+- [ ] **Step 1: Create the directory and file**
+
+Create `resources/js/maps/station-map.js` with this content:
+
+```js
+import L from 'leaflet';
+
+// Fix Leaflet's broken default icon paths when bundled with Vite
+import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
+import markerIcon from 'leaflet/dist/images/marker-icon.png';
+import markerShadow from 'leaflet/dist/images/marker-shadow.png';
+
+delete L.Icon.Default.prototype._getIconUrl;
+L.Icon.Default.mergeOptions({
+ iconUrl: markerIcon,
+ iconRetinaUrl: markerIcon2x,
+ shadowUrl: markerShadow,
+});
+
+const CLASSIFICATION_COLOURS = {
+ current: '#22c55e', // green-500 — price updated within 24h
+ recent: '#64748b', // slate-500 — 24–48h
+ stale: '#f59e0b', // amber-500 — 48–120h
+ outdated: '#ef4444', // red-500 — 120h+
+};
+
+const UK_CENTRE = [54.0, -2.0];
+const UK_ZOOM = 6;
+
+export function stationMap(results, meta) {
+ return {
+ results,
+ meta,
+ _map: null,
+ _markers: [],
+
+ init() {
+ 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: '©
OpenStreetMap contributors',
+ maxZoom: 19,
+ }).addTo(this._map);
+
+ // Plot immediately if results are already available (e.g. after page reload)
+ if (this.results && this.results.length > 0) {
+ this._plotMarkers();
+ }
+
+ this.$watch('results', () => this._plotMarkers());
+ },
+
+ _clearMarkers() {
+ this._markers.forEach((m) => m.remove());
+ this._markers = [];
+ },
+
+ _plotMarkers() {
+ this._clearMarkers();
+
+ if (!this.results || this.results.length === 0) {
+ return;
+ }
+
+ const bounds = [];
+
+ this.results.forEach((station) => {
+ const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b';
+ const miles = (station.distance_km * 0.621371).toFixed(1);
+ const supermarketTag = station.is_supermarket
+ ? '
Supermarket'
+ : '';
+
+ const popup = `
+
+ ${station.name}${supermarketTag}
+ ${Number(station.price).toFixed(1)}p
+ ${miles} miles away
+ ${station.address}, ${station.postcode}
+
+ `;
+
+ const marker = L.circleMarker([station.lat, station.lng], {
+ radius: 9,
+ fillColor: colour,
+ color: '#ffffff',
+ weight: 2,
+ opacity: 1,
+ fillOpacity: 0.85,
+ }).bindPopup(popup);
+
+ marker.addTo(this._map);
+ this._markers.push(marker);
+ bounds.push([station.lat, station.lng]);
+ });
+
+ // Centre on search point from meta if available, else fit station bounds
+ if (this.meta && this.meta.lat && this.meta.lng) {
+ this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
+ } else {
+ this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
+ }
+ },
+ };
+}
+```
+
+- [ ] **Step 2: Verify no syntax errors**
+
+```bash
+cd /Users/bitstream/code/fuel-price && node --input-type=module < resources/js/maps/station-map.js 2>&1 | head -5
+```
+
+Expected: Either no output (success) or only an import error about Leaflet not being in Node context — that's fine; the goal is no syntax errors.
+
+Actually run the build instead:
+
+```bash
+cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10
+```
+
+Expected: Build succeeds (no parse/import errors).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add resources/js/maps/station-map.js
+git commit -m "feat: create station-map Alpine/Leaflet component"
+```
+
+---
+
+## Task 4: Register the Alpine component in app.js
+
+**Files:**
+- Modify: `resources/js/app.js`
+
+- [ ] **Step 1: Update app.js**
+
+Replace the entire content of `resources/js/app.js` with:
+
+```js
+import { stationMap } from './maps/station-map.js';
+
+document.addEventListener('alpine:init', () => {
+ Alpine.data('stationMap', stationMap);
+});
+```
+
+- [ ] **Step 2: Verify build**
+
+```bash
+cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add resources/js/app.js
+git commit -m "feat: register stationMap Alpine component"
+```
+
+---
+
+## Task 5: Add map to the station-search Blade template
+
+**Files:**
+- Modify: `resources/views/livewire/public/station-search.blade.php`
+
+- [ ] **Step 1: Add map legend and map div inside the results section**
+
+In `resources/views/livewire/public/station-search.blade.php`, locate the `@if (! empty($results))` block (around line 71). Replace the existing `
` results list section with a version that includes the map above it:
+
+Replace this block (starting at line 71):
+
+```blade
+ @if (! empty($results))
+
+ {{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
+ · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
+ · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
+
+
+
+```
+
+with:
+
+```blade
+ @if (! empty($results))
+
+ {{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
+ · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
+ · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
+
+
+ {{-- Map --}}
+
+
+ {{-- Legend --}}
+
+ Current (<24h)
+ Recent (24–48h)
+ Stale (2–5 days)
+ Outdated (5+ days)
+
+
+
+```
+
+- [ ] **Step 2: Verify build**
+
+```bash
+cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add resources/views/livewire/public/station-search.blade.php
+git commit -m "feat: add Leaflet map and colour legend to station search results"
+```
+
+---
+
+## Task 6: Update Livewire component test meta fixture
+
+**Files:**
+- Modify: `tests/Feature/Livewire/StationSearchTest.php`
+
+The existing tests fake the API response with a `meta` array. Now that the real API returns `lat`/`lng`, the fakes should match to prevent subtle mismatches in future tests.
+
+- [ ] **Step 1: Update the faked meta in `populates results and meta on successful search`**
+
+In `tests/Feature/Livewire/StationSearchTest.php`, find the `'meta'` array in the `Http::fake` response (around line 55) and add `lat` and `lng`:
+
+Change:
+
+```php
+ 'meta' => [
+ 'count' => 1,
+ 'fuel_type' => 'e10',
+ 'radius_km' => 8.05,
+ 'lowest_pence' => 14390,
+ 'highest_pence' => 14390,
+ 'cheapest_price_pence' => 14390,
+ 'avg_pence' => 14390.0,
+ ],
+```
+
+to:
+
+```php
+ 'meta' => [
+ 'count' => 1,
+ 'fuel_type' => 'e10',
+ 'radius_km' => 8.05,
+ 'lat' => 51.5010,
+ 'lng' => -0.1415,
+ 'lowest_pence' => 14390,
+ 'highest_pence' => 14390,
+ 'cheapest_price_pence' => 14390,
+ 'avg_pence' => 14390.0,
+ ],
+```
+
+- [ ] **Step 2: Run full Livewire test file**
+
+```bash
+cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -10
+```
+
+Expected: All tests PASS.
+
+- [ ] **Step 3: Run pint**
+
+```bash
+cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
+```
+
+- [ ] **Step 4: Run all modified test files one final time**
+
+```bash
+cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -15
+```
+
+Expected: All tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/Feature/Livewire/StationSearchTest.php
+git commit -m "test: add lat/lng to faked meta fixture in StationSearchTest"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+
+| Requirement | Task |
+|---|---|
+| Map centred on user's location | Task 2 adds lat/lng to meta; Task 3 uses it for `fitBounds` |
+| Plots station markers with price info in popup | Task 3 — `_plotMarkers()` with `bindPopup` |
+| Colour-codes markers by price classification | Task 3 — `CLASSIFICATION_COLOURS` map; Task 5 — legend |
+| Leaflet + OSM (no API costs) | Task 1 — OSM tile layer, no API key needed |
+
+**Placeholder scan:** No TBD or TODO in plan tasks. All code blocks are complete.
+
+**Type consistency:**
+- `stationMap(results, meta)` — factory signature matches `x-data="stationMap(@entangle('results'), @entangle('meta'))"` in Task 5.
+- `_plotMarkers()`, `_clearMarkers()` — consistent across Task 3.
+- `CLASSIFICATION_COLOURS` keyed on `current/recent/stale/outdated` — matches `PriceClassification` enum values and `StationResource` output.