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.