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