From 7f60f88542153b3216527b90b0f63ea04c1bd6de Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:05:33 +0200 Subject: [PATCH] fix(maintenance): make the window form usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action labels wrapped to two lines and crushed the column — now icon buttons with tooltips, like every other admin table. The form column was too narrow for its content: the datetime fields silently clipped their own value, so they are stacked and full width now, with duration chips because typing an end timestamp by hand is the fiddliest part of the form. Host rows show a real selected state and a per-datacenter count, and the impact column says "1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)". Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/Maintenance.php | 47 +++++++++ lang/de/maintenance.php | 6 +- lang/en/maintenance.php | 6 +- resources/views/components/ui/icon.blade.php | 2 + .../livewire/admin/maintenance.blade.php | 99 ++++++++++++++----- tests/Feature/Admin/MaintenanceTest.php | 33 +++++++ 6 files changed, 167 insertions(+), 26 deletions(-) diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index 60e1cc5..a4eb55c 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -45,6 +45,52 @@ class Maintenance extends Component : array_values(array_unique([...$current, ...$ids])); } + /** + * Set the end from the start. Typing a full timestamp by hand is the + * fiddliest part of this form, and the end is almost always "start plus a + * round number of minutes". + */ + public function setDuration(int $minutes): void + { + $this->authorize('maintenance.manage'); + + $start = $this->parsed($this->startsAt); + if ($start === null) { + // No start yet: assume the next half hour, which is what someone + // scheduling a window in a hurry means anyway. + $start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute(); + $this->startsAt = $start->format('Y-m-d\TH:i'); + } + + $this->endsAt = $start->copy()->addMinutes($minutes)->format('Y-m-d\TH:i'); + } + + /** Minutes between start and end, or null while either is unusable. */ + public function durationMinutes(): ?int + { + $start = $this->parsed($this->startsAt); + $end = $this->parsed($this->endsAt); + + if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) { + return null; + } + + return (int) $start->diffInMinutes($end); + } + + private function parsed(string $value): ?\Illuminate\Support\Carbon + { + if (trim($value) === '') { + return null; + } + + try { + return \Illuminate\Support\Carbon::parse($value); + } catch (\Throwable) { + return null; // half-typed input is normal while the field is live + } + } + public function saveDraft(): void { $this->authorize('maintenance.manage'); @@ -196,6 +242,7 @@ class Maintenance extends Component 'windows' => $windows, 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), + 'durationMinutes' => $this->durationMinutes(), ]); } } diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index aa5bc90..d6f40ac 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -10,7 +10,8 @@ return [ 'col_impact' => 'Betroffen', 'col_state' => 'Status', 'col_actions' => 'Aktionen', - 'impact' => ':hosts Host(s) · :customers Kunde(n)', + 'impact_hosts' => '{0} keine Hosts|{1} 1 Host|[2,*] :count Hosts', + 'impact_customers' => '{0} keine Kunden|{1} 1 Kunde|[2,*] :count Kunden', 'state_draft' => 'Entwurf', 'state_upcoming' => 'Geplant', @@ -24,6 +25,9 @@ return [ 'field_public_hint' => 'Dieser Text erscheint im Kundenportal und in der E-Mail.', 'field_internal_ph' => 'Nur für das Team sichtbar.', 'selected' => ':n ausgewählt', + 'duration' => 'Dauer', + 'duration_value' => ':n min', + 'select_none' => 'Keine', 'select_all' => 'Alle', 'field_title' => 'Titel', 'field_public' => 'Beschreibung für Kunden', diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 421567f..3a73935 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -10,7 +10,8 @@ return [ 'col_impact' => 'Affected', 'col_state' => 'Status', 'col_actions' => 'Actions', - 'impact' => ':hosts host(s) · :customers customer(s)', + 'impact_hosts' => '{0} no hosts|{1} 1 host|[2,*] :count hosts', + 'impact_customers' => '{0} no customers|{1} 1 customer|[2,*] :count customers', 'state_draft' => 'Draft', 'state_upcoming' => 'Scheduled', @@ -24,6 +25,9 @@ return [ 'field_public_hint' => 'This text appears in the customer portal and the email.', 'field_internal_ph' => 'Visible to your team only.', 'selected' => ':n selected', + 'duration' => 'Duration', + 'duration_value' => ':n min', + 'select_none' => 'None', 'select_all' => 'All', 'field_title' => 'Title', 'field_public' => 'Customer-facing description', diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 9350f38..850ee8e 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -18,6 +18,8 @@ 'download' => '', 'alert-triangle' => '', 'check' => '', + 'bell' => '', + 'calendar' => '', 'qr-code' => '', 'lock' => '', 'unlock' => '', diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php index 1b290f2..29cbf8d 100644 --- a/resources/views/livewire/admin/maintenance.blade.php +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -4,7 +4,7 @@

{{ __('maintenance.subtitle') }}

-
+
{{-- Windows list --}}
@if ($windows->isEmpty()) @@ -27,18 +27,36 @@ {{ $w['title'] }} {{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }} - {{ __('maintenance.impact', ['hosts' => $w['hosts'], 'customers' => $w['affected']]) }} + + {{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }} + · + {{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }} + {{ __('maintenance.state_'.$w['state']) }} -
+ {{-- Icon buttons: the labels wrapped to two lines and made the + column unreadable. Same pattern as the other admin tables. --}} +
@if ($w['is_draft']) - + @endif @if ($w['notifiable']) - + @endif @if ($w['cancellable']) - + @endif
@@ -73,15 +91,36 @@ class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30">
-
- @foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key]) -
- - - @error($field)

