From 2dc399966eafadd7e9754c7a076a89a9bb92b7f9 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 21 Aug 2026 14:33:01 +0200 Subject: [PATCH 1/3] Build the calendar grid in the given timezone --- src/Tags/Events.php | 5 ++-- tests/Tags/EventsTest.php | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 1a18b7a..cf05b2d 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -38,9 +38,10 @@ public function calendar(): Collection $month = $this->params->get('month', now()->englishMonth); $year = $this->params->get('year', now()->year); + $timezone = $this->params->get('timezone', Generator::defaultTimezone()); - $from = parse_date($month.' '.$year)->startOfMonth()->startOfWeek(); - $to = parse_date($month.' '.$year)->endOfMonth()->endOfWeek(); + $from = parse_date($month.' '.$year)->shiftTimezone($timezone)->startOfMonth()->startOfWeek(); + $to = parse_date($month.' '.$year)->shiftTimezone($timezone)->endOfMonth()->endOfWeek(); $occurrences = $this ->generator() diff --git a/tests/Tags/EventsTest.php b/tests/Tags/EventsTest.php index 446ba42..e24bd49 100755 --- a/tests/Tags/EventsTest.php +++ b/tests/Tags/EventsTest.php @@ -174,6 +174,69 @@ ); }); +test('calendar does not leak a stray day when the timezone is behind the app timezone', function () { + Carbon::setTestNow('september 1, 2026 10:00'); + + Entry::all()->each->delete(); + + // 1:30am UTC on the first day of the grid is the evening of the day before in Los Angeles + Entry::make() + ->collection('events') + ->slug('sunset-special') + ->data([ + 'title' => 'Sunset Special', + 'start_date' => '2026-08-31', + 'start_time' => '01:30', + 'end_time' => '04:30', + ])->save(); + + $this->tag + ->setContext([]) + ->setParameters([ + 'collection' => 'events', + 'month' => 'September', + 'year' => 2026, + 'timezone' => 'America/Los_Angeles', + ]); + + $days = collect($this->tag->calendar()); + + expect($days->pluck('date'))->not->toContain('2026-08-30'); + + expect($days->count() % 7)->toBe(0); + + $first = Carbon::parse($days->first()['date']); + + $days->values()->each( + fn ($day, $i) => expect($day['date'])->toBe($first->copy()->addDays($i)->toDateString()) + ); +}); + +test('calendar grid stays whole across a daylight saving change', function () { + Carbon::setTestNow('november 1, 2026 10:00'); + + Entry::all()->each->delete(); + + $this->tag + ->setContext([]) + ->setParameters([ + 'collection' => 'events', + 'month' => 'November', + 'year' => 2026, + 'timezone' => 'America/Los_Angeles', + ]); + + $days = collect($this->tag->calendar()); + + expect($days->count() % 7)->toBe(0); + + $first = Carbon::parse($days->first()['date']); + + $days->values()->each( + fn ($day, $i) => expect($day['date'])->toBe($first->copy()->addDays($i)->toDateString()) + ); +}); + test('can generate in occurrences', function () { Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); From 61500e44ab4a7c63a55e5a4d350b8fd9ef004f37 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 21 Aug 2026 14:33:28 +0200 Subject: [PATCH 2/3] Drop occurrences that span past the calendar grid --- src/Tags/Events.php | 5 ++++- tests/Tags/EventsTest.php | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index cf05b2d..6bb01cb 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -43,13 +43,16 @@ public function calendar(): Collection $from = parse_date($month.' '.$year)->shiftTimezone($timezone)->startOfMonth()->startOfWeek(); $to = parse_date($month.' '.$year)->shiftTimezone($timezone)->endOfMonth()->endOfWeek(); + $emptyDates = $this->makeEmptyDates(from: $from, to: $to); + $occurrences = $this ->generator() ->between(from: $from, to: $to) ->groupBy($this->spanningDays()) + ->only($emptyDates->keys()) ->map(fn (EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences)); - $days = $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); + $days = $this->output($emptyDates->merge($occurrences)->values()); CarbonImmutable::setLocale($currentLocale); diff --git a/tests/Tags/EventsTest.php b/tests/Tags/EventsTest.php index e24bd49..414d5d6 100755 --- a/tests/Tags/EventsTest.php +++ b/tests/Tags/EventsTest.php @@ -212,6 +212,44 @@ ); }); +test('calendar does not leak a stray day for an occurrence spanning past the last day of the grid', function () { + Carbon::setTestNow('september 1, 2026 10:00'); + + Entry::all()->each->delete(); + + // in Kyiv this runs from 11pm on the last day of the grid until 2am the day after it + Entry::make() + ->collection('events') + ->slug('overnight-special') + ->data([ + 'title' => 'Overnight Special', + 'start_date' => '2026-10-04', + 'start_time' => '20:00', + 'end_time' => '23:00', + ])->save(); + + $this->tag + ->setContext([]) + ->setParameters([ + 'collection' => 'events', + 'month' => 'September', + 'year' => 2026, + 'timezone' => 'Europe/Kyiv', + ]); + + $days = collect($this->tag->calendar()); + + expect($days->pluck('date'))->not->toContain('2026-10-05'); + + expect($days->count() % 7)->toBe(0); + + $first = Carbon::parse($days->first()['date']); + + $days->values()->each( + fn ($day, $i) => expect($day['date'])->toBe($first->copy()->addDays($i)->toDateString()) + ); +}); + test('calendar grid stays whole across a daylight saving change', function () { Carbon::setTestNow('november 1, 2026 10:00'); From aaa3a50c2f39437dee61ab6782cac5c918e48530 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 21 Aug 2026 12:06:14 -0700 Subject: [PATCH 3/3] Explain why calendar occurrences are limited to grid dates. --- src/Tags/Events.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 6bb01cb..2f8fb2c 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -49,6 +49,7 @@ public function calendar(): Collection ->generator() ->between(from: $from, to: $to) ->groupBy($this->spanningDays()) + // Drop days outside the grid so merge() can't append stray cells. ->only($emptyDates->keys()) ->map(fn (EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences));