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>
12 KiB
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\Heroiconenum
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 toBacktestResourceandWeeklyForecastResource)- 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 betweenstarts_atandends_at, else "Inactive" (grey). Implementation:IconColumnorTextColumnwithstate(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:
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—BadgeColumnwith three colors:rising→ orangefalling→ green (cheaper next week is good news for users)flat→ grey
magnitude_pence— show asTextColumnformatted asX.Xp(divide by 100). Usestate(fn ($r) => round($r->magnitude_pence / 100, 1).'p').ridge_confidence— show asX%. Color the cell amber when < 40, default otherwise.flagged_duty_change—IconColumn::boolean(), only show when true (usevisibleFromor 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
reasoningtext in a wideTextEntry(it's the human-readable explanation generated byReasoningGenerator). - A small
Section::make('Model')panel withmodel_versionas 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—TextColumnformattedX.X%. Apply conditional color:- red if
< 60 - amber if
60–62(marginal, per spec) - green if
62–75(ship gate) - red if
> 75ANDleak_suspected = true(suspicious)
- red if
mae_pence—TextColumnformattedX.XXp.leak_suspected—IconColumn::boolean()red icon.eval_startandeval_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 whencoefficients_json IS NOT NULL(the naive baseline has it null). Usevisible(fn ($r) => $r->coefficients_json !== null).
Common conventions
Each resource:
- Uses
php artisan make:filament-resource <Model> --no-interactionto scaffold (or copy ApiLogResource as a template). - Lives in
app/Filament/Resources/— auto-discovered. - Has a Heroicon nav icon. Suggestions:
WatchedEventResource→Heroicon::FlagWeeklyForecastResource→Heroicon::ChartBarBacktestResource→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:
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, assertlivewire(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— assertcanCreate()andcanEdit()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
- Three resources live, navigable from the admin sidebar under "Forecasting".
- WatchedEventResource supports full CRUD; the other two are list+view only.
- Pest tests for each resource pass.
php artisan test --compact --parallelshows the full suite green (currently 320 tests + however many you add).vendor/bin/pint --dirty --format agentreports{"result":"pass"}.- The dashboard widget
StatsOverviewWidget::weeklyForecastStat()already reads fromweekly_forecasts— no changes required there.
What you do NOT need to do
- No migrations — all six tables already exist.
- No model changes —
WatchedEvent,WeeklyForecast,Backtestall 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, orWeeklyPumpPrice. 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.