Files
fuel-price/docs/superpowers/plans/2026-04-06-station-map.md
Ovidiu U 5bc6ca720c
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add price classification enum and reliable sort option
2026-04-06 09:58:45 +01:00

491 lines
16 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.

# 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 `<div>` 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 — 2448h
stale: '#f59e0b', // amber-500 — 48120h
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
: '';
const popup = `
<div style="min-width:160px">
<strong style="font-size:13px">${station.name}</strong>${supermarketTag}<br>
<span style="font-size:20px;font-weight:700;color:${colour}">${Number(station.price).toFixed(1)}p</span><br>
<span style="font-size:12px;color:#6b7280">${miles} miles away</span><br>
<span style="font-size:11px;color:#9ca3af">${station.address}, ${station.postcode}</span>
</div>
`;
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 `<div class="space-y-2">` results list section with a version that includes the map above it:
Replace this block (starting at line 71):
```blade
@if (! empty($results))
<p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
&middot; Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
&middot; Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
</p>
<div class="space-y-2">
```
with:
```blade
@if (! empty($results))
<p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
&middot; Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
&middot; Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
</p>
{{-- Map --}}
<div
x-data="stationMap(@entangle('results'), @entangle('meta'))"
class="mb-4 h-72 overflow-hidden rounded-xl border border-neutral-200 sm:h-96 dark:border-neutral-700"
></div>
{{-- Legend --}}
<div class="mb-3 flex flex-wrap gap-3 text-xs text-zinc-500 dark:text-zinc-400">
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-green-500"></span> Current (&lt;24h)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-slate-500"></span> Recent (2448h)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-amber-500"></span> Stale (25 days)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-red-500"></span> Outdated (5+ days)</span>
</div>
<div class="space-y-2">
```
- [ ] **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.