491 lines
16 KiB
Markdown
491 lines
16 KiB
Markdown
# 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 — 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: '© <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
|
||
· Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
|
||
· 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
|
||
· Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
|
||
· 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 (<24h)</span>
|
||
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-slate-500"></span> Recent (24–48h)</span>
|
||
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-amber-500"></span> Stale (2–5 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.
|