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:
@@ -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 Apr–May
|
||||
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 Apr–May 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 `60–62` (marginal, per spec)
|
||||
- green if `62–75` (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.
|
||||
Reference in New Issue
Block a user