diff --git a/app/Livewire/ConversionHistoryTable.php b/app/Livewire/ConversionHistoryTable.php
new file mode 100644
index 0000000..c215708
--- /dev/null
+++ b/app/Livewire/ConversionHistoryTable.php
@@ -0,0 +1,182 @@
+resetPage();
+ }
+
+ public function updatedStatus(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedSourceFormat(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedTargetFormat(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedDateFrom(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedDateTo(): void
+ {
+ $this->resetPage();
+ }
+
+ public function statusOptions(): array
+ {
+ return [
+ 'all' => 'All',
+ 'queued' => 'Queued',
+ 'processing' => 'Processing',
+ 'completed' => 'Completed',
+ 'failed' => 'Failed',
+ 'cancelled' => 'Cancelled',
+ 'expired' => 'Expired',
+ ];
+ }
+
+ public function formatOptions(): array
+ {
+ return [
+ 'all' => 'All formats',
+ 'png' => 'PNG',
+ 'jpg' => 'JPG',
+ 'webp' => 'WEBP',
+ 'pdf' => 'PDF',
+ 'gif' => 'GIF',
+ 'bmp' => 'BMP',
+ 'svg' => 'SVG',
+ 'tiff' => 'TIFF',
+ ];
+ }
+
+ public function jobs(): LengthAwarePaginator
+ {
+ return ConversionJob::query()
+ ->where('user_id', auth()->id())
+ ->with(['sourceFile', 'resultFile', 'creditCharge'])
+ ->when(trim($this->search) !== '', function (Builder $query) {
+ $search = '%'.trim($this->search).'%';
+
+ $query->where(function (Builder $query) use ($search) {
+ $query
+ ->where('source_format', 'like', $search)
+ ->orWhere('target_format', 'like', $search)
+ ->orWhereHas('sourceFile', fn (Builder $q) => $q->where('original_name', 'like', $search))
+ ->orWhereHas('resultFile', fn (Builder $q) => $q->where('original_name', 'like', $search));
+ });
+ })
+ ->when($this->status !== 'all', fn (Builder $query) => $query->where('status', $this->status))
+ ->when($this->sourceFormat !== 'all', fn (Builder $query) => $query->where('source_format', $this->sourceFormat))
+ ->when($this->targetFormat !== 'all', fn (Builder $query) => $query->where('target_format', $this->targetFormat))
+ ->when($this->dateFrom, fn (Builder $query) => $query->whereDate('created_at', '>=', $this->dateFrom))
+ ->when($this->dateTo, fn (Builder $query) => $query->whereDate('created_at', '<=', $this->dateTo))
+ ->latest()
+ ->paginate(15);
+ }
+
+ public function canDownload(ConversionJob $job): bool
+ {
+ return $job->isCompleted()
+ && $job->result_file_id !== null
+ && $job->resultFile !== null
+ && ! $job->resultFile->isExpired();
+ }
+
+ public function canConvertAgain(ConversionJob $job): bool
+ {
+ return $job->sourceFile !== null && ! $job->sourceFile->isExpired();
+ }
+
+ public function convertAgain(int $jobId): void
+ {
+ $job = ConversionJob::query()
+ ->where('user_id', auth()->id())
+ ->with('sourceFile')
+ ->find($jobId);
+
+ if (! $job || ! $this->canConvertAgain($job)) {
+ $this->dispatch('toast', type: 'error', message: 'Original file expired. Upload it again to repeat this conversion.');
+
+ return;
+ }
+
+ $this->dispatch('conversion-repeat-requested', conversionJobId: $job->id);
+ $this->redirectRoute('dashboard');
+ }
+
+ public function statusBadgeVariant(ConversionStatus $status): string
+ {
+ return match ($status) {
+ ConversionStatus::Queued => 'purple',
+ ConversionStatus::Processing => 'warning',
+ ConversionStatus::Completed => 'success',
+ ConversionStatus::Failed => 'danger',
+ default => 'neutral',
+ };
+ }
+
+ public function formatBytes(?int $bytes): string
+ {
+ if ($bytes === null) {
+ return '—';
+ }
+
+ if ($bytes >= 1_000_000_000) {
+ return round($bytes / 1_000_000_000, 1).' GB';
+ }
+
+ if ($bytes >= 1_000_000) {
+ return round($bytes / 1_000_000, 1).' MB';
+ }
+
+ if ($bytes >= 1_000) {
+ return round($bytes / 1_000).' KB';
+ }
+
+ return $bytes.' B';
+ }
+
+ public function render(): View
+ {
+ return view('livewire.conversion-history-table', [
+ 'jobs' => $this->jobs(),
+ ]);
+ }
+}
diff --git a/resources/views/history/index.blade.php b/resources/views/history/index.blade.php
new file mode 100644
index 0000000..31e08ee
--- /dev/null
+++ b/resources/views/history/index.blade.php
@@ -0,0 +1,10 @@
+
+
+ Conversion History
+ Find and reuse your previous conversions.
+
+
+
+
+
+
diff --git a/resources/views/livewire/conversion-history-table.blade.php b/resources/views/livewire/conversion-history-table.blade.php
new file mode 100644
index 0000000..139cf5f
--- /dev/null
+++ b/resources/views/livewire/conversion-history-table.blade.php
@@ -0,0 +1,135 @@
+
+ {{-- Filters --}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{-- Table --}}
+
+ @if ($jobs->isEmpty())
+
+
No conversions found
+
Try adjusting your filters or upload a file to start converting
+
+ @else
+
+
+
+
+ | File Name |
+ From |
+ To |
+ Size |
+ Created |
+ Completed |
+ Status |
+ Credits |
+ Actions |
+
+
+
+ @foreach ($jobs as $job)
+
+ |
+ {{ $job->sourceFile?->original_name ?? '—' }}
+ |
+
+ {{ $job->source_format ? strtoupper($job->source_format) : '—' }}
+ |
+
+ {{ $job->target_format ? strtoupper($job->target_format) : '—' }}
+ |
+
+ {{ $this->formatBytes($job->sourceFile?->size_bytes) }}
+ |
+
+ {{ $job->created_at->format('M d, Y H:i') }}
+ |
+
+ {{ $job->completed_at?->format('M d, Y H:i') ?? '—' }}
+ |
+
+
+ {{ ucfirst($job->status->value) }}
+
+ |
+
+ @if ($job->creditCharge !== null && $job->creditCharge->captured_amount !== null)
+ {{ $job->creditCharge->captured_amount }} {{ $job->creditCharge->captured_amount === 1 ? 'credit' : 'credits' }}
+ @else
+ —
+ @endif
+ |
+
+
+ @if ($this->canDownload($job))
+ Download
+ @endif
+
+ @if ($this->canConvertAgain($job))
+
+ @endif
+
+ |
+
+ @endforeach
+
+
+
+
+
+ {{ $jobs->links() }}
+
+ @endif
+
+
diff --git a/routes/web.php b/routes/web.php
index eea5ae5..46c4ec5 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -36,6 +36,9 @@
Route::view('/billing/credits/success', 'billing.credits-success')->name('billing.credits.success');
Route::view('/billing/credits/cancel', 'billing.credits-cancel')->name('billing.credits.cancel');
+ Route::view('/history', 'history.index')
+ ->name('history');
+
Route::get('/conversions/{conversion}/download', DownloadConversionResultController::class)
->name('conversions.download');
diff --git a/tests/Feature/History/HistoryPageAccessTest.php b/tests/Feature/History/HistoryPageAccessTest.php
new file mode 100644
index 0000000..88b3667
--- /dev/null
+++ b/tests/Feature/History/HistoryPageAccessTest.php
@@ -0,0 +1,19 @@
+get('/history')
+ ->assertRedirect('/login');
+});
+
+it('allows authenticated user to access history page', function () {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->get('/history')
+ ->assertOk()
+ ->assertSee('Conversion History');
+});
diff --git a/tests/Feature/History/HistoryPageSmokeTest.php b/tests/Feature/History/HistoryPageSmokeTest.php
new file mode 100644
index 0000000..612bf46
--- /dev/null
+++ b/tests/Feature/History/HistoryPageSmokeTest.php
@@ -0,0 +1,65 @@
+create();
+
+ $source = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'final-smoke.png',
+ ]);
+
+ $result = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'final-smoke.jpg',
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ $job = ConversionJob::factory()->for($user)->completed()->create([
+ 'source_file_id' => $source->id,
+ 'result_file_id' => $result->id,
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->create([
+ 'captured_amount' => 1,
+ 'status' => 'captured',
+ ]);
+
+ $this->actingAs($user)
+ ->get('/history')
+ ->assertOk()
+ ->assertSee('Conversion History')
+ ->assertSee('final-smoke.png')
+ ->assertSee('PNG')
+ ->assertSee('JPG')
+ ->assertSee('Completed')
+ ->assertSee('1 credit');
+});
+
+it('does not leak other user conversion data on history page', function () {
+ $user = User::factory()->create();
+ $otherUser = User::factory()->create();
+
+ $ownFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'my-file.png',
+ ]);
+
+ $otherFile = FileRecord::factory()->for($otherUser)->create([
+ 'original_name' => 'their-file.png',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($ownFile, 'sourceFile')->create();
+ ConversionJob::factory()->for($otherUser)->for($otherFile, 'sourceFile')->create();
+
+ $this->actingAs($user)
+ ->get('/history')
+ ->assertOk()
+ ->assertSee('my-file.png')
+ ->assertDontSee('their-file.png');
+});
diff --git a/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php b/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php
new file mode 100644
index 0000000..7b517a6
--- /dev/null
+++ b/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php
@@ -0,0 +1,85 @@
+create();
+
+ $result = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'photo.jpg',
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ $job = ConversionJob::factory()->for($user)->completed()->create([
+ 'result_file_id' => $result->id,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('Download')
+ ->assertSee(route('conversions.download', $job));
+});
+
+it('does not show active download for expired result', function () {
+ $user = User::factory()->create();
+
+ $result = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'photo.jpg',
+ 'expires_at' => now()->subDay(),
+ ]);
+
+ ConversionJob::factory()->for($user)->completed()->create([
+ 'result_file_id' => $result->id,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertDontSee('Download');
+});
+
+it('does not show download for failed job', function () {
+ $user = User::factory()->create();
+
+ ConversionJob::factory()->for($user)->failed()->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertDontSee('Download');
+});
+
+it('shows convert again for completed job with available source file', function () {
+ $user = User::factory()->create();
+
+ $source = FileRecord::factory()->for($user)->create([
+ 'expires_at' => now()->addDay(),
+ ]);
+
+ $job = ConversionJob::factory()->for($user)->completed()->for($source, 'sourceFile')->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('Convert Again')
+ ->call('convertAgain', $job->id)
+ ->assertDispatched('conversion-repeat-requested', conversionJobId: $job->id)
+ ->assertRedirect(route('dashboard'));
+});
+
+it('does not show convert again when source file is expired', function () {
+ $user = User::factory()->create();
+
+ $source = FileRecord::factory()->for($user)->create([
+ 'expires_at' => now()->subDay(),
+ ]);
+
+ ConversionJob::factory()->for($user)->completed()->for($source, 'sourceFile')->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertDontSee('Convert Again');
+});
diff --git a/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php b/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php
new file mode 100644
index 0000000..aa615d7
--- /dev/null
+++ b/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php
@@ -0,0 +1,187 @@
+create();
+
+ $matchingFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'marketing-banner.png',
+ ]);
+
+ $otherFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'invoice.pdf',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($matchingFile, 'sourceFile')->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($otherFile, 'sourceFile')->create([
+ 'source_format' => 'pdf',
+ 'target_format' => 'jpg',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('search', 'marketing')
+ ->assertSee('marketing-banner.png')
+ ->assertDontSee('invoice.pdf');
+});
+
+it('filters history by search query on format', function () {
+ $user = User::factory()->create();
+
+ $pngFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'image.png',
+ ]);
+
+ $pdfFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'document.pdf',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($pngFile, 'sourceFile')->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($pdfFile, 'sourceFile')->create([
+ 'source_format' => 'pdf',
+ 'target_format' => 'jpg',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('search', 'png')
+ ->assertSee('image.png')
+ ->assertDontSee('document.pdf');
+});
+
+it('filters history by status', function () {
+ $user = User::factory()->create();
+
+ $completedSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'completed-file.png',
+ ]);
+
+ $failedSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'failed-file.png',
+ ]);
+
+ ConversionJob::factory()->for($user)->completed()->for($completedSource, 'sourceFile')->create();
+ ConversionJob::factory()->for($user)->failed()->for($failedSource, 'sourceFile')->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('status', 'completed')
+ ->assertSee('completed-file.png')
+ ->assertDontSee('failed-file.png');
+});
+
+it('filters history by date range', function () {
+ $user = User::factory()->create();
+
+ $insideFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'inside-range.png',
+ ]);
+
+ $outsideFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'outside-range.png',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($insideFile, 'sourceFile')->create([
+ 'created_at' => now()->subDays(3),
+ ]);
+
+ ConversionJob::factory()->for($user)->for($outsideFile, 'sourceFile')->create([
+ 'created_at' => now()->subDays(30),
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('dateFrom', now()->subDays(7)->toDateString())
+ ->set('dateTo', now()->toDateString())
+ ->assertSee('inside-range.png')
+ ->assertDontSee('outside-range.png');
+});
+
+it('filters history by source format', function () {
+ $user = User::factory()->create();
+
+ $pngSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'png-source-file.png',
+ ]);
+
+ $jpgSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'jpg-source-file.jpg',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($pngSource, 'sourceFile')->create([
+ 'source_format' => 'png',
+ 'target_format' => 'webp',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($jpgSource, 'sourceFile')->create([
+ 'source_format' => 'jpg',
+ 'target_format' => 'webp',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('sourceFormat', 'png')
+ ->assertSee('png-source-file.png')
+ ->assertDontSee('jpg-source-file.jpg');
+});
+
+it('filters history by target format', function () {
+ $user = User::factory()->create();
+
+ $jpgTargetSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'to-jpg-file.png',
+ ]);
+
+ $webpTargetSource = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'to-webp-file.png',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($jpgTargetSource, 'sourceFile')->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ ConversionJob::factory()->for($user)->for($webpTargetSource, 'sourceFile')->create([
+ 'source_format' => 'png',
+ 'target_format' => 'webp',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->set('targetFormat', 'jpg')
+ ->assertSee('to-jpg-file.png')
+ ->assertDontSee('to-webp-file.png');
+});
+
+it('resets pagination when filter changes', function () {
+ $user = User::factory()->create();
+
+ for ($i = 0; $i < 20; $i++) {
+ ConversionJob::factory()->for($user)->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+ }
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->call('gotoPage', 2)
+ ->assertSet('paginators.page', 2)
+ ->set('search', 'something')
+ ->assertSet('paginators.page', 1);
+});
diff --git a/tests/Feature/Livewire/ConversionHistoryTableTest.php b/tests/Feature/Livewire/ConversionHistoryTableTest.php
new file mode 100644
index 0000000..eaf0423
--- /dev/null
+++ b/tests/Feature/Livewire/ConversionHistoryTableTest.php
@@ -0,0 +1,146 @@
+create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertOk();
+});
+
+it('shows only current user conversion jobs in history', function () {
+ $user = User::factory()->create();
+ $otherUser = User::factory()->create();
+
+ $ownFile = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'own-image.png',
+ ]);
+
+ $otherFile = FileRecord::factory()->for($otherUser)->create([
+ 'original_name' => 'other-image.png',
+ ]);
+
+ ConversionJob::factory()
+ ->for($user)
+ ->for($ownFile, 'sourceFile')
+ ->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ ConversionJob::factory()
+ ->for($otherUser)
+ ->for($otherFile, 'sourceFile')
+ ->create([
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('own-image.png')
+ ->assertDontSee('other-image.png');
+});
+
+it('renders conversion history table columns', function () {
+ $user = User::factory()->create();
+
+ $source = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'photo.png',
+ 'size_bytes' => 102400,
+ ]);
+
+ $result = FileRecord::factory()->for($user)->create([
+ 'original_name' => 'photo.jpg',
+ 'size_bytes' => 51200,
+ ]);
+
+ ConversionJob::factory()->for($user)->create([
+ 'source_file_id' => $source->id,
+ 'result_file_id' => $result->id,
+ 'source_format' => 'png',
+ 'target_format' => 'jpg',
+ 'status' => ConversionStatus::Completed,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('photo.png')
+ ->assertSee('PNG')
+ ->assertSee('JPG')
+ ->assertSee('Completed');
+});
+
+it('renders status badges in history table', function () {
+ $user = User::factory()->create();
+
+ ConversionJob::factory()->for($user)->completed()->create();
+ ConversionJob::factory()->for($user)->failed()->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('Completed')
+ ->assertSee('Failed');
+});
+
+it('renders captured credit cost in history table', function () {
+ $user = User::factory()->create();
+
+ $job = ConversionJob::factory()->for($user)->completed()->create();
+
+ ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->create([
+ 'captured_amount' => 2,
+ 'status' => 'captured',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('2 credits');
+});
+
+it('renders dash when job has no captured credit charge', function () {
+ $user = User::factory()->create();
+
+ ConversionJob::factory()->for($user)->failed()->create();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class)
+ ->assertSee('—');
+});
+
+it('eager-loads sourceFile resultFile and creditCharge without N+1 queries', function () {
+ $user = User::factory()->create();
+
+ for ($i = 0; $i < 5; $i++) {
+ $source = FileRecord::factory()->for($user)->create();
+ $result = FileRecord::factory()->for($user)->create();
+ $job = ConversionJob::factory()->for($user)->completed()->create([
+ 'source_file_id' => $source->id,
+ 'result_file_id' => $result->id,
+ ]);
+ ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->captured()->create();
+ }
+
+ DB::enableQueryLog();
+
+ Livewire::actingAs($user)
+ ->test(ConversionHistoryTable::class);
+
+ $queryCount = count(DB::getQueryLog());
+ DB::disableQueryLog();
+
+ // With eager loading: auth check + jobs + sourceFile + resultFile + creditCharge + pagination count = ~6-7 queries.
+ // N+1 would produce 3 queries per row (15+ queries for 5 rows).
+ expect($queryCount)->toBeLessThan(10);
+});