From bd779f00e5fefb8c50971bd333b660e87ba6ec11 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 17:32:21 +0200 Subject: [PATCH] Show times on the operator's clock, keep storing them in UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console announced an update for "spätestens um 15:21" while the clock on the wall said 17:21. Not a formatting slip in one string: fourteen views and four components printed database values straight out, so every absolute time in the product was two hours out all summer and one hour out all winter. Two of those views looked as though they had handled it. They called ->timezone(config('app.timezone')) which reads like "convert to local time" and, app.timezone being the STORAGE zone that must stay UTC, converts to UTC — a no-op wearing the costume of a fix, which is worse than no call at all because it stops the next person looking. That exact string is now banned by test. Worse than the labels were two FORMS. Maintenance windows and plan versions filled their datetime-local fields from UTC and parsed what came back as UTC. A datetime-local field carries no offset — it is the digits a person reads off their own clock — so an operator typing 21:00 scheduled a window for 23:00 their time, and nothing anywhere said so. App\Support\LocalTime now holds toField() and fromField() side by side, because getting one end right is not half a fix, it is a fresh bug. The mechanism is one Carbon macro, ->local(). It copies before converting: Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp would rewrite the model attribute as a side effect and anything comparing or saving it afterwards would be an hour or two out. Both Carbon classes get the same body — Carbon keeps macros in one global table, so the second registration replaces the first for every Carbon class, and two different bodies meant the immutable version, safe for itself, silently became the mutable one's implementation too. My own test caught that. The existing tests had not caught any of this because they built their expected values with the same wrong call the views used — a test that recomputes the implementation asserts nothing. They now assert the wall clock, and the queued update additionally asserts that the UTC time is NOT shown. Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest: no Blade or Livewire component may format an absolute time without ->local(), the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are checked, ->local() must not mutate, and a form field must round-trip to the same instant. APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests. Co-Authored-By: Claude Opus 5 --- .env.example | 3 + CLAUDE.md | 55 ++++++++ app/Livewire/Admin/Maintenance.php | 20 +-- app/Livewire/Admin/Overview.php | 6 +- app/Livewire/Admin/PlanVersions.php | 7 +- app/Livewire/Admin/Revenue.php | 2 +- app/Livewire/Backups.php | 2 +- app/Livewire/Invoices.php | 4 +- app/Providers/AppServiceProvider.php | 27 ++++ app/Support/LocalTime.php | 48 +++++++ config/app.php | 19 +++ deploy/clupilot.env.example | 3 + resources/views/layouts/portal-app.blade.php | 4 +- .../livewire/admin/maintenance.blade.php | 2 +- .../livewire/admin/plan-versions.blade.php | 4 +- .../views/livewire/admin/plans.blade.php | 2 +- .../views/livewire/admin/settings.blade.php | 2 +- resources/views/livewire/billing.blade.php | 2 +- resources/views/livewire/cloud.blade.php | 2 +- resources/views/livewire/dashboard.blade.php | 6 +- resources/views/livewire/settings.blade.php | 2 +- .../mail/maintenance-announcement.blade.php | 2 +- resources/views/status.blade.php | 2 +- tests/Feature/Admin/MaintenanceTest.php | 13 +- tests/Feature/Admin/PlanAdminTest.php | 9 +- tests/Feature/Admin/UpdateButtonTest.php | 12 +- tests/Feature/DisplayTimezoneTest.php | 127 ++++++++++++++++++ 27 files changed, 337 insertions(+), 50 deletions(-) create mode 100644 app/Support/LocalTime.php create mode 100644 tests/Feature/DisplayTimezoneTest.php diff --git a/.env.example b/.env.example index dc7ba31..e61aec3 100644 --- a/.env.example +++ b/.env.example @@ -207,3 +207,6 @@ GITEA_RUNNER_TOKEN= GITEA_RUNNER_NAME=clupilot-local # Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner). GITEA_RUNNER_VERSION=0.2.6 + +# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC. +APP_DISPLAY_TIMEZONE=Europe/Vienna diff --git a/CLAUDE.md b/CLAUDE.md index 5c181d9..fe05960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,3 +51,58 @@ Verboten: `tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt `inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln. + +--- + +## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr + +**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.** + +Verboten: + +1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)` + liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`. +2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt + UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine. +3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus + gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`. + Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen + Uhr abliest. + +Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich. + +### Warum das durchgerutscht ist + +Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die +Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt +aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in +Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist +schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab. + +Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und +Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein +eingetragenes „21:00" wurde zu 23:00 Ortszeit. + +Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben +falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft +nichts. + +### Wie es jetzt gebaut ist + +- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe + `Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt. +- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem + Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße + Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf — + Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung + ersetzt die erste für alle. +- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand + eine ändert, ohne die andere zu sehen. + +### Erzwungen durch + +`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil +darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten, +Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()` +verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe +Zeitpunkt zurück. diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index a4eb55c..afa2bff 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Support\LocalTime; use App\Models\Datacenter; use App\Models\Host; use App\Models\MaintenanceWindow; @@ -59,10 +60,10 @@ class Maintenance extends Component // 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->startsAt = LocalTime::toField($start); } - $this->endsAt = $start->copy()->addMinutes($minutes)->format('Y-m-d\TH:i'); + $this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes)); } /** Minutes between start and end, or null while either is unusable. */ @@ -78,17 +79,10 @@ class Maintenance extends Component return (int) $start->diffInMinutes($end); } + /** What the operator typed, as UTC. Half-typed input is normal here. */ 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 - } + return LocalTime::fromField($value); } public function saveDraft(): void @@ -118,8 +112,8 @@ class Maintenance extends Component 'hostIds.*' => 'integer|exists:hosts,id', ]); - $starts = Carbon::parse($data['startsAt']); - $ends = Carbon::parse($data['endsAt']); + $starts = LocalTime::fromField($data['startsAt']); + $ends = LocalTime::fromField($data['endsAt']); if ($ends->lessThanOrEqualTo($starts)) { $this->addError('endsAt', __('maintenance.end_after_start')); diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 2ab43b9..58a2556 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -134,15 +134,15 @@ class Overview extends Component $counts = Instance::query() ->where('created_at', '>=', $start) ->get(['created_at']) - ->countBy(fn (Instance $i) => $i->created_at->format('Y-m')); + ->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m')); $labels = []; $data = []; for ($i = 0; $i < 12; $i++) { $month = $start->copy()->addMonths($i); - $labels[] = $month->locale($locale)->isoFormat('MMM'); - $data[] = (int) ($counts[$month->format('Y-m')] ?? 0); + $labels[] = $month->local()->locale($locale)->isoFormat('MMM'); + $data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0); } return [ diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index 907e497..bdd2e40 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Support\LocalTime; use App\Models\PlanFamily; use App\Models\PlanVersion; use App\Models\Subscription; @@ -172,7 +173,7 @@ class PlanVersions extends Component public function choose(string $versionUuid): void { $this->publishing = $versionUuid; - $this->availableFrom = now()->format('Y-m-d\TH:i'); + $this->availableFrom = LocalTime::toField(now()); $this->availableUntil = ''; } @@ -203,8 +204,8 @@ class PlanVersions extends Component try { app(PlanCatalogue::class)->publish( $version, - Carbon::parse($this->availableFrom), - $this->availableUntil !== '' ? Carbon::parse($this->availableUntil) : null, + LocalTime::fromField($this->availableFrom), + LocalTime::fromField($this->availableUntil), ); } catch (RuntimeException $e) { $this->addError('availableFrom', $e->getMessage()); diff --git a/app/Livewire/Admin/Revenue.php b/app/Livewire/Admin/Revenue.php index d15bc7a..99e245b 100644 --- a/app/Livewire/Admin/Revenue.php +++ b/app/Livewire/Admin/Revenue.php @@ -196,7 +196,7 @@ class Revenue extends Component in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())), locale: $locale, ), - 'when' => $r->occurred_at?->translatedFormat('d. MMM') ?? '—', + 'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—', ]) ->all(); } diff --git a/app/Livewire/Backups.php b/app/Livewire/Backups.php index 68bf95b..875039a 100644 --- a/app/Livewire/Backups.php +++ b/app/Livewire/Backups.php @@ -14,7 +14,7 @@ class Backups extends Component { $locale = app()->getLocale(); $gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB'; - $date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt); + $date = fn (string $iso, string $fmt) => Carbon::parse($iso)->local()->locale($locale)->isoFormat($fmt); // 14 days of backup sizes for the bar chart. $sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4]; diff --git a/app/Livewire/Invoices.php b/app/Livewire/Invoices.php index 38ae1bd..dbf232a 100644 --- a/app/Livewire/Invoices.php +++ b/app/Livewire/Invoices.php @@ -14,10 +14,10 @@ class Invoices extends Component { $locale = app()->getLocale(); $eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale); - $date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL'); + $date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL'); $months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01']) - ->map(fn ($m) => Carbon::parse($m)->locale($locale)->isoFormat('MMM'))->all(); + ->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all(); $rows = [ ['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'], diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e40acba..aeab946 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -24,8 +24,10 @@ use App\Http\Middleware\RestrictConsoleNetwork; use App\Mail\MaintenanceCancelledMail; use App\Models\MaintenanceNotification; use App\Services\Maintenance\MaintenanceNotifier; +use Carbon\CarbonImmutable; use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSent; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; @@ -56,6 +58,31 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + // Every timestamp that gets shown to somebody goes through ->local() + // first. Storage is UTC and stays UTC; this is the last step before a + // human reads it. + // + // It exists as one macro rather than a convention because the + // convention failed: fourteen views formatted times straight out of + // the database, and two of them converted to the STORAGE zone — which + // reads like "convert to local" and, that zone being UTC, does + // nothing. A no-op in the costume of a fix is worse than no call at + // all, because it stops the next person looking. + // + // copy() is not decoration: Illuminate\Support\Carbon is MUTABLE, so + // setTimezone() on the instance would rewrite the model attribute as + // a side effect of rendering it, and whatever compared or saved that + // value afterwards would be an hour or two out. + // Registered on both classes with the SAME body on purpose: Carbon + // keeps macros in one global table, so a second registration replaces + // the first for every Carbon class. Two different bodies means the + // last one wins for both — which is how the immutable version, safe + // for itself, silently became the mutable one's implementation too. + $local = fn () => $this->copy()->setTimezone(config('app.display_timezone')); + + Carbon::macro('local', $local); + CarbonImmutable::macro('local', $local); + // Livewire posts every component action to /livewire/update, which a // path-based guard would skip. Marking the host restriction persistent // makes Livewire re-apply it from the component's original route, so an diff --git a/app/Support/LocalTime.php b/app/Support/LocalTime.php new file mode 100644 index 0000000..226edcd --- /dev/null +++ b/app/Support/LocalTime.php @@ -0,0 +1,48 @@ +local()->format('Y-m-d\TH:i') ?? ''; + } + + /** + * What a person typed into such a field, as UTC for storage. + * + * Returns null for anything unusable rather than throwing: these fields + * are read on every keystroke, and half of "2026-07-2" is normal. + */ + public static function fromField(string $value): ?Carbon + { + if (trim($value) === '') { + return null; + } + + try { + // Parsed IN the display zone — the string carries no offset, so + // without this it would be read as UTC and silently shifted. + return Carbon::parse($value, config('app.display_timezone'))->utc(); + } catch (\Throwable) { + return null; + } + } +} diff --git a/config/app.php b/config/app.php index 8136f69..31a2bcd 100644 --- a/config/app.php +++ b/config/app.php @@ -86,6 +86,25 @@ return [ 'timezone' => 'UTC', + /* + |-------------------------------------------------------------------------- + | Display Timezone + |-------------------------------------------------------------------------- + | + | The zone times are SHOWN in. Storage stays UTC above — that is what makes + | timestamps comparable across hosts — but nobody running this reads UTC, + | and an update announced for "15:21" that starts at 17:21 is worse than no + | time at all. + | + | These are two different settings and were once treated as one: several + | views called ->timezone(config('app.timezone')), which reads like a + | conversion to local time and is in fact a conversion to UTC, i.e. nothing. + | Use ->local() on any Carbon instance about to be displayed. + | + */ + + 'display_timezone' => env('APP_DISPLAY_TIMEZONE', 'Europe/Vienna'), + /* |-------------------------------------------------------------------------- | Application Locale Configuration diff --git a/deploy/clupilot.env.example b/deploy/clupilot.env.example index 9bb3339..6623640 100644 --- a/deploy/clupilot.env.example +++ b/deploy/clupilot.env.example @@ -39,3 +39,6 @@ MAIL_FROM_ADDRESS=noreply@clupilot.com # Private key used to onboard Proxmox hosts. Put the key file on the server # first (chmod 600) and point at it — a PEM does not fit in a .env line. CLUPILOT_SSH_KEY_PATH=/opt/clupilot/secrets/proxmox_ed25519 + +# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC. +APP_DISPLAY_TIMEZONE=Europe/Vienna diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index aa46312..1766e6c 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -74,8 +74,8 @@

