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>
This commit is contained in:
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Backtests;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\Backtests\Pages\ListBacktests;
|
||||
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
|
||||
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
|
||||
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
|
||||
use App\Models\Backtest;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BacktestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Backtest::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||
|
||||
protected static ?string $navigationLabel = 'Backtests';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BacktestInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BacktestsTable::configure($table);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBacktests::route('/'),
|
||||
'view' => ViewBacktest::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Backtests\Pages;
|
||||
|
||||
use App\Filament\Resources\Backtests\BacktestResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBacktests extends ListRecords
|
||||
{
|
||||
protected static string $resource = BacktestResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Backtests\Pages;
|
||||
|
||||
use App\Filament\Resources\Backtests\BacktestResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBacktest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BacktestResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Backtests\Schemas;
|
||||
|
||||
use App\Models\Backtest;
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class BacktestInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->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<string, mixed>|null $features
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Backtests\Tables;
|
||||
|
||||
use App\Models\Backtest;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BacktestsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||
|
||||
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateWatchedEvent extends CreateRecord
|
||||
{
|
||||
protected static string $resource = WatchedEventResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||
|
||||
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWatchedEvent extends EditRecord
|
||||
{
|
||||
protected static string $resource = WatchedEventResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||
|
||||
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWatchedEvents extends ListRecords
|
||||
{
|
||||
protected static string $resource = WatchedEventResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class WatchedEventForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents\Tables;
|
||||
|
||||
use App\Models\WatchedEvent;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class WatchedEventsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WatchedEvents;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
|
||||
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
|
||||
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
|
||||
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
|
||||
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
|
||||
use App\Models\WatchedEvent;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class WatchedEventResource extends Resource
|
||||
{
|
||||
protected static ?string $model = WatchedEvent::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||
|
||||
protected static ?string $navigationLabel = 'Watched Events';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return WatchedEventForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return WatchedEventsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListWatchedEvents::route('/'),
|
||||
'create' => CreateWatchedEvent::route('/create'),
|
||||
'edit' => EditWatchedEvent::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||
|
||||
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWeeklyForecasts extends ListRecords
|
||||
{
|
||||
protected static string $resource = WeeklyForecastResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||
|
||||
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewWeeklyForecast extends ViewRecord
|
||||
{
|
||||
protected static string $resource = WeeklyForecastResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
|
||||
|
||||
use App\Models\WeeklyForecast;
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class WeeklyForecastInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WeeklyForecasts\Tables;
|
||||
|
||||
use App\Models\WeeklyForecast;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class WeeklyForecastsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WeeklyForecasts;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
|
||||
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
|
||||
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
|
||||
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
|
||||
use App\Models\WeeklyForecast;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WeeklyForecastResource extends Resource
|
||||
{
|
||||
protected static ?string $model = WeeklyForecast::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||
|
||||
protected static ?string $navigationLabel = 'Weekly Forecasts';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return WeeklyForecastInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return WeeklyForecastsTable::configure($table);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListWeeklyForecasts::route('/'),
|
||||
'view' => ViewWeeklyForecast::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<WatchedEventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -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<WeeklyForecastFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user