Files
fuel-alert/docs/superpowers/plans/2026-05-03-filament-forecasting-resources.md
Ovidiu U 8dad223d06 feat(admin): add Filament resources for the forecasting stack
Adds three resources under a new "Forecasting" navigation group: a full-CRUD
WatchedEventResource for the Layer 5 volatility detector, plus read-only
WeeklyForecastResource and BacktestResource so the ridge model output and
its calibration can be inspected without SQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:13:05 +01:00

333 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Filament Admin Resources for the Forecasting Stack
## Goal
Add three Filament v5 admin resources for the new forecasting tables.
The forecasting pipeline has been built end-to-end (see
`docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md`) but
the new tables have no admin UI — the only way to inspect them is via
SQL or `php artisan tinker`.
This plan is self-contained: a fresh Claude session reading only this
document, the spec doc above, and CLAUDE.md should be able to ship the
work without referring back to any other conversation.
---
## Filament version + project conventions
This project uses **Filament v5**. CLAUDE.md captures the namespace
rules — copy-pasted here so you don't have to look:
- Form fields (`TextInput`, `Select`, etc.) → `Filament\Forms\Components\`
- Infolist entries (`TextEntry`, `IconEntry`) → `Filament\Infolists\Components\`
- Layout components (`Grid`, `Section`, `Fieldset`) → `Filament\Schemas\Components\`
- Schema utilities (`Get`, `Set`) → `Filament\Schemas\Components\Utilities\`
- Actions (`DeleteAction`, `CreateAction`) → `Filament\Actions\` (never sub-namespaced)
- Icons → `Filament\Support\Icons\Heroicon` enum
Use static `make()` everywhere. For conditional form logic use
`Get $get` from `Filament\Schemas\Components\Utilities\Get`.
**Before writing the first resource, read one existing resource in the
project to confirm house style.** Good candidates:
- `app/Filament/Resources/ApiLogResource.php` (read-only, similar shape
to `BacktestResource` and `WeeklyForecastResource`)
- Any Stations/Search resource for full-CRUD patterns relevant to
`WatchedEventResource`
Resources auto-discover by directory scan — no panel registration
needed. Use `php artisan make:filament-resource <Model> --no-interaction`
to scaffold each.
---
## Resources to build (in this order)
### 1. WatchedEventResource — full CRUD ★ highest priority
**Why first:** the *only* table with write operations the operator
actually needs. The Layer 5 volatility detector reads
`watched_events` to fire its manual trigger ("Iran tensions AprMay
2026" etc.). Without this resource the only way to add a row is via
SQL — which defeats the point of a manual flag.
**Model:** `App\Models\WatchedEvent` (already exists).
**Schema (already migrated):**
```
id BIGINT PK
label VARCHAR(128)
starts_at DATETIME
ends_at DATETIME
notes TEXT NULL
timestamps
INDEX (starts_at, ends_at)
```
**Operations:** Create, Edit, Delete, List, View.
**Form (Create + Edit):**
- `TextInput::make('label')->required()->maxLength(128)` — short
geopolitical event label, e.g. "Iran tensions AprMay 2026".
- `DateTimePicker::make('starts_at')->required()`
when the event period starts.
- `DateTimePicker::make('ends_at')->required()->after('starts_at')`
when the event period ends. Used by Layer 5 to decide if today is
covered.
- `Textarea::make('notes')->maxLength(2000)` — optional context for
future-you / other operators.
**Table (List):**
Columns:
- `label` — searchable.
- `starts_at` — date/time, sortable.
- `ends_at` — date/time, sortable.
- A computed badge column showing **"Active"** (green) when
`now()` is between `starts_at` and `ends_at`, else "Inactive" (grey).
Implementation: `IconColumn` or `TextColumn` with `state(closure)`
using a Get-style accessor on the model.
- `notes` — toggleable, hidden by default.
Default sort: `starts_at DESC` so the latest event is on top.
**Filters:**
- "Currently active" — toggle filter that adds
`where('starts_at', '<=', now())->where('ends_at', '>=', now())`.
**No infolist needed** — the form view in edit mode is enough.
---
### 2. WeeklyForecastResource — read-only
**Why:** daily/weekly check of "what did the model predict?". Most-
viewed read-only page once forecasts start landing.
**Model:** `App\Models\WeeklyForecast` (already exists, casts in place).
**Schema:**
```
id BIGINT PK
forecast_for DATE — Monday the forecast covers
model_version VARCHAR(64)
direction ENUM('rising','falling','flat')
magnitude_pence SMALLINT — predicted Δ × 100, signed
ridge_confidence TINYINT UNSIGNED — 0..100
flagged_duty_change BOOLEAN
reasoning TEXT
generated_at DATETIME
timestamps
UNIQUE (forecast_for, model_version)
INDEX (forecast_for, generated_at)
```
**Operations:** List, View only. No create / edit / delete (the
ridge model writes these rows; humans never edit forecasts).
In the resource, set:
```php
public static function canCreate(): bool { return false; }
public static function canEdit(Model $record): bool { return false; }
public static function canDelete(Model $record): bool { return false; }
```
**Table:**
Columns (in order):
- `forecast_for` — date, sortable, default sort DESC.
- `direction``BadgeColumn` with three colors:
- `rising` → orange
- `falling` → green (cheaper next week is good news for users)
- `flat` → grey
- `magnitude_pence` — show as `TextColumn` formatted as `X.Xp` (divide
by 100). Use `state(fn ($r) => round($r->magnitude_pence / 100, 1).'p')`.
- `ridge_confidence` — show as `X%`. Color the cell amber when < 40,
default otherwise.
- `flagged_duty_change` `IconColumn::boolean()`, only show when true
(use `visibleFrom` or just always show with off icon hidden).
- `model_version` toggleable, default hidden. Searchable.
- `generated_at` toggleable, default hidden.
Default sort: `forecast_for DESC`.
**Filters:**
- `direction` multi-select.
- "High confidence" toggle: `where('ridge_confidence', '>=', 70)`.
- "Duty-change-adjacent" toggle: `where('flagged_duty_change', true)`.
**Infolist (View page):**
Single-record view should display:
- All columns above.
- The full `reasoning` text in a wide `TextEntry` (it's the
human-readable explanation generated by `ReasoningGenerator`).
- A small `Section::make('Model')` panel with `model_version` as
read-only and a hint "calibration table from the matching backtest
determines the displayed confidence".
---
### 3. BacktestResource — read-only
**Why:** model-health audit. Operator needs to see "is the latest
ridge run still beating the gate? has anything regressed?" without
SQL.
**Model:** `App\Models\Backtest` (already exists, casts in place).
**Schema:**
```
id BIGINT PK
model_version VARCHAR(64) UNIQUE
features_json JSON
coefficients_json JSON NULL
train_start DATE
train_end DATE
eval_start DATE
eval_end DATE
directional_accuracy DECIMAL(5,2) NULL
mae_pence DECIMAL(5,2) NULL
calibration_table JSON NULL
leak_suspected BOOLEAN
ran_at DATETIME
timestamps
INDEX (ran_at)
```
**Operations:** List, View only. Disable create / edit / delete.
**Table:**
Columns:
- `model_version` searchable. Truncate display at 32 chars
(`limit(32)`).
- `directional_accuracy` `TextColumn` formatted `X.X%`. Apply
conditional color:
- red if `< 60`
- amber if `6062` (marginal, per spec)
- green if `6275` (ship gate)
- red if `> 75` AND `leak_suspected = true` (suspicious)
- `mae_pence` `TextColumn` formatted `X.XXp`.
- `leak_suspected` `IconColumn::boolean()` red icon.
- `eval_start` and `eval_end` toggleable, default hidden.
- `ran_at` sortable, default sort DESC.
**Filters:**
- "Suspicious accuracy (leak_suspected)" toggle.
- "Below ship gate" toggle: `where('directional_accuracy', '<', 62)`.
**Infolist (View page):**
This is the most useful screen of the three because the JSON columns
are where the model lives:
- All scalar columns (model_version, accuracy, MAE, leak flag).
- `KeyValueEntry::make('calibration_table')->state(fn ($r) =>
collect($r->calibration_table ?? [])->mapWithKeys(fn ($v, $k) =>
[$k => round($v * 100, 1).'%'])->all())` — render the
`{magnitude_bin → empirical_hit_rate}` map as a key-value table.
- `KeyValueEntry::make('features_json')` — show the feature spec.
- `coefficients_json` — collapsed code block, only shown when
`coefficients_json IS NOT NULL` (the naive baseline has it null).
Use `visible(fn ($r) => $r->coefficients_json !== null)`.
---
## Common conventions
Each resource:
- Uses `php artisan make:filament-resource <Model> --no-interaction`
to scaffold (or copy ApiLogResource as a template).
- Lives in `app/Filament/Resources/` — auto-discovered.
- Has a Heroicon nav icon. Suggestions:
- `WatchedEventResource` → `Heroicon::Flag`
- `WeeklyForecastResource` → `Heroicon::ChartBar`
- `BacktestResource` → `Heroicon::Beaker`
- Has a `navigationGroup = 'Forecasting'` so all three cluster in the
sidebar (set as a static property on the resource class).
- Read-only resources should `protected static bool $shouldRegisterNavigation = true;`
but disable mutating actions per the snippet above.
---
## Tests
Per CLAUDE.md test enforcement: every resource needs at least one
Pest test. Use `pestphp/pest-plugin-livewire` (already installed —
`use function Pest\Livewire\livewire`).
Authenticate as an admin user in `beforeEach`:
```php
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
```
Minimum coverage per resource (one or two assertions each is enough
— Filament's framework handles most of the heavy lifting):
**WatchedEventResource:**
- `it lists watched events` — create a couple, assert `livewire(ListWatchedEvents::class)->assertCanSeeTableRecords($events)`.
- `it creates a watched event from the form` — fill form, call create, assert DB row.
- `it validates ends_at is after starts_at` — submit with bad dates, assert form error.
**WeeklyForecastResource:**
- `it lists weekly forecasts sorted by forecast_for desc`.
- `it disables create and edit` — assert `canCreate()` and `canEdit()` return false.
**BacktestResource:**
- `it lists backtests`.
- `it shows the calibration table on the view page` — create a Backtest with a known calibration_table, view, assert the rendered key-value entries appear.
- `it disables create and edit`.
Tests live under `tests/Feature/Admin/<Resource>Test.php` to match
the existing `OilPredictionResourceTest`-style location.
---
## Definition of Done
1. Three resources live, navigable from the admin sidebar under
"Forecasting".
2. WatchedEventResource supports full CRUD; the other two are
list+view only.
3. Pest tests for each resource pass.
4. `php artisan test --compact --parallel` shows the full suite green
(currently 320 tests + however many you add).
5. `vendor/bin/pint --dirty --format agent` reports `{"result":"pass"}`.
6. The dashboard widget `StatsOverviewWidget::weeklyForecastStat()`
already reads from `weekly_forecasts` — no changes required there.
---
## What you do NOT need to do
- No migrations — all six tables already exist.
- No model changes — `WatchedEvent`, `WeeklyForecast`, `Backtest`
all have correct fillable + casts.
- No new commands or jobs — the data-population pipeline is wired.
- No StatsOverviewWidget changes.
- Do NOT build resources for `LlmOverlay`, `VolatilityRegime`,
`ForecastOutcome`, or `WeeklyPumpPrice`. Those were considered
Tier 2/3 and explicitly deferred.
---
## Reference
- `docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md` —
the source of truth for what each table represents and how the
forecasting pipeline produces its rows.
- `CLAUDE.md` — project conventions, including the Filament rules
reproduced at the top of this plan.
- Existing resources in `app/Filament/Resources/` — copy house style.