diff --git a/app/Filament/Widgets/BrentPriceChartWidget.php b/app/Filament/Widgets/BrentPriceChartWidget.php index daaf2d5..6d9109e 100644 --- a/app/Filament/Widgets/BrentPriceChartWidget.php +++ b/app/Filament/Widgets/BrentPriceChartWidget.php @@ -11,6 +11,8 @@ class BrentPriceChartWidget extends ChartWidget protected ?string $pollingInterval = null; + protected static bool $isDiscovered = false; + protected function getData(): array { $prices = BrentPrice::orderBy('date') diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php index cbf185a..95d4db7 100644 --- a/app/Filament/Widgets/StatsOverviewWidget.php +++ b/app/Filament/Widgets/StatsOverviewWidget.php @@ -4,6 +4,7 @@ namespace App\Filament\Widgets; use App\Models\ApiLog; use App\Models\PricePrediction; +use App\Models\Search; use App\Models\Station; use App\Models\User; use Carbon\Carbon; @@ -18,6 +19,7 @@ class StatsOverviewWidget extends BaseWidget { return [ $this->usersStat(), + $this->searchesStat(), $this->stationsStat(), $this->oilPredictionStat(), $this->apiErrorsStat(), @@ -31,6 +33,13 @@ class StatsOverviewWidget extends BaseWidget ->color('primary'); } + private function searchesStat(): Stat + { + return Stat::make('Total searches', Search::count()) + ->icon('heroicon-o-magnifying-glass') + ->color('primary'); + } + private function stationsStat(): Stat { $count = Station::count(); @@ -58,8 +67,7 @@ class StatsOverviewWidget extends BaseWidget $ageHours = $prediction->generated_at->diffInHours(now()); $color = $ageHours > 24 ? 'warning' : 'success'; $value = ucfirst($prediction->direction->value) - .' · '.$prediction->confidence.'%' - .' · '.strtoupper($prediction->source->value); + .' · '.$prediction->confidence.'%'; return Stat::make('Latest oil prediction', $value) ->description('Generated '.$prediction->generated_at->diffForHumans()) diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 0c17f7f..36c42e6 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -11,7 +11,6 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; -use Filament\Widgets\AccountWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; @@ -39,7 +38,6 @@ class AdminPanelProvider extends PanelProvider ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ - AccountWidget::class, StatsOverviewWidget::class, ]) ->middleware([ diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..b3b4460 --- /dev/null +++ b/docs/api-reference.md @@ -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." } +```