diff --git a/app/Filament/NavigationGroup.php b/app/Filament/NavigationGroup.php index 1342975..4de7274 100644 --- a/app/Filament/NavigationGroup.php +++ b/app/Filament/NavigationGroup.php @@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel case Data; + case Forecasting; + case System; public function getLabel(): string @@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel return match ($this) { self::Users => 'Users', self::Data => 'Data', + self::Forecasting => 'Forecasting', self::System => 'System', }; } @@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel return match ($this) { self::Users => 'heroicon-o-users', self::Data => 'heroicon-o-circle-stack', + self::Forecasting => null, self::System => 'heroicon-o-cog-6-tooth', }; } diff --git a/app/Filament/Resources/Backtests/BacktestResource.php b/app/Filament/Resources/Backtests/BacktestResource.php new file mode 100644 index 0000000..0de95e1 --- /dev/null +++ b/app/Filament/Resources/Backtests/BacktestResource.php @@ -0,0 +1,62 @@ + ListBacktests::route('/'), + 'view' => ViewBacktest::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/Backtests/Pages/ListBacktests.php b/app/Filament/Resources/Backtests/Pages/ListBacktests.php new file mode 100644 index 0000000..be5bfbd --- /dev/null +++ b/app/Filament/Resources/Backtests/Pages/ListBacktests.php @@ -0,0 +1,16 @@ +components([ + Section::make('Run')->columns(3)->schema([ + TextEntry::make('model_version')->columnSpanFull(), + TextEntry::make('directional_accuracy') + ->label('Accuracy') + ->state(fn (Backtest $record): string => $record->directional_accuracy === null + ? '—' + : round((float) $record->directional_accuracy, 1).'%'), + TextEntry::make('mae_pence') + ->label('MAE') + ->state(fn (Backtest $record): string => $record->mae_pence === null + ? '—' + : number_format((float) $record->mae_pence, 2).'p'), + IconEntry::make('leak_suspected') + ->label('Leak suspected') + ->boolean() + ->trueColor('danger'), + TextEntry::make('train_start')->date('d M Y'), + TextEntry::make('train_end')->date('d M Y'), + TextEntry::make('eval_start')->date('d M Y'), + TextEntry::make('eval_end')->date('d M Y'), + TextEntry::make('ran_at')->dateTime('d M Y H:i'), + ]), + Section::make('Calibration table') + ->description('Empirical hit rate per magnitude bin from the eval window.') + ->schema([ + KeyValueEntry::make('calibration_table') + ->hiddenLabel() + ->keyLabel('Magnitude bin') + ->valueLabel('Empirical hit rate') + ->state(fn (Backtest $record): array => collect($record->calibration_table ?? []) + ->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%']) + ->all()) + ->columnSpanFull(), + ]), + Section::make('Feature spec')->schema([ + KeyValueEntry::make('features_json') + ->hiddenLabel() + ->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json)) + ->columnSpanFull(), + ]), + Section::make('Coefficients') + ->visible(fn (Backtest $record) => $record->coefficients_json !== null) + ->collapsed() + ->schema([ + TextEntry::make('coefficients_json') + ->hiddenLabel() + ->state(fn (Backtest $record): string => json_encode( + $record->coefficients_json, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) ?: '') + ->columnSpanFull(), + ]), + ]); + } + + /** + * KeyValueEntry expects a flat string-keyed map, so collapse nested arrays + * into JSON strings rather than dropping them. + * + * @param array|null $features + * @return array + */ + protected static function flattenForKeyValue(?array $features): array + { + return collect($features ?? []) + ->mapWithKeys(fn ($value, $key) => [ + (string) $key => is_scalar($value) + ? (string) $value + : (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''), + ]) + ->all(); + } +} diff --git a/app/Filament/Resources/Backtests/Tables/BacktestsTable.php b/app/Filament/Resources/Backtests/Tables/BacktestsTable.php new file mode 100644 index 0000000..81441a0 --- /dev/null +++ b/app/Filament/Resources/Backtests/Tables/BacktestsTable.php @@ -0,0 +1,94 @@ +columns([ + TextColumn::make('model_version') + ->searchable() + ->limit(32) + ->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null), + TextColumn::make('directional_accuracy') + ->label('Accuracy') + ->state(fn (Backtest $record): string => $record->directional_accuracy === null + ? '—' + : round((float) $record->directional_accuracy, 1).'%') + ->color(fn (Backtest $record) => self::accuracyColor($record)) + ->sortable(), + TextColumn::make('mae_pence') + ->label('MAE') + ->state(fn (Backtest $record): string => $record->mae_pence === null + ? '—' + : number_format((float) $record->mae_pence, 2).'p') + ->sortable(), + IconColumn::make('leak_suspected') + ->label('Leak?') + ->boolean() + ->trueColor('danger'), + TextColumn::make('eval_start') + ->date('d M Y') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('eval_end') + ->date('d M Y') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('ran_at') + ->dateTime('d M Y H:i') + ->sortable(), + ]) + ->defaultSort('ran_at', 'desc') + ->filters([ + Filter::make('leak_suspected') + ->label('Suspicious accuracy (leak suspected)') + ->toggle() + ->query(fn (Builder $query) => $query->where('leak_suspected', true)), + Filter::make('below_ship_gate') + ->label('Below ship gate') + ->toggle() + ->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } + + protected static function accuracyColor(Backtest $record): ?string + { + if ($record->directional_accuracy === null) { + return null; + } + + $accuracy = (float) $record->directional_accuracy; + + if ($accuracy > 75 && $record->leak_suspected) { + return 'danger'; + } + + if ($accuracy < 60) { + return 'danger'; + } + + if ($accuracy < 62) { + return 'warning'; + } + + if ($accuracy <= 75) { + return 'success'; + } + + return null; + } +} diff --git a/app/Filament/Resources/WatchedEvents/Pages/CreateWatchedEvent.php b/app/Filament/Resources/WatchedEvents/Pages/CreateWatchedEvent.php new file mode 100644 index 0000000..f1494fa --- /dev/null +++ b/app/Filament/Resources/WatchedEvents/Pages/CreateWatchedEvent.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('label') + ->required() + ->maxLength(128) + ->helperText('Short geopolitical event label, e.g. "Iran tensions Apr–May 2026".'), + DateTimePicker::make('starts_at') + ->label('Starts at') + ->required(), + DateTimePicker::make('ends_at') + ->label('Ends at') + ->required() + ->after('starts_at'), + Textarea::make('notes') + ->maxLength(2000) + ->rows(4) + ->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/WatchedEvents/Tables/WatchedEventsTable.php b/app/Filament/Resources/WatchedEvents/Tables/WatchedEventsTable.php new file mode 100644 index 0000000..fc9ae72 --- /dev/null +++ b/app/Filament/Resources/WatchedEvents/Tables/WatchedEventsTable.php @@ -0,0 +1,71 @@ +columns([ + TextColumn::make('label') + ->searchable() + ->sortable() + ->limit(60) + ->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null), + TextColumn::make('starts_at') + ->dateTime('d M Y H:i') + ->sortable(), + TextColumn::make('ends_at') + ->dateTime('d M Y H:i') + ->sortable(), + TextColumn::make('status') + ->label('Status') + ->badge() + ->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive') + ->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'), + TextColumn::make('notes') + ->limit(50) + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('starts_at', 'desc') + ->filters([ + Filter::make('currently_active') + ->label('Currently active') + ->toggle() + ->query(fn (Builder $query) => $query + ->where('starts_at', '<=', now()) + ->where('ends_at', '>=', now())), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + protected static function isActive(WatchedEvent $record): bool + { + $now = now(); + + return $record->starts_at !== null + && $record->ends_at !== null + && $record->starts_at->lessThanOrEqualTo($now) + && $record->ends_at->greaterThanOrEqualTo($now); + } +} diff --git a/app/Filament/Resources/WatchedEvents/WatchedEventResource.php b/app/Filament/Resources/WatchedEvents/WatchedEventResource.php new file mode 100644 index 0000000..37c590d --- /dev/null +++ b/app/Filament/Resources/WatchedEvents/WatchedEventResource.php @@ -0,0 +1,48 @@ + ListWatchedEvents::route('/'), + 'create' => CreateWatchedEvent::route('/create'), + 'edit' => EditWatchedEvent::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/WeeklyForecasts/Pages/ListWeeklyForecasts.php b/app/Filament/Resources/WeeklyForecasts/Pages/ListWeeklyForecasts.php new file mode 100644 index 0000000..e29ffba --- /dev/null +++ b/app/Filament/Resources/WeeklyForecasts/Pages/ListWeeklyForecasts.php @@ -0,0 +1,16 @@ +components([ + Section::make('Forecast')->columns(3)->schema([ + TextEntry::make('forecast_for')->date('d M Y'), + TextEntry::make('direction') + ->badge() + ->color(fn (string $state) => match ($state) { + 'rising' => 'warning', + 'falling' => 'success', + default => 'gray', + }), + TextEntry::make('magnitude_pence') + ->label('Magnitude') + ->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)), + TextEntry::make('ridge_confidence') + ->label('Confidence') + ->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%') + ->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null), + IconEntry::make('flagged_duty_change') + ->label('Duty change adjacent') + ->boolean() + ->trueColor('warning'), + TextEntry::make('generated_at')->dateTime('d M Y H:i'), + ]), + Section::make('Reasoning')->schema([ + TextEntry::make('reasoning') + ->columnSpanFull() + ->placeholder('No reasoning recorded.'), + ]), + Section::make('Model') + ->description('Calibration table from the matching backtest determines the displayed confidence.') + ->schema([ + TextEntry::make('model_version')->columnSpanFull(), + ]), + ]); + } + + protected static function formatMagnitude(?int $magnitudePence): string + { + if ($magnitudePence === null) { + return '—'; + } + + $pence = round($magnitudePence / 100, 1); + $sign = $pence > 0 ? '+' : ''; + + return $sign.$pence.'p'; + } +} diff --git a/app/Filament/Resources/WeeklyForecasts/Tables/WeeklyForecastsTable.php b/app/Filament/Resources/WeeklyForecasts/Tables/WeeklyForecastsTable.php new file mode 100644 index 0000000..9dd9311 --- /dev/null +++ b/app/Filament/Resources/WeeklyForecasts/Tables/WeeklyForecastsTable.php @@ -0,0 +1,87 @@ +columns([ + TextColumn::make('forecast_for') + ->label('Forecast for') + ->date('d M Y') + ->sortable(), + TextColumn::make('direction') + ->badge() + ->color(fn (string $state) => match ($state) { + 'rising' => 'warning', + 'falling' => 'success', + default => 'gray', + }), + TextColumn::make('magnitude_pence') + ->label('Magnitude') + ->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)) + ->sortable(), + TextColumn::make('ridge_confidence') + ->label('Confidence') + ->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%') + ->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null) + ->sortable(), + IconColumn::make('flagged_duty_change') + ->label('Duty change') + ->boolean() + ->trueColor('warning'), + TextColumn::make('model_version') + ->searchable() + ->limit(32) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('generated_at') + ->dateTime('d M Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('forecast_for', 'desc') + ->filters([ + SelectFilter::make('direction') + ->multiple() + ->options([ + 'rising' => 'Rising', + 'falling' => 'Falling', + 'flat' => 'Flat', + ]), + Filter::make('high_confidence') + ->label('High confidence') + ->toggle() + ->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)), + Filter::make('flagged_duty_change') + ->label('Duty-change-adjacent') + ->toggle() + ->query(fn (Builder $query) => $query->where('flagged_duty_change', true)), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } + + protected static function formatMagnitude(?int $magnitudePence): string + { + if ($magnitudePence === null) { + return '—'; + } + + $pence = round($magnitudePence / 100, 1); + $sign = $pence > 0 ? '+' : ''; + + return $sign.$pence.'p'; + } +} diff --git a/app/Filament/Resources/WeeklyForecasts/WeeklyForecastResource.php b/app/Filament/Resources/WeeklyForecasts/WeeklyForecastResource.php new file mode 100644 index 0000000..e2c6847 --- /dev/null +++ b/app/Filament/Resources/WeeklyForecasts/WeeklyForecastResource.php @@ -0,0 +1,62 @@ + ListWeeklyForecasts::route('/'), + 'view' => ViewWeeklyForecast::route('/{record}'), + ]; + } +} diff --git a/app/Models/WatchedEvent.php b/app/Models/WatchedEvent.php index 6531f03..3b15933 100644 --- a/app/Models/WatchedEvent.php +++ b/app/Models/WatchedEvent.php @@ -2,7 +2,9 @@ namespace App\Models; +use Database\Factories\WatchedEventFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -13,6 +15,9 @@ use Illuminate\Database\Eloquent\Model; ])] class WatchedEvent extends Model { + /** @use HasFactory */ + use HasFactory; + protected function casts(): array { return [ diff --git a/app/Models/WeeklyForecast.php b/app/Models/WeeklyForecast.php index 48bf305..fd46659 100644 --- a/app/Models/WeeklyForecast.php +++ b/app/Models/WeeklyForecast.php @@ -2,7 +2,9 @@ namespace App\Models; +use Database\Factories\WeeklyForecastFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -17,6 +19,9 @@ use Illuminate\Database\Eloquent\Model; ])] class WeeklyForecast extends Model { + /** @use HasFactory */ + use HasFactory; + protected function casts(): array { return [ diff --git a/database/factories/WatchedEventFactory.php b/database/factories/WatchedEventFactory.php new file mode 100644 index 0000000..4964cd8 --- /dev/null +++ b/database/factories/WatchedEventFactory.php @@ -0,0 +1,39 @@ + */ +class WatchedEventFactory extends Factory +{ + public function definition(): array + { + $startsAt = fake()->dateTimeBetween('-30 days', '+30 days'); + $endsAt = (clone $startsAt)->modify('+'.fake()->numberBetween(1, 14).' days'); + + return [ + 'label' => fake()->sentence(3), + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'notes' => fake()->boolean() ? fake()->paragraph() : null, + ]; + } + + public function active(): static + { + return $this->state([ + 'starts_at' => now()->subDays(2), + 'ends_at' => now()->addDays(2), + ]); + } + + public function inactive(): static + { + return $this->state([ + 'starts_at' => now()->subDays(30), + 'ends_at' => now()->subDays(15), + ]); + } +} diff --git a/database/factories/WeeklyForecastFactory.php b/database/factories/WeeklyForecastFactory.php new file mode 100644 index 0000000..8ba22f7 --- /dev/null +++ b/database/factories/WeeklyForecastFactory.php @@ -0,0 +1,24 @@ + */ +class WeeklyForecastFactory extends Factory +{ + public function definition(): array + { + return [ + 'forecast_for' => now()->startOfWeek()->toDateString(), + 'model_version' => 'ridge-'.fake()->unique()->bothify('????????'), + 'direction' => fake()->randomElement(['rising', 'falling', 'flat']), + 'magnitude_pence' => fake()->numberBetween(-300, 300), + 'ridge_confidence' => fake()->numberBetween(20, 90), + 'flagged_duty_change' => false, + 'reasoning' => fake()->paragraph(), + 'generated_at' => now(), + ]; + } +} diff --git a/docs/superpowers/plans/2026-05-03-filament-forecasting-resources.md b/docs/superpowers/plans/2026-05-03-filament-forecasting-resources.md new file mode 100644 index 0000000..010ecff --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-filament-forecasting-resources.md @@ -0,0 +1,332 @@ +# 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. diff --git a/tests/Feature/Admin/BacktestResourceTest.php b/tests/Feature/Admin/BacktestResourceTest.php new file mode 100644 index 0000000..43cee42 --- /dev/null +++ b/tests/Feature/Admin/BacktestResourceTest.php @@ -0,0 +1,59 @@ +admin = User::factory()->admin()->create(); + $this->actingAs($this->admin); +}); + +it('lists backtests', function () { + $backtests = Backtest::factory()->count(3)->create(); + + Livewire::test(ListBacktests::class) + ->assertOk() + ->assertCanSeeTableRecords($backtests); +}); + +it('shows the calibration table on the view page', function () { + $backtest = Backtest::factory()->create([ + 'calibration_table' => [ + '0.0-0.5' => 0.55, + '0.5-1.0' => 0.65, + '1.0+' => 0.72, + ], + ]); + + Livewire::test(ViewBacktest::class, ['record' => $backtest->id]) + ->assertOk() + ->assertSee('0.0-0.5') + ->assertSee('55%') + ->assertSee('1.0+') + ->assertSee('72%'); +}); + +it('disables create and edit', function () { + $backtest = Backtest::factory()->create(); + + expect(BacktestResource::canCreate())->toBeFalse() + ->and(BacktestResource::canEdit($backtest))->toBeFalse() + ->and(BacktestResource::canDelete($backtest))->toBeFalse(); +}); + +it('filters backtests below the ship gate', function () { + $shippable = Backtest::factory()->create(['directional_accuracy' => 65.00]); + $marginal = Backtest::factory()->create(['directional_accuracy' => 58.00]); + + Livewire::test(ListBacktests::class) + ->filterTable('below_ship_gate') + ->assertCanSeeTableRecords([$marginal]) + ->assertCanNotSeeTableRecords([$shippable]); +}); diff --git a/tests/Feature/Admin/WatchedEventResourceTest.php b/tests/Feature/Admin/WatchedEventResourceTest.php new file mode 100644 index 0000000..f8ed21e --- /dev/null +++ b/tests/Feature/Admin/WatchedEventResourceTest.php @@ -0,0 +1,58 @@ +admin = User::factory()->admin()->create(); + $this->actingAs($this->admin); +}); + +it('lists watched events', function () { + $events = WatchedEvent::factory()->count(3)->create(); + + Livewire::test(ListWatchedEvents::class) + ->assertOk() + ->assertCanSeeTableRecords($events); +}); + +it('creates a watched event from the form', function () { + Livewire::test(CreateWatchedEvent::class) + ->fillForm([ + 'label' => 'Iran tensions Apr–May 2026', + 'starts_at' => '2026-04-01 00:00:00', + 'ends_at' => '2026-05-31 23:59:00', + 'notes' => 'Geopolitical event affecting Brent crude.', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(WatchedEvent::where('label', 'Iran tensions Apr–May 2026')->exists())->toBeTrue(); +}); + +it('validates ends_at is after starts_at', function () { + Livewire::test(CreateWatchedEvent::class) + ->fillForm([ + 'label' => 'Bad dates', + 'starts_at' => '2026-05-01 00:00:00', + 'ends_at' => '2026-04-01 00:00:00', + ]) + ->call('create') + ->assertHasFormErrors(['ends_at']); +}); + +it('filters to currently active events', function () { + $active = WatchedEvent::factory()->active()->create(['label' => 'Active event']); + $inactive = WatchedEvent::factory()->inactive()->create(['label' => 'Inactive event']); + + Livewire::test(ListWatchedEvents::class) + ->filterTable('currently_active') + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$inactive]); +}); diff --git a/tests/Feature/Admin/WeeklyForecastResourceTest.php b/tests/Feature/Admin/WeeklyForecastResourceTest.php new file mode 100644 index 0000000..aeca237 --- /dev/null +++ b/tests/Feature/Admin/WeeklyForecastResourceTest.php @@ -0,0 +1,43 @@ +admin = User::factory()->admin()->create(); + $this->actingAs($this->admin); +}); + +it('lists weekly forecasts sorted by forecast_for desc', function () { + $older = WeeklyForecast::factory()->create(['forecast_for' => '2026-04-06']); + $newer = WeeklyForecast::factory()->create(['forecast_for' => '2026-05-04']); + + Livewire::test(ListWeeklyForecasts::class) + ->assertOk() + ->assertCanSeeTableRecords([$newer, $older], inOrder: true); +}); + +it('disables create and edit', function () { + $forecast = WeeklyForecast::factory()->create(); + + expect(WeeklyForecastResource::canCreate())->toBeFalse(); + expect(WeeklyForecastResource::canEdit($forecast))->toBeFalse(); + expect(WeeklyForecastResource::canDelete($forecast))->toBeFalse(); +}); + +it('renders the view page for a forecast', function () { + $forecast = WeeklyForecast::factory()->create([ + 'reasoning' => 'Brent stabilising; supermarket cycle entering bottom.', + ]); + + Livewire::test(ViewWeeklyForecast::class, ['record' => $forecast->id]) + ->assertOk() + ->assertSee('Brent stabilising; supermarket cycle entering bottom.'); +});