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

12 KiB
Raw Blame History

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:

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.
  • directionBadgeColumn 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_changeIconColumn::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_accuracyTextColumn 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_penceTextColumn formatted X.XXp.
  • leak_suspectedIconColumn::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:
    • WatchedEventResourceHeroicon::Flag
    • WeeklyForecastResourceHeroicon::ChartBar
    • BacktestResourceHeroicon::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, 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.