feat: add OilPredictionResource with run-prediction header action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-04 14:20:51 +01:00
parent d602c8bde4
commit d936175090
4 changed files with 216 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Filament\Resources;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
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 OilPredictionResource extends Resource
{
protected static ?string $model = PricePrediction::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
protected static string|\UnitEnum|null $navigationGroup = 'Data';
protected static ?string $navigationLabel = 'Oil Predictions';
protected static ?int $navigationSort = 3;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('predicted_for')
->date('d M Y')
->sortable(),
TextColumn::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::Ewma => 'info',
}),
TextColumn::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextColumn::make('confidence')
->suffix('%')
->sortable(),
TextColumn::make('reasoning')
->limit(60)
->placeholder('—'),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('predicted_for', 'desc')
->filters([
SelectFilter::make('source')
->options([
PredictionSource::Llm->value => 'LLM',
PredictionSource::Ewma->value => 'EWMA',
]),
SelectFilter::make('direction')
->options([
TrendDirection::Rising->value => 'Rising',
TrendDirection::Falling->value => 'Falling',
TrendDirection::Flat->value => 'Flat',
]),
Filter::make('predicted_for')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(function (Builder $query, array $data) {
$query
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
}),
])
->recordActions([
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Prediction')->schema([
TextEntry::make('predicted_for')->date('d M Y'),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::Ewma => 'info',
}),
TextEntry::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextEntry::make('confidence')->suffix('%'),
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
])->columns(3),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => ListOilPredictions::route('/'),
'view' => ViewOilPrediction::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListOilPredictions extends ListRecords
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('runPrediction')
->label('Run Prediction Now')
->icon('heroicon-o-cpu-chip')
->requiresConfirmation()
->modalHeading('Run oil price prediction?')
->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.')
->action(function () {
$result = Artisan::call('oil:predict', ['--fetch' => true]);
if ($result === 0) {
Notification::make()
->title('Prediction generated successfully')
->success()
->send();
} else {
Notification::make()
->title('Prediction failed')
->body('Check API Logs for details.')
->danger()
->send();
}
}),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOilPrediction extends ViewRecord
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,27 @@
<?php
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Models\PricePrediction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
it('renders the oil prediction list', function () {
$predictions = PricePrediction::factory()->count(3)->create();
Livewire::test(ListOilPredictions::class)
->assertOk()
->assertCanSeeTableRecords($predictions);
});
it('has a run prediction header action', function () {
Livewire::test(ListOilPredictions::class)
->assertActionExists('runPrediction');
});