{{ $message }}

@enderror -
- @endforeach +
+
+ @foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key]) +
+ +
+ + +
+ @error($field)

{{ $message }}

@enderror +
+ @endforeach +
+ + {{-- Typing an end time by hand is the fiddliest part of this form; + these set it from the start time. --}} +
+ {{ __('maintenance.duration') }} + @foreach ([30, 60, 120, 240] as $minutes) + + @endforeach + @if ($durationMinutes !== null && ! in_array($durationMinutes, [30, 60, 120, 240], true)) + {{ __('maintenance.duration_value', ['n' => $durationMinutes]) }} + @endif +
{{-- Host picker, grouped by datacenter --}} @@ -91,23 +130,35 @@ {{ __('maintenance.selected', ['n' => count($hostIds)]) }}
@error('hostIds')

{{ $message }}

@enderror -
+
@foreach ($datacenters as $dc) - @php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp + @php + $dcHosts = $hosts->where('datacenter', $dc->code); + $dcSelected = $dcHosts->whereIn('id', $hostIds)->count(); + @endphp @if ($dcHosts->isNotEmpty()) -
-
-

{{ $dc->name }}

+
+
+

+ {{ $dc->name }} + @if ($dcSelected) + {{ $dcSelected }}/{{ $dcHosts->count() }} + @endif +

+ class="rounded-pill border border-line px-2 py-0.5 text-[11px] font-semibold text-muted transition hover:border-accent-border hover:text-accent-text"> + {{ $dcSelected === $dcHosts->count() ? __('maintenance.select_none') : __('maintenance.select_all') }} +
-
+
@foreach ($dcHosts as $host) -
diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php index e5e5d7e..2479185 100644 --- a/tests/Feature/Admin/MaintenanceTest.php +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -110,3 +110,36 @@ it('shows the banner to an affected customer within the horizon', function () { $other = affectedCustomerOn(Host::factory()->active()->create()); expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0); }); + +it('fills the end time from a duration chip', function () { + $owner = operator('Owner'); + + // With a start set, the chip just adds the minutes. + $component = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + ->set('startsAt', '2026-08-01T22:00') + ->call('setDuration', 120); + + expect($component->get('endsAt'))->toBe('2026-08-02T00:00') + ->and($component->instance()->durationMinutes())->toBe(120); + + // Without one, it starts at the next half hour rather than doing nothing. + $fresh = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + ->call('setDuration', 60); + + expect($fresh->get('startsAt'))->not->toBe('') + ->and($fresh->instance()->durationMinutes())->toBe(60); +}); + +it('reports no duration while the times are unusable', function () { + $component = Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Maintenance::class); + + expect($component->instance()->durationMinutes())->toBeNull(); + + // A half-typed value must not blow up the live-updating form. + $component->set('startsAt', '2026-08-')->set('endsAt', ''); + expect($component->instance()->durationMinutes())->toBeNull(); + + // An end before the start is not a duration either. + $component->set('startsAt', '2026-08-01T22:00')->set('endsAt', '2026-08-01T21:00'); + expect($component->instance()->durationMinutes())->toBeNull(); +});