Show times on the operator's clock, keep storing them in UTC
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 <noreply@anthropic.com>
feat/mailboxes
tested-20260727-1540-bd779f0
parent
186c2e46d1
commit
bd779f00e5
|
|
@ -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
|
||||
|
|
|
|||
55
CLAUDE.md
55
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.
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* The two halves of a wall clock, kept in one place.
|
||||
*
|
||||
* A `datetime-local` field has no timezone: it is the numbers a person reads
|
||||
* off their own clock. That makes it a round trip with two ends, and getting
|
||||
* one end right is not half a fix — it is a fresh bug. Filling a field from a
|
||||
* UTC timestamp shows an operator 21:00 for a window that starts at 23:00
|
||||
* their time; parsing what they type as UTC stores 21:00 for the 19:00 they
|
||||
* meant. Do both, or neither works.
|
||||
*
|
||||
* They live as a pair here so that nobody can change one end without the other
|
||||
* being in front of them.
|
||||
*/
|
||||
final class LocalTime
|
||||
{
|
||||
/** The value for a datetime-local input, in the reader's own zone. */
|
||||
public static function toField(?Carbon $at): string
|
||||
{
|
||||
return $at?->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@
|
|||
<p class="text-sm font-semibold text-ink">{{ $mw->title }}</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted">
|
||||
{{ $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')]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
@php $tone = ['draft' => 'info', 'upcoming' => 'provisioning', 'active' => 'warning', 'completed' => 'active', 'cancelled' => 'suspended'][$w['state']] ?? 'info'; @endphp
|
||||
<tr wire:key="mw-{{ $w['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-4 py-3 font-medium text-ink">{{ $w['title'] }}</td>
|
||||
<td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }}</td>
|
||||
<td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->local()->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->local()->isoFormat('HH:mm') }}</td>
|
||||
<td class="px-4 py-3 text-xs text-muted whitespace-nowrap">
|
||||
{{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }}
|
||||
<span class="text-muted">·</span>
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@
|
|||
<p class="mt-1 text-xs text-muted">
|
||||
@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') }}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
v{{ $family['live']->version }}
|
||||
@if ($family['next'])
|
||||
<span class="block text-xs text-muted">
|
||||
{{ __('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')]) }}
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
|
||||
@endif
|
||||
@elseif ($update['next_check_at'])
|
||||
<p class="font-medium text-body">{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->timezone(config('app.timezone'))->format('H:i')]) }}</p>
|
||||
<p class="font-medium text-body">{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->local()->format('H:i')]) }}</p>
|
||||
@endif
|
||||
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ $order->label() }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
|
||||
{{ __('billing.cart.added', ['when' => $order->created_at->local()->isoFormat('LL')]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div class="min-w-0 text-sm">
|
||||
<p class="font-semibold text-ink">{{ $maintenance->title }}</p>
|
||||
<p class="text-body">{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->isoFormat('LLL'), 'end' => $maintenance->ends_at->isoFormat('LT')]) }}</p>
|
||||
<p class="text-body">{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->local()->isoFormat('LLL'), 'end' => $maintenance->ends_at->local()->isoFormat('LT')]) }}</p>
|
||||
@if ($maintenance->public_description)
|
||||
<p class="mt-0.5 text-muted">{{ $maintenance->public_description }}</p>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<header class="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="lbl">
|
||||
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
|
||||
{{ __('dashboard.sheet', ['when' => $asOf->local()->locale($locale)->isoFormat('LL, LT')]) }}
|
||||
</p>
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
<td class="px-5 py-4">
|
||||
<span class="font-semibold text-ink">{{ __('dashboard.proofs.item.'.$proof['key']) }}</span>
|
||||
<span class="mt-1 block font-mono text-xs text-muted">
|
||||
{{ $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
|
||||
</span>
|
||||
</td>
|
||||
<td class="w-px whitespace-nowrap px-5 py-4 text-right">
|
||||
|
|
@ -254,7 +254,7 @@
|
|||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-2.5">
|
||||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.due') }}</dt>
|
||||
<dd class="font-mono text-sm text-ink">{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}</dd>
|
||||
<dd class="font-mono text-sm text-ink">{{ $nextInvoice['due']?->local()->locale($locale)->isoFormat('LL') ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<x-ui.button variant="secondary" :href="route('invoices')" wire:navigate class="mt-4 w-full">
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
|
||||
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->isoFormat('LL')]) }}</p>
|
||||
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($hasActivePackage)
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="meta">Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr</p>
|
||||
<p class="meta">Stand: {{ $checkedAt->local()->format('d.m.Y, H:i') }} Uhr</p>
|
||||
|
||||
<p class="note">
|
||||
Jede Zeile wird bei jedem Aufruf neu gemessen — aus Überwachung, Sicherungsprotokoll und
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Support\LocalTime;
|
||||
use App\Livewire\Admin\Maintenance;
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
use App\Models\Customer;
|
||||
|
|
@ -27,8 +28,8 @@ it('publishes a window and emails affected customers once (idempotent)', functio
|
|||
|
||||
Livewire::actingAs(operator('Owner'))->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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Support\LocalTime;
|
||||
use App\Livewire\Admin\ConfirmDeletePlanDraft;
|
||||
use App\Livewire\Admin\PlanVersions;
|
||||
use App\Livewire\Admin\Plans;
|
||||
|
|
@ -158,7 +159,7 @@ it('publishes a draft into a window, and refuses one that overlaps', function ()
|
|||
// Straight into the running version's window: refused, and said so on the
|
||||
// form rather than thrown.
|
||||
$page->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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Times are stored in UTC and shown in the operator's zone.
|
||||
*
|
||||
* The bug this exists to prevent: a console announcing that an update would
|
||||
* start "spätestens um 15:21" while the clock on the wall said 17:21. Two of
|
||||
* the offending views even looked as though they had handled it — they called
|
||||
*
|
||||
* ->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('');
|
||||
});
|
||||
Loading…
Reference in New Issue