feat: add Filament admin panel with migrations and design spec
- Add AdminPanelProvider mounting panel at `/admin` with `is_admin` auth guard - Add `is_admin` boolean column to users table - Add brent_prices and price_predictions tables with appropriate indexes - Add comprehensive admin design spec covering resources, dashboard, navigation, and build order - Configure default panel with amber primary color and standard middleware stack - Add compiled Filament assets (actions.js, app.css)
This commit is contained in:
@@ -392,22 +392,55 @@ FUEL_FINDER_CLIENT_SECRET=
|
||||
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
||||
```
|
||||
|
||||
## Postcodes.io — postcode → lat/lng
|
||||
## Postcodes.io — location resolution
|
||||
|
||||
- URL: `https://api.postcodes.io/postcodes/{postcode}`
|
||||
- Free, no API key required
|
||||
- Called once on user registration / when postcode changes
|
||||
- Store resolved `lat` + `lng` on `users` table
|
||||
- Cache postcode lookups for 30 days (postcodes rarely change coordinates)
|
||||
- Handled by `PostcodeService::resolve(string $query): ?LocationResult`
|
||||
- Returns `LocationResult` DTO with `query`, `displayName`, `lat`, `lng`
|
||||
- Results cached for 30 days — cache key `postcode:{normalised_input}`
|
||||
- Failed lookups are NOT cached — retried on next request
|
||||
- Input is auto-detected:
|
||||
|
||||
## FRED API (St. Louis Fed) — Brent crude direction
|
||||
| Input type | Example | Endpoint |
|
||||
|---|---|---|
|
||||
| Full postcode | `SW1A 1AA` | `GET /postcodes/{postcode}` |
|
||||
| Outcode (district) | `PE7` | `GET /outcodes/{outcode}` |
|
||||
| Place / city name | `Manchester` | `GET /places?q={query}&limit=1` |
|
||||
|
||||
- Series: `DCOILBRENTEU` (daily Brent spot price)
|
||||
- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json`
|
||||
- Free API key required — stored as `FRED_API_KEY` in .env
|
||||
- Fetched once daily via scheduler at 7am
|
||||
- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))`
|
||||
- Only the 5-day trend direction is used by the scoring engine
|
||||
**Anonymous search flow:** user types a postcode/city → `PostcodeService::resolve()` → lat/lng stored in a JSON cookie (30 days) alongside the query string. On return visits, cookie lat/lng is used directly — postcodes.io is only called when the search term changes.
|
||||
|
||||
**Registered users:** postcode resolved once on registration, lat/lng stored on `users` table — not re-resolved unless postcode changes.
|
||||
|
||||
## FRED API (St. Louis Fed) — Brent crude prices
|
||||
|
||||
- Series: `DCOILBRENTEU` (daily Brent spot price, USD/barrel)
|
||||
- Endpoint: `GET https://api.stlouisfed.org/fred/series/observations`
|
||||
- Params: `series_id=DCOILBRENTEU`, `sort_order=desc`, `limit=30`, `file_type=json`
|
||||
- Free API key required — stored as `FRED_API_KEY` in `.env`
|
||||
- Handled by `OilPriceService::fetchBrentPrices()`
|
||||
- Fetched daily at 7am via `oil:predict --fetch` scheduler command
|
||||
- FRED uses `"."` as a placeholder for non-trading days (weekends/holidays) — filtered out before insert
|
||||
- Stored in `brent_prices` table, upserted on `date` primary key
|
||||
|
||||
## Anthropic API — oil price direction prediction
|
||||
|
||||
- Endpoint: `POST https://api.anthropic.com/v1/messages`
|
||||
- Model: `claude-haiku-4-5-20251001` (configurable via `ANTHROPIC_MODEL` in `.env`)
|
||||
- Key stored as `ANTHROPIC_API_KEY` in `.env`
|
||||
- Handled by `OilPriceService::generateLlmPrediction()`
|
||||
- Called once daily after FRED fetch — sends last 30 days of Brent prices + pre-computed EWMA context
|
||||
- Response must be JSON: `{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "..."}`
|
||||
- Model sometimes wraps JSON in markdown code fences — these are stripped before `json_decode`
|
||||
- Confidence is capped at 85 regardless of what the model returns
|
||||
- On any failure (API error, malformed JSON, invalid direction) → falls back to EWMA silently
|
||||
- Result stored in `price_predictions` table with `source = 'llm'`
|
||||
|
||||
**EWMA fallback (`OilPriceService::generateEwmaPrediction()`):**
|
||||
- Compares 3-day EWMA vs 7-day EWMA on chronological Brent price data
|
||||
- Threshold: ±1.5% change → rising/falling; below → flat
|
||||
- Confidence capped at 65 (simpler model)
|
||||
- Used when: no `ANTHROPIC_API_KEY` set, or LLM call fails
|
||||
- Result stored in `price_predictions` table with `source = 'ewma'`
|
||||
|
||||
## OneSignal — push notifications
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This keeps the app API-extractable later without a rewrite.
|
||||
|
||||
```
|
||||
app/
|
||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, RunScoringEngine)
|
||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine)
|
||||
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
||||
├── Livewire/ # Classic two-file Livewire components
|
||||
├── Models/ # Eloquent models
|
||||
@@ -20,7 +20,11 @@ app/
|
||||
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
||||
│ ├── StationTaggingService.php # Supermarket brand detection
|
||||
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
||||
│ └── SubscriptionService.php # Cashier/tier helpers
|
||||
│ ├── SubscriptionService.php # Cashier/tier helpers
|
||||
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng
|
||||
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
|
||||
│ ├── LocationResult.php # DTO returned by PostcodeService
|
||||
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
|
||||
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
||||
|
||||
resources/views/
|
||||
|
||||
@@ -91,6 +91,31 @@ INDEX (user_id, expires_at)
|
||||
```
|
||||
OTP codes expire after 10 minutes. Mark `used_at` on success — never delete rows.
|
||||
|
||||
### brent_prices (daily Brent crude from FRED)
|
||||
```
|
||||
date DATE PRIMARY KEY — trading day (no weekends/holidays)
|
||||
price_usd DECIMAL(8,2) — spot price USD per barrel
|
||||
```
|
||||
Populated daily by `OilPriceService::fetchBrentPrices()` via FRED API.
|
||||
FRED returns `"."` for non-trading days — these are filtered out before insert.
|
||||
Upserted on refetch so duplicate dates never occur.
|
||||
|
||||
### price_predictions (oil price direction forecast)
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
predicted_for DATE — the date this prediction covers
|
||||
source ENUM('llm','ewma') — which method generated it
|
||||
direction ENUM('rising','falling','flat')
|
||||
confidence TINYINT — 0–100 (LLM max 85, EWMA max 65)
|
||||
reasoning TEXT NULLABLE — plain-English explanation
|
||||
generated_at DATETIME
|
||||
|
||||
INDEX (predicted_for, source)
|
||||
```
|
||||
Generated daily by `OilPriceService::generatePrediction()`.
|
||||
LLM (Anthropic) is tried first; EWMA is used as fallback if LLM fails or key not set.
|
||||
Signal 4 in AlertScoringService reads from this table — never from brent_prices directly.
|
||||
|
||||
## Supermarket brands (StationTaggingService)
|
||||
|
||||
Match station `name` (case-insensitive) against:
|
||||
|
||||
@@ -32,8 +32,11 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
- Weight: 15 points max
|
||||
|
||||
### Signal 4 — Brent crude direction (LOW WEIGHT)
|
||||
- Fetched daily from FRED API, stored in a simple `brent_prices` table
|
||||
- 5-day trend: rising ≥ 3% → mild fill_up pressure; falling ≥ 3% → mild wait
|
||||
- Read from `price_predictions` table — never query `brent_prices` directly in scoring
|
||||
- `OilPriceService::generatePrediction()` runs daily at 7am and writes the prediction
|
||||
- LLM (`source = 'llm'`) is preferred; EWMA (`source = 'ewma'`) is the fallback
|
||||
- Direction `rising` → mild fill_up pressure; `falling` → mild wait; `flat` → no signal
|
||||
- Points awarded proportionally to confidence: `(confidence / 100) * 10`
|
||||
- Weight: 10 points max
|
||||
|
||||
## Confidence thresholds
|
||||
|
||||
47
app/Console/Commands/PredictOilPrices.php
Normal file
47
app/Console/Commands/PredictOilPrices.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\OilPriceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class PredictOilPrices extends Command
|
||||
{
|
||||
protected $signature = 'oil:predict {--fetch : Fetch latest FRED prices before predicting}';
|
||||
|
||||
protected $description = 'Generate a Brent crude oil price direction prediction';
|
||||
|
||||
public function handle(OilPriceService $service): int
|
||||
{
|
||||
try {
|
||||
if ($this->option('fetch')) {
|
||||
$this->info('Fetching latest Brent crude prices from FRED...');
|
||||
$service->fetchBrentPrices();
|
||||
}
|
||||
|
||||
$this->info('Generating prediction...');
|
||||
$prediction = $service->generatePrediction();
|
||||
|
||||
if ($prediction === null) {
|
||||
$this->error('Could not generate a prediction — not enough price data.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Done. [%s] direction=%s confidence=%d%% — %s',
|
||||
strtoupper($prediction->source->value),
|
||||
$prediction->direction->value,
|
||||
$prediction->confidence,
|
||||
$prediction->reasoning,
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
$this->error("Prediction failed: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
9
app/Enums/PredictionSource.php
Normal file
9
app/Enums/PredictionSource.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PredictionSource: string
|
||||
{
|
||||
case Llm = 'llm';
|
||||
case Ewma = 'ewma';
|
||||
}
|
||||
10
app/Enums/TrendDirection.php
Normal file
10
app/Enums/TrendDirection.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TrendDirection: string
|
||||
{
|
||||
case Rising = 'rising';
|
||||
case Falling = 'falling';
|
||||
case Flat = 'flat';
|
||||
}
|
||||
31
app/Models/BrentPrice.php
Normal file
31
app/Models/BrentPrice.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property Carbon $date
|
||||
* @property string $price_usd
|
||||
*/
|
||||
#[Fillable(['date', 'price_usd'])]
|
||||
class BrentPrice extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'date';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'price_usd' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Models/PricePrediction.php
Normal file
35
app/Models/PricePrediction.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property Carbon $predicted_for
|
||||
* @property PredictionSource $source
|
||||
* @property TrendDirection $direction
|
||||
* @property int $confidence
|
||||
* @property string|null $reasoning
|
||||
* @property Carbon $generated_at
|
||||
*/
|
||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||
class PricePrediction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'predicted_for' => 'date',
|
||||
'source' => PredictionSource::class,
|
||||
'direction' => TrendDirection::class,
|
||||
'confidence' => 'integer',
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,7 +16,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
@@ -32,6 +34,11 @@ class User extends Authenticatable
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $this->is_admin === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
|
||||
59
app/Providers/Filament/AdminPanelProvider.php
Normal file
59
app/Providers/Filament/AdminPanelProvider.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
PreventRequestForgery::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
280
app/Services/OilPriceService.php
Normal file
280
app/Services/OilPriceService.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class OilPriceService
|
||||
{
|
||||
/**
|
||||
* Decay factor for EWMA. Higher = more weight on recent prices.
|
||||
*/
|
||||
private const float EWMA_ALPHA = 0.3;
|
||||
|
||||
/**
|
||||
* Minimum % change in EWMA to be considered rising/falling.
|
||||
*/
|
||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||||
|
||||
/**
|
||||
* EWMA confidence is capped lower than LLM — it's a simpler model.
|
||||
*/
|
||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
||||
|
||||
/**
|
||||
* LLM confidence is capped — no model should be certain about oil prices.
|
||||
*/
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
/**
|
||||
* Minimum price rows needed before EWMA is meaningful.
|
||||
*/
|
||||
private const int EWMA_MIN_ROWS = 14;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch the last 30 days of Brent crude prices from FRED and store them.
|
||||
*/
|
||||
public function fetchBrentPrices(): void
|
||||
{
|
||||
$url = 'https://api.stlouisfed.org/fred/series/observations';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('fred', 'GET', $url, fn () => Http::timeout(10)
|
||||
->get($url, [
|
||||
'series_id' => 'DCOILBRENTEU',
|
||||
'api_key' => config('services.fred.api_key'),
|
||||
'sort_order' => 'desc',
|
||||
'limit' => 30,
|
||||
'file_type' => 'json',
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = collect($response->json('observations') ?? [])
|
||||
->filter(fn (array $obs) => $obs['value'] !== '.') // FRED uses '.' for missing data
|
||||
->map(fn (array $obs) => [
|
||||
'date' => $obs['date'],
|
||||
'price_usd' => (float) $obs['value'],
|
||||
])
|
||||
->all();
|
||||
|
||||
if (empty($rows)) {
|
||||
Log::warning('OilPriceService: no valid FRED observations returned');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('OilPriceService: fetchBrentPrices failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a prediction using LLM first, falling back to EWMA.
|
||||
* Stores the result in price_predictions and returns it.
|
||||
*/
|
||||
public function generatePrediction(): ?PricePrediction
|
||||
{
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
|
||||
if ($prices->count() < self::EWMA_MIN_ROWS) {
|
||||
Log::warning('OilPriceService: not enough price data to generate prediction', [
|
||||
'rows' => $prices->count(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$prediction = null;
|
||||
|
||||
if (config('services.anthropic.api_key')) {
|
||||
$prediction = $this->generateLlmPrediction($prices);
|
||||
}
|
||||
|
||||
$prediction ??= $this->generateEwmaPrediction($prices);
|
||||
|
||||
if ($prediction !== null) {
|
||||
PricePrediction::create($prediction->toArray());
|
||||
}
|
||||
|
||||
return $prediction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Option B — LLM prediction via Anthropic API.
|
||||
* Sends recent prices + pre-computed EWMA context and asks for direction + confidence.
|
||||
*/
|
||||
public function generateLlmPrediction(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date');
|
||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
||||
|
||||
$priceList = $chronological
|
||||
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
|
||||
->implode("\n");
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Your goal is to predict the short-term direction over the next 3–5 days.
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
|
||||
Pre-computed indicators:
|
||||
- 3-day EWMA: \${$ewma3}
|
||||
- 7-day EWMA: \${$ewma7}
|
||||
- 14-day EWMA: \${$ewma14}
|
||||
|
||||
Respond with JSON only, no other text:
|
||||
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence"}
|
||||
PROMPT;
|
||||
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withHeaders([
|
||||
'x-api-key' => config('services.anthropic.api_key'),
|
||||
'anthropic-version' => '2023-06-01',
|
||||
])
|
||||
->post($url, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 256,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OilPriceService: Anthropic request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('content.0.text') ?? '';
|
||||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||||
$text = preg_replace('/```\s*$/m', '', $text);
|
||||
$data = json_decode(trim($text), true);
|
||||
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$direction = TrendDirection::tryFrom($data['direction']);
|
||||
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error('OilPriceService: invalid direction in LLM response', ['direction' => $data['direction']]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => PredictionSource::Llm,
|
||||
'direction' => $direction,
|
||||
'confidence' => $confidence,
|
||||
'reasoning' => $data['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('OilPriceService: generateLlmPrediction failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
||||
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
||||
*/
|
||||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
|
||||
|
||||
if (count($chronological) < self::EWMA_MIN_ROWS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
||||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
||||
|
||||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
||||
|
||||
[$direction, $confidence] = match (true) {
|
||||
$changePct >= self::EWMA_THRESHOLD_PCT => [
|
||||
TrendDirection::Rising,
|
||||
$this->ewmaConfidence($changePct),
|
||||
],
|
||||
$changePct <= -self::EWMA_THRESHOLD_PCT => [
|
||||
TrendDirection::Falling,
|
||||
$this->ewmaConfidence(abs($changePct)),
|
||||
],
|
||||
default => [TrendDirection::Flat, 50],
|
||||
};
|
||||
|
||||
$reasoning = sprintf(
|
||||
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
|
||||
$ewma3,
|
||||
$ewma7,
|
||||
abs($changePct),
|
||||
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
|
||||
);
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => PredictionSource::Ewma,
|
||||
'direction' => $direction,
|
||||
'confidence' => $confidence,
|
||||
'reasoning' => $reasoning,
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute Exponential Weighted Moving Average for a series of prices.
|
||||
*
|
||||
* @param float[] $prices Chronological order (oldest first)
|
||||
*/
|
||||
private function computeEwma(array $prices): float
|
||||
{
|
||||
$ema = $prices[0];
|
||||
|
||||
foreach (array_slice($prices, 1) as $price) {
|
||||
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
|
||||
}
|
||||
|
||||
return round($ema, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score.
|
||||
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
||||
*/
|
||||
private function ewmaConfidence(float $changePct): int
|
||||
{
|
||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||
|
||||
return (int) round(max(30, $scaled));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
"name": "laravel/livewire-starter-kit",
|
||||
"type": "project",
|
||||
"description": "The official Laravel starter kit for Livewire.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"filament/filament": "^5.0",
|
||||
"laravel/fortify": "^1.34",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
@@ -69,7 +67,8 @@
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
"@php artisan package:discover --ansi",
|
||||
"@php artisan filament:upgrade"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
|
||||
1859
composer.lock
generated
1859
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -41,4 +41,13 @@ return [
|
||||
'client_secret' => env('FUEL_FINDER_CLIENT_SECRET'),
|
||||
],
|
||||
|
||||
'fred' => [
|
||||
'api_key' => env('FRED_API_KEY'),
|
||||
],
|
||||
|
||||
'anthropic' => [
|
||||
'api_key' => env('ANTHROPIC_API_KEY'),
|
||||
'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('brent_prices', function (Blueprint $table) {
|
||||
$table->date('date')->primary();
|
||||
$table->decimal('price_usd')->comment('Brent crude spot price in USD per barrel');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('brent_prices');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('price_predictions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->date('predicted_for')->comment('The date this prediction covers');
|
||||
$table->enum('source', ['llm', 'ewma']);
|
||||
$table->enum('direction', ['rising', 'falling', 'flat']);
|
||||
$table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score');
|
||||
$table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary');
|
||||
$table->datetime('generated_at');
|
||||
|
||||
$table->index(['predicted_for', 'source']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('price_predictions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('remember_token');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
};
|
||||
160
docs/superpowers/specs/2026-04-04-filament-admin-design.md
Normal file
160
docs/superpowers/specs/2026-04-04-filament-admin-design.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Filament Admin Panel — Design Spec
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Stack:** Laravel 11, Filament v5, Livewire v4
|
||||
**Scope:** Admin panel for FuelAlert — internal tooling only, no user-facing exposure
|
||||
|
||||
---
|
||||
|
||||
## 1. Access & Auth
|
||||
|
||||
- Filament panel mounted at `/admin`
|
||||
- Protected by `is_admin` boolean on `users` table
|
||||
- Migration adds `is_admin TINYINT(1) DEFAULT 0` to `users`
|
||||
- `AdminPanelProvider` uses `->authGuard('web')` (same guard as the main app — no separate admin users table)
|
||||
- `canAccessPanel()` check: `$user->is_admin === true`
|
||||
- `uovidiu@sent.com` seeded as admin via a dedicated `AdminSeeder` (idempotent — safe to re-run)
|
||||
- No self-registration on the admin panel — access is granted only via the `is_admin` flag
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard Page
|
||||
|
||||
Default landing page. Four `StatsOverviewWidget` cards:
|
||||
|
||||
| Stat | Source | Alert |
|
||||
|---|---|---|
|
||||
| Total users | `users` count | — |
|
||||
| Stations in DB | `stations` count + `max(last_seen_at)` | — |
|
||||
| Latest oil prediction | Most recent `price_predictions` row — direction, confidence, source | Yellow if > 24h old |
|
||||
| API errors (24h) | `api_logs` where `status_code >= 400` or `error IS NOT NULL`, last 24h | Red if > 0 |
|
||||
|
||||
No charts on the dashboard — keep it scannable. Charts live inside individual resources.
|
||||
|
||||
---
|
||||
|
||||
## 3. Resources
|
||||
|
||||
### 3.1 Users (`UserResource`)
|
||||
|
||||
**Purpose:** View all registered users, manage admin flag, correct postcodes.
|
||||
|
||||
**Table columns:** name, email, postcode, is_admin (badge), created_at
|
||||
**Filters:** is_admin toggle
|
||||
**Actions:**
|
||||
- Edit: `is_admin` toggle, `postcode` text field (no other fields editable from admin)
|
||||
- Delete: allowed (hard delete, with confirmation modal)
|
||||
|
||||
**Notes:**
|
||||
- No subscription tier column yet — add when Cashier is integrated
|
||||
- No impersonation in v1 — add later when user dashboard exists
|
||||
|
||||
---
|
||||
|
||||
### 3.2 API Logs (`ApiLogResource`)
|
||||
|
||||
**Purpose:** Primary debugging tool — see every outbound HTTP call made by the scheduler.
|
||||
|
||||
**Table columns:** service (badge), method, url (truncated), status_code (colour-coded), duration_ms, error (truncated), created_at
|
||||
**Filters:** service (select: `fuel_finder`, `fred`, `anthropic`), errors only (toggle: where error IS NOT NULL or status >= 400), date range
|
||||
**Default sort:** created_at DESC
|
||||
**Actions:** View (modal showing full url, full error, request/response if available) — no edit, no delete
|
||||
|
||||
**Colour coding for status_code:**
|
||||
- 2xx → success (green)
|
||||
- 4xx → warning (yellow)
|
||||
- 5xx / null → danger (red)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Oil Predictions (`OilPredictionResource`)
|
||||
|
||||
**Purpose:** Verify the daily `oil:predict` job is running and producing sensible output.
|
||||
|
||||
**Table columns:** predicted_for (date), source (badge: LLM / EWMA), direction (badge: rising/falling/flat with colour), confidence (progress bar 0–100), reasoning (truncated), generated_at
|
||||
**Filters:** source, direction, date range
|
||||
**Default sort:** predicted_for DESC
|
||||
**Actions:**
|
||||
- View (modal showing full reasoning text)
|
||||
- **Run prediction now** (page-level action): executes `php artisan oil:predict` via `Artisan::call()`, shows success/failure notification
|
||||
|
||||
**No edit/delete** — predictions are immutable audit records.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Brent Prices (`BrentPriceResource`)
|
||||
|
||||
**Purpose:** Verify FRED fetch is populating data correctly.
|
||||
|
||||
**Table columns:** date, price_usd
|
||||
**Default sort:** date DESC
|
||||
**Actions:** none
|
||||
|
||||
Simple read-only table. No filters needed — data is always chronological. Include a line chart widget (`BrentPriceChartWidget`) showing the last 30 days using Filament's built-in chart widget.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Stations (`StationResource`)
|
||||
|
||||
**Purpose:** Browse the ~14,500 UK stations; verify supermarket tagging and closure flags.
|
||||
|
||||
**Table columns:** trading_name, brand_name, postcode, city, is_supermarket (badge), is_motorway_service_station (badge), temporary_closure (badge), last_seen_at
|
||||
**Filters:** is_supermarket, is_motorway_service_station, temporary_closure, permanent_closure, postcode prefix (text search)
|
||||
**Search:** trading_name, brand_name, postcode
|
||||
**Default sort:** last_seen_at DESC
|
||||
**Actions:**
|
||||
- View (modal with full address, amenities, opening_times, fuel_types JSON)
|
||||
- **Trigger full poll** (page-level action): dispatches a queued `PollFuelPricesJob` (wraps `fuel:poll --full`) rather than calling Artisan synchronously — full station refresh on 14,500 records would exceed HTTP timeout. Shows "Poll dispatched to queue" notification immediately; result visible in API Logs once complete.
|
||||
|
||||
**No edit** — station data is owned by Fuel Finder API, overwritten on each poll.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resources Planned But Not Built Yet
|
||||
|
||||
These will be added once the underlying tables/services exist:
|
||||
|
||||
| Resource | Depends on |
|
||||
|---|---|
|
||||
| Alerts Log | `alerts` table + `NotificationDispatchService` |
|
||||
| Scoring Results | `scoring_results` table + `AlertScoringService` |
|
||||
| Subscriptions | Cashier integration + `subscriptions` table |
|
||||
|
||||
---
|
||||
|
||||
## 5. Navigation Groups
|
||||
|
||||
```
|
||||
Dashboard (no group)
|
||||
├── Users
|
||||
Data
|
||||
├── Stations
|
||||
├── Brent Prices
|
||||
├── Oil Predictions
|
||||
System
|
||||
├── API Logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Build Order
|
||||
|
||||
1. Migration — add `is_admin` to `users`
|
||||
2. `AdminSeeder` — seed `uovidiu@sent.com` as admin
|
||||
3. `AdminPanelProvider` — mount panel, configure auth
|
||||
4. `ApiLogResource` — highest immediate value
|
||||
5. `UserResource` — manage admin flag
|
||||
6. `OilPredictionResource` + run-prediction action
|
||||
7. `BrentPriceResource` + chart widget
|
||||
8. `StationResource` + full-poll action
|
||||
9. Dashboard widgets (last — depends on all resources being stable)
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of Scope (v1)
|
||||
|
||||
- Role-based permissions beyond `is_admin` — single admin user is sufficient
|
||||
- Activity log / audit trail of admin actions
|
||||
- Dark mode customisation
|
||||
- Custom Filament theme — use default
|
||||
- Two-factor auth on admin panel — covered by app-level 2FA (Fortify)
|
||||
2
public/css/filament/filament/app.css
Normal file
2
public/css/filament/filament/app.css
Normal file
File diff suppressed because one or more lines are too long
1
public/fonts/filament/filament/inter/index.css
Normal file
1
public/fonts/filament/filament/inter/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
public/js/filament/actions/actions.js
Normal file
1
public/js/filament/actions/actions.js
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
|
||||
1
public/js/filament/filament/app.js
Normal file
1
public/js/filament/filament/app.js
Normal file
File diff suppressed because one or more lines are too long
13
public/js/filament/filament/echo.js
Normal file
13
public/js/filament/filament/echo.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/checkbox-list.js
Normal file
1
public/js/filament/forms/components/checkbox-list.js
Normal file
@@ -0,0 +1 @@
|
||||
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
|
||||
38
public/js/filament/forms/components/code-editor.js
Normal file
38
public/js/filament/forms/components/code-editor.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/color-picker.js
Normal file
1
public/js/filament/forms/components/color-picker.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/date-time-picker.js
Normal file
1
public/js/filament/forms/components/date-time-picker.js
Normal file
File diff suppressed because one or more lines are too long
116
public/js/filament/forms/components/file-upload.js
Normal file
116
public/js/filament/forms/components/file-upload.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/key-value.js
Normal file
1
public/js/filament/forms/components/key-value.js
Normal file
@@ -0,0 +1 @@
|
||||
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
|
||||
51
public/js/filament/forms/components/markdown-editor.js
Normal file
51
public/js/filament/forms/components/markdown-editor.js
Normal file
File diff suppressed because one or more lines are too long
144
public/js/filament/forms/components/rich-editor.js
Normal file
144
public/js/filament/forms/components/rich-editor.js
Normal file
File diff suppressed because one or more lines are too long
11
public/js/filament/forms/components/select.js
Normal file
11
public/js/filament/forms/components/select.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/slider.js
Normal file
1
public/js/filament/forms/components/slider.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/forms/components/tags-input.js
Normal file
1
public/js/filament/forms/components/tags-input.js
Normal file
@@ -0,0 +1 @@
|
||||
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};
|
||||
1
public/js/filament/forms/components/textarea.js
Normal file
1
public/js/filament/forms/components/textarea.js
Normal file
@@ -0,0 +1 @@
|
||||
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
|
||||
1
public/js/filament/notifications/notifications.js
Normal file
1
public/js/filament/notifications/notifications.js
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{function c(s,t=()=>{}){let i=!1;return function(){i?t.apply(this,arguments):(i=!0,s.apply(this,arguments))}}var d=s=>{s.data("notificationComponent",({notification:t})=>({isShown:!1,computedStyle:null,transitionDuration:null,transitionEasing:null,unsubscribeLivewireHook:null,init(){this.computedStyle=window.getComputedStyle(this.$el),this.transitionDuration=parseFloat(this.computedStyle.transitionDuration)*1e3,this.transitionEasing=this.computedStyle.transitionTimingFunction,this.configureTransitions(),this.configureAnimations(),t.duration&&t.duration!=="persistent"&&setTimeout(()=>{if(!this.$el.matches(":hover")){this.close();return}this.$el.addEventListener("mouseleave",()=>this.close())},t.duration),this.isShown=!0},configureTransitions(){let i=this.computedStyle.display,e=()=>{s.mutateDom(()=>{this.$el.style.setProperty("display",i),this.$el.style.setProperty("visibility","visible")}),this.$el._x_isShown=!0},o=()=>{s.mutateDom(()=>{this.$el._x_isShown?this.$el.style.setProperty("visibility","hidden"):this.$el.style.setProperty("display","none")})},r=c(n=>n?e():o(),n=>{this.$el._x_toggleAndCascadeWithTransitions(this.$el,n,e,o)});s.effect(()=>r(this.isShown))},configureAnimations(){let i;this.unsubscribeLivewireHook=Livewire.interceptMessage(({onFinish:e,onSuccess:o})=>{requestAnimationFrame(()=>{let r=()=>this.$el.getBoundingClientRect().top,n=r();e(()=>{i=()=>{this.isShown&&this.$el.animate([{transform:`translateY(${n-r()}px)`},{transform:"translateY(0px)"}],{duration:this.transitionDuration,easing:this.transitionEasing})},this.$el.getAnimations().forEach(l=>l.finish())}),o(({payload:l})=>{l?.snapshot?.data?.isFilamentNotificationsComponent&&typeof i=="function"&&i()})})})},close(){this.isShown=!1,setTimeout(()=>window.dispatchEvent(new CustomEvent("notificationClosed",{detail:{id:t.id}})),this.transitionDuration)},markAsRead(){window.dispatchEvent(new CustomEvent("markedNotificationAsRead",{detail:{id:t.id}}))},markAsUnread(){window.dispatchEvent(new CustomEvent("markedNotificationAsUnread",{detail:{id:t.id}}))},destroy(){this.unsubscribeLivewireHook?.()}}))};var h=class{constructor(){return this.id(crypto.randomUUID?.()??"10000000-1000-4000-8000-100000000000".replace(/[018]/g,t=>(+t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>+t/4).toString(16))),this}id(t){return this.id=t,this}title(t){return this.title=t,this}body(t){return this.body=t,this}actions(t){return this.actions=t,this}status(t){return this.status=t,this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconColor(t){return this.iconColor=t,this}duration(t){return this.duration=t,this}seconds(t){return this.duration(t*1e3),this}persistent(){return this.duration("persistent"),this}danger(){return this.status("danger"),this}info(){return this.status("info"),this}success(){return this.status("success"),this}warning(){return this.status("warning"),this}view(t){return this.view=t,this}viewData(t){return this.viewData=t,this}send(){return window.dispatchEvent(new CustomEvent("notificationSent",{detail:{notification:this}})),this}},a=class{constructor(t){return this.name(t),this}name(t){return this.name=t,this}color(t){return this.color=t,this}dispatch(t,i){return this.event(t),this.eventData(i),this}dispatchSelf(t,i){return this.dispatch(t,i),this.dispatchDirection="self",this}dispatchTo(t,i,e){return this.dispatch(i,e),this.dispatchDirection="to",this.dispatchToComponent=t,this}emit(t,i){return this.dispatch(t,i),this}emitSelf(t,i){return this.dispatchSelf(t,i),this}emitTo(t,i,e){return this.dispatchTo(t,i,e),this}dispatchDirection(t){return this.dispatchDirection=t,this}dispatchToComponent(t){return this.dispatchToComponent=t,this}event(t){return this.event=t,this}eventData(t){return this.eventData=t,this}extraAttributes(t){return this.extraAttributes=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}outlined(t=!0){return this.isOutlined=t,this}disabled(t=!0){return this.isDisabled=t,this}label(t){return this.label=t,this}close(t=!0){return this.shouldClose=t,this}openUrlInNewTab(t=!0){return this.shouldOpenUrlInNewTab=t,this}size(t){return this.size=t,this}url(t){return this.url=t,this}view(t){return this.view=t,this}button(){return this.view("filament::components.button.index"),this}grouped(){return this.view("filament::components.dropdown.list.item"),this}iconButton(){return this.view("filament::components.icon-button"),this}link(){return this.view("filament::components.link"),this}},u=class{constructor(t){return this.actions(t),this}actions(t){return this.actions=t.map(i=>i.grouped()),this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}label(t){return this.label=t,this}tooltip(t){return this.tooltip=t,this}};window.FilamentNotificationAction=a;window.FilamentNotificationActionGroup=u;window.FilamentNotification=h;document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})();
|
||||
1
public/js/filament/schemas/components/actions.js
Normal file
1
public/js/filament/schemas/components/actions.js
Normal file
@@ -0,0 +1 @@
|
||||
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
|
||||
1
public/js/filament/schemas/components/tabs.js
Normal file
1
public/js/filament/schemas/components/tabs.js
Normal file
@@ -0,0 +1 @@
|
||||
function v({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:r}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:i,onSuccess:a})=>{a(()=>{this.$nextTick(()=>{if(i.component.id!==g)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,a,l,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let c=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),o=Math.ceil(c.clientWidth),d=s?Math.ceil(s.clientWidth):0;return{label:o,badge:d,total:o+(d>0?a+d:0)}});for(let n=0;n<t.length;n++){let c=u.slice(0,n+1).reduce((p,y)=>p+y,0),s=n*i,o=b.slice(n+1),d=o.length>0,D=d?Math.max(...o.map(p=>p.total)):0,W=d?l+D+a+h+i:0;if(c+s+W>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),a=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),c=this.findOverflowIndex(i,l,h,b,n,u);i.forEach((s,o)=>s.style.display=a[o]),c!==-1&&(this.withinDropdownIndex=c),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{v as default};
|
||||
1
public/js/filament/schemas/components/wizard.js
Normal file
1
public/js/filament/schemas/components/wizard.js
Normal file
@@ -0,0 +1 @@
|
||||
function p({isSkippable:i,isStepPersistedInQueryString:n,key:r,startStep:o,stepQueryStringKey:h}){return{step:null,init(){this.step=this.getSteps().at(o-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(h,this.step),history.replaceState(null,document.title,t.toString())}}}export{p as default};
|
||||
1
public/js/filament/schemas/schemas.js
Normal file
1
public/js/filament/schemas/schemas.js
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{var o=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var a=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},d=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i})=>({handleFormValidationError(e){e.detail.livewireId===i&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let t=n;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(a(e,t,r)),$set:(t,r,s,l=!1)=>n.$set(a(e,t,s),r,l),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",o),Livewire.interceptMessage(({message:i,onSuccess:e})=>{e(({payload:n})=>{n.effects?.dispatches?.forEach(t=>{if(!t.params?.awaitSchemaComponent)return;let r=Array.from(i.component.el.querySelectorAll(`[wire\\:partial="schema-component::${t.params.awaitSchemaComponent}"]`)).filter(s=>d(s)===i.component);if(r.length!==1){if(r.length>1)throw`Multiple schema components found with key [${t.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${component.id}-${t.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(t.name,{detail:t.params}))},{once:!0})}})})})});})();
|
||||
46
public/js/filament/support/support.js
Normal file
46
public/js/filament/support/support.js
Normal file
File diff suppressed because one or more lines are too long
1
public/js/filament/tables/components/columns/checkbox.js
Normal file
1
public/js/filament/tables/components/columns/checkbox.js
Normal file
@@ -0,0 +1 @@
|
||||
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
|
||||
11
public/js/filament/tables/components/columns/select.js
Normal file
11
public/js/filament/tables/components/columns/select.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||this.getNormalizedState()===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
|
||||
1
public/js/filament/tables/components/columns/toggle.js
Normal file
1
public/js/filament/tables/components/columns/toggle.js
Normal file
@@ -0,0 +1 @@
|
||||
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
|
||||
1
public/js/filament/tables/tables.js
Normal file
1
public/js/filament/tables/tables.js
Normal file
File diff suppressed because one or more lines are too long
30
public/js/filament/widgets/components/chart.js
Normal file
30
public/js/filament/widgets/components/chart.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -21,3 +21,10 @@ Schedule::command('fuel:poll --full')
|
||||
->withoutOverlapping()
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
// Fetch FRED prices and generate oil price prediction daily at 7am
|
||||
Schedule::command('oil:predict --fetch')
|
||||
->dailyAt('07:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
229
tests/Unit/Services/OilPriceServiceTest.php
Normal file
229
tests/Unit/Services/OilPriceServiceTest.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\OilPriceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = new OilPriceService(new ApiLogger);
|
||||
});
|
||||
|
||||
// --- fetchBrentPrices ---
|
||||
|
||||
it('fetches and stores brent prices from FRED', function (): void {
|
||||
Http::fake([
|
||||
'*/fred/series/observations*' => Http::response([
|
||||
'observations' => [
|
||||
['date' => '2026-03-31', 'value' => '74.50'],
|
||||
['date' => '2026-04-01', 'value' => '75.10'],
|
||||
['date' => '2026-04-02', 'value' => '73.80'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->fetchBrentPrices();
|
||||
|
||||
expect(BrentPrice::count())->toBe(3)
|
||||
->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80');
|
||||
});
|
||||
|
||||
it('filters out FRED missing value markers', function (): void {
|
||||
Http::fake([
|
||||
'*/fred/series/observations*' => Http::response([
|
||||
'observations' => [
|
||||
['date' => '2026-04-01', 'value' => '75.10'],
|
||||
['date' => '2026-04-02', 'value' => '.'], // weekend/holiday
|
||||
['date' => '2026-04-03', 'value' => '74.20'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->fetchBrentPrices();
|
||||
|
||||
expect(BrentPrice::count())->toBe(2)
|
||||
->and(BrentPrice::find('2026-04-02'))->toBeNull();
|
||||
});
|
||||
|
||||
it('upserts existing brent price rows on refetch', function (): void {
|
||||
Http::fake([
|
||||
'*/fred/series/observations*' => Http::sequence()
|
||||
->push(['observations' => [['date' => '2026-04-01', 'value' => '74.00']]])
|
||||
->push(['observations' => [['date' => '2026-04-01', 'value' => '75.50']]]),
|
||||
]);
|
||||
|
||||
$this->service->fetchBrentPrices();
|
||||
$this->service->fetchBrentPrices();
|
||||
|
||||
expect(BrentPrice::count())->toBe(1)
|
||||
->and(BrentPrice::find('2026-04-01')->price_usd)->toBe('75.50');
|
||||
});
|
||||
|
||||
// --- generateEwmaPrediction ---
|
||||
|
||||
it('detects a rising trend when 3-day EWMA exceeds 7-day EWMA by threshold', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 70.0 + ($i * 2.0),
|
||||
]));
|
||||
|
||||
$prediction = $this->service->generateEwmaPrediction($prices);
|
||||
|
||||
expect($prediction->direction)->toBe(TrendDirection::Rising)
|
||||
->and($prediction->source)->toBe(PredictionSource::Ewma)
|
||||
->and($prediction->confidence)->toBeGreaterThan(0)
|
||||
->and($prediction->confidence)->toBeLessThanOrEqual(65);
|
||||
});
|
||||
|
||||
it('detects a falling trend when 3-day EWMA falls below 7-day EWMA by threshold', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 85.0 - ($i * 2.0),
|
||||
]));
|
||||
|
||||
$prediction = $this->service->generateEwmaPrediction($prices);
|
||||
|
||||
expect($prediction->direction)->toBe(TrendDirection::Falling)
|
||||
->and($prediction->source)->toBe(PredictionSource::Ewma);
|
||||
});
|
||||
|
||||
it('returns flat when price movement is within threshold', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 75.0 + (($i % 2 === 0) ? 0.1 : -0.1),
|
||||
]));
|
||||
|
||||
$prediction = $this->service->generateEwmaPrediction($prices);
|
||||
|
||||
expect($prediction->direction)->toBe(TrendDirection::Flat)
|
||||
->and($prediction->confidence)->toBe(50);
|
||||
});
|
||||
|
||||
it('returns null when fewer than 14 prices are available', function (): void {
|
||||
$prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(10 - $i)->toDateString(),
|
||||
'price_usd' => 75.0,
|
||||
]));
|
||||
|
||||
expect($this->service->generateEwmaPrediction($prices))->toBeNull();
|
||||
});
|
||||
|
||||
// --- generateLlmPrediction ---
|
||||
|
||||
it('generates an LLM prediction and stores it', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 75.0 + $i,
|
||||
]));
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [
|
||||
['text' => '{"direction":"rising","confidence":72,"reasoning":"Consistent upward trend over 14 days."}'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$prediction = $this->service->generateLlmPrediction($prices);
|
||||
|
||||
expect($prediction->direction)->toBe(TrendDirection::Rising)
|
||||
->and($prediction->source)->toBe(PredictionSource::Llm)
|
||||
->and($prediction->confidence)->toBe(72)
|
||||
->and($prediction->reasoning)->toBe('Consistent upward trend over 14 days.');
|
||||
});
|
||||
|
||||
it('caps LLM confidence at 85', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 75.0,
|
||||
]));
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [
|
||||
['text' => '{"direction":"falling","confidence":99,"reasoning":"Very confident."}'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$prediction = $this->service->generateLlmPrediction($prices);
|
||||
|
||||
expect($prediction->confidence)->toBe(85);
|
||||
});
|
||||
|
||||
it('returns null when LLM returns malformed JSON', function (): void {
|
||||
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
|
||||
'date' => now()->subDays(14 - $i)->toDateString(),
|
||||
'price_usd' => 75.0,
|
||||
]));
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [['text' => 'Sorry, I cannot help with that.']],
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($this->service->generateLlmPrediction($prices))->toBeNull();
|
||||
});
|
||||
|
||||
// --- generatePrediction (orchestrator) ---
|
||||
|
||||
it('uses LLM when API key is configured', function (): void {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::insert(
|
||||
collect(range(1, 20))->map(fn (int $i) => [
|
||||
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||
'price_usd' => 75.0 + $i,
|
||||
])->all()
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [
|
||||
['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$prediction = $this->service->generatePrediction();
|
||||
|
||||
expect($prediction->source)->toBe(PredictionSource::Llm)
|
||||
->and(PricePrediction::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to EWMA when LLM fails', function (): void {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::insert(
|
||||
collect(range(1, 20))->map(fn (int $i) => [
|
||||
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||
'price_usd' => 75.0 + ($i * 0.8),
|
||||
])->all()
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$prediction = $this->service->generatePrediction();
|
||||
|
||||
expect($prediction->source)->toBe(PredictionSource::Ewma)
|
||||
->and(PricePrediction::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when there is insufficient price data', function (): void {
|
||||
BrentPrice::insert([
|
||||
['date' => now()->subDays(2)->toDateString(), 'price_usd' => 75.0],
|
||||
['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0],
|
||||
]);
|
||||
|
||||
expect($this->service->generatePrediction())->toBeNull()
|
||||
->and(PricePrediction::count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user