diff --git a/app/Livewire/Recordings/Index.php b/app/Livewire/Recordings/Index.php index 6922be1..3d3a8f7 100644 --- a/app/Livewire/Recordings/Index.php +++ b/app/Livewire/Recordings/Index.php @@ -5,6 +5,8 @@ namespace App\Livewire\Recordings; use App\Models\Recording; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; @@ -25,9 +27,26 @@ class Index extends Component #[Url(as: 'q', history: true)] public string $search = ''; + #[Url(as: 'sort', history: true)] + public string $sortBy = 'uploaded'; + + #[Url(as: 'dir', history: true)] + public string $sortDirection = 'desc'; + + /** + * @var array + */ + private const SORTABLE = [ + 'title' => 'title', + 'duration' => 'duration_seconds', + 'status' => 'transcription_status', + 'uploaded' => 'created_at', + ]; + public function mount(): void { $this->userId = (int) Auth::id(); + $this->normalizeSort(); } public function updatedSearch(): void @@ -35,6 +54,22 @@ class Index extends Component $this->resetPage(); } + public function sort(string $column): void + { + if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) { + return; + } + + if ($this->sortBy === $column) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc'; + } + + $this->resetPage(); + } + /** * Re-render when any of this user's recordings broadcast a status change. */ @@ -127,7 +162,9 @@ class Index extends Component $totalCount = $user->recordings()->count(); - $query = $user->recordings()->latest(); + $this->normalizeSort(); + + $query = $user->recordings(); $search = trim($this->search); @@ -135,6 +172,8 @@ class Index extends Component $query->search($search); } + $this->applySort($query); + $recordings = $query->paginate(20); $pendingCount = $user->recordings() @@ -155,4 +194,34 @@ class Index extends Component 'totalCount' => $totalCount, ]); } + + private function normalizeSort(): void + { + if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) { + $this->sortBy = 'uploaded'; + } + + if (! in_array($this->sortDirection, ['asc', 'desc'], true)) { + $this->sortDirection = 'desc'; + } + } + + /** + * @param Builder|HasMany $query + */ + private function applySort(Builder|HasMany $query): void + { + $direction = $this->sortDirection === 'asc' ? 'asc' : 'desc'; + + if ($this->sortBy === 'words') { + $query->orderByRaw( + 'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction, + ['', ' ', ''], + ); + } else { + $query->orderBy(self::SORTABLE[$this->sortBy], $direction); + } + + $query->orderByDesc('id'); + } } diff --git a/app/Livewire/Recordings/Show.php b/app/Livewire/Recordings/Show.php index 22edfb3..d64507c 100644 --- a/app/Livewire/Recordings/Show.php +++ b/app/Livewire/Recordings/Show.php @@ -7,6 +7,7 @@ use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Component; #[Layout('layouts.app')] @@ -14,6 +15,8 @@ class Show extends Component { public Recording $recording; + public int $userId; + public function mount(Recording $recording): void { Gate::authorize('view', $recording); @@ -22,6 +25,22 @@ class Show extends Component $recording->refresh(); $this->recording = $recording; + $this->userId = (int) $recording->user_id; + } + + /** + * Re-render when this recording broadcasts a status change (same channel as the index). + * + * @param array $event + */ + #[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')] + public function onTranscriptionUpdated(array $event = []): void + { + if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) { + return; + } + + $this->recording->refresh(); } public function startTranscription(): void @@ -64,6 +83,10 @@ class Show extends Component public function render(): View { + if ($this->recording->isTranscribing()) { + $this->recording->refresh(); + } + return view('livewire.recordings.show') ->title($this->recording->title); } diff --git a/resources/js/transcription.js b/resources/js/transcription.js index 78498b9..77619bd 100644 --- a/resources/js/transcription.js +++ b/resources/js/transcription.js @@ -66,12 +66,16 @@ function subscribeToRecording(recordingId, handler) { channel.listen('.RecordingTranscriptionUpdated', handler); return () => { - window.Echo.leave(channelName); + // Prefer stopListening over leave() so a remount does not drop other subscribers. + if (typeof channel.stopListening === 'function') { + channel.stopListening('.RecordingTranscriptionUpdated'); + } }; } /** * Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate. + * Livewire also listens on the user recordings channel and polls while active. */ export function transcriptionMonitor({ statusUrl, initial }) { return { @@ -82,6 +86,7 @@ export function transcriptionMonitor({ statusUrl, initial }) { }, pollError: null, tickTimer: null, + hydrateTimer: null, leaveChannel: null, get badgeColor() { @@ -108,11 +113,13 @@ export function transcriptionMonitor({ statusUrl, initial }) { if (this.status.is_active) { this.beginTick(); this.hydrateOnce(); + this.beginHydratePoll(); } }, destroy() { this.stopTick(); + this.stopHydratePoll(); if (this.leaveChannel) { this.leaveChannel(); @@ -131,12 +138,14 @@ export function transcriptionMonitor({ statusUrl, initial }) { if (this.status.is_active) { this.beginTick(); + this.beginHydratePoll(); } else { this.stopTick(); + this.stopHydratePoll(); } - if (wasActive && !this.status.is_active - && !this.status.has_transcript + if (wasActive && ! this.status.is_active + && ! this.status.has_transcript && this.status.status !== 'failed' && this.status.status !== 'cancelled') { window.location.reload(); @@ -158,26 +167,47 @@ export function transcriptionMonitor({ statusUrl, initial }) { } }, - tickElapsed() { - if (!this.status.is_active || this.status.elapsed_seconds == null) { + beginHydratePoll() { + if (this.hydrateTimer || ! this.statusUrl) { return; } - this.status.elapsed_seconds += 1; - this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds); + this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000); + }, + + stopHydratePoll() { + if (this.hydrateTimer) { + clearInterval(this.hydrateTimer); + this.hydrateTimer = null; + } + }, + + tickElapsed() { + if (! this.status.is_active || this.status.elapsed_seconds == null) { + return; + } + + const next = this.status.elapsed_seconds + 1; + + this.status = { + ...this.status, + elapsed_seconds: next, + elapsed_human: formatElapsed(next), + }; }, async hydrateOnce() { - if (!this.statusUrl) { + if (! this.statusUrl) { return; } try { const response = await fetch(this.statusUrl, { headers: { Accept: 'application/json' }, + credentials: 'same-origin', }); - if (!response.ok) { + if (! response.ok) { throw new Error('Status request failed (' + response.status + ')'); } diff --git a/resources/views/livewire/recordings/index.blade.php b/resources/views/livewire/recordings/index.blade.php index aa8824c..a625125 100644 --- a/resources/views/livewire/recordings/index.blade.php +++ b/resources/views/livewire/recordings/index.blade.php @@ -92,39 +92,53 @@ - - Title - Duration - Words - Status - Uploaded + + Title + + + Duration + + + Words + + + Status + + + Uploaded + @foreach ($recordings as $recording) - - - - - -
{{ $recording->title }} + + + + @if ($preview = $recording->transcriptFirstLine()) {{ $preview }} @@ -147,29 +181,11 @@ {{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }} - + - @if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress)) -
- {{ $recording->transcription_progress }} -
- @elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress)) -
- @if ($recording->transcription_percent) - {{ $recording->transcription_percent }}% · - @endif - {{ $recording->transcription_progress }} -
- @endif
{{ $recording->created_at?->format('Y-m-d H:i') }} diff --git a/resources/views/livewire/recordings/show.blade.php b/resources/views/livewire/recordings/show.blade.php index 3b7a589..173c686 100644 --- a/resources/views/livewire/recordings/show.blade.php +++ b/resources/views/livewire/recordings/show.blade.php @@ -1,13 +1,19 @@
isTranscribing()) + wire:poll.2s.visible + @endif > +
← Recordings @@ -222,4 +228,5 @@ No transcript yet. Transcription starts automatically after upload, or use the button above. +
diff --git a/tests/Feature/Recordings/IndexTest.php b/tests/Feature/Recordings/IndexTest.php index c9adc1f..30a740b 100644 --- a/tests/Feature/Recordings/IndexTest.php +++ b/tests/Feature/Recordings/IndexTest.php @@ -125,8 +125,8 @@ class IndexTest extends TestCase Livewire::test(Index::class) ->assertSee('wire:poll', false) - ->assertSee('Transcribing locally…') - ->assertSee('40%') + ->assertSee('Transcribing') + ->assertDontSee('Transcribing locally…') ->assertSeeHtml('bg-amber-400'); } @@ -151,7 +151,7 @@ class IndexTest extends TestCase Livewire::test(Index::class) ->assertSee('Waiting in line') ->assertSee('Queued') - ->assertSee('Queued — waiting to start…') + ->assertDontSee('Queued — waiting to start…') ->assertDontSee('5%') ->assertSeeHtml('bg-zinc-400/15') ->assertDontSeeHtml('bg-amber-400'); @@ -282,4 +282,59 @@ class IndexTest extends TestCase $this->assertDatabaseHas('recordings', ['id' => $recording->id]); } + + public function test_recordings_can_be_sorted_by_title(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Zebra', + 'original_filename' => 'z.mp3', + 'file_path' => 'recordings/z.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'created_at' => now()->subDay(), + ]); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Alpha', + 'original_filename' => 'a.mp3', + 'file_path' => 'recordings/a.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'created_at' => now(), + ]); + + Livewire::test(Index::class) + ->call('sort', 'title') + ->assertSet('sortBy', 'title') + ->assertSet('sortDirection', 'asc') + ->assertSeeInOrder(['Alpha', 'Zebra']) + ->call('sort', 'title') + ->assertSet('sortDirection', 'desc') + ->assertSeeInOrder(['Zebra', 'Alpha']); + } + + public function test_invalid_sort_column_is_ignored(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Only one', + 'original_filename' => 'one.mp3', + 'file_path' => 'recordings/one.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + ]); + + Livewire::test(Index::class) + ->call('sort', 'not_a_column') + ->assertSet('sortBy', 'uploaded') + ->assertSet('sortDirection', 'desc'); + } } diff --git a/tests/Feature/Recordings/ShowTest.php b/tests/Feature/Recordings/ShowTest.php index 2eb1986..914e829 100644 --- a/tests/Feature/Recordings/ShowTest.php +++ b/tests/Feature/Recordings/ShowTest.php @@ -59,4 +59,81 @@ class ShowTest extends TestCase $this->assertDatabaseMissing('recordings', ['id' => $recording->id]); } + + public function test_show_polls_while_transcription_is_active(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'In progress', + 'original_filename' => 'active.mp3', + 'file_path' => 'recordings/active.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_progress' => 'Queued — waiting to start…', + 'transcription_percent' => null, + 'transcription_driver' => 'local', + 'transcription_started_at' => now(), + ]); + + Livewire::test(Show::class, ['recording' => $recording]) + ->assertSee('wire:poll', false) + ->assertSee('Queued — waiting to start…'); + } + + public function test_show_does_not_poll_when_transcription_is_idle(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Finished', + 'original_filename' => 'done.mp3', + 'file_path' => 'recordings/done.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'all done', + ]); + + Livewire::test(Show::class, ['recording' => $recording]) + ->assertDontSee('wire:poll', false); + } + + public function test_show_refreshes_recording_on_transcription_broadcast(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Live update', + 'original_filename' => 'live.mp3', + 'file_path' => 'recordings/live.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'pending', + 'transcription_progress' => 'Queued — waiting to start…', + 'transcription_driver' => 'local', + 'transcription_started_at' => now(), + ]); + + $component = Livewire::test(Show::class, ['recording' => $recording]); + + $recording->update([ + 'transcription_status' => 'processing', + 'transcription_progress' => 'Transcribing locally…', + 'transcription_percent' => 40, + ]); + + $component + ->call('onTranscriptionUpdated', [ + 'id' => $recording->id, + 'status' => 'processing', + ]) + ->assertSet('recording.transcription_status', 'processing') + ->assertSet('recording.transcription_percent', 40) + ->assertSee('Transcribing locally…'); + } }