# 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 --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 --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/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.