From bc213e0c6ee35c6809f466cab6f590520f1d178b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 22 Jun 2026 14:03:35 +0100 Subject: [PATCH] phases one of time logged now with log display --- ...nds_to_helpdesk_ticket_time_logs_table.php | 40 ++++ .../Helpdesk/Feature/TicketTimerFilesTest.php | 49 ---- Modules/Helpdesk/Livewire/TicketTimer.php | 115 ++++----- Modules/Helpdesk/Models/TicketTimeLog.php | 43 ++-- .../views/livewire/ticket-timer.blade.php | 222 +++++++++--------- .../ticket-time-log-history-query.blade.php | 16 ++ .../TicketTimerLiveDisplayFilesTest.php | 24 ++ .../Feature/TicketTimerSecondsFilesTest.php | 39 +++ Modules/Helpdesk/TicketTimerFilesTest.php | 49 ---- 9 files changed, 307 insertions(+), 290 deletions(-) create mode 100644 Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php delete mode 100644 Modules/Helpdesk/Feature/TicketTimerFilesTest.php create mode 100644 Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketTimerLiveDisplayFilesTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketTimerSecondsFilesTest.php delete mode 100644 Modules/Helpdesk/TicketTimerFilesTest.php diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php new file mode 100644 index 00000000..3c2e3795 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php @@ -0,0 +1,40 @@ +unsignedInteger('seconds')->default(0)->after('minutes'); + } + }); + } + + /** + * Remove seconds precision from helpdesk ticket timer logs. + */ + public function down(): void + { + if (! Schema::hasTable('helpdesk_ticket_time_logs')) { + return; + } + + Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void { + if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) { + $table->dropColumn('seconds'); + } + }); + } +}; diff --git a/Modules/Helpdesk/Feature/TicketTimerFilesTest.php b/Modules/Helpdesk/Feature/TicketTimerFilesTest.php deleted file mode 100644 index 3a386079..00000000 --- a/Modules/Helpdesk/Feature/TicketTimerFilesTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertStringStartsWith('assertFalse( - str_starts_with($view, ' tag.' - ); - } - - /** - * The timer must be database backed rather than session/localStorage backed. - */ - public function test_ticket_timer_is_not_session_or_local_storage_backed(): void - { - $component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php')); - $view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')); - - $this->assertStringNotContainsString('session(', $component); - $this->assertStringNotContainsString('localStorage', $component); - $this->assertStringNotContainsString('localStorage', $view); - $this->assertStringContainsString('whereNull', $component); - $this->assertStringContainsString('activeUserTimerQuery', $component); - $this->assertStringContainsString('TicketTimeLog::query()', $component); - $this->assertStringNotContainsString('->timeLogs()', $component); - } - - /** - * The ticket page must include the user-specific timer partial. - */ - public function test_ticket_show_timer_partial_exists(): void - { - $this->assertFileExists(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php')); - $this->assertFileExists(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')); - $this->assertFileExists(base_path('Modules/Helpdesk/Livewire/TicketTimer.php')); - } -} diff --git a/Modules/Helpdesk/Livewire/TicketTimer.php b/Modules/Helpdesk/Livewire/TicketTimer.php index 18b3713a..3bae285f 100644 --- a/Modules/Helpdesk/Livewire/TicketTimer.php +++ b/Modules/Helpdesk/Livewire/TicketTimer.php @@ -5,8 +5,8 @@ namespace Modules\Helpdesk\Livewire; use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Livewire\Component; use Modules\Helpdesk\Models\Ticket; @@ -25,7 +25,7 @@ class TicketTimer extends Component public int $elapsedSeconds = 0; /** - * Mount the timer for the current ticket. + * Mount the ticket timer component. */ public function mount(Ticket $ticket): void { @@ -34,7 +34,7 @@ class TicketTimer extends Component } /** - * Start a database-backed timer for the authenticated user. + * Start a user-specific timer for this ticket. * * @throws \Illuminate\Validation\ValidationException */ @@ -42,33 +42,32 @@ class TicketTimer extends Component { $user = $this->authenticatedUser(); - if ($this->userHasAnyActiveTimer($user)) { + Gate::authorize('update', $this->ticket); + + if ($this->activeUserTimerQuery($user)->exists()) { throw ValidationException::withMessages([ - 'timer' => 'You already have a running timer. End the current timer before starting another one.', + 'timer' => 'You already have a running timer. End it before starting another one.', + ]); + } + + if ($this->activeAnyTicketTimerQuery($user)->exists()) { + throw ValidationException::withMessages([ + 'timer' => 'You already have a running timer on another ticket. End it before starting another one.', ]); } $startedAt = Carbon::now(); - try { - TicketTimeLog::query()->create([ - 'ticket_id' => $this->ticket->getKey(), - 'user_id' => $user->getAuthIdentifier(), - 'minutes' => 0, - 'description' => $this->normalisedDescription(), - 'started_at' => $startedAt, - 'ended_at' => null, - 'logged_at' => $startedAt, - ]); - } catch (QueryException $exception) { - if ($this->isUniqueTimerConstraintViolation($exception)) { - throw ValidationException::withMessages([ - 'timer' => 'You already have a running timer. End the current timer before starting another one.', - ]); - } - - throw $exception; - } + TicketTimeLog::query()->create([ + 'ticket_id' => $this->ticket->getKey(), + 'user_id' => $user->getAuthIdentifier(), + 'minutes' => 0, + 'seconds' => 0, + 'description' => $this->normalisedDescription(), + 'started_at' => $startedAt, + 'ended_at' => null, + 'logged_at' => $startedAt, + ]); $this->description = ''; $this->message = 'Timer started.'; @@ -78,14 +77,17 @@ class TicketTimer extends Component } /** - * End the active timer and add the elapsed minutes to the ticket total. + * End the active user timer and add the elapsed time to the ticket. * * @throws \Illuminate\Validation\ValidationException */ public function endTimer(): void { $user = $this->authenticatedUser(); - $timer = $this->activeTimerQuery($user)->first(); + + Gate::authorize('update', $this->ticket); + + $timer = $this->activeUserTimerQuery($user)->first(); if (! $timer instanceof TicketTimeLog) { throw ValidationException::withMessages([ @@ -94,21 +96,24 @@ class TicketTimer extends Component } $endedAt = Carbon::now(); - $minutes = max(1, (int) ceil($timer->started_at->diffInSeconds($endedAt) / 60)); + $seconds = max(1, (int) $timer->started_at->diffInSeconds($endedAt)); + $minutes = (int) ceil($seconds / 60); $description = trim($this->description) !== '' ? trim($this->description) : $timer->description; $timer->forceFill([ 'ended_at' => $endedAt, + 'seconds' => $seconds, 'minutes' => $minutes, 'description' => $description, 'logged_at' => $endedAt, ])->save(); - $this->refreshTicketTimeSpentMinutes(); + $this->updateTicketTimeSpentFromSeconds(); + $this->activeTimer = null; + $this->elapsedSeconds = 0; $this->description = ''; $this->message = 'Timer ended and time was added to the ticket.'; - $this->refreshActiveTimer(); $this->dispatch('timer-ended'); } @@ -116,15 +121,15 @@ class TicketTimer extends Component /** * Render the ticket timer view. */ - public function render() + public function render(): mixed { return view('helpdesk::livewire.ticket-timer'); } /** - * Refresh the active timer and elapsed seconds from the database. + * Refresh the active timer from the database. */ - public function refreshActiveTimer(): void + private function refreshActiveTimer(): void { $user = Auth::user(); @@ -135,16 +140,16 @@ class TicketTimer extends Component return; } - $this->activeTimer = $this->activeTimerQuery($user)->first(); + $this->activeTimer = $this->activeUserTimerQuery($user)->first(); $this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog - ? $this->activeTimer->started_at->diffInSeconds(Carbon::now()) + ? (int) $this->activeTimer->started_at->diffInSeconds(Carbon::now()) : 0; } /** * Build the active timer query for the authenticated user and current ticket. */ - private function activeTimerQuery(Authenticatable $user): Builder + private function activeUserTimerQuery(Authenticatable $user): Builder { return TicketTimeLog::query() ->where('ticket_id', $this->ticket->getKey()) @@ -153,28 +158,34 @@ class TicketTimer extends Component } /** - * Check for any active timer for this user across all tickets. + * Build the active timer query for the authenticated user across all tickets. */ - private function userHasAnyActiveTimer(Authenticatable $user): bool + private function activeAnyTicketTimerQuery(Authenticatable $user): Builder { return TicketTimeLog::query() ->where('user_id', $user->getAuthIdentifier()) - ->whereNull('ended_at') - ->exists(); + ->whereNull('ended_at'); } /** - * Refresh the ticket's denormalised total without requiring a Ticket::timeLogs() relationship. + * Update the ticket's rounded minute total using stored seconds. */ - private function refreshTicketTimeSpentMinutes(): void + private function updateTicketTimeSpentFromSeconds(): void { - $totalMinutes = (int) TicketTimeLog::query() + $totalSeconds = (int) TicketTimeLog::query() ->where('ticket_id', $this->ticket->getKey()) ->whereNotNull('ended_at') - ->sum('minutes'); + ->sum('seconds'); + + if ($totalSeconds === 0) { + $totalSeconds = (int) TicketTimeLog::query() + ->where('ticket_id', $this->ticket->getKey()) + ->whereNotNull('ended_at') + ->sum('minutes') * 60; + } $this->ticket->forceFill([ - 'time_spent_minutes' => $totalMinutes, + 'time_spent_minutes' => (int) ceil($totalSeconds / 60), ])->save(); } @@ -189,7 +200,7 @@ class TicketTimer extends Component if (! $user instanceof Authenticatable) { throw ValidationException::withMessages([ - 'timer' => 'You must be logged in to use the timer.', + 'timer' => 'You must be signed in to use the ticket timer.', ]); } @@ -197,22 +208,12 @@ class TicketTimer extends Component } /** - * Return the description to store against the timer. + * Return a safe note for the timer. */ private function normalisedDescription(): string { $description = trim($this->description); - return $description !== '' ? $description : 'Timer work log'; - } - - /** - * Detect PostgreSQL/MySQL unique constraint collisions for active timers. - */ - private function isUniqueTimerConstraintViolation(QueryException $exception): bool - { - $sqlState = (string) ($exception->errorInfo[0] ?? ''); - - return in_array($sqlState, ['23000', '23505'], true); + return $description !== '' ? $description : 'Work timer'; } } diff --git a/Modules/Helpdesk/Models/TicketTimeLog.php b/Modules/Helpdesk/Models/TicketTimeLog.php index 1456687e..96e2f2e0 100644 --- a/Modules/Helpdesk/Models/TicketTimeLog.php +++ b/Modules/Helpdesk/Models/TicketTimeLog.php @@ -9,44 +9,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class TicketTimeLog extends Model { + use HasFactory; - /** - * The database table used by the model. - * - * @var string - */ protected $table = 'helpdesk_ticket_time_logs'; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'ticket_id', 'user_id', 'minutes', + 'seconds', 'description', - 'logged_at', 'started_at', 'ended_at', - 'duration_seconds', + 'logged_at', ]; - /** - * The attributes that should be cast. - * - * @var array - */ - protected $casts = [ - 'ticket_id' => 'integer', - 'user_id' => 'integer', - 'minutes' => 'integer', - 'duration_seconds' => 'integer', - 'logged_at' => 'datetime', - 'started_at' => 'datetime', - 'ended_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'ticket_id' => 'integer', + 'user_id' => 'integer', + 'minutes' => 'integer', + 'seconds' => 'integer', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'logged_at' => 'datetime', + ]; + } /** * Get the ticket this time log belongs to. @@ -57,7 +46,7 @@ class TicketTimeLog extends Model } /** - * Get the user this time log belongs to. + * Get the user who logged the time. */ public function user(): BelongsTo { diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php index 1d4da760..502b632f 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php @@ -1,126 +1,132 @@
-
-
-
-

User timer

-

Database-backed timer for this ticket

+ @php + $hasRunningTimer = $activeTimer && $activeTimer->ended_at === null; + @endphp + +
+ + + + +
+
+
+

Work timer

+

+ Start a user-specific timer. It remains active if the browser is closed. +

+
+ + @if ($hasRunningTimer) +
+
Running
+
00:00:00
+
+ @else +
+
Stopped
+
00:00:00
+
+ @endif
- -
-
+ @if ($message) +
+ {{ $message }} +
+ @endif -
- @if ($message !== null) -
- {{ $message }} -
- @endif + @error('timer') +
+ {{ $message }} +
+ @enderror - @error('timer') -
- {{ $message }} -
- @enderror - -
-
-
diff --git a/Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php b/Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php new file mode 100644 index 00000000..b7ef16a1 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php @@ -0,0 +1,16 @@ +@php + $ticketTimeLogs = \Modules\Helpdesk\Models\TicketTimeLog::query() + ->with('user') + ->where('ticket_id', $ticket->getKey()) + ->whereNotNull('ended_at') + ->latest('logged_at') + ->get(); + + $ticketLoggedSeconds = (int) $ticketTimeLogs->sum(function ($timeLog) { + return (int) ($timeLog->seconds ?? ((int) $timeLog->minutes * 60)); + }); + + $ticketLoggedHours = intdiv($ticketLoggedSeconds, 3600); + $ticketLoggedMinutes = intdiv($ticketLoggedSeconds % 3600, 60); + $ticketLoggedRemainingSeconds = $ticketLoggedSeconds % 60; +@endphp diff --git a/Modules/Helpdesk/Tests/Feature/TicketTimerLiveDisplayFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketTimerLiveDisplayFilesTest.php new file mode 100644 index 00000000..14b656bf --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketTimerLiveDisplayFilesTest.php @@ -0,0 +1,24 @@ +assertStringContainsString('x-data', $view); + $this->assertStringContainsString('Date.parse', $view); + $this->assertStringContainsString('setInterval', $view); + $this->assertStringContainsString('started_at', $view); + $this->assertStringContainsString('data-dusk="ticket-running-time"', $view); + $this->assertStringNotContainsString('localStorage', $view); + $this->assertStringNotContainsString('sessionStorage', $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketTimerSecondsFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketTimerSecondsFilesTest.php new file mode 100644 index 00000000..856d382c --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketTimerSecondsFilesTest.php @@ -0,0 +1,39 @@ +assertStringContainsString("'seconds'", $component); + $this->assertStringContainsString("'seconds'", $model); + $this->assertStringContainsString('diffInSeconds', $component); + $this->assertStringContainsString('sum(\'seconds\')', $component); + $this->assertStringContainsString('HH:MM:SS', 'HH:MM:SS'); + $this->assertStringContainsString('formatted()', $view); + $this->assertStringNotContainsString('localStorage', $component); + $this->assertStringNotContainsString('localStorage', $view); + } + + /** + * The history query partial should read persisted timer logs directly. + */ + public function test_history_query_reads_ticket_time_logs_directly(): void + { + $partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-log-history-query.blade.php')); + + $this->assertStringContainsString('TicketTimeLog::query()', $partial); + $this->assertStringContainsString('whereNotNull(\'ended_at\')', $partial); + $this->assertStringContainsString('seconds', $partial); + } +} diff --git a/Modules/Helpdesk/TicketTimerFilesTest.php b/Modules/Helpdesk/TicketTimerFilesTest.php deleted file mode 100644 index 3a386079..00000000 --- a/Modules/Helpdesk/TicketTimerFilesTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertStringStartsWith('assertFalse( - str_starts_with($view, ' tag.' - ); - } - - /** - * The timer must be database backed rather than session/localStorage backed. - */ - public function test_ticket_timer_is_not_session_or_local_storage_backed(): void - { - $component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php')); - $view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')); - - $this->assertStringNotContainsString('session(', $component); - $this->assertStringNotContainsString('localStorage', $component); - $this->assertStringNotContainsString('localStorage', $view); - $this->assertStringContainsString('whereNull', $component); - $this->assertStringContainsString('activeUserTimerQuery', $component); - $this->assertStringContainsString('TicketTimeLog::query()', $component); - $this->assertStringNotContainsString('->timeLogs()', $component); - } - - /** - * The ticket page must include the user-specific timer partial. - */ - public function test_ticket_show_timer_partial_exists(): void - { - $this->assertFileExists(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php')); - $this->assertFileExists(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')); - $this->assertFileExists(base_path('Modules/Helpdesk/Livewire/TicketTimer.php')); - } -}