{{ $mw->title }}

{{ $isActive - ? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')]) - : __('maintenance.banner_upcoming', ['start' => $mw->starts_at->isoFormat('LLL'), 'end' => $mw->ends_at->isoFormat('LT')]) }} + ? __('maintenance.banner_active', ['end' => $mw->ends_at->local()->isoFormat('LT')]) + : __('maintenance.banner_upcoming', ['start' => $mw->starts_at->local()->isoFormat('LLL'), 'end' => $mw->ends_at->local()->isoFormat('LT')]) }}

diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php index ca1e3c5..8ce160e 100644 --- a/resources/views/livewire/admin/maintenance.blade.php +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -26,7 +26,7 @@ @php $tone = ['draft' => 'info', 'upcoming' => 'provisioning', 'active' => 'warning', 'completed' => 'active', 'cancelled' => 'suspended'][$w['state']] ?? 'info'; @endphp {{ $w['title'] }} - {{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }} + {{ $w['starts_at']->local()->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->local()->isoFormat('HH:mm') }} {{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }} · diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index 39b6424..a56c192 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -51,8 +51,8 @@

@if ($version->isPublished()) {{ __('plans.window', [ - 'from' => $version->available_from->isoFormat('L LT'), - 'until' => $version->available_until?->isoFormat('L LT') ?? __('plans.open_ended'), + 'from' => $version->available_from->local()->isoFormat('L LT'), + 'until' => $version->available_until?->local()->isoFormat('L LT') ?? __('plans.open_ended'), ]) }} @else {{ __('plans.draft_hint') }} diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php index 4bb1134..f6bebb4 100644 --- a/resources/views/livewire/admin/plans.blade.php +++ b/resources/views/livewire/admin/plans.blade.php @@ -39,7 +39,7 @@ v{{ $family['live']->version }} @if ($family['next']) - {{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }} + {{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->local()->isoFormat('L')]) }} @endif @else diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index c607afa..678fab7 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -94,7 +94,7 @@

