# 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.