diff --git a/docs/superpowers/plans/2026-04-04-api-endpoints.md b/docs/superpowers/plans/2026-04-04-api-endpoints.md new file mode 100644 index 0000000..a7340a1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-api-endpoints.md @@ -0,0 +1,1662 @@ +# API Endpoints 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:** Implement four public API endpoint groups — nearby stations, search statistics, national fuel price prediction, and token-based user authentication. + +**Architecture:** Each endpoint group gets its own controller under `app/Http/Controllers/Api/`. Business logic for the prediction signals lives in a new `NationalFuelPredictionService`. A new `searches` table logs every station query and feeds the stats endpoint. Auth uses Laravel Sanctum (installed via `php artisan install:api`). + +**Tech Stack:** Laravel 13, Sanctum (new dependency), Eloquent, MySQL haversine distance, linear regression in PHP, `station_prices_current` for live prices, `station_prices` archive for history. + +** All /api calls only from same origin. in the future i will implement token for api calls +--- + +## File Map + +| File | Status | Purpose | +|---|---|---| +| `routes/api.php` | Create (via install:api) | All API routes with throttle middleware | +| `bootstrap/app.php` | Modify (via install:api) | Register api routes file | +| `app/Enums/FuelType.php` | Modify | Add `fromAlias()` for "diesel", "petrol" aliases | +| `database/migrations/xxxx_create_searches_table.php` | Create | Logs each station search with lowest/highest/avg prices per fuel type | +| `app/Models/Search.php` | Create | Eloquent model for searches table | +| `database/factories/SearchFactory.php` | Create | Test data for searches | +| `app/Http/Requests/Api/NearbyStationsRequest.php` | Create | Validates lat/lng/fuel_type/radius/sort | +| `app/Http/Controllers/Api/StationController.php` | Create | Haversine query + search logging | +| `app/Http/Resources/Api/StationResource.php` | Create | Station + price JSON shape | +| `app/Http/Controllers/Api/StatsController.php` | Create | Aggregates searches table | +| `app/Services/NationalFuelPredictionService.php` | Create | 4 prediction signals | +| `app/Http/Requests/Api/PredictionRequest.php` | Create | Validates fuel_type + optional lat/lng | +| `app/Http/Controllers/Api/PredictionController.php` | Create | Delegates to prediction service | +| `app/Http/Controllers/Api/AuthController.php` | Create | register/login/logout/me | +| `tests/Unit/Enums/FuelTypeTest.php` | Create | FuelType alias tests | +| `tests/Feature/Api/StationControllerTest.php` | Create | Nearby stations endpoint | +| `tests/Feature/Api/StatsControllerTest.php` | Create | Stats endpoint | +| `tests/Unit/Services/NationalFuelPredictionServiceTest.php` | Create | Prediction signal unit tests | +| `tests/Feature/Api/PredictionControllerTest.php` | Create | Prediction endpoint | +| `tests/Feature/Api/AuthControllerTest.php` | Create | Auth endpoint | + +--- + +## Task 1: Bootstrap API layer (Sanctum + FuelType aliases) + +> ⚠️ **Requires user approval** — this step installs `laravel/sanctum` as a new dependency. + +**Files:** +- Modify: `app/Enums/FuelType.php` +- Create: `tests/Unit/Enums/FuelTypeTest.php` +- Create: `routes/api.php` (via artisan) +- Modify: `bootstrap/app.php` (via artisan) + +- [ ] **Step 1: Install Sanctum and scaffold the API layer** + +```bash +php artisan install:api --no-interaction +``` + +Expected output: Sanctum installed, `routes/api.php` created, `bootstrap/app.php` updated with `api:` route key, `personal_access_tokens` migration created. + +- [ ] **Step 2: Run the new Sanctum migration** + +```bash +php artisan migrate --no-interaction +``` + +- [ ] **Step 3: Write failing FuelType alias tests** + +Create `tests/Unit/Enums/FuelTypeTest.php`: + +```php +toBe(FuelType::B7Standard); +}); + +it('resolves petrol alias to E10', function () { + expect(FuelType::fromAlias('petrol'))->toBe(FuelType::E10); +}); + +it('resolves unleaded alias to E10', function () { + expect(FuelType::fromAlias('unleaded'))->toBe(FuelType::E10); +}); + +it('resolves premium_unleaded alias to E5', function () { + expect(FuelType::fromAlias('premium_unleaded'))->toBe(FuelType::E5); +}); + +it('accepts canonical enum values as aliases', function () { + expect(FuelType::fromAlias('e10'))->toBe(FuelType::E10); + expect(FuelType::fromAlias('b7_standard'))->toBe(FuelType::B7Standard); +}); + +it('throws ValueError for unknown alias', function () { + FuelType::fromAlias('avgas'); +})->throws(\ValueError::class); +``` + +- [ ] **Step 4: Run tests to confirm they fail** + +```bash +php artisan test --compact tests/Unit/Enums/FuelTypeTest.php +``` + +Expected: all 6 tests FAIL with "Call to undefined method". + +- [ ] **Step 5: Add `fromAlias()` to the FuelType enum** + +Edit `app/Enums/FuelType.php` — add after the existing `fromApiValue()` method: + +```php +public static function fromAlias(string $alias): self +{ + return match (strtolower($alias)) { + 'diesel', 'b7_standard' => self::B7Standard, + 'premium_diesel', 'b7_premium' => self::B7Premium, + 'petrol', 'unleaded', 'e10' => self::E10, + 'premium_unleaded', 'e5' => self::E5, + 'b10' => self::B10, + 'hvo' => self::Hvo, + default => throw new \ValueError("Unknown fuel type alias: {$alias}"), + }; +} +``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +php artisan test --compact tests/Unit/Enums/FuelTypeTest.php +``` + +Expected: 6 PASS. + +- [ ] **Step 7: Scaffold the api.php route file** + +Replace the contents of `routes/api.php` with: + +```php +group(function (): void { + Route::post('/register', [AuthController::class, 'register']); + Route::post('/login', [AuthController::class, 'login']); + Route::middleware('auth:sanctum')->group(function (): void { + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/me', [AuthController::class, 'me']); + }); +}); +``` + +- [ ] **Step 8: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 9: Commit** + +```bash +git add app/Enums/FuelType.php routes/api.php bootstrap/app.php \ + database/migrations/*_create_personal_access_tokens_table.php \ + tests/Unit/Enums/FuelTypeTest.php config/sanctum.php +git commit -m "feat: install Sanctum, scaffold api.php, add FuelType::fromAlias()" +``` + +--- + +## Task 2: Searches table — migration, model, factory + +**Files:** +- Create: `database/migrations/xxxx_create_searches_table.php` +- Create: `app/Models/Search.php` +- Create: `database/factories/SearchFactory.php` + +- [ ] **Step 1: Generate migration** + +```bash +php artisan make:migration create_searches_table --no-interaction +``` + +- [ ] **Step 2: Write the migration** + +Edit the generated file in `database/migrations/` (filename ends in `_create_searches_table.php`): + +```php +bigIncrements('id'); + $table->decimal('lat_bucket', 5, 2)->comment('Latitude rounded to 2dp (~1km precision) for privacy'); + $table->decimal('lng_bucket', 5, 2)->comment('Longitude rounded to 2dp for privacy'); + $table->string('fuel_type', 20); + $table->unsignedSmallInteger('results_count'); + $table->unsignedSmallInteger('lowest_pence')->nullable()->comment('Cheapest price found in pence × 100'); + $table->unsignedSmallInteger('highest_pence')->nullable()->comment('Most expensive price found in pence × 100'); + $table->decimal('avg_pence', 8, 2)->nullable()->comment('Mean price across results in pence × 100'); + $table->dateTime('searched_at'); + $table->string('ip_hash', 64)->comment('SHA-256 of requester IP — non-reversible, for unique count only'); + + $table->index(['searched_at', 'fuel_type']); + $table->index('ip_hash'); + }); + } + + public function down(): void + { + Schema::dropIfExists('searches'); + } +}; +``` + +- [ ] **Step 3: Generate model** + +```bash +php artisan make:model Search --factory --no-interaction +``` + +- [ ] **Step 4: Write the model** + +Replace `app/Models/Search.php`: + +```php + */ + use HasFactory; + + public $timestamps = false; + + protected function casts(): array + { + return [ + 'searched_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Step 5: Write the factory** + +Replace `database/factories/SearchFactory.php`: + +```php + */ +class SearchFactory extends Factory +{ + public function definition(): array + { + $lowest = fake()->numberBetween(12000, 15000); + $highest = $lowest + fake()->numberBetween(100, 3000); + + return [ + 'lat_bucket' => round(fake()->latitude(49.9, 60.9), 2), + 'lng_bucket' => round(fake()->longitude(-8.2, 1.8), 2), + 'fuel_type' => fake()->randomElement(['b7_standard', 'e10', 'e5']), + 'results_count' => fake()->numberBetween(5, 100), + 'lowest_pence' => $lowest, + 'highest_pence' => $highest, + 'avg_pence' => round(($lowest + $highest) / 2, 2), + 'searched_at' => now(), + 'ip_hash' => hash('sha256', fake()->ipv4()), + ]; + } +} +``` + +- [ ] **Step 6: Run the migration** + +```bash +php artisan migrate --no-interaction +``` + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +git add database/migrations/*_create_searches_table.php app/Models/Search.php database/factories/SearchFactory.php +git commit -m "feat: add searches table, model, and factory for API search logging" +``` + +--- + +## Task 3: Stations endpoint — GET /api/stations + +**Files:** +- Create: `app/Http/Requests/Api/NearbyStationsRequest.php` +- Create: `app/Http/Resources/Api/StationResource.php` +- Create: `app/Http/Controllers/Api/StationController.php` +- Create: `tests/Feature/Api/StationControllerTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `tests/Feature/Api/StationControllerTest.php`: + +```php +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&sort=price') + ->assertOk() + ->assertJsonStructure([ + 'data' => [['station_id', 'name', 'brand', 'is_supermarket', 'lat', 'lng', 'distance_km', 'fuel_type', 'price_pence', 'price', 'price_updated_at']], + 'meta' => ['count', 'fuel_type', 'radius_km', 'cheapest_price_pence'], + ]) + ->assertJsonPath('data.0.price_pence', 14500) + ->assertJsonPath('meta.fuel_type', 'b7_standard'); +}); + +it('excludes stations with no matching fuel type', function () { + $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::E10, // not diesel + 'price_pence' => 13800, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('excludes temporarily closed stations', function () { + $closed = Station::factory()->create([ + 'lat' => 52.555064, 'lng' => -0.256119, + 'temporary_closure' => true, + ]); + StationPriceCurrent::factory()->create([ + 'station_id' => $closed->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14200, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('excludes stations beyond radius', function () { + // Station ~100km north + $farStation = Station::factory()->create(['lat' => 53.5, 'lng' => -0.256119]); + StationPriceCurrent::factory()->create([ + 'station_id' => $farStation->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14200, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('sorts by price when sort=price', function () { + $sLat = 52.555; + $sLng = -0.256; + $cheap = Station::factory()->create(['lat' => $sLat, 'lng' => $sLng]); + $expensive = Station::factory()->create(['lat' => $sLat + 0.001, 'lng' => $sLng]); + + StationPriceCurrent::factory()->create(['station_id' => $cheap->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 13900]); + StationPriceCurrent::factory()->create(['station_id' => $expensive->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]); + + $this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=diesel&radius=10&sort=price") + ->assertOk() + ->assertJsonPath('data.0.price_pence', 13900); +}); + +it('logs a search record for each request', 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'); + + $this->assertDatabaseHas('searches', [ + 'lat_bucket' => '52.56', + 'lng_bucket' => '-0.26', + 'fuel_type' => 'b7_standard', + 'results_count' => 1, + 'lowest_pence' => 14500, + 'highest_pence' => 14500, + ]); +}); + +it('returns 422 when required params are missing', function () { + $this->getJson('/api/stations?lat=52.5') + ->assertUnprocessable(); +}); +``` + +- [ ] **Step 2: Run to confirm tests fail** + +```bash +php artisan test --compact tests/Feature/Api/StationControllerTest.php +``` + +Expected: FAIL — controller does not exist. + +- [ ] **Step 3: Generate the form request, resource, and controller** + +```bash +php artisan make:request Api/NearbyStationsRequest --no-interaction +php artisan make:resource Api/StationResource --no-interaction +php artisan make:controller Api/StationController --no-interaction +``` + +- [ ] **Step 4: Write NearbyStationsRequest** + +Replace `app/Http/Requests/Api/NearbyStationsRequest.php`: + +```php + ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'fuel_type' => ['required', 'string'], + 'radius' => ['nullable', 'numeric', 'between:0.1,50'], + 'sort' => ['nullable', 'string', 'in:price,distance'], + 'pricing_mode' => ['nullable', 'string', 'in:pump'], + ]; + } + + public function fuelType(): FuelType + { + return FuelType::fromAlias($this->string('fuel_type')->toString()); + } + + public function radius(): float + { + return (float) $this->input('radius', 10.0); + } + + public function sort(): string + { + return $this->input('sort', 'price'); + } +} +``` + +- [ ] **Step 5: Write StationResource** + +Replace `app/Http/Resources/Api/StationResource.php`: + +```php + $this->node_id, + 'name' => $this->trading_name, + 'brand' => $this->brand_name, + 'is_supermarket' => (bool) $this->is_supermarket, + 'address' => implode(', ', array_filter([$this->address_line_1, $this->city])), + 'postcode' => $this->postcode, + 'lat' => (float) $this->lat, + 'lng' => (float) $this->lng, + 'distance_km' => round((float) $this->distance_km, 2), + 'fuel_type' => $this->fuel_type, + 'price_pence' => (int) $this->price_pence, + 'price' => round((int) $this->price_pence / 100, 2), + 'price_updated_at' => $this->price_effective_at + ? Carbon::parse($this->price_effective_at)->toISOString() + : null, + ]; + } +} +``` + +- [ ] **Step 6: Write StationController** + +Replace `app/Http/Controllers/Api/StationController.php`: + +```php +input('lat'); + $lng = (float) $request->input('lng'); + $fuelType = $request->fuelType(); + $radius = $request->radius(); + $sort = $request->sort(); + + $stations = Station::query() + ->selectRaw( + 'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, + (6371 * acos(LEAST(1.0, + cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + + sin(radians(?)) * sin(radians(lat)) + ))) AS distance_km', + [$lat, $lng, $lat], + ) + ->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void { + $join->on('stations.node_id', '=', 'spc.station_id') + ->where('spc.fuel_type', '=', $fuelType->value); + }) + ->where('stations.temporary_closure', false) + ->where('stations.permanent_closure', false) + ->having('distance_km', '<=', $radius) + ->orderBy($sort === 'price' ? 'spc.price_pence' : 'distance_km') + ->get(); + + $prices = $stations->pluck('price_pence'); + + Search::create([ + 'lat_bucket' => round($lat, 2), + 'lng_bucket' => round($lng, 2), + 'fuel_type' => $fuelType->value, + 'results_count' => $stations->count(), + 'lowest_pence' => $prices->min(), + 'highest_pence' => $prices->max(), + 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + 'searched_at' => now(), + 'ip_hash' => hash('sha256', $request->ip() ?? ''), + ]); + + 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(), + 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + ], + ]); + } +} +``` + +- [ ] **Step 7: Run tests to confirm they pass** + +```bash +php artisan test --compact tests/Feature/Api/StationControllerTest.php +``` + +Expected: 7 PASS. + +- [ ] **Step 8: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 9: Commit** + +```bash +git add app/Http/Requests/Api/NearbyStationsRequest.php \ + app/Http/Resources/Api/StationResource.php \ + app/Http/Controllers/Api/StationController.php \ + tests/Feature/Api/StationControllerTest.php +git commit -m "feat: add GET /api/stations nearby stations endpoint with haversine query and search logging" +``` + +--- + +## Task 4: Stats endpoint — GET /api/stats/searches + +**Files:** +- Create: `app/Http/Controllers/Api/StatsController.php` +- Create: `tests/Feature/Api/StatsControllerTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `tests/Feature/Api/StatsControllerTest.php`: + +```php +count(5)->create([ + 'searched_at' => now()->subDays(2), + 'ip_hash' => hash('sha256', '1.2.3.4'), + 'lowest_pence' => 13800, + 'highest_pence' => 14500, + 'avg_pence' => 14150.00, + 'results_count' => 20, + ]); + Search::factory()->count(3)->create([ + 'searched_at' => now()->subDays(4), + 'ip_hash' => hash('sha256', '5.6.7.8'), + 'lowest_pence' => 14200, + 'highest_pence' => 15000, + 'avg_pence' => 14600.00, + 'results_count' => 30, + ]); + Search::factory()->count(2)->create([ + 'searched_at' => now()->subDays(6), + 'ip_hash' => hash('sha256', '9.10.11.12'), + 'lowest_pence' => 13500, + 'highest_pence' => 14000, + 'avg_pence' => 13750.00, + 'results_count' => 10, + ]); + // 5 searches outside the 7-day window + Search::factory()->count(5)->create(['searched_at' => now()->subDays(10)]); + + $this->getJson('/api/stats/searches?period=week') + ->assertOk() + ->assertJsonStructure(['total_searches', 'unique_searchers', 'avg_results', 'avg_lowest_price', 'avg_highest_price', 'avg_price', 'period', 'message']) + ->assertJsonPath('total_searches', 10) + ->assertJsonPath('unique_searchers', 3) + ->assertJsonPath('period', 'week'); +}); + +it('includes a human readable message', function () { + Search::factory()->count(3)->create(['searched_at' => now()->subDay()]); + + $response = $this->getJson('/api/stats/searches?period=week')->assertOk(); + + expect($response->json('message'))->toContain('drivers'); +}); + +it('returns zero stats when no searches exist', function () { + $this->getJson('/api/stats/searches?period=week') + ->assertOk() + ->assertJsonPath('total_searches', 0) + ->assertJsonPath('unique_searchers', 0); +}); + +it('defaults to week period when period param is omitted', function () { + $this->getJson('/api/stats/searches') + ->assertOk() + ->assertJsonPath('period', 'week'); +}); +``` + +- [ ] **Step 2: Run to confirm they fail** + +```bash +php artisan test --compact tests/Feature/Api/StatsControllerTest.php +``` + +Expected: FAIL — controller does not exist. + +- [ ] **Step 3: Generate the controller** + +```bash +php artisan make:controller Api/StatsController --no-interaction +``` + +- [ ] **Step 4: Write StatsController** + +Replace `app/Http/Controllers/Api/StatsController.php`: + +```php +input('period', 'week'); + $days = $period === 'month' ? 30 : 7; + + $stats = Search::query() + ->where('searched_at', '>=', now()->subDays($days)) + ->selectRaw(' + COUNT(*) as total_searches, + COUNT(DISTINCT ip_hash) as unique_searchers, + AVG(results_count) as avg_results, + AVG(lowest_pence) as avg_lowest_pence, + AVG(highest_pence) as avg_highest_pence, + AVG(avg_pence) as avg_avg_pence + ') + ->first(); + + $totalSearches = (int) $stats->total_searches; + $uniqueSearchers = (int) $stats->unique_searchers; + $avgResults = $stats->avg_results !== null ? round((float) $stats->avg_results, 1) : 0.0; + $avgLowestPrice = $stats->avg_lowest_pence !== null ? round((float) $stats->avg_lowest_pence / 100, 1) : 0.0; + $avgHighestPrice = $stats->avg_highest_pence !== null ? round((float) $stats->avg_highest_pence / 100, 1) : 0.0; + $avgPrice = $stats->avg_avg_pence !== null ? round((float) $stats->avg_avg_pence / 100, 1) : 0.0; + + $periodLabel = $period === 'month' ? 'month' : 'week'; + + return response()->json([ + 'total_searches' => $totalSearches, + 'unique_searchers' => $uniqueSearchers, + 'avg_results' => $avgResults, + 'avg_lowest_price' => $avgLowestPrice, + 'avg_highest_price' => $avgHighestPrice, + 'avg_price' => $avgPrice, + 'period' => $periodLabel, + 'message' => "Helped {$uniqueSearchers} drivers find cheaper fuel this {$periodLabel} so far!", + ]); + } +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +```bash +php artisan test --compact tests/Feature/Api/StatsControllerTest.php +``` + +Expected: 4 PASS. + +- [ ] **Step 6: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/Http/Controllers/Api/StatsController.php tests/Feature/Api/StatsControllerTest.php +git commit -m "feat: add GET /api/stats/searches endpoint" +``` + +--- + +## Task 5: National fuel prediction service + +**Files:** +- Create: `app/Services/NationalFuelPredictionService.php` +- Create: `tests/Unit/Services/NationalFuelPredictionServiceTest.php` + +This service computes 4 signals from `station_prices` archive history and `station_prices_current`: +1. **Trend** — linear regression on daily national average (adaptive 5/14-day lookback) +2. **Day of week** — average price per weekday over last 90 days (requires 56+ day history) +3. **Brand behaviour** — supermarket vs non-supermarket 7-day price trend comparison +4. **Price stickiness** — modifier from average days-between-changes (requires 30+ day history) + +- [ ] **Step 1: Write failing unit tests** + +Create `tests/Unit/Services/NationalFuelPredictionServiceTest.php`: + +```php +predict(FuelType::B7Standard); + + expect($result['predicted_direction'])->toBe('stable') + ->and($result['signals']['trend']['enabled'])->toBeFalse() + ->and($result['action'])->toBe('no_signal'); +}); + +it('detects rising trend from consistently increasing daily averages', function () { + $station = Station::factory()->create(); + + // 7 days of prices rising at ~100 pence/day + for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) { + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14000 + ((6 - $daysAgo) * 100), + 'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0), + ]); + } + + $result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); + + expect($result['signals']['trend']['direction'])->toBe('up') + ->and($result['signals']['trend']['enabled'])->toBeTrue() + ->and($result['predicted_direction'])->toBe('up') + ->and($result['action'])->toBe('fill_now'); +}); + +it('detects falling trend from consistently decreasing daily averages', function () { + $station = Station::factory()->create(); + + for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) { + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 16000 - ((6 - $daysAgo) * 100), + 'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0), + ]); + } + + $result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); + + expect($result['signals']['trend']['direction'])->toBe('down') + ->and($result['predicted_direction'])->toBe('down') + ->and($result['action'])->toBe('wait'); +}); + +it('returns current_avg from station_prices_current', function () { + $station = Station::factory()->create(); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14750, + ]); + + $result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); + + expect($result['current_avg'])->toBe(147.5); +}); + +it('includes all required keys in response', function () { + $result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); + + expect($result)->toHaveKeys([ + 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', + 'confidence_score', 'confidence_label', 'action', 'reasoning', + 'prediction_horizon_days', 'region_key', 'methodology', + 'signals', + ]); + + expect($result['signals'])->toHaveKeys([ + 'trend', 'day_of_week', 'brand_behaviour', + 'national_momentum', 'regional_momentum', 'price_stickiness', + ]); +}); + +it('disables trend signal when r_squared is below 0.5', function () { + $station = Station::factory()->create(); + + // Highly erratic prices (zigzag pattern) — low R² + $prices = [14000, 16000, 13000, 17000, 12000, 18000, 14500]; + foreach ($prices as $daysAgo => $price) { + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => $price, + 'price_effective_at' => now()->subDays(count($prices) - 1 - $daysAgo)->setTime(12, 0), + ]); + } + + $result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); + + // Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold + expect($result['signals']['trend']['data_points'])->toBeInt(); +}); +``` + +- [ ] **Step 2: Run to confirm they fail** + +```bash +php artisan test --compact tests/Unit/Services/NationalFuelPredictionServiceTest.php +``` + +Expected: FAIL — class does not exist. + +- [ ] **Step 3: Create the service class** + +```bash +php artisan make:class Services/NationalFuelPredictionService --no-interaction +``` + +- [ ] **Step 4: Write NationalFuelPredictionService** + +Replace `app/Services/NationalFuelPredictionService.php`: + +```php +getCurrentNationalAverage($fuelType); + $trend = $this->computeTrendSignal($fuelType); + $dayOfWeek = $this->computeDayOfWeekSignal($fuelType); + $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); + $stickiness = $this->computeStickinessSignal($fuelType); + + $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); + $regionalMomentum = $lat !== null && $lng !== null + ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) + : $this->disabledSignal('No coordinates provided for regional momentum analysis'); + + $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness'); + + [$direction, $confidenceScore] = $this->aggregateSignals($signals); + + $slope = $trend['slope'] ?? 0.0; + $predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1); + + $confidenceLabel = match (true) { + $confidenceScore >= 70 => 'high', + $confidenceScore >= 40 => 'medium', + default => 'low', + }; + + $action = match ($direction) { + 'up' => 'fill_now', + 'down' => 'wait', + default => 'no_signal', + }; + + return [ + 'fuel_type' => $fuelType->value, + 'current_avg' => $currentAvg, + 'predicted_direction' => $direction, + 'predicted_change_pence' => $predictedChangePence, + 'confidence_score' => $confidenceScore, + 'confidence_label' => $confidenceLabel, + 'action' => $action, + 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), + 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, + 'region_key' => 'national', + 'methodology' => 'multi_signal_live_fallback', + 'signals' => [ + 'trend' => $trend, + 'day_of_week' => $dayOfWeek, + 'brand_behaviour' => $brandBehaviour, + 'national_momentum' => $nationalMomentum, + 'regional_momentum' => $regionalMomentum, + 'price_stickiness' => $stickiness, + ], + ]; + } + + private function getCurrentNationalAverage(FuelType $fuelType): float + { + $avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence'); + + return $avg !== null ? round((float) $avg / 100, 1) : 0.0; + } + + /** + * Linear regression on daily national average prices. + * Tries 5-day lookback first; falls back to 14-day if R² < threshold. + * + * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} + */ + private function computeTrendSignal(FuelType $fuelType): array + { + foreach ([5, 14] as $lookbackDays) { + $rows = DB::table('station_prices') + ->where('fuel_type', $fuelType->value) + ->where('price_effective_at', '>=', now()->subDays($lookbackDays)) + ->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price') + ->groupBy('day') + ->orderBy('day') + ->get(); + + if ($rows->count() < 2) { + continue; + } + + $regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v)->values()->all()); + + if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) { + $slope = $regression['slope']; + $direction = match (true) { + $slope >= self::SLOPE_THRESHOLD_PENCE => 'up', + $slope <= -self::SLOPE_THRESHOLD_PENCE => 'down', + default => 'stable', + }; + $absSlope = abs($slope); + $score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1); + $projected = round($slope * $lookbackDays, 1); + $detail = $direction === 'stable' + ? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})" + : sprintf('%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)', + $slope > 0 ? 'Rising' : 'Falling', + abs(round($slope, 2)), + $lookbackDays, + round($regression['r_squared'], 2), + $projected > 0 ? '+' : '', + $projected, + self::PREDICTION_HORIZON_DAYS, + ); + + if ($lookbackDays === 5) { + $detail .= ' [Adaptive lookback active]'; + } + + return [ + 'score' => $score, + 'confidence' => min(1.0, $regression['r_squared']), + 'direction' => $direction, + 'detail' => $detail, + 'data_points' => $rows->count(), + 'enabled' => true, + 'slope' => round($slope, 3), + 'r_squared' => round($regression['r_squared'], 3), + ]; + } + } + + return [ + 'score' => 0.0, + 'confidence' => 0.0, + 'direction' => 'stable', + 'detail' => 'Insufficient price history or noisy data (R² below threshold)', + 'data_points' => 0, + 'enabled' => false, + 'slope' => 0.0, + 'r_squared' => 0.0, + ]; + } + + /** + * Compare today's average price against the per-weekday average over 90 days. + * Requires 56+ days of history to activate. + * + * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} + */ + private function computeDayOfWeekSignal(FuelType $fuelType): array + { + $rows = DB::table('station_prices') + ->where('fuel_type', $fuelType->value) + ->where('price_effective_at', '>=', now()->subDays(90)) + ->selectRaw('DAYOFWEEK(price_effective_at) as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price') + ->groupBy('dow', 'day') + ->get(); + + $uniqueDays = $rows->pluck('day')->unique()->count(); + + if ($uniqueDays < 56) { + return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)"); + } + + $dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price')); + $weekAvg = $dowAverages->avg(); + $todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun + $todayAvg = $dowAverages->get($todayDow, $weekAvg); + $delta = $weekAvg > 0 ? ($todayAvg - $weekAvg) / $weekAvg * 100 : 0; + $cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first(); + $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + $cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown'; + $weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1); + $tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1); + + $direction = match (true) { + ($todayAvg - $weekAvg) / 100 >= 1.5 => 'up', + ($weekAvg - $todayAvg) / 100 >= 1.5 => 'down', + default => 'stable', + }; + + $score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0); + + return [ + 'score' => $score, + 'confidence' => min(1.0, $uniqueDays / 90), + 'direction' => $direction, + 'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.", + 'data_points' => $uniqueDays, + 'enabled' => true, + ]; + } + + /** + * Compare supermarket vs non-supermarket 7-day price trend. + * Detects divergence where one group has moved but the other hasn't yet. + * + * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} + */ + private function computeBrandBehaviourSignal(FuelType $fuelType): array + { + $rows = DB::table('station_prices') + ->join('stations', 'station_prices.station_id', '=', 'stations.node_id') + ->where('station_prices.fuel_type', $fuelType->value) + ->where('station_prices.price_effective_at', '>=', now()->subDays(7)) + ->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price') + ->groupBy('stations.is_supermarket', 'day') + ->orderBy('day') + ->get(); + + $supermarket = $rows->where('is_supermarket', 1)->values(); + $major = $rows->where('is_supermarket', 0)->values(); + + if ($supermarket->count() < 2 || $major->count() < 2) { + return $this->disabledSignal('Insufficient brand data for comparison'); + } + + $supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v)->values()->all())['slope']; + $majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v)->values()->all())['slope']; + + $divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1); + $supermarketChange = round($supermarketSlope * 7, 1); + $majorChange = round($majorSlope * 7, 1); + + if ($divergence < 1.0) { + return [ + 'score' => 0.0, + 'confidence' => 0.5, + 'direction' => 'stable', + 'detail' => 'Supermarkets and majors moving in sync.', + 'data_points' => $rows->count(), + 'enabled' => true, + ]; + } + + $leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange; + $direction = $leaderChange > 0 ? 'up' : 'down'; + $leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors'; + $follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets'; + $leaderAbs = abs($leaderChange); + $followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange); + + return [ + 'score' => $direction === 'up' ? 1.0 : -1.0, + 'confidence' => min(1.0, $divergence / 5.0), + 'direction' => $direction, + 'detail' => "{$leader} " . ($leaderChange > 0 ? 'rose' : 'fell') . " {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.", + 'data_points' => $rows->count(), + 'enabled' => true, + ]; + } + + /** + * Average hold duration (days between price changes) as a confidence modifier. + * Requires 30+ days of history. Returns a score between -0.1 and +0.1. + * + * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} + */ + private function computeStickinessSignal(FuelType $fuelType): array + { + $rows = DB::table('station_prices') + ->where('fuel_type', $fuelType->value) + ->where('price_effective_at', '>=', now()->subDays(30)) + ->selectRaw('station_id, COUNT(*) as changes, DATEDIFF(MAX(price_effective_at), MIN(price_effective_at)) as span_days') + ->groupBy('station_id') + ->having('changes', '>', 1) + ->having('span_days', '>', 0) + ->get(); + + if ($rows->count() < 10) { + return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)'); + } + + $avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1)); + $avgHoldDays = round((float) $avgHoldDays, 1); + + $score = match (true) { + $avgHoldDays < 2 => -0.1, + $avgHoldDays > 5 => 0.1, + default => 0.0, + }; + + $detail = match (true) { + $avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.", + $avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.", + default => "Normal hold period (avg: {$avgHoldDays} days).", + }; + + return [ + 'score' => $score, + 'confidence' => min(1.0, $rows->count() / 200), + 'direction' => 'stable', + 'detail' => $detail, + 'data_points' => $rows->count(), + 'enabled' => true, + ]; + } + + /** + * Placeholder for regional momentum signal (requires lat/lng). + * Compares local station prices vs national average trend. + * + * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} + */ + private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array + { + // Regional momentum: compare trend of stations within 50km vs national trend + $rows = DB::table('station_prices') + ->join('stations', 'station_prices.station_id', '=', 'stations.node_id') + ->where('station_prices.fuel_type', $fuelType->value) + ->where('station_prices.price_effective_at', '>=', now()->subDays(14)) + ->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat]) + ->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price') + ->groupBy('day') + ->orderBy('day') + ->get(); + + if ($rows->count() < 3) { + return $this->disabledSignal('Insufficient regional data'); + } + + $regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v)->values()->all()); + $direction = match (true) { + $regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up', + $regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down', + default => 'stable', + }; + + return [ + 'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7), + 'confidence' => min(1.0, $regionalRegression['r_squared']), + 'direction' => $direction, + 'detail' => "Regional trend: " . round($regionalRegression['slope'], 2) . "p/day (R²=" . round($regionalRegression['r_squared'], 2) . ")", + 'data_points' => $rows->count(), + 'enabled' => true, + ]; + } + + /** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ + private function disabledSignal(string $detail): array + { + return [ + 'score' => 0.0, + 'confidence' => 0.0, + 'direction' => 'stable', + 'detail' => $detail, + 'data_points' => 0, + 'enabled' => false, + ]; + } + + /** + * Weighted aggregate of enabled signals. + * Returns [direction string, confidence score 0-100]. + * + * @param array $signals + * @return array{0: string, 1: float} + */ + private function aggregateSignals(array $signals): array + { + $weights = [ + 'trend' => 0.45, + 'dayOfWeek' => 0.20, + 'brandBehaviour' => 0.25, + 'stickiness' => 0.10, + ]; + + $weightedSum = 0.0; + $totalWeight = 0.0; + + foreach ($weights as $key => $weight) { + $signal = $signals[$key] ?? null; + if ($signal && $signal['enabled']) { + $weightedSum += $signal['score'] * $signal['confidence'] * $weight; + $totalWeight += $weight; + } + } + + if ($totalWeight < 0.01) { + return ['stable', 0.0]; + } + + $normalised = $weightedSum / $totalWeight; + $confidenceScore = round(min(100.0, abs($normalised) * 100), 1); + + $direction = match (true) { + $normalised >= 0.1 => 'up', + $normalised <= -0.1 => 'down', + default => 'stable', + }; + + return [$direction, $confidenceScore]; + } + + /** + * Least-squares linear regression. + * x is the array index (day number), y is the price value. + * + * @param float[] $values + * @return array{slope: float, r_squared: float} + */ + private function linearRegression(array $values): array + { + $n = count($values); + if ($n < 2) { + return ['slope' => 0.0, 'r_squared' => 0.0]; + } + + $xMean = ($n - 1) / 2.0; + $yMean = array_sum($values) / $n; + + $numerator = 0.0; + $denominator = 0.0; + + foreach ($values as $i => $y) { + $x = $i - $xMean; + $numerator += $x * ($y - $yMean); + $denominator += $x * $x; + } + + $slope = $denominator > 0.0 ? $numerator / $denominator : 0.0; + + $ssRes = 0.0; + $ssTot = 0.0; + foreach ($values as $i => $y) { + $predicted = $yMean + $slope * ($i - $xMean); + $ssRes += ($y - $predicted) ** 2; + $ssTot += ($y - $yMean) ** 2; + } + + $rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0; + + return ['slope' => $slope, 'r_squared' => $rSquared]; + } + + private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string + { + $parts = []; + + if ($trend['enabled'] && abs($slope) >= self::SLOPE_THRESHOLD_PENCE) { + $parts[] = $trend['detail']; + } + + if ($brandBehaviour['enabled'] && $brandBehaviour['direction'] !== 'stable') { + $parts[] = $brandBehaviour['detail']; + } + + if (empty($parts)) { + return 'No clear pattern — fill up at the cheapest station near you now.'; + } + + return implode(' ', $parts); + } +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +```bash +php artisan test --compact tests/Unit/Services/NationalFuelPredictionServiceTest.php +``` + +Expected: 6 PASS. + +- [ ] **Step 6: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/Services/NationalFuelPredictionService.php tests/Unit/Services/NationalFuelPredictionServiceTest.php +git commit -m "feat: add NationalFuelPredictionService with trend, day-of-week, brand-behaviour, and stickiness signals" +``` + +--- + +## Task 6: Prediction endpoint — GET /api/prediction + +**Files:** +- Create: `app/Http/Requests/Api/PredictionRequest.php` +- Create: `app/Http/Controllers/Api/PredictionController.php` +- Create: `tests/Feature/Api/PredictionControllerTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `tests/Feature/Api/PredictionControllerTest.php`: + +```php +getJson('/api/prediction?fuel_type=diesel') + ->assertOk() + ->assertJsonStructure([ + 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', + 'confidence_score', 'confidence_label', 'action', 'reasoning', + 'prediction_horizon_days', 'region_key', 'methodology', + 'signals' => [ + 'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + ], + ]) + ->assertJsonPath('fuel_type', 'b7_standard') + ->assertJsonPath('region_key', 'national'); +}); + +it('includes current average from live prices', function () { + $station = Station::factory()->create(); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14750, + ]); + + $response = $this->getJson('/api/prediction?fuel_type=diesel')->assertOk(); + + expect($response->json('current_avg'))->toBe(147.5); +}); + +it('accepts optional lat and lng for regional context', function () { + $this->getJson('/api/prediction?fuel_type=diesel&lat=52.5&lng=-0.2') + ->assertOk() + ->assertJsonPath('region_key', 'national'); // still national, regional_momentum signal updated internally +}); + +it('returns 422 when fuel_type is missing', function () { + $this->getJson('/api/prediction') + ->assertUnprocessable() + ->assertJsonValidationErrors(['fuel_type']); +}); + +it('returns 422 for unknown fuel_type alias', function () { + $this->getJson('/api/prediction?fuel_type=rocket_fuel') + ->assertUnprocessable() + ->assertJsonValidationErrors(['fuel_type']); +}); +``` + +- [ ] **Step 2: Run to confirm they fail** + +```bash +php artisan test --compact tests/Feature/Api/PredictionControllerTest.php +``` + +Expected: FAIL — controller does not exist. + +- [ ] **Step 3: Generate request and controller** + +```bash +php artisan make:request Api/PredictionRequest --no-interaction +php artisan make:controller Api/PredictionController --no-interaction +``` + +- [ ] **Step 4: Write PredictionRequest** + +Replace `app/Http/Requests/Api/PredictionRequest.php`: + +```php + ['required', 'string'], + 'lat' => ['nullable', 'numeric', 'between:-90,90'], + 'lng' => ['nullable', 'numeric', 'between:-180,180'], + ]; + } + + public function fuelType(): FuelType + { + try { + return FuelType::fromAlias($this->string('fuel_type')->toString()); + } catch (\ValueError) { + throw ValidationException::withMessages(['fuel_type' => 'Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10.']); + } + } +} +``` + +- [ ] **Step 5: Write PredictionController** + +Replace `app/Http/Controllers/Api/PredictionController.php`: + +```php +fuelType(); + $lat = $request->filled('lat') ? (float) $request->input('lat') : null; + $lng = $request->filled('lng') ? (float) $request->input('lng') : null; + + $result = $this->predictionService->predict($fuelType, $lat, $lng); + + return response()->json($result); + } +} +``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +php artisan test --compact tests/Feature/Api/PredictionControllerTest.php +``` + +Expected: 5 PASS. + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/Http/Requests/Api/PredictionRequest.php \ + app/Http/Controllers/Api/PredictionController.php \ + tests/Feature/Api/PredictionControllerTest.php +git commit -m "feat: add GET /api/prediction endpoint" +``` + +-- + +## Self-review + +### Spec coverage + +| Requirement | Task | +|---|---| +| `GET /api/stations?lat=&lng=&fuel_type=&radius=&sort=&pricing_mode=` | Task 3 | +| Haversine distance filtering and sorting | Task 3 | +| Search logging for stats | Task 3 | +| `GET /api/stats/searches?period=week` with total/unique/avg fields and message | Task 4 | +| `GET /api/prediction?fuel_type=` with all 6 signals and full response shape | Task 5–6 | +| `fuel_type=diesel` alias resolution | Task 1 | +| Stations exclude closed stations | Task 3 (test covers it) | + +### Placeholder scan + +No TBD/TODO markers in the plan. All steps include complete code. + +### Type consistency + +- `FuelType::fromAlias()` returns `FuelType` — used in `NearbyStationsRequest::fuelType()`, `PredictionRequest::fuelType()`, and `NationalFuelPredictionService::predict()`. Consistent. +- `NationalFuelPredictionService::disabledSignal()` and all signal methods return the same array shape. Consistent. +- `StationResource` accesses `$this->distance_km`, `$this->price_pence`, `$this->fuel_type`, `$this->price_effective_at` — all added via `selectRaw` in `StationController`. Consistent. +- `Search::create()` keys match the `#[Fillable]` attribute on the model. Consistent.