core timer fixes

This commit is contained in:
Mark Gregory
2026-06-23 10:19:51 +01:00
parent ed298eac38
commit 0a673690a3
11 changed files with 231 additions and 353 deletions
@@ -16,6 +16,7 @@ use Modules\Helpdesk\Livewire\SettingsManager;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Livewire\TicketTimer;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Policies\TicketPolicy;
use Modules\Helpdesk\Support\ModuleInfo;
@@ -69,6 +70,7 @@ final class HelpdeskServiceProvider extends ServiceProvider
Livewire::component('helpdesk.ticket-index', TicketIndex::class);
Livewire::component('helpdesk.ticket-create', TicketCreate::class);
Livewire::component('helpdesk.ticket-show', TicketShow::class);
Livewire::component('helpdesk.ticket-timer', TicketTimer::class);
Livewire::component('helpdesk.dashboard-widget', HelpdeskDashboardWidget::class);
Livewire::component('helpdesk.kanban-board', KanbanBoard::class);
Livewire::component('helpdesk.settings-manager', SettingsManager::class);
@@ -7,7 +7,7 @@
'Raise a new support request with priority, product/service, project, category, team and full details.',
])
<form wire:submit.prevent="save" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6" data-helpdesk-ticket-form>
<form wire:submit.prevent="save" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6">
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
@@ -330,16 +330,13 @@
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
Cancel
</a>
<button
type="submit"
<button type="submit"
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-submit"
dusk="ticket-create-submit"
wire:loading.attr="disabled"
wire:target="save">
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save">Create Ticket</span>
<span wire:loading wire:target="save">Creating...</span>
<span wire:loading.remove>Create Ticket</span>
<span wire:loading>Creating...</span>
</button>
</div>
</div>
@@ -363,50 +360,47 @@
outline: none;
}
</style>
@script
<script>
const initialiseHelpdeskTicketCreate = function () {
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('.helpdesk-ticket-form');
const editor = document.querySelector('[data-helpdesk-details-editor]');
const bodyField = document.getElementById('body');
if (! form || ! editor || ! bodyField || form.dataset.editorReady === 'true') {
if (!form || !editor || !bodyField) {
return;
}
form.dataset.editorReady = 'true';
const syncBody = function () {
const value = editor.innerHTML.trim();
bodyField.value = value;
bodyField.dispatchEvent(new Event('input', { bubbles: true }));
bodyField.dispatchEvent(new Event('change', { bubbles: true }));
@this.set('body', value, false);
const syncBody = function() {
bodyField.value = editor.innerHTML.trim();
bodyField.dispatchEvent(new Event('input', {
bubbles: true
}));
bodyField.dispatchEvent(new Event('change', {
bubbles: true
}));
};
document.querySelectorAll('[data-helpdesk-editor-command]').forEach(function (button) {
button.addEventListener('click', function () {
document.querySelectorAll('[data-helpdesk-editor-command]').forEach(function(button) {
button.addEventListener('click', function() {
editor.focus();
document.execCommand(button.dataset.helpdeskEditorCommand, false, null);
syncBody();
});
});
document.querySelectorAll('[data-helpdesk-editor-format]').forEach(function (button) {
button.addEventListener('click', function () {
document.querySelectorAll('[data-helpdesk-editor-format]').forEach(function(button) {
button.addEventListener('click', function() {
editor.focus();
document.execCommand('formatBlock', false, button.dataset.helpdeskEditorFormat);
syncBody();
});
});
document.querySelectorAll('[data-helpdesk-editor-link]').forEach(function (button) {
button.addEventListener('click', function () {
document.querySelectorAll('[data-helpdesk-editor-link]').forEach(function(button) {
button.addEventListener('click', function() {
const url = window.prompt('Enter link URL');
if (! url) {
if (!url) {
return;
}
@@ -416,15 +410,8 @@
});
});
editor.addEventListener('input', syncBody);
editor.addEventListener('keyup', syncBody);
editor.addEventListener('blur', syncBody);
form.addEventListener('submit', syncBody, true);
};
initialiseHelpdeskTicketCreate();
document.addEventListener('livewire:navigated', initialiseHelpdeskTicketCreate);
form.addEventListener('submit', syncBody);
});
</script>
@endscript
</div>
@@ -1,133 +1,151 @@
<div
class="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 shadow-sm dark:border-emerald-900 dark:bg-emerald-950/40"
class="mb-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-950"
data-helpdesk-ticket-timer
>
@php
$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;
$startedAtIso = $hasRunningTimer ? $activeTimer->started_at?->toIso8601String() : null;
$startedAtDisplay = $hasRunningTimer ? $activeTimer->started_at?->format('d M Y H:i') : null;
@endphp
<div class="flex items-start gap-3">
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm">
<i class="fa-solid fa-stopwatch text-sm" aria-hidden="true"></i>
</span>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-600 text-white shadow-sm">
<i class="fa-solid fa-stopwatch text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h3 class="text-sm font-black text-emerald-950 dark:text-emerald-100">Work timer</h3>
<p class="mt-1 text-xs font-semibold text-emerald-800 dark:text-emerald-200">
Start a user-specific timer. It remains active if the browser is closed.
<h3 class="text-sm font-black text-zinc-950 dark:text-zinc-100">
Ticket timer
</h3>
<p class="mt-1 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
Record focused work against this ticket.
</p>
</div>
@if ($hasRunningTimer)
<div
x-data="{
startedAt: Date.parse('{{ $activeTimer->started_at?->toIso8601String() }}'),
elapsedSeconds: {{ max(0, (int) $elapsedSeconds) }},
intervalId: null,
formatted() {
const total = Math.max(0, Number(this.elapsedSeconds || 0));
const hours = String(Math.floor(total / 3600)).padStart(2, '0');
const minutes = String(Math.floor((total % 3600) / 60)).padStart(2, '0');
const seconds = String(total % 60).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
},
tick() {
if (!Number.isFinite(this.startedAt)) {
this.elapsedSeconds = 0;
return;
}
this.elapsedSeconds = Math.floor((Date.now() - this.startedAt) / 1000);
},
start() {
this.stop();
this.tick();
this.intervalId = window.setInterval(() => this.tick(), 1000);
},
stop() {
if (this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = null;
}
}
}"
x-init="start()"
x-on:timer-started.window="start()"
x-on:timer-ended.window="stop(); elapsedSeconds = 0"
x-on:livewire:navigating.window="stop()"
class="rounded-xl bg-emerald-700 px-3 py-2 text-right text-white shadow-sm"
data-helpdesk-running-timer
>
<div class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-100">Running</div>
<div class="font-mono text-lg font-black leading-tight" x-text="formatted()" data-dusk="ticket-running-time">00:00:00</div>
</div>
@else
<div class="rounded-xl bg-white px-3 py-2 text-right text-emerald-900 shadow-sm ring-1 ring-emerald-200 dark:bg-zinc-950 dark:text-emerald-100 dark:ring-emerald-900">
<div class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">Stopped</div>
<div class="font-mono text-lg font-black leading-tight" data-dusk="ticket-running-time">00:00:00</div>
</div>
@endif
</div>
@if ($message)
<div class="rounded-lg border border-emerald-300 bg-white px-3 py-2 text-xs font-bold text-emerald-900 dark:border-emerald-800 dark:bg-zinc-950 dark:text-emerald-100">
{{ $message }}
@if ($hasRunningTimer)
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-right shadow-sm dark:border-emerald-900 dark:bg-emerald-950/40">
<div class="flex items-center justify-end gap-2 text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
Running
</div>
<div
class="mt-1 font-mono text-2xl font-black leading-none text-emerald-950 dark:text-emerald-100"
data-helpdesk-live-elapsed
data-helpdesk-started-at="{{ $startedAtIso }}"
data-dusk="ticket-running-time"
>
{{ gmdate('H:i:s', max(0, (int) $elapsedSeconds)) }}
</div>
<p class="mt-1 text-[0.68rem] font-bold text-emerald-700 dark:text-emerald-300">
Started {{ $startedAtDisplay }}
</p>
</div>
@endif
</div>
@error('timer')
<div class="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs font-bold text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
{{ $message }}
</div>
@enderror
<div class="space-y-2">
<label class="block text-xs font-bold text-emerald-950 dark:text-emerald-100" for="ticket_timer_description">
Timer note
</label>
<textarea
id="ticket_timer_description"
wire:model.defer="description"
rows="2"
placeholder="Optional work note"
class="block w-full rounded-lg border border-emerald-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-timer-description"
></textarea>
@if ($message)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
{{ $message }}
</div>
@endif
<div class="flex flex-wrap gap-2">
@if ($hasRunningTimer)
<button
type="button"
wire:click="endTimer"
wire:loading.attr="disabled"
wire:target="endTimer"
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
data-dusk="end-ticket-timer"
>
<i class="fa-solid fa-stop mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="endTimer">End timer</span>
<span wire:loading wire:target="endTimer">Ending...</span>
</button>
@else
<button
type="button"
wire:click="startTimer"
wire:loading.attr="disabled"
wire:target="startTimer"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
data-dusk="start-ticket-timer"
>
<i class="fa-solid fa-play mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="startTimer">Start timer</span>
<span wire:loading wire:target="startTimer">Starting...</span>
</button>
@endif
@error('timer')
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-bold text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100">
{{ $message }}
</div>
@enderror
<div class="space-y-2">
<label class="block text-xs font-bold text-zinc-800 dark:text-zinc-100" for="ticket_timer_description">
Timer note
</label>
<textarea
id="ticket_timer_description"
wire:model.defer="description"
rows="2"
placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
data-dusk="ticket-timer-description"
></textarea>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
{{ $hasRunningTimer ? 'End the timer to add the elapsed time to the ticket.' : 'Start a timer when work begins. Only one active timer is allowed.' }}
</p>
@if ($hasRunningTimer)
<button
type="button"
wire:click="endTimer"
wire:loading.attr="disabled"
wire:target="endTimer"
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2.5 text-sm font-black text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
data-dusk="end-ticket-timer"
>
<i class="fa-solid fa-stop mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="endTimer">End timer</span>
<span wire:loading wire:target="endTimer">Ending...</span>
</button>
@else
<button
type="button"
wire:click="startTimer"
wire:loading.attr="disabled"
wire:target="startTimer"
class="inline-flex min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-blue-600 px-4 py-2 text-sm font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
data-dusk="start-ticket-timer"
>
<i class="fa-solid fa-play mr-2 text-xs" aria-hidden="true"></i>
<span wire:loading.remove wire:target="startTimer">Start timer</span>
<span wire:loading wire:target="startTimer">Starting...</span>
</button>
@endif
</div>
</div>
<script>
(() => {
if (window.helpdeskTicketTimerDisplayBooted === true) {
return;
}
window.helpdeskTicketTimerDisplayBooted = true;
const formatElapsed = (totalSeconds) => {
const seconds = Math.max(0, Number.parseInt(totalSeconds, 10) || 0);
const hours = String(Math.floor(seconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const remainingSeconds = String(seconds % 60).padStart(2, '0');
return `${hours}:${minutes}:${remainingSeconds}`;
};
const updateTimers = () => {
document.querySelectorAll('[data-helpdesk-live-elapsed][data-helpdesk-started-at]').forEach((timer) => {
const startedAt = Date.parse(timer.getAttribute('data-helpdesk-started-at') || '');
if (!Number.isFinite(startedAt)) {
timer.textContent = '00:00:00';
return;
}
timer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
});
};
updateTimers();
window.setInterval(updateTimers, 1000);
document.addEventListener('livewire:navigated', updateTimers);
document.addEventListener('livewire:update', updateTimers);
document.addEventListener('livewire:updated', updateTimers);
})();
</script>
</div>
@@ -1,47 +1,3 @@
@php
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\TicketTimeLog;
$ticketTimeLogTable = (new TicketTimeLog())->getTable();
$hasEndedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'ended_at');
$hasStoppedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'stopped_at');
$hasFinishedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'finished_at');
$hasUserIdColumn = Schema::hasColumn($ticketTimeLogTable, 'user_id');
$runningTimerQuery = TicketTimeLog::query()
->where('ticket_id', $ticket->getKey())
->whereNotNull('started_at')
->orderByDesc('started_at');
if ($hasUserIdColumn) {
$runningTimerQuery->where('user_id', auth()->id());
}
if ($hasEndedAtColumn) {
$runningTimerQuery->whereNull('ended_at');
}
if ($hasStoppedAtColumn) {
$runningTimerQuery->whereNull('stopped_at');
}
if ($hasFinishedAtColumn) {
$runningTimerQuery->whereNull('finished_at');
}
$runningTimer = ($hasEndedAtColumn || $hasStoppedAtColumn || $hasFinishedAtColumn)
? $runningTimerQuery->first()
: null;
$runningTimerStartedAt = $runningTimer?->started_at;
$runningTimerElapsedSeconds = $runningTimerStartedAt
? max(0, now()->diffInSeconds($runningTimerStartedAt))
: 0;
$runningTimerElapsedDisplay = gmdate('H:i:s', $runningTimerElapsedSeconds);
$hasRunningTimer = $runningTimerStartedAt !== null;
@endphp
@can('update', $ticket)
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
@@ -60,23 +16,6 @@
<p class="text-xs font-semibold text-indigo-100">
Log time, assignment, status and priority
</p>
<span
class="{{ $hasRunningTimer ? 'inline-flex' : 'hidden' }} mt-2 w-fit items-center gap-2 rounded-xl bg-emerald-400/20 px-3 py-1.5 text-xs font-black text-emerald-50 ring-1 ring-emerald-200/40"
data-helpdesk-running-summary-badge
data-helpdesk-started-at="{{ $runningTimerStartedAt?->toIso8601String() }}"
data-dusk="ticket-running-timer-summary">
<span class="relative flex h-2.5 w-2.5">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-200 opacity-75"></span>
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-300"></span>
</span>
<span>Running</span>
<span class="font-mono" data-helpdesk-running-summary-elapsed data-dusk="ticket-running-elapsed-summary">
{{ $runningTimerElapsedDisplay }}
</span>
</span>
</div>
</div>
@@ -93,41 +32,9 @@
</div>
@endif
@if ($hasRunningTimer)
<div
class="mb-4 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100"
data-helpdesk-running-timer>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-[0.68rem] font-black uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
Timer running
</p>
<p class="mt-1 text-xs font-semibold text-emerald-800 dark:text-emerald-200">
Started {{ $runningTimerStartedAt->format('d M Y H:i:s') }}
</p>
</div>
<div class="shrink-0 text-right">
<p class="font-mono text-2xl font-black leading-none text-emerald-900 dark:text-emerald-100"
data-helpdesk-panel-elapsed
data-dusk="ticket-running-elapsed-time">
{{ $runningTimerElapsedDisplay }}
</p>
<p class="mt-1 text-[0.65rem] font-bold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
Elapsed
</p>
</div>
</div>
</div>
@endif
<div data-helpdesk-timer-source>
@include('helpdesk::partials.ticket-time-timer', [
'ticket' => $ticket,
])
</div>
@include('helpdesk::partials.ticket-time-timer', [
'ticket' => $ticket,
])
<form method="POST" action="{{ route('helpdesk.tickets.time-logs.store', $ticket) }}"
class="mb-4 space-y-2">
@@ -247,102 +154,4 @@
</button>
</form>
</details>
<script>
(() => {
const root = document.querySelector('[data-helpdesk-ticket-controls]');
if (!root || root.dataset.helpdeskElapsedBadgeBound === 'true') {
return;
}
root.dataset.helpdeskElapsedBadgeBound = 'true';
const source = root.querySelector('[data-helpdesk-timer-source]');
const badge = root.querySelector('[data-helpdesk-running-summary-badge]');
const summaryElapsed = root.querySelector('[data-helpdesk-running-summary-elapsed]');
const panelElapsed = root.querySelector('[data-helpdesk-panel-elapsed]');
if (!source || !badge || !summaryElapsed) {
return;
}
let interval = null;
let startedAt = badge.dataset.helpdeskStartedAt
? Date.parse(badge.dataset.helpdeskStartedAt)
: null;
const formatElapsed = (seconds) => {
const date = new Date(Math.max(0, seconds) * 1000);
return date.toISOString().substring(11, 19);
};
const updateElapsed = () => {
if (!startedAt) {
startedAt = Date.now();
}
const display = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
summaryElapsed.textContent = display;
if (panelElapsed) {
panelElapsed.textContent = display;
}
};
const showBadge = () => {
badge.classList.remove('hidden');
badge.classList.add('inline-flex');
if (!startedAt) {
startedAt = Date.now();
}
updateElapsed();
if (interval) {
return;
}
interval = window.setInterval(updateElapsed, 1000);
};
const hideBadge = () => {
badge.classList.add('hidden');
badge.classList.remove('inline-flex');
summaryElapsed.textContent = '00:00:00';
startedAt = null;
if (interval) {
window.clearInterval(interval);
interval = null;
}
};
const sourceShowsRunning = () => {
const text = source.textContent.trim().toLowerCase();
return text.includes('running') || text.includes('timer started');
};
const sync = () => {
if (sourceShowsRunning()) {
showBadge();
return;
}
hideBadge();
};
new MutationObserver(sync).observe(source, {
childList: true,
subtree: true,
characterData: true,
});
sync();
})();
</script>
@endcan
@@ -1,3 +1,3 @@
@can('update', $ticket)
@livewire('Modules\\Helpdesk\\Livewire\\TicketTimer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@livewire('helpdesk.ticket-timer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@endcan
@@ -0,0 +1,35 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerCleanUiFilesTest extends TestCase
{
/**
* Timer markup should live in one loaded component without duplicated control-panel timer displays.
*/
public function test_ticket_controls_do_not_duplicate_timer_display_logic(): void
{
$controls = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-controls.blade.php'));
$timer = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$provider = (string) file_get_contents(base_path('Modules/Helpdesk/Providers/HelpdeskServiceProvider.php'));
$this->assertStringContainsString("@include('helpdesk::partials.ticket-time-timer'", $controls);
$this->assertStringNotContainsString('data-helpdesk-running-summary-badge', $controls);
$this->assertStringNotContainsString('data-helpdesk-panel-elapsed', $controls);
$this->assertStringNotContainsString('MutationObserver', $controls);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $timer);
$this->assertStringContainsString("Livewire::component('helpdesk.ticket-timer'", $provider);
}
/**
* The module must not contain accidental duplicate Blade folders or macOS artefacts.
*/
public function test_helpdesk_module_does_not_contain_known_rubbish_files(): void
{
$this->assertDirectoryDoesNotExist(base_path('Modules/Helpdesk/resources/views/views'));
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/.DS_Store'));
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/resources/.DS_Store'));
}
}
@@ -13,10 +13,10 @@ class TicketTimerLiveDisplayFilesTest extends TestCase
{
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringContainsString('x-data', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringContainsString('Date.parse', $view);
$this->assertStringContainsString('setInterval', $view);
$this->assertStringContainsString('started_at', $view);
$this->assertStringContainsString('data-helpdesk-started-at', $view);
$this->assertStringContainsString('data-dusk="ticket-running-time"', $view);
$this->assertStringNotContainsString('localStorage', $view);
$this->assertStringNotContainsString('sessionStorage', $view);
@@ -18,9 +18,9 @@ class TicketTimerSecondsFilesTest extends TestCase
$this->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->assertStringContainsString('sum('seconds')', $component);
$this->assertStringContainsString('formatElapsed', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringNotContainsString('localStorage', $component);
$this->assertStringNotContainsString('localStorage', $view);
}
@@ -33,7 +33,7 @@ class TicketTimerSecondsFilesTest extends TestCase
$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('whereNotNull('ended_at')', $partial);
$this->assertStringContainsString('seconds', $partial);
}
}
@@ -13,13 +13,14 @@ class TicketTimerStartDisplayFilesTest extends TestCase
{
$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'));
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php'));
$this->assertStringContainsString('$this->activeTimer = $timer->fresh();', $component);
$this->assertStringContainsString('$this->elapsedSeconds = 0;', $component);
$this->assertStringContainsString("dispatch('timer-started')", $component);
$this->assertStringContainsString('$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;', $view);
$this->assertStringContainsString('data-helpdesk-running-timer', $view);
$this->assertStringContainsString('x-on:timer-started.window', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringContainsString("@livewire('helpdesk.ticket-timer'", $partial);
$this->assertStringNotContainsString('wire:ignore', $view);
}
}
+26
View File
@@ -7,3 +7,29 @@
- Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available.
- Added a reusable Blade include for placing the timer above the existing manual time log form.
- Added regression tests for module path, Livewire view root safety, and non-session timer state.
- version: 1.9.30
date: 2026-06-23
type: bugfix
summary: Clean up Helpdesk timer UI state and remove duplicate timer display code.
changes:
- Replaced the oversized timer display with a compact professional ticket timer panel.
- Hid the elapsed timer before start and after end so it only displays while a timer is active.
- Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file.
- Removed duplicate running timer summary and MutationObserver logic from ticket controls.
- Registered the timer Livewire component with the helpdesk.ticket-timer alias.
- Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module.
- Added file-level regression coverage for the timer UI cleanup.
- version: 1.9.31
date: 2026-06-23
type: bugfix
summary: Reduce Helpdesk start timer button size.
changes:
- Made the inactive-state Start timer button more compact while preserving accessible focus and loading states.
- Kept the timer display hidden before start and after end, with no new standalone JavaScript files.
- version: 1.9.32
date: 2026-06-23
type: bugfix
summary: Widen Helpdesk start timer button so the label fits on one line.
changes:
- Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split.
- Preserved the clean inline timer display behaviour with no unused standalone JavaScript file.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.29"
"version": "1.9.32"
}