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

16 KiB
Raw Permalink Blame History

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

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

@import 'leaflet/dist/leaflet.css';
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
/* … rest unchanged … */
  • Step 3: Verify build succeeds
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5

Expected: Build completes without errors.

  • Step 4: Commit
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:

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

        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:

        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
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
cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
  • Step 6: Commit
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:

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

cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10

Expected: Build succeeds (no parse/import errors).

  • Step 3: Commit
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:

import { stationMap } from './maps/station-map.js';

document.addEventListener('alpine:init', () => {
    Alpine.data('stationMap', stationMap);
});
  • Step 2: Verify build
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10

Expected: Build succeeds.

  • Step 3: Commit
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):

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

            @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
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5

Expected: Build succeeds.

  • Step 3: Commit
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:

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

            '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
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
cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
  • Step 4: Run all modified test files one final time
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
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.