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