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

1663 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<?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**
```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
<?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**
```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
<?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**
```bash
php artisan make:model Search --factory --no-interaction
```
- [ ] **Step 4: Write the model**
Replace `app/Models/Search.php`:
```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
<?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**
```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
<?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**
```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
<?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
<?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
<?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**
```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
<?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**
```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
<?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**
```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
<?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**
```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
<?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**
```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
<?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**
```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
<?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
<?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**
```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 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.