docs: add comprehensive API reference documentation
Adds complete API reference (api-reference.md) covering all endpoints: /api/stations (nearby search with postcode/lat+lng), /api/stats/searches (aggregated search stats), /api/prediction (7-day price forecast with multi-signal breakdown), and auth routes (register/login/logout/me). Includes request/response examples, error shapes, fuel type aliases, and signal structure details. Also removes unused AccountWidget from admin panel, disables BrentPriceChartWidget discovery, and adds searches stat to StatsOverviewWidget. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ class BrentPriceChartWidget extends ChartWidget
|
|||||||
|
|
||||||
protected ?string $pollingInterval = null;
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected function getData(): array
|
protected function getData(): array
|
||||||
{
|
{
|
||||||
$prices = BrentPrice::orderBy('date')
|
$prices = BrentPrice::orderBy('date')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Filament\Widgets;
|
|||||||
|
|
||||||
use App\Models\ApiLog;
|
use App\Models\ApiLog;
|
||||||
use App\Models\PricePrediction;
|
use App\Models\PricePrediction;
|
||||||
|
use App\Models\Search;
|
||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -18,6 +19,7 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
$this->usersStat(),
|
$this->usersStat(),
|
||||||
|
$this->searchesStat(),
|
||||||
$this->stationsStat(),
|
$this->stationsStat(),
|
||||||
$this->oilPredictionStat(),
|
$this->oilPredictionStat(),
|
||||||
$this->apiErrorsStat(),
|
$this->apiErrorsStat(),
|
||||||
@@ -31,6 +33,13 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
->color('primary');
|
->color('primary');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function searchesStat(): Stat
|
||||||
|
{
|
||||||
|
return Stat::make('Total searches', Search::count())
|
||||||
|
->icon('heroicon-o-magnifying-glass')
|
||||||
|
->color('primary');
|
||||||
|
}
|
||||||
|
|
||||||
private function stationsStat(): Stat
|
private function stationsStat(): Stat
|
||||||
{
|
{
|
||||||
$count = Station::count();
|
$count = Station::count();
|
||||||
@@ -58,8 +67,7 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
$ageHours = $prediction->generated_at->diffInHours(now());
|
$ageHours = $prediction->generated_at->diffInHours(now());
|
||||||
$color = $ageHours > 24 ? 'warning' : 'success';
|
$color = $ageHours > 24 ? 'warning' : 'success';
|
||||||
$value = ucfirst($prediction->direction->value)
|
$value = ucfirst($prediction->direction->value)
|
||||||
.' · '.$prediction->confidence.'%'
|
.' · '.$prediction->confidence.'%';
|
||||||
.' · '.strtoupper($prediction->source->value);
|
|
||||||
|
|
||||||
return Stat::make('Latest oil prediction', $value)
|
return Stat::make('Latest oil prediction', $value)
|
||||||
->description('Generated '.$prediction->generated_at->diffForHumans())
|
->description('Generated '.$prediction->generated_at->diffForHumans())
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use Filament\Pages\Dashboard;
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
use Filament\Widgets\AccountWidget;
|
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||||
@@ -39,7 +38,6 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||||
->widgets([
|
->widgets([
|
||||||
AccountWidget::class,
|
|
||||||
StatsOverviewWidget::class,
|
StatsOverviewWidget::class,
|
||||||
])
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
|
|||||||
353
docs/api-reference.md
Normal file
353
docs/api-reference.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# FuelAlert API Reference
|
||||||
|
|
||||||
|
Base URL: `https://fuel-price.test/api`
|
||||||
|
All endpoints return JSON. No auth required on public endpoints (same-origin only for now — token auth planned).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stations
|
||||||
|
|
||||||
|
### GET `/api/stations`
|
||||||
|
|
||||||
|
Returns nearby petrol stations with live prices for a given fuel type.
|
||||||
|
|
||||||
|
**Location — provide one of:**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `postcode` | string | UK postcode, outcode, or place name (resolved via postcodes.io) |
|
||||||
|
| `lat` + `lng` | float | Decimal lat/lng (required if no postcode) |
|
||||||
|
|
||||||
|
**Optional:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fuel_type` | string | — | **Required.** See fuel type aliases below |
|
||||||
|
| `radius` | float | `10.0` | Search radius in km (0.1–50) |
|
||||||
|
| `sort` | string | `"price"` | `"price"` or `"distance"` |
|
||||||
|
| `pricing_mode` | string | — | `"pump"` (reserved, no effect yet) |
|
||||||
|
|
||||||
|
**Fuel type aliases** (`fuel_type` accepts any of these):
|
||||||
|
|
||||||
|
| Alias | Maps to |
|
||||||
|
|---|---|
|
||||||
|
| `petrol`, `unleaded`, `e10` | E10 (standard unleaded) |
|
||||||
|
| `premium_unleaded`, `e5` | E5 (super unleaded) |
|
||||||
|
| `diesel`, `b7_standard` | B7 Standard (standard diesel) |
|
||||||
|
| `premium_diesel`, `b7_premium` | B7 Premium (premium diesel) |
|
||||||
|
| `b10` | B10 (biodiesel blend) |
|
||||||
|
| `hvo` | HVO (hydrotreated vegetable oil) |
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
```
|
||||||
|
GET /api/stations?postcode=SW1A1AA&fuel_type=petrol&radius=5&sort=price
|
||||||
|
GET /api/stations?lat=51.5074&lng=-0.1278&fuel_type=diesel&radius=10&sort=distance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"station_id": "0028acef5f3afc41...",
|
||||||
|
"name": "Alex Fuel Station",
|
||||||
|
"brand": "BP",
|
||||||
|
"is_supermarket": false,
|
||||||
|
"address": "123 High Street, London",
|
||||||
|
"postcode": "SW1A 1AA",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"distance_km": 1.23,
|
||||||
|
"fuel_type": "e10",
|
||||||
|
"price_pence": 14390,
|
||||||
|
"price": 143.9,
|
||||||
|
"price_updated_at": "2026-04-05T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"count": 12,
|
||||||
|
"fuel_type": "e10",
|
||||||
|
"radius_km": 10.0,
|
||||||
|
"lowest_pence": 14290,
|
||||||
|
"highest_pence": 14890,
|
||||||
|
"cheapest_price_pence": 14290,
|
||||||
|
"avg_pence": 14523.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `price_pence` is the raw integer (e.g. `14390` = 143.90p). `price` is the float in pence (e.g. `143.9`).
|
||||||
|
- Closed stations (temporary or permanent) are excluded.
|
||||||
|
- Every call logs a search record used by the stats endpoint.
|
||||||
|
|
||||||
|
**Error — postcode not found:**
|
||||||
|
```json
|
||||||
|
{ "errors": { "postcode": ["Postcode not found."] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error — missing location:**
|
||||||
|
```json
|
||||||
|
{ "errors": { "lat": ["The lat field is required when postcode is not present."] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
### GET `/api/stats/searches`
|
||||||
|
|
||||||
|
Aggregate search statistics. Useful for a "social proof" counter on the frontend.
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `period` | string | `"week"` | `"week"` (7 days) or `"month"` (30 days) |
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
```
|
||||||
|
GET /api/stats/searches
|
||||||
|
GET /api/stats/searches?period=month
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_searches": 3842,
|
||||||
|
"unique_searchers": 1201,
|
||||||
|
"avg_results": 14.3,
|
||||||
|
"avg_lowest_price": 141.2,
|
||||||
|
"avg_highest_price": 148.7,
|
||||||
|
"avg_price": 144.9,
|
||||||
|
"period": "week",
|
||||||
|
"message": "Helped 1201 drivers find cheaper fuel this week so far!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `avg_lowest_price`, `avg_highest_price`, `avg_price` are in **pence** as floats (e.g. `141.2` = 141.2p).
|
||||||
|
- `unique_searchers` is based on hashed IP — approximate unique users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prediction
|
||||||
|
|
||||||
|
### GET `/api/prediction`
|
||||||
|
|
||||||
|
National (or regional) fuel price direction forecast for the next 7 days, based on live price data signals.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `fuel_type` | string | **Required.** Same aliases as `/api/stations` |
|
||||||
|
| `lat` | float | Optional. Enables regional momentum signal |
|
||||||
|
| `lng` | float | Optional. Enables regional momentum signal |
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
```
|
||||||
|
GET /api/prediction?fuel_type=diesel
|
||||||
|
GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fuel_type": "e10",
|
||||||
|
"current_avg": 143.9,
|
||||||
|
"predicted_direction": "down",
|
||||||
|
"predicted_change_pence": -2.1,
|
||||||
|
"confidence_score": 74.5,
|
||||||
|
"confidence_label": "high",
|
||||||
|
"action": "wait",
|
||||||
|
"reasoning": "National prices have been falling at 0.3p/day. Supermarkets led last week — independents typically follow within 48h.",
|
||||||
|
"prediction_horizon_days": 7,
|
||||||
|
"region_key": "national",
|
||||||
|
"methodology": "multi_signal_live_fallback",
|
||||||
|
"signals": {
|
||||||
|
"trend": {
|
||||||
|
"score": -0.8,
|
||||||
|
"confidence": 0.92,
|
||||||
|
"direction": "down",
|
||||||
|
"detail": "Slope: -0.30p/day over 5 days (R²=0.92) [Adaptive lookback active]",
|
||||||
|
"data_points": 5,
|
||||||
|
"enabled": true,
|
||||||
|
"slope": -0.3,
|
||||||
|
"r_squared": 0.92
|
||||||
|
},
|
||||||
|
"day_of_week": {
|
||||||
|
"score": 0.0,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"direction": "stable",
|
||||||
|
"detail": "Insufficient history for day-of-week pattern.",
|
||||||
|
"data_points": 0,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"brand_behaviour": {
|
||||||
|
"score": -1.0,
|
||||||
|
"confidence": 0.6,
|
||||||
|
"direction": "down",
|
||||||
|
"detail": "Supermarkets fell 3.5p vs majors 0.5p (divergence: 3.0p). Expect majors to follow.",
|
||||||
|
"data_points": 2800,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"national_momentum": {
|
||||||
|
"score": 0.0,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"direction": "stable",
|
||||||
|
"detail": "National momentum disabled for national predictions",
|
||||||
|
"data_points": 0,
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"regional_momentum": {
|
||||||
|
"score": 0.0,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"direction": "stable",
|
||||||
|
"detail": "No coordinates provided for regional momentum analysis",
|
||||||
|
"data_points": 0,
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"price_stickiness": {
|
||||||
|
"score": 0.05,
|
||||||
|
"confidence": 0.8,
|
||||||
|
"direction": "stable",
|
||||||
|
"detail": "Sticky prices (avg hold: 5.2 days) — more predictable.",
|
||||||
|
"data_points": 160,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key fields:**
|
||||||
|
|
||||||
|
| Field | Values | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `predicted_direction` | `"up"`, `"down"`, `"stable"` | Price trend direction |
|
||||||
|
| `action` | `"fill_now"`, `"wait"`, `"no_signal"` | Consumer-facing recommendation |
|
||||||
|
| `confidence_label` | `"high"` (≥70), `"medium"` (≥40), `"low"` (<40) | Signal strength |
|
||||||
|
| `predicted_change_pence` | float | Expected p/litre change over 7 days |
|
||||||
|
| `current_avg` | float | Current national average in pence (e.g. `143.9` = 143.9p) |
|
||||||
|
|
||||||
|
**Signal structure** (each signal in `signals`):
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `score` | float (-1.0 to 1.0) | Negative = falling pressure, positive = rising |
|
||||||
|
| `confidence` | float (0.0–1.0) | How reliable this signal is |
|
||||||
|
| `direction` | `"up"` / `"down"` / `"stable"` | Signal direction |
|
||||||
|
| `detail` | string | Human-readable explanation |
|
||||||
|
| `data_points` | int | Number of price records used |
|
||||||
|
| `enabled` | bool | False if signal was skipped (missing data/coords) |
|
||||||
|
|
||||||
|
**Error — unknown fuel type:**
|
||||||
|
```json
|
||||||
|
{ "errors": { "fuel_type": ["Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10."] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
> **Note:** Auth routes are implemented (`AuthController` exists) but not yet wired into `routes/api.php`. Add when token-based access is needed.
|
||||||
|
|
||||||
|
### POST `/api/auth/register`
|
||||||
|
|
||||||
|
Create a new account and receive a Sanctum token.
|
||||||
|
|
||||||
|
**Body (JSON):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"password_confirmation": "secret123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `201`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "1|abc123...",
|
||||||
|
"user": {
|
||||||
|
"id": 42,
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"created_at": "2026-04-05T10:00:00.000000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST `/api/auth/login`
|
||||||
|
|
||||||
|
**Body (JSON):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"password": "secret123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "1|abc123...",
|
||||||
|
"user": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `401` (wrong credentials):**
|
||||||
|
```json
|
||||||
|
{ "message": "Invalid credentials." }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST `/api/auth/logout`
|
||||||
|
|
||||||
|
Revokes the current token.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer {token}`
|
||||||
|
|
||||||
|
**Response `200`:**
|
||||||
|
```json
|
||||||
|
{ "message": "Logged out." }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/auth/me`
|
||||||
|
|
||||||
|
Returns the authenticated user.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer {token}`
|
||||||
|
|
||||||
|
**Response `200`:** Full `User` model JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the Token (when auth is wired up)
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer 1|abc123...
|
||||||
|
```
|
||||||
|
|
||||||
|
All protected routes must include this header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Shapes
|
||||||
|
|
||||||
|
**Validation error (422):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "The fuel type field is required.",
|
||||||
|
"errors": {
|
||||||
|
"fuel_type": ["The fuel type field is required."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unauthenticated (401):**
|
||||||
|
```json
|
||||||
|
{ "message": "Unauthenticated." }
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user