Files
fuel-price/docs/superpowers/plans/2026-04-04-api-endpoints.md
Ovidiu U 70cb40ff5d
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add UserResource with is_admin toggle and delete
User management resource with editable is_admin field, postcode support,
admin filter, and inline delete action. Includes list and edit pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:03:52 +01:00

58 KiB
Raw Blame History

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

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
php artisan migrate --no-interaction
  • Step 3: Write failing FuelType alias tests

Create tests/Unit/Enums/FuelTypeTest.php:

<?php

use App\Enums\FuelType;

it('resolves diesel alias to B7Standard', function () {
    expect(FuelType::fromAlias('diesel'))->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
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:

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

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController;
use Illuminate\Support\Facades\Route;

Route::get('/stations', [StationController::class, 'index']);
Route::get('/stats/searches', [StatsController::class, 'searches']);
Route::get('/prediction', [PredictionController::class, 'index']);

Route::prefix('auth')->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
vendor/bin/pint --dirty --format agent
  • Step 9: Commit
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

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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('searches', function (Blueprint $table): void {
            $table->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
php artisan make:model Search --factory --no-interaction
  • Step 4: Write the model

Replace app/Models/Search.php:

<?php

namespace App\Models;

use Database\Factories\SearchFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
class Search extends Model
{
    /** @use HasFactory<SearchFactory> */
    use HasFactory;

    public $timestamps = false;

    protected function casts(): array
    {
        return [
            'searched_at' => 'datetime',
        ];
    }
}
  • Step 5: Write the factory

Replace database/factories/SearchFactory.php:

<?php

namespace Database\Factories;

use App\Models\Search;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Search> */
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
php artisan migrate --no-interaction
  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
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

use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPriceCurrent;

it('returns stations near coordinates filtered by fuel type', 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&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
php artisan test --compact tests/Feature/Api/StationControllerTest.php

Expected: FAIL — controller does not exist.

  • Step 3: Generate the form request, resource, and controller
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

namespace App\Http\Requests\Api;

use App\Enums\FuelType;
use Illuminate\Foundation\Http\FormRequest;

class NearbyStationsRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'lat'          => ['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

namespace App\Http\Resources\Api;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;

class StationResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'station_id'      => $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

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\JsonResponse;

class StationController extends Controller
{
    public function index(NearbyStationsRequest $request): JsonResponse
    {
        $lat = (float) $request->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
php artisan test --compact tests/Feature/Api/StationControllerTest.php

Expected: 7 PASS.

  • Step 8: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 9: Commit
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

use App\Models\Search;
use Illuminate\Support\Carbon;

it('returns search stats for current week', function () {
    // 10 searches within the rolling 7 days (3 unique IPs)
    Search::factory()->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
php artisan test --compact tests/Feature/Api/StatsControllerTest.php

Expected: FAIL — controller does not exist.

  • Step 3: Generate the controller
php artisan make:controller Api/StatsController --no-interaction
  • Step 4: Write StatsController

Replace app/Http/Controllers/Api/StatsController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Search;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StatsController extends Controller
{
    public function searches(Request $request): JsonResponse
    {
        $period = $request->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
php artisan test --compact tests/Feature/Api/StatsControllerTest.php

Expected: 4 PASS.

  • Step 6: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 7: Commit
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

use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPrice;
use App\Models\StationPriceCurrent;
use App\Services\NationalFuelPredictionService;

it('returns no_signal when there is insufficient price history', function () {
    $result = app(NationalFuelPredictionService::class)->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
php artisan test --compact tests/Unit/Services/NationalFuelPredictionServiceTest.php

Expected: FAIL — class does not exist.

  • Step 3: Create the service class
php artisan make:class Services/NationalFuelPredictionService --no-interaction
  • Step 4: Write NationalFuelPredictionService

Replace app/Services/NationalFuelPredictionService.php:

<?php

namespace App\Services;

use App\Enums\FuelType;
use App\Models\StationPriceCurrent;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class NationalFuelPredictionService
{
    private const float R_SQUARED_THRESHOLD = 0.5;
    private const float SLOPE_THRESHOLD_PENCE = 0.3;
    private const int PREDICTION_HORIZON_DAYS = 7;

    /**
     * @return array{
     *     fuel_type: string,
     *     current_avg: float,
     *     predicted_direction: string,
     *     predicted_change_pence: float,
     *     confidence_score: float,
     *     confidence_label: string,
     *     action: string,
     *     reasoning: string,
     *     prediction_horizon_days: int,
     *     region_key: string,
     *     methodology: string,
     *     signals: array
     * }
     */
    public function predict(FuelType $fuelType, ?float $lat = null, ?float $lng = null): array
    {
        $currentAvg = $this->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<string, array{score: float, confidence: float, enabled: bool}> $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
php artisan test --compact tests/Unit/Services/NationalFuelPredictionServiceTest.php

Expected: 6 PASS.

  • Step 6: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 7: Commit
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

use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPriceCurrent;

it('returns a prediction response for diesel', function () {
    $this->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
php artisan test --compact tests/Feature/Api/PredictionControllerTest.php

Expected: FAIL — controller does not exist.

  • Step 3: Generate request and controller
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

namespace App\Http\Requests\Api;

use App\Enums\FuelType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;

class PredictionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'fuel_type' => ['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

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\PredictionRequest;
use App\Services\NationalFuelPredictionService;
use Illuminate\Http\JsonResponse;

class PredictionController extends Controller
{
    public function __construct(
        private readonly NationalFuelPredictionService $predictionService,
    ) {}

    public function index(PredictionRequest $request): JsonResponse
    {
        $fuelType = $request->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
php artisan test --compact tests/Feature/Api/PredictionControllerTest.php

Expected: 5 PASS.

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
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 56
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.