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(''); +});