{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}

@endif @elseif ($update['next_check_at']) -

{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->timezone(config('app.timezone'))->format('H:i')]) }}

+

{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->local()->format('H:i')]) }}

@endif

{{ __('admin_settings.update_offline_hint') }}

diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index da4532b..ee385f7 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -57,7 +57,7 @@

{{ $order->label() }}

- {{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }} + {{ __('billing.cart.added', ['when' => $order->created_at->local()->isoFormat('LL')]) }}

diff --git a/resources/views/livewire/cloud.blade.php b/resources/views/livewire/cloud.blade.php index 1150f29..203e1d8 100644 --- a/resources/views/livewire/cloud.blade.php +++ b/resources/views/livewire/cloud.blade.php @@ -28,7 +28,7 @@

{{ $maintenance->title }}

-

{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->isoFormat('LLL'), 'end' => $maintenance->ends_at->isoFormat('LT')]) }}

+

{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->local()->isoFormat('LLL'), 'end' => $maintenance->ends_at->local()->isoFormat('LT')]) }}

@if ($maintenance->public_description)

{{ $maintenance->public_description }}

@endif diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 6903ff3..67ec9c4 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -11,7 +11,7 @@

- {{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }} + {{ __('dashboard.sheet', ['when' => $asOf->local()->locale($locale)->isoFormat('LL, LT')]) }}

