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>
This commit is contained in:
Ovidiu U
2026-05-03 09:13:05 +01:00
parent 1c46667f56
commit 8dad223d06
25 changed files with 1289 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
# 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.