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>
This commit is contained in:
@@ -6,15 +6,20 @@ Produces a "fill up now or wait?" recommendation per user based on their local
|
||||
station history. Output is one of: `fill_up`, `wait`, `no_signal`.
|
||||
Never guess — stay silent (no_signal) when signals conflict or data is insufficient.
|
||||
|
||||
## The 4 signals (in priority order)
|
||||
## The 5 signals (in priority order)
|
||||
|
||||
### Signal 1 — Local price trend (HIGHEST WEIGHT)
|
||||
- Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng)
|
||||
- Use last 14 days of history for `e10` (or user's preferred fuel type)
|
||||
- Calculate 3-day rolling average vs 7-day rolling average
|
||||
- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal
|
||||
- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal
|
||||
- **Flat**: difference < 0.5p → neutral, no signal
|
||||
- **Use linear regression, not rolling averages:**
|
||||
- Run least-squares regression on `(recorded_at, price_pence)` pairs
|
||||
- Calculate slope (pence/day) and R² (goodness of fit, 0–1)
|
||||
- Only use the regression result if R² ≥ 0.5 — below that, data is too noisy
|
||||
- Use adaptive lookback: try 5 days first (best signal on sharp moves), fall back to 14 days if R² < 0.5
|
||||
- **Falling**: slope ≤ -0.3p/day AND R² ≥ 0.5 → wait signal, points scale with slope magnitude
|
||||
- **Rising**: slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up signal
|
||||
- **Flat / noisy**: |slope| < 0.3 OR R² < 0.5 → no signal from this source
|
||||
- Store slope, R², lookback_days, and data_points in signal output
|
||||
- Weight: 40 points max
|
||||
|
||||
### Signal 2 — Supermarket anchor effect (HIGH WEIGHT)
|
||||
@@ -22,6 +27,7 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
- Check if supermarket cut price in last 48 hours (> 1p drop)
|
||||
- Check if nearest non-supermarket stations have NOT yet followed
|
||||
- If supermarket cut AND independents haven't moved → strong wait signal
|
||||
- Also check the inverse: if supermarket RAISED and independents haven't → mild fill_up
|
||||
- Weight: 35 points max
|
||||
|
||||
### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data)
|
||||
@@ -39,6 +45,16 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
- Points awarded proportionally to confidence: `(confidence / 100) * 10`
|
||||
- Weight: 10 points max
|
||||
|
||||
### Signal 5 — Price stickiness (CONFIDENCE MODIFIER)
|
||||
- Per station: calculate average hold duration (days between price changes) from history
|
||||
- Requires 30+ days of history to activate
|
||||
- Use as a confidence modifier, not a directional signal:
|
||||
- avg hold < 2 days → reduce overall confidence by 5 points (volatile, hard to predict)
|
||||
- avg hold 2–4 days → neutral, no adjustment
|
||||
- avg hold > 5 days → increase overall confidence by 5 points (predictable, sticky)
|
||||
- Store avg_hold_days and data_points in signal output
|
||||
- Applied after all other signals are summed (±5 points)
|
||||
|
||||
## Confidence thresholds
|
||||
|
||||
- Score 70–100: strong signal → fire recommendation + notification
|
||||
@@ -51,29 +67,50 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
|
||||
```php
|
||||
[
|
||||
'recommendation' => 'wait', // fill_up | wait | no_signal
|
||||
'confidence' => 78, // 0-100
|
||||
'signals' => [
|
||||
'trend' => ['direction' => 'falling', 'points' => 32],
|
||||
'recommendation' => 'wait',
|
||||
'confidence' => 78,
|
||||
'signals' => [
|
||||
'trend' => [
|
||||
'direction' => 'falling',
|
||||
'slope' => -1.07, // pence per day
|
||||
'r_squared' => 0.96,
|
||||
'lookback_days' => 5,
|
||||
'data_points' => 5,
|
||||
'points' => 32,
|
||||
],
|
||||
'supermarket' => ['triggered' => true, 'points' => 35],
|
||||
'day_pattern' => ['triggered' => false, 'points' => 0],
|
||||
'brent' => ['direction' => 'flat', 'points' => 0],
|
||||
'stickiness' => ['avg_hold_days' => 2.8, 'modifier' => 0],
|
||||
],
|
||||
'local_avg_pence' => 14380, // 143.80p
|
||||
'trend_delta' => -2.3, // pence change over 7 days
|
||||
'local_avg_pence' => 14380, // 143.80p
|
||||
'trend_delta' => -2.3, // pence change over lookback period
|
||||
]
|
||||
```
|
||||
|
||||
## Human-readable reason strings
|
||||
|
||||
Always generate a plain-English reason for the recommendation:
|
||||
- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||
- "Prices are rising in your area — filling up today avoids paying more later."
|
||||
- "Prices near you have been falling at 1.1p/day for 5 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||
- "Prices are rising sharply in your area (+7.5p expected this week) — filling up today avoids paying more later."
|
||||
- "No clear pattern this week — fill up at the cheapest station near you now."
|
||||
|
||||
Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications.
|
||||
|
||||
## Data quality — anomaly rejection
|
||||
|
||||
The Fuel Finder API contains dirty data (live example: 1369.0p/litre in national index).
|
||||
Reject a price record before storing or scoring if:
|
||||
- `price_pence > 25000` (over 250p/litre — physically implausible for UK pump prices)
|
||||
- `price_pence < 10000` (under 100p/litre — almost certainly a decimal entry error)
|
||||
- Price changed by more than 20p in a single update from the same station
|
||||
(flag for review, do not use in scoring)
|
||||
|
||||
Log rejected records to an `anomalous_prices` table for monitoring.
|
||||
Never let a dirty data point skew the regression slope or collapse R².
|
||||
|
||||
## Accuracy self-tracking
|
||||
|
||||
After 3 days, check if `wait` recommendation was correct (prices did fall further).
|
||||
Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area."
|
||||
Store outcome in `scoring_results` for future display:
|
||||
"This signal has been right X% of the time in your area."
|
||||
35
app/Filament/NavigationGroup.php
Normal file
35
app/Filament/NavigationGroup.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
enum NavigationGroup implements HasIcon, HasLabel
|
||||
{
|
||||
case Users;
|
||||
|
||||
case Data;
|
||||
|
||||
case System;
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Users => 'Users',
|
||||
self::Data => 'Data',
|
||||
self::System => 'System',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string|BackedEnum|Htmlable|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::Users => 'heroicon-o-users',
|
||||
self::Data => 'heroicon-o-circle-stack',
|
||||
self::System => 'heroicon-o-cog-6-tooth',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog;
|
||||
use App\Models\ApiLog;
|
||||
@@ -21,9 +22,7 @@ class ApiLogResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ApiLog::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-server';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'System';
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
|
||||
|
||||
protected static ?string $navigationLabel = 'API Logs';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
|
||||
use App\Models\BrentPrice;
|
||||
use Filament\Resources\Resource;
|
||||
@@ -12,9 +13,7 @@ class BrentPriceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BrentPrice::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-currency-dollar';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Data';
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brent Prices';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
|
||||
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
|
||||
use App\Models\PricePrediction;
|
||||
@@ -23,9 +24,7 @@ class OilPredictionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PricePrediction::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Data';
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Oil Predictions';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\StationResource\Pages\ListStations;
|
||||
use App\Filament\Resources\StationResource\Pages\ViewStation;
|
||||
use App\Models\Station;
|
||||
@@ -20,9 +21,7 @@ class StationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Station::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-map-pin';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Data';
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Stations';
|
||||
|
||||
@@ -32,10 +31,13 @@ class StationResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('trading_name')->searchable()->sortable(),
|
||||
TextColumn::make('trading_name')->searchable()->sortable()
|
||||
->limit(25)
|
||||
->tooltip(fn (Station $record) => strlen($record->trading_name) > 45 ? $record->trading_name : null),
|
||||
TextColumn::make('brand_name')->searchable()->placeholder('—'),
|
||||
TextColumn::make('postcode')->searchable(),
|
||||
TextColumn::make('city'),
|
||||
TextColumn::make('city')
|
||||
->description(fn (Station $record) => $record->postcode)
|
||||
->searchable(['city', 'postcode']),
|
||||
IconColumn::make('is_supermarket')->label('Supermarket')->boolean(),
|
||||
IconColumn::make('is_motorway_service_station')->label('Motorway')->boolean(),
|
||||
IconColumn::make('temporary_closure')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||
use App\Models\User;
|
||||
@@ -20,7 +21,7 @@ class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
|
||||
@@ -8,23 +8,25 @@ class StationTaggingService
|
||||
{
|
||||
/** @var array<string, string> brand keyword → normalised brand name */
|
||||
private const SUPERMARKET_BRANDS = [
|
||||
'tesco' => 'Tesco',
|
||||
'asda' => 'Asda',
|
||||
'tesco' => 'Tesco',
|
||||
'asda' => 'Asda',
|
||||
'morrisons' => 'Morrisons',
|
||||
'sainsbury' => 'Sainsbury\'s',
|
||||
'aldi' => 'Aldi',
|
||||
'lidl' => 'Lidl',
|
||||
'costco' => 'Costco',
|
||||
'aldi' => 'Aldi',
|
||||
'lidl' => 'Lidl',
|
||||
'costco' => 'Costco',
|
||||
];
|
||||
|
||||
public function tag(Station $station): void
|
||||
{
|
||||
$name = strtolower($station->trading_name);
|
||||
$tradingName = strtolower($station->trading_name);
|
||||
$brandName = strtolower($station->brand_name ?? '');
|
||||
|
||||
foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) {
|
||||
if (str_contains($name, $keyword)) {
|
||||
if (str_contains($tradingName, $keyword) || str_contains($brandName, $keyword)) {
|
||||
$station->is_supermarket = true;
|
||||
$station->brand_name = $normalisedBrand;
|
||||
$station->brand_name = $normalisedBrand;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
2044
docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Normal file
2044
docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ use App\Models\Station;
|
||||
use App\Services\StationTaggingService;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = new StationTaggingService();
|
||||
$this->service = new StationTaggingService;
|
||||
});
|
||||
|
||||
it('marks tesco station as supermarket and normalises brand', function (): void {
|
||||
@@ -54,10 +54,23 @@ it('handles case insensitive matching', function (): void {
|
||||
->and($station->brand_name)->toBe('Morrisons');
|
||||
});
|
||||
|
||||
it('marks station as supermarket when brand_name matches even if trading_name does not', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'PETERBOROUGH EXTRA - PETROL FILLING STATION',
|
||||
'brand_name' => 'TESCO',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeTrue()
|
||||
->and($station->brand_name)->toBe('Tesco');
|
||||
});
|
||||
|
||||
it('does not overwrite brand_name for non-supermarket stations', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'Shell Garage',
|
||||
'brand_name' => 'Shell',
|
||||
'trading_name' => 'Shell Garage',
|
||||
'brand_name' => 'Shell',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user