{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }} @@ -175,7 +175,7 @@ {{ __('dashboard.proofs.item.'.$proof['key']) }} - {{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif + {{ $proof['at']?->local()->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif @@ -254,7 +254,7 @@

{{ __('dashboard.invoice.due') }}
-
{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}
+
{{ $nextInvoice['due']?->local()->locale($locale)->isoFormat('LL') ?? '—' }}
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 7ea22b9..b151168 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -194,7 +194,7 @@

{{ __('settings.cancel_scheduled_title') }}

-

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->isoFormat('LL')]) }}

+

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}

@elseif ($hasActivePackage) diff --git a/resources/views/mail/maintenance-announcement.blade.php b/resources/views/mail/maintenance-announcement.blade.php index 258cec8..d720586 100644 --- a/resources/views/mail/maintenance-announcement.blade.php +++ b/resources/views/mail/maintenance-announcement.blade.php @@ -5,7 +5,7 @@ **{{ $title }}** -{{ __('maintenance.mail_window', ['start' => $startsAt->isoFormat('LLLL'), 'end' => $endsAt->isoFormat('LLLL')]) }} +{{ __('maintenance.mail_window', ['start' => $startsAt->local()->isoFormat('LLLL'), 'end' => $endsAt->local()->isoFormat('LLLL')]) }} @if ($description) {{ $description }} diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php index 7fea40c..0fd23cb 100644 --- a/resources/views/status.blade.php +++ b/resources/views/status.blade.php @@ -106,7 +106,7 @@ @endforeach
-

Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr

+

Stand: {{ $checkedAt->local()->format('d.m.Y, H:i') }} Uhr

Jede Zeile wird bei jedem Aufruf neu gemessen — aus Überwachung, Sicherungsprotokoll und diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php index 2479185..db8e248 100644 --- a/tests/Feature/Admin/MaintenanceTest.php +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -1,5 +1,6 @@ test(Maintenance::class) ->set('title', 'Netz-Wartung') - ->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) - ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->set('startsAt', LocalTime::toField(now()->addDay())) + ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->set('hostIds', [$host->id]) ->call('publish')->assertHasNoErrors(); @@ -84,16 +85,16 @@ it('scopes the per-instance badge to that instance host', function () { it('requires a host to publish', function () { Livewire::actingAs(operator('Owner'))->test(Maintenance::class) ->set('title', 'X') - ->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) - ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->set('startsAt', LocalTime::toField(now()->addDay())) + ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->set('hostIds', []) ->call('publish')->assertHasErrors(['hostIds']); }); it('forbids non-privileged roles from managing maintenance', function () { Livewire::actingAs(operator('Support'))->test(Maintenance::class) - ->set('title', 'X')->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) - ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->set('title', 'X')->set('startsAt', LocalTime::toField(now()->addDay())) + ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->call('saveDraft')->assertForbidden(); }); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 77ada69..e51ed4e 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -1,5 +1,6 @@ call('choose', $draft->uuid) - ->set('availableFrom', now()->format('Y-m-d\TH:i')) + ->set('availableFrom', LocalTime::toField(now())) ->call('publish') ->assertHasErrors('availableFrom'); @@ -169,7 +170,7 @@ it('publishes a draft into a window, and refuses one that overlaps', function () $page->call('close', $live->uuid); $page->call('choose', $draft->uuid) - ->set('availableFrom', now()->addMinute()->format('Y-m-d\TH:i')) + ->set('availableFrom', LocalTime::toField(now()->addMinute())) ->call('publish') ->assertHasNoErrors(); @@ -184,8 +185,8 @@ it('will not publish a window that ends before it starts', function () { $draft = teamFamily()->versions()->where('version', 2)->sole(); $page->call('choose', $draft->uuid) - ->set('availableFrom', now()->addWeek()->format('Y-m-d\TH:i')) - ->set('availableUntil', now()->format('Y-m-d\TH:i')) + ->set('availableFrom', LocalTime::toField(now()->addWeek())) + ->set('availableUntil', LocalTime::toField(now())) ->call('publish') ->assertHasErrors('availableUntil'); }); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 3f73b51..c01b0c6 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -144,8 +144,16 @@ it('says when a queued update will actually start', function () { Livewire::actingAs(operator('Owner')) ->test(AdminSettings::class) + // Asserted against the operator's wall clock, not against whatever the + // view happens to do. The old version of this test built its expected + // value with the same call the view used, so it agreed with the bug: + // the console announced 15:21 for an update that ran at 17:21 and every + // test passed. ->assertSee(__('admin_settings.update_queued', [ - 'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'), + 'time' => $state['next_check_at']->local()->format('H:i'), + ])) + ->assertDontSee(__('admin_settings.update_queued', [ + 'time' => $state['next_check_at']->utc()->format('H:i'), ])); }); @@ -216,7 +224,7 @@ it('does not present the last failure’s step as this update’s progress', fun Livewire::actingAs(operator('Owner')) ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_queued', [ - 'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'), + 'time' => $state['next_check_at']->local()->format('H:i'), ])) ->assertDontSee(__('admin_settings.update_phase.migrate')); }); diff --git a/tests/Feature/DisplayTimezoneTest.php b/tests/Feature/DisplayTimezoneTest.php new file mode 100644 index 0000000..72d353e --- /dev/null +++ b/tests/Feature/DisplayTimezoneTest.php @@ -0,0 +1,127 @@ +timezone(config('app.timezone')) + * + * which reads like "convert to local time" and, because app.timezone is the + * STORAGE zone and must stay UTC, converts to UTC: a no-op wearing the costume + * of a fix. The other twelve views simply printed the database value. + * + * So the rule is mechanical rather than remembered: anything absolute that a + * person will read goes through ->local() first. + */ + +/** Absolute formats. diffForHumans() is relative and reads the same anywhere. */ +function absoluteTimeCalls(string $source): array +{ + $hits = []; + + foreach (explode("\n", $source) as $n => $line) { + if (! preg_match('/->(isoFormat|translatedFormat)\(|->format\([\'"][^\'"]*[YmdHis][^\'"]*[\'"]\)/', $line)) { + continue; + } + + // A line may format several times; one ->local() per formatted value. + $formats = preg_match_all('/->(isoFormat|translatedFormat|format)\(/', $line); + $locals = preg_match_all('/->local\(\)/', $line); + + if ($locals < $formats) { + $hits[] = ($n + 1).': '.trim($line); + } + } + + return $hits; +} + +it('converts every displayed time to the display zone', function () { + $offenders = []; + + $files = collect(File::allFiles(resource_path('views'))) + ->merge(File::allFiles(app_path('Livewire'))) + ->filter(fn ($f) => in_array($f->getExtension(), ['php'], true)); + + foreach ($files as $file) { + $hits = absoluteTimeCalls($file->getContents()); + + if ($hits !== []) { + $offenders[str_replace(base_path().'/', '', $file->getPathname())] = $hits; + } + } + + expect($offenders)->toBe([]); +}); + +it('never dresses a UTC no-op up as a timezone conversion', function () { + // ->timezone(config('app.timezone')) is the specific trap: it looks like a + // conversion and does nothing, which is worse than no call at all because + // it stops anyone looking further. + $offenders = []; + + foreach (collect(File::allFiles(resource_path('views')))->merge(File::allFiles(app_path())) as $file) { + if (str_contains($file->getContents(), "timezone(config('app.timezone'))")) { + $offenders[] = str_replace(base_path().'/', '', $file->getPathname()); + } + } + + expect($offenders)->toBe([]); +}); + +it('keeps storage in UTC', function () { + // The display zone must never leak into the storage zone. Comparable + // timestamps across hosts depend on this staying put. + expect(config('app.timezone'))->toBe('UTC') + ->and(config('app.display_timezone'))->not->toBe('UTC'); +}); + +it('shows a summer time and a winter time in the right zone', function () { + // Both sides of the DST boundary, because a fixed +1 offset would pass a + // January test and be an hour out all summer. + $winter = Carbon::parse('2026-01-15 14:00:00', 'UTC'); + $summer = Carbon::parse('2026-07-15 14:00:00', 'UTC'); + + expect($winter->local()->format('H:i'))->toBe('15:00') + ->and($summer->local()->format('H:i'))->toBe('16:00'); +}); + +it('leaves the stored value untouched when showing it', function () { + // ->local() must not mutate the instance it was called on: the same object + // is often written back or compared after being rendered. + $stored = Carbon::parse('2026-07-15 14:00:00', 'UTC'); + $stored->local()->format('H:i'); + + expect($stored->format('H:i T'))->toBe('14:00 UTC'); +}); + +it('takes a wall-clock field back as the time the operator meant', function () { + // The half that is easy to forget. A datetime-local field carries no + // offset, so 21:00 typed by someone in Vienna is 19:00 UTC — parsed + // without a zone it would be stored as 21:00 UTC and the window would run + // two hours late. + expect(\App\Support\LocalTime::fromField('2026-07-15T21:00')->format('H:i T'))->toBe('19:00 UTC') + // Winter, where the offset is one hour, not two. + ->and(\App\Support\LocalTime::fromField('2026-01-15T21:00')->format('H:i T'))->toBe('20:00 UTC'); +}); + +it('survives the round trip through a form field', function () { + // What goes into the field must come back as the same instant. + $stored = Carbon::parse('2026-07-15 19:00:00', 'UTC'); + $shown = \App\Support\LocalTime::toField($stored); + + expect($shown)->toBe('2026-07-15T21:00') + ->and(\App\Support\LocalTime::fromField($shown)->equalTo($stored))->toBeTrue(); +}); + +it('treats an empty field as no time rather than as now', function () { + expect(\App\Support\LocalTime::fromField(''))->toBeNull() + ->and(\App\Support\LocalTime::fromField(' '))->toBeNull() + ->and(\App\Support\LocalTime::toField(null))->toBe(''); +});