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 Permalink 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.