From fa3389c25c7e5ccda9200ce292131ac75d172955 Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 16 Mar 2026 15:11:00 -0700 Subject: [PATCH 01/22] Reorganize the fields --- resources/fieldsets/event.yaml | 74 ++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/resources/fieldsets/event.yaml b/resources/fieldsets/event.yaml index 594ccb4..4993cf2 100644 --- a/resources/fieldsets/event.yaml +++ b/resources/fieldsets/event.yaml @@ -11,7 +11,7 @@ fields: monthly: Monthly every: Every multi_day: Multi-Day - width: 33 + width: 50 display: Recurrence default: none - @@ -22,14 +22,7 @@ fields: default: UTC type: dictionary display: Timezone - - - handle: all_day - field: - type: toggle - width: 33 - display: 'All Day?' - unless: - recurrence: 'equals multi_day' + width: 50 - handle: specific_days field: @@ -75,6 +68,13 @@ fields: multi_day: 'equals true' recurrence: 'equals multi_day' format: Y-m-d + - + handle: end_date_spacer + field: + type: spacer + width: 33 + if: + recurrence: 'equals none' - handle: end_date field: @@ -91,11 +91,44 @@ fields: if: recurrence: 'contains_any daily, weekly, monthly, every' format: Y-m-d + - + handle: exclude_dates + field: + type: grid + fullscreen: false + display: 'Exclude Days' + add_row: 'Add Day' + if_any: + recurrence: 'contains_any monthly, daily, weekly, every' + fields: + - + handle: date + field: + type: date + allow_blank: false + allow_time: false + require_time: false + input_format: YYYY/M/D/YYYY + display: Date + format: Y-m-d + - + handle: times_sections + field: + type: section + display: Times + - + handle: all_day + field: + type: toggle + width: 33 + display: 'All Day?' + unless: + recurrence: 'equals multi_day' - handle: start_time field: type: time - width: 25 + width: 33 display: 'Start Time' instructions: 'Input in [24-hour format](https://en.wikipedia.org/wiki/24-hour_clock)' unless_any: @@ -106,7 +139,7 @@ fields: handle: end_time field: type: time - width: 25 + width: 33 display: 'End Time' instructions: 'Input in [24-hour format](https://en.wikipedia.org/wiki/24-hour_clock)' unless_any: @@ -170,22 +203,3 @@ fields: field: 'events::event.all_day' config: width: 25 - - - handle: exclude_dates - field: - type: grid - display: 'Exclude Days' - add_row: 'Add Day' - if_any: - recurrence: 'contains_any monthly, daily, weekly, every' - fields: - - - handle: date - field: - type: date - allow_blank: false - allow_time: false - require_time: false - input_format: YYYY/M/D/YYYY - display: Date - format: Y-m-d From e36411654892b829e6e3b5bc4581177af4216e62 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 18 Mar 2026 11:34:40 -0700 Subject: [PATCH 02/22] wip --- src/ServiceProvider.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index afc41c3..7679343 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,14 +5,17 @@ use Statamic\Entries\Entry; use Statamic\Facades\Collection; use Statamic\Fields\Field; +use Statamic\Fields\Fields; use Statamic\Fields\Value; use Statamic\Fieldtypes\Dictionary; use Statamic\Providers\AddonServiceProvider; +use Statamic\Statamic; class ServiceProvider extends AddonServiceProvider { public function bootAddon() { + // Fields::default('events_timezone', fn () => Statamic::displayTimezone()); collect(Events::setting('collections', [['collection' => 'events']])) ->each(fn (array $collection) => Collection::computed( $collection['collection'], From 55aa50efd9410d4c0806733a3356a4448c28b3c0 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 20 Mar 2026 13:33:08 +0100 Subject: [PATCH 03/22] Add passing and failing test --- tests/EventsTest.php | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/EventsTest.php b/tests/EventsTest.php index 0d84ef7..1817cf4 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -2,6 +2,7 @@ namespace TransformStudios\Events\Tests; +use Carbon\CarbonImmutable; use Illuminate\Support\Carbon; use Statamic\Extensions\Pagination\LengthAwarePaginator; use Statamic\Facades\Entry; @@ -386,3 +387,45 @@ expect($occurrences)->toBeEmpty(); }); + + +test('app and event in same timezone ', function () { + $startDate = CarbonImmutable::createFromDate(2026, 2, 28); + Entry::make() + ->collection('events') + ->data([ + 'start_date' => $startDate->toDateString(), + 'start_time' => '05:00', + 'end_time' => '23:00', + 'all_day' => false + ])->save(); + + $events = Events::fromCollection('events') + ->between( + CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), + CarbonImmutable::createFromDate(2026, 2, 28)->endOfDay() + ); + + expect($events)->toHaveCount(1); +}); + +test('app and event in different timezone ', function () { + $startDate = CarbonImmutable::createFromDate(2026, 2, 28); + Entry::make() + ->collection('events') + ->data([ + 'start_date' => $startDate->toDateString(), + 'timezone' => 'America/Los_Angeles', + 'start_time' => '05:00', + 'end_time' => '23:00', + 'all_day' => false + ])->save(); + + $events = Events::fromCollection('events') + ->between( + CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), + CarbonImmutable::createFromDate(2026, 2, 28)->endOfDay() + ); + + expect($events)->toHaveCount(1); +}); From 62cdfe6ea30f17996f8ae326e697a6e44724eaf6 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 20 Mar 2026 11:21:29 -0700 Subject: [PATCH 04/22] better test, pass test --- src/Types/SingleDayEvent.php | 25 ++++++++++++++++++++++++- tests/EventsTest.php | 15 +++++++-------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Types/SingleDayEvent.php b/src/Types/SingleDayEvent.php index 50128d8..bfbfddd 100644 --- a/src/Types/SingleDayEvent.php +++ b/src/Types/SingleDayEvent.php @@ -4,15 +4,38 @@ use RRule\RRule; use RRule\RRuleInterface; +use RRule\RSet; class SingleDayEvent extends Event { protected function rule(): RRuleInterface { + if ($this->spansDays()) { + $rset = new RSet; + $rset->addRRule([ + 'count' => 1, + 'dtstart' => $this->start(), + 'freq' => RRule::DAILY, + ]); + $rset->addRRule([ + 'count' => 1, + 'dtstart' => $this->end(), + 'freq' => RRule::DAILY, + ]); + + return $rset; + } + return new RRule([ 'count' => 1, - 'dtstart' => $this->start()->setTimeFromTimeString($this->endTime()), + 'dtstart' => $this->end(), 'freq' => RRule::DAILY, ]); + + } + + private function spansDays(): bool + { + return $this->start()->setTimezone('UTC')->day != $this->end()->setTimezone('UTC')->day; } } diff --git a/tests/EventsTest.php b/tests/EventsTest.php index 1817cf4..04ec9c0 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -388,43 +388,42 @@ expect($occurrences)->toBeEmpty(); }); - test('app and event in same timezone ', function () { - $startDate = CarbonImmutable::createFromDate(2026, 2, 28); + $startDate = CarbonImmutable::createFromDate(2026, 2, 15); Entry::make() ->collection('events') ->data([ 'start_date' => $startDate->toDateString(), 'start_time' => '05:00', 'end_time' => '23:00', - 'all_day' => false + 'all_day' => false, ])->save(); $events = Events::fromCollection('events') ->between( CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), - CarbonImmutable::createFromDate(2026, 2, 28)->endOfDay() + CarbonImmutable::createFromDate(2026, 2, 15)->endOfDay() ); expect($events)->toHaveCount(1); }); test('app and event in different timezone ', function () { - $startDate = CarbonImmutable::createFromDate(2026, 2, 28); + $startDate = CarbonImmutable::createFromDate(2026, 2, 15); Entry::make() ->collection('events') ->data([ 'start_date' => $startDate->toDateString(), 'timezone' => 'America/Los_Angeles', 'start_time' => '05:00', - 'end_time' => '23:00', - 'all_day' => false + 'end_time' => '16:00', + 'all_day' => false, ])->save(); $events = Events::fromCollection('events') ->between( CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), - CarbonImmutable::createFromDate(2026, 2, 28)->endOfDay() + CarbonImmutable::createFromDate(2026, 2, 15)->endOfDay() ); expect($events)->toHaveCount(1); From 74d4dd88255f9284b29bd15f224d67e716c9c93e Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 20 Mar 2026 11:21:36 -0700 Subject: [PATCH 05/22] small tidy --- src/Events.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Events.php b/src/Events.php index d05c350..8950d88 100644 --- a/src/Events.php +++ b/src/Events.php @@ -200,7 +200,7 @@ private function isMultiDay(Entry $occurrence): bool private function occurrences(callable $generator): EntryCollection { return $this->entries - ->filter(fn (Entry $occurrence) => $this->hasStartDate($occurrence)) + ->filter(fn (Entry $event) => $this->hasStartDate($event)) // take each event and generate the occurrences ->flatMap(callback: $generator) ->reject(fn (Entry $occurrence) => collect($occurrence->exclude_dates) From 052971ed4b0c593a162b74a3b12b3b0f7121070f Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 20 Mar 2026 11:42:36 -0700 Subject: [PATCH 06/22] Tidy --- src/Types/SingleDayEvent.php | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/Types/SingleDayEvent.php b/src/Types/SingleDayEvent.php index bfbfddd..48c2318 100644 --- a/src/Types/SingleDayEvent.php +++ b/src/Types/SingleDayEvent.php @@ -10,28 +10,22 @@ class SingleDayEvent extends Event { protected function rule(): RRuleInterface { + $rset = tap(new RSet)->addRRule([ + 'count' => 1, + 'dtstart' => $this->end(), + 'freq' => RRule::DAILY, + ]); + + // if the occurrence spans days, include the start so that it's picked up on the "between" method if ($this->spansDays()) { - $rset = new RSet; $rset->addRRule([ 'count' => 1, 'dtstart' => $this->start(), 'freq' => RRule::DAILY, ]); - $rset->addRRule([ - 'count' => 1, - 'dtstart' => $this->end(), - 'freq' => RRule::DAILY, - ]); - - return $rset; } - return new RRule([ - 'count' => 1, - 'dtstart' => $this->end(), - 'freq' => RRule::DAILY, - ]); - + return $rset; } private function spansDays(): bool From 1ac50fadaaf9ec4b3afb76c0b3e355d0115b5904 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 20 Mar 2026 12:56:40 -0700 Subject: [PATCH 07/22] Tests --- tests/EventsTest.php | 20 --- tests/Types/RecurringDailyEventsTest.php | 169 +++------------------- tests/Types/RecurringEveryXEventsTest.php | 117 --------------- 3 files changed, 22 insertions(+), 284 deletions(-) diff --git a/tests/EventsTest.php b/tests/EventsTest.php index 04ec9c0..cea5da6 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -388,26 +388,6 @@ expect($occurrences)->toBeEmpty(); }); -test('app and event in same timezone ', function () { - $startDate = CarbonImmutable::createFromDate(2026, 2, 15); - Entry::make() - ->collection('events') - ->data([ - 'start_date' => $startDate->toDateString(), - 'start_time' => '05:00', - 'end_time' => '23:00', - 'all_day' => false, - ])->save(); - - $events = Events::fromCollection('events') - ->between( - CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), - CarbonImmutable::createFromDate(2026, 2, 15)->endOfDay() - ); - - expect($events)->toHaveCount(1); -}); - test('app and event in different timezone ', function () { $startDate = CarbonImmutable::createFromDate(2026, 2, 15); Entry::make() diff --git a/tests/Types/RecurringDailyEventsTest.php b/tests/Types/RecurringDailyEventsTest.php index daf09f0..ac7c9b0 100755 --- a/tests/Types/RecurringDailyEventsTest.php +++ b/tests/Types/RecurringDailyEventsTest.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Statamic\Facades\Entry; use TransformStudios\Events\EventFactory; +use TransformStudios\Events\Events; test('null next date if now after end date', function () { $recurringEntry = Entry::make() @@ -68,150 +69,24 @@ expect($nextOccurrences[0]->start)->toEqual($startDate); }); -// public function test_can_generate_next_day_if_after() -// { -// $startDate = CarbonImmutable::now()->setTimeFromTimeString('11:00:00'); -// $event = [ -// 'start_date' => $startDate->toDateString(), -// 'start_time' => '11:00', -// 'end_time' => '12:00', -// 'recurrence' => 'daily', -// ]; -// Carbon::setTestNow($startDate->addMinute()); -// $event = EventFactory::createFromArray($event); -// $nextOccurrences = $event->nextOccurrences(1); -// $this->assertEquals($startDate->addDay(), $nextDate->start()); -// } -// public function test_can_generate_next_x_dates_from_today_before_event_time() -// { -// $startDate = Carbon::now()->setTimeFromTimeString('11:00:00'); -// $event = EventFactory::createFromArray( -// [ -// 'start_date' => $startDate->toDateString(), -// 'start_time' => '11:00', -// 'end_time' => '12:00', -// 'recurrence' => 'daily', -// ] -// ); -// for ($x = 0; $x < 2; $x++) { -// $events[] = $startDate->copy()->addDays($x); -// } -// $this->events->add($event); -// Carbon::setTestNow($startDate->copy()->subMinutes(1)); -// $nextDates = $this->events->upcoming(2); -// $this->assertCount(2, $nextDates); -// $this->assertEquals($events[0], $nextDates[0]->start()); -// $this->assertEquals($events[1], $nextDates[1]->start()); -// } -// public function test_can_generate_next_x_dates_from_today() -// { -// $startDate = Carbon::now()->setTimeFromTimeString('11:00:00'); -// $event = EventFactory::createFromArray([ -// 'start_date' => $startDate->toDateString(), -// 'start_time' => '11:00', -// 'end_time' => '12:00', -// 'recurrence' => 'daily', -// ]); -// for ($x = 0; $x < 3; $x++) { -// $events[] = $startDate->copy()->addDays($x); -// } -// $this->events->add($event); -// Carbon::setTestNow($startDate->copy()->addMinutes(1)); -// $nextDates = $this->events->upcoming(3); -// $this->assertCount(3, $nextDates); -// $this->assertEquals($events[0], $nextDates[0]->start()); -// $this->assertEquals($events[1], $nextDates[1]->start()); -// $this->assertEquals($events[2], $nextDates[2]->start()); -// } -// public function test_generates_all_occurrences_when_daily_after_start_date() -// { -// $startDate = Carbon::now()->setTimeFromTimeString('11:00:00'); -// $event = EventFactory::createFromArray( -// [ -// 'start_date' => $startDate->copy()->addDay()->toDateString(), -// 'start_time' => '11:00', -// 'end_time' => '12:00', -// 'end_date' => $startDate->copy()->addDays(3)->toDateString(), -// 'recurrence' => 'daily', -// ] -// ); -// for ($x = 2; $x <= 3; $x++) { -// $events[] = $startDate->copy()->addDays($x); -// } -// $this->events->add($event); -// Carbon::setTestNow($startDate->copy()->addDays(1)->addHour(1)); -// $nextEvents = $this->events->upcoming(3); -// $this->assertCount(2, $nextEvents); -// $this->assertEquals($events[0], $nextEvents[0]->start()); -// $this->assertEquals($events[1], $nextEvents[1]->start()); -// } -// public function test_can_get_last_day_when_before() -// { -// Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); -// $this->events->add(EventFactory::createFromArray([ -// 'id' => 'daily-event', -// 'start_date' => Carbon::now()->toDateString(), -// 'start_time' => '13:00', -// 'end_time' => '15:00', -// 'recurrence' => 'daily', -// 'end_date' => Carbon::now()->addDays(7)->toDateString(), -// ])); -// $from = Carbon::now()->addDays(7); -// $to = Carbon::now()->endOfDay()->addDays(10); -// $events = $this->events->all($from, $to); -// $this->assertCount(1, $events); -// } -// public function test_generates_all_daily_occurrences_single_event_from_to() -// { -// Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); -// $this->events->add(EventFactory::createFromArray([ -// 'id' => 'daily-event', -// 'start_date' => Carbon::now()->toDateString(), -// 'start_time' => '13:00', -// 'end_time' => '15:00', -// 'recurrence' => 'daily', -// 'end_date' => Carbon::now()->addDays(7)->toDateString(), -// ])); -// $from = Carbon::now()->subDays(1); -// $to = Carbon::now()->endOfDay()->addDays(10); -// $events = $this->events->all($from, $to); -// $this->assertCount(8, $events); -// } -// public function test_generates_all_daily_occurrences_single_event_from_to_without_end_date() -// { -// Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); -// $this->events->add(EventFactory::createFromArray([ -// 'id' => 'daily-event', -// 'start_date' => Carbon::now()->toDateString(), -// 'start_time' => '13:00', -// 'end_time' => '15:00', -// 'recurrence' => 'daily', -// ])); -// $from = Carbon::now()->subDays(1); -// $to = Carbon::now()->endOfDay()->addDays(10); -// $events = $this->events->all($from, $to); -// $this->assertCount(11, $events); -// } -// public function test_can_exclude_dates() -// { -// Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); -// $this->events->add(EventFactory::createFromArray([ -// 'id' => 'daily-event', -// 'start_date' => Carbon::now()->toDateString(), -// 'start_time' => '13:00', -// 'end_time' => '15:00', -// 'recurrence' => 'daily', -// 'except' => [ -// ['date' => Carbon::now()->addDays(2)->toDateString()], -// ['date' => Carbon::now()->addDays(4)->toDateString()], -// ], -// ])); -// $from = Carbon::now()->subDays(1); -// $to = Carbon::now()->endOfDay()->addDays(5); -// $events = $this->events->all($from, $to)->toArray(); -// $this->assertCount(4, $events); -// $this->assertEquals(Carbon::now()->toDateString(), $events[0]['start_date']); -// $this->assertEquals(Carbon::now()->addDays(1)->toDateString(), $events[1]['start_date']); -// $this->assertEquals(Carbon::now()->addDays(3)->toDateString(), $events[2]['start_date']); -// $this->assertEquals(Carbon::now()->addDays(5)->toDateString(), $events[3]['start_date']); -// } +test('app and event in different timezone ', function () { + $startDate = CarbonImmutable::createFromDate(2026, 2, 15); + Entry::make() + ->collection('events') + ->data([ + 'start_date' => $startDate->toDateString(), + 'timezone' => 'America/Los_Angeles', + 'start_time' => '05:00', + 'end_time' => '16:00', + 'recurrence' => 'monthly', + 'specific_days' => ['third_monday'], + ])->save(); + + $events = Events::fromCollection('events') + ->between( + CarbonImmutable::createFromDate(2026, 2, 15)->startOfDay(), + CarbonImmutable::createFromDate(2026, 3, 16)->endOfDay() + ); + + expect($events)->toHaveCount(2); +}); diff --git a/tests/Types/RecurringEveryXEventsTest.php b/tests/Types/RecurringEveryXEventsTest.php index 1079266..87f52d6 100755 --- a/tests/Types/RecurringEveryXEventsTest.php +++ b/tests/Types/RecurringEveryXEventsTest.php @@ -245,120 +245,3 @@ expect($occurrences[0]->start)->toEqual($events[0]); expect($occurrences[1]->start)->toEqual($events[1]); }); - -/* - public function test_can_get_last_day_when_before() - { - Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); - - $event = [ - 'id' => 'daily-event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '13:00', - 'end_time' => '15:00', - 'recurrence' => 'every', - 'interval' => 2, - 'period' => 'days', - 'end_date' => Carbon::now()->addDays(8)->toDateString(), - ]; - - $this->events->add(EventFactory::createFromArray($event)); - - $from = Carbon::now()->addDays(7); - $to = Carbon::now()->endOfDay()->addDays(10); - - $events = $this->events->all($from, $to); - - $this->assertCount(1, $events); - - $event['start_date'] = Carbon::now()->addDays(8)->toDateString(); - - $this->assertEquals($event, $events[0]->toArray()); - } - - public function test_generates_all_daily_occurrences_single_event_from_to_with_end_date() - { - Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); - - $this->events->add(EventFactory::createFromArray( - [ - 'id' => 'daily-event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '13:00', - 'end_time' => '15:00', - 'recurrence' => 'every', - 'interval' => 2, - 'period' => 'days', - 'end_date' => Carbon::now()->addDays(8)->toDateString(), - ] - )); - - $from = Carbon::now()->subDays(1); - $to = Carbon::now()->endOfDay()->addDays(10); - - $events = $this->events->all($from, $to); - - $this->assertCount(5, $events); - } - - public function test_generates_all_daily_occurrences_single_event_from_to_without_end_date() - { - Carbon::setTestNow(Carbon::now()->setTimeFromTimeString('10:30')); - - $this->events->add(EventFactory::createFromArray( - [ - 'id' => 'daily-event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '13:00', - 'end_time' => '15:00', - 'recurrence' => 'every', - 'interval' => 2, - 'period' => 'days', - ] - )); - - $from = Carbon::now()->subDays(1); - $to = Carbon::now()->endOfDay()->addDays(10); - - $events = $this->events->all($from, $to); - - $this->assertCount(6, $events); - } - - public function test_can_generate_next_x_weeks_if_in_different_weeks() - { - $event = EventFactory::createFromArray( - [ - 'start_date' => '2020-01-03', - 'start_time' => '11:00', - 'end_time' => '12:00', - 'recurrence' => 'every', - 'interval' => 2, - 'period' => 'weeks', - ] - ); - - $day = $event->upcomingDate(Carbon::parse('2021-01-31')); - - $this->assertNotNull($day); - $this->assertEquals('2021-02-12', $day->startDate()); - } - - public function test_returns_null_when_dates_between_dont_have_event() - { - $event = EventFactory::createFromArray( - [ - 'start_date' => '2021-01-29', - 'start_time' => '11:00', - 'end_time' => '12:00', - 'recurrence' => 'every', - 'interval' => 2, - 'period' => 'weeks', - ] - ); - - $dates = $event->datesBetween('2021-02-18', '2021-02-19'); - - $this->assertEmpty($dates); - } -*/ From bea96565255c014f076f94805314d11ece64c1a3 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 20 Mar 2026 12:56:51 -0700 Subject: [PATCH 08/22] could be usefule on the front end --- src/Types/Event.php | 5 +++++ src/Types/SingleDayEvent.php | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 8728ae6..e1893fc 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -79,6 +79,11 @@ public function nextOccurrences(int $limit = 1): Collection return $this->collect($this->rule()->getOccurrencesAfter(date: now(), inclusive: true, limit: $limit)); } + public function spansDays(): bool + { + return $this->start()->setTimezone(config('app.timezone'))->day != $this->end()->setTimezone(config('app.timezone'))->day; + } + public function startTime(): string { return $this->start_time ?? now()->startOfDay()->toTimeString('second'); diff --git a/src/Types/SingleDayEvent.php b/src/Types/SingleDayEvent.php index 48c2318..f82a76c 100644 --- a/src/Types/SingleDayEvent.php +++ b/src/Types/SingleDayEvent.php @@ -27,9 +27,4 @@ protected function rule(): RRuleInterface return $rset; } - - private function spansDays(): bool - { - return $this->start()->setTimezone('UTC')->day != $this->end()->setTimezone('UTC')->day; - } } From 38bccf6f0bd6da8a95eeeac8e2aa323f1a33aa9f Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 23 Mar 2026 16:03:22 -0700 Subject: [PATCH 09/22] fix properly --- src/Types/Event.php | 12 +++++----- src/Types/SingleDayEvent.php | 14 +---------- tests/EventsTest.php | 36 ++++++++++++++++++++--------- tests/Types/SingleDayEventsTest.php | 18 +++++++++++++++ 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index e1893fc..d443f97 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -64,7 +64,12 @@ public function isRecurring(): bool public function occurrencesBetween(string|CarbonInterface $from, string|CarbonInterface $to): Collection { - return $this->collect($this->rule()->getOccurrencesBetween(begin: $from, end: $to)); + $tz = $this->timezone['name']; + + return $this->collect($this->rule()->getOccurrencesBetween( + begin: $from->shiftTimezone($tz), + end: $to->shiftTimezone($tz) + )); } public function occursOnDate(string|CarbonInterface $date): bool @@ -79,11 +84,6 @@ public function nextOccurrences(int $limit = 1): Collection return $this->collect($this->rule()->getOccurrencesAfter(date: now(), inclusive: true, limit: $limit)); } - public function spansDays(): bool - { - return $this->start()->setTimezone(config('app.timezone'))->day != $this->end()->setTimezone(config('app.timezone'))->day; - } - public function startTime(): string { return $this->start_time ?? now()->startOfDay()->toTimeString('second'); diff --git a/src/Types/SingleDayEvent.php b/src/Types/SingleDayEvent.php index f82a76c..f58aff8 100644 --- a/src/Types/SingleDayEvent.php +++ b/src/Types/SingleDayEvent.php @@ -4,27 +4,15 @@ use RRule\RRule; use RRule\RRuleInterface; -use RRule\RSet; class SingleDayEvent extends Event { protected function rule(): RRuleInterface { - $rset = tap(new RSet)->addRRule([ + return new RRule([ 'count' => 1, 'dtstart' => $this->end(), 'freq' => RRule::DAILY, ]); - - // if the occurrence spans days, include the start so that it's picked up on the "between" method - if ($this->spansDays()) { - $rset->addRRule([ - 'count' => 1, - 'dtstart' => $this->start(), - 'freq' => RRule::DAILY, - ]); - } - - return $rset; } } diff --git a/tests/EventsTest.php b/tests/EventsTest.php index cea5da6..dade95f 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -388,23 +388,37 @@ expect($occurrences)->toBeEmpty(); }); -test('app and event in different timezone ', function () { - $startDate = CarbonImmutable::createFromDate(2026, 2, 15); +test('app and event in same timezone ', function () { + $date = CarbonImmutable::createFromDate(2026, 2, 28); Entry::make() ->collection('events') ->data([ - 'start_date' => $startDate->toDateString(), + 'start_date' => $date->toDateString(), + 'start_time' => '05:00', + 'end_time' => '23:00', + ])->save(); + + $events1 = Events::fromCollection('events')->between($date->startOfMonth(), $date->endOfMonth()); + $events2 = Events::fromCollection('events')->between($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); + + expect($events1)->toHaveCount(1); + expect($events2)->toHaveCount(1); +}); + +test('app and event in different timezone', function () { + $date = CarbonImmutable::createFromDate(2026, 2, 28); + Entry::make() + ->collection('events') + ->data([ + 'start_date' => $date->toDateString(), 'timezone' => 'America/Los_Angeles', 'start_time' => '05:00', - 'end_time' => '16:00', - 'all_day' => false, + 'end_time' => '23:00', ])->save(); - $events = Events::fromCollection('events') - ->between( - CarbonImmutable::createFromDate(2026, 2, 1)->startOfDay(), - CarbonImmutable::createFromDate(2026, 2, 15)->endOfDay() - ); + $events1 = Events::fromCollection('events')->between($date->startOfMonth(), $date->endOfMonth()); + $events2 = Events::fromCollection('events')->between($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); - expect($events)->toHaveCount(1); + expect($events1)->toHaveCount(1); + expect($events2)->toHaveCount(1); }); diff --git a/tests/Types/SingleDayEventsTest.php b/tests/Types/SingleDayEventsTest.php index 867f563..66c7d0b 100755 --- a/tests/Types/SingleDayEventsTest.php +++ b/tests/Types/SingleDayEventsTest.php @@ -144,3 +144,21 @@ expect($nextOccurrences[0]->has_end_time)->toBeFalse(); }); + +test('app and event in different timezone ', function () { + $date = CarbonImmutable::createFromDate(2026, 2, 28); + $entry = Entry::make() + ->collection('events') + ->data([ + 'start_date' => $date->toDateString(), + 'timezone' => 'America/Los_Angeles', + 'start_time' => '05:00', + 'end_time' => '23:00', + ]); + + $events1 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()); + $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); + + expect($events1)->toHaveCount(1); + expect($events2)->toHaveCount(1); +}); From 68af710d163b4387a900428e9b4cfb1c8814dffe Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 23 Mar 2026 16:03:37 -0700 Subject: [PATCH 10/22] can list weekdays --- src/Tags/Events.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 277c407..7a244b9 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; +use Carbon\CarbonPeriod; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Statamic\Contracts\Query\Builder; @@ -47,6 +48,16 @@ public function calendar(): Collection return $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); } + public function daysOfWeek(): Collection + { + return collect(CarbonPeriod::dates(now()->startOfWeek(), now()->endOfWeek())) + ->map(fn (Carbon $date) => [ + 'short' => $date->format('D')[0], + 'medium' => $date->format('D'), + 'long' => $date->format('l'), + ]); + } + public function downloadLink(): string { return route( From bbdcef1dcc611db3c1c22a9c8273cff41d5e7b4c Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 25 Mar 2026 12:29:58 -0700 Subject: [PATCH 11/22] use site locale --- src/Modifiers/IsEndOfWeek.php | 14 ++++++++++++-- src/Modifiers/IsStartOfWeek.php | 14 ++++++++++++-- src/Tags/Events.php | 14 +++++++++++++- tests/Modifiers/IsStartOfWeekTest.php | 26 ++++++++++++++++++++++++++ tests/Tags/EventsTest.php | 22 ++++++++++++++++++++++ tests/TestCase.php | 9 --------- 6 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 tests/Modifiers/IsStartOfWeekTest.php diff --git a/src/Modifiers/IsEndOfWeek.php b/src/Modifiers/IsEndOfWeek.php index ec09b4e..b7b9301 100755 --- a/src/Modifiers/IsEndOfWeek.php +++ b/src/Modifiers/IsEndOfWeek.php @@ -3,16 +3,26 @@ namespace TransformStudios\Events\Modifiers; use Carbon\CarbonImmutable; +use Statamic\Facades\Site; use Statamic\Modifiers\Modifier; class IsEndOfWeek extends Modifier { public function index($value, $params, $context) { + /* + have to do this because Statamic sets the Carbon locale + to the `lang` of the site, instead of the `locale` + */ + $currentLocale = CarbonImmutable::getLocale(); + CarbonImmutable::setLocale(Site::current()->locale()); + $date = CarbonImmutable::parse($value); - $date->isSameDay($date->locale(CarbonImmutable::getLocale())->startOfWeek()); + $isStartOfWeek = $date->dayOfWeek == now()->endOfWeek()->dayOfWeek; + + CarbonImmutable::setLocale($currentLocale); - return $date->dayOfWeek == now()->endOfWeek()->dayOfWeek; + return $isStartOfWeek; } } diff --git a/src/Modifiers/IsStartOfWeek.php b/src/Modifiers/IsStartOfWeek.php index e011221..b300bf7 100755 --- a/src/Modifiers/IsStartOfWeek.php +++ b/src/Modifiers/IsStartOfWeek.php @@ -3,16 +3,26 @@ namespace TransformStudios\Events\Modifiers; use Carbon\CarbonImmutable; +use Statamic\Facades\Site; use Statamic\Modifiers\Modifier; class IsStartOfWeek extends Modifier { public function index($value, $params, $context) { + /* + have to do this because Statamic sets the Carbon locale + to the `lang` of the site, instead of the `locale` + */ + $currentLocale = CarbonImmutable::getLocale(); + CarbonImmutable::setLocale(Site::current()->locale()); + $date = CarbonImmutable::parse($value); - $date->isSameDay($date->locale(CarbonImmutable::getLocale())->startOfWeek()); + $isStartOfWeek = $date->dayOfWeek == now()->startOfWeek()->dayOfWeek; + + CarbonImmutable::setLocale($currentLocale); - return $date->dayOfWeek == now()->startOfWeek()->dayOfWeek; + return $isStartOfWeek; } } diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 7a244b9..52af70e 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -13,6 +13,7 @@ use Statamic\Entries\Entry; use Statamic\Entries\EntryCollection; use Statamic\Facades\Compare; +use Statamic\Facades\Site; use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Tags\Concerns\OutputsItems; @@ -50,12 +51,23 @@ public function calendar(): Collection public function daysOfWeek(): Collection { - return collect(CarbonPeriod::dates(now()->startOfWeek(), now()->endOfWeek())) + /* + have to do this because Statamic sets the Carbon locale + to the `lang` of the site, instead of the `locale` + */ + $currentLocale = Carbon::getLocale(); + Carbon::setLocale(Site::current()->locale()); + + $days = collect(CarbonPeriod::dates(now()->startOfWeek(), now()->endOfWeek())) ->map(fn (Carbon $date) => [ 'short' => $date->format('D')[0], 'medium' => $date->format('D'), 'long' => $date->format('l'), ]); + + Carbon::setLocale($currentLocale); + + return $days; } public function downloadLink(): string diff --git a/tests/Modifiers/IsStartOfWeekTest.php b/tests/Modifiers/IsStartOfWeekTest.php new file mode 100644 index 0000000..0a28048 --- /dev/null +++ b/tests/Modifiers/IsStartOfWeekTest.php @@ -0,0 +1,26 @@ +andReturn(new Site('default', [ + 'name' => 'Laravel', + 'url' => '/', + 'locale' => 'en_US', + 'lang' => 'en', + ], true)); + + Carbon::setTestNow('2026-3-25 12:00pm'); + + $modified = modify('2026-3-22'); + expect($modified)->toBe(true); +}); + +function modify(string $value) +{ + return Modify::value($value)->isStartOfWeek()->fetch(); +} diff --git a/tests/Tags/EventsTest.php b/tests/Tags/EventsTest.php index ad53291..a36301d 100755 --- a/tests/Tags/EventsTest.php +++ b/tests/Tags/EventsTest.php @@ -5,6 +5,8 @@ use Illuminate\Support\Carbon; use Statamic\Facades\Cascade; use Statamic\Facades\Entry; +use Statamic\Facades\Site as SiteFacade; +use Statamic\Sites\Site; use Statamic\Support\Arr; use TransformStudios\Events\Tags\Events; @@ -387,3 +389,23 @@ expect($this->tag->today())->toHaveCount(0); }); + +it('sets the correct short form of the days of week', function () { + expect($this->tag->daysOfWeek())->pluck('short')->toBe(['M', 'Tu', 'W', 'Th', 'F', 'Sa', 'Su']); +}); + +it('uses the current site locale to get days of week', function () { + SiteFacade::shouldReceive('current') + ->andReturn(new Site('default', [ + 'name' => 'Laravel', + 'url' => '/', + 'locale' => 'en_US', + 'lang' => 'en', + ], true)); + + expect($this->tag->daysOfWeek())->first()->toBe([ + 'short' => 'S', + 'medium' => 'Sun', + 'long' => 'Sunday', + ]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index cf978cc..eb0f6d0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -28,15 +28,6 @@ abstract class TestCase extends AddonTestCase protected Blueprint $blueprint; - protected function setUp(): void - { - parent::setUp(); - - if (! file_exists($this->fakeStacheDirectory)) { - mkdir($this->fakeStacheDirectory, 0777, true); - } - } - protected function getEnvironmentSetUp($app) { parent::getEnvironmentSetUp($app); From e2dccc2132be70f52c5858c869957fc42d014d72 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 25 Mar 2026 12:34:32 -0700 Subject: [PATCH 12/22] fix test --- tests/Tags/EventsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tags/EventsTest.php b/tests/Tags/EventsTest.php index a36301d..008935f 100755 --- a/tests/Tags/EventsTest.php +++ b/tests/Tags/EventsTest.php @@ -391,7 +391,7 @@ }); it('sets the correct short form of the days of week', function () { - expect($this->tag->daysOfWeek())->pluck('short')->toBe(['M', 'Tu', 'W', 'Th', 'F', 'Sa', 'Su']); + expect($this->tag->daysOfWeek())->pluck('short')->toMatchArray(['M', 'T', 'W', 'T', 'F', 'S', 'S']); }); it('uses the current site locale to get days of week', function () { From 449cf5c6b58308763936a7003d6ae4ea64b50461 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 25 Mar 2026 13:59:06 -0700 Subject: [PATCH 13/22] move the creation to setup --- tests/TestCase.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index eb0f6d0..41d7f5e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,14 +20,25 @@ abstract class TestCase extends AddonTestCase protected string $addonServiceProvider = ServiceProvider::class; - protected $fakeStacheDirectory = __DIR__.'/__fixtures__/dev-null'; - protected $shouldFakeVersion = true; protected Collection $collection; protected Blueprint $blueprint; + protected function setUp(): void + { + parent::setUp(); + + Taxonomy::make('categories')->save(); + Term::make('one')->taxonomy('categories')->dataForLocale('default', [])->save(); + Term::make('two')->taxonomy('categories')->dataForLocale('default', [])->save(); + + $this->collection = CollectionFacade::make('events') + ->taxonomies(['categories']) + ->save(); + } + protected function getEnvironmentSetUp($app) { parent::getEnvironmentSetUp($app); @@ -40,13 +51,6 @@ protected function getEnvironmentSetUp($app) Fieldset::addNamespace('events', __DIR__.'/../resources/fieldsets'); app()->extend(BlueprintRepository::class, fn ($repo) => $repo->setDirectory(__DIR__.'/__fixtures__/blueprints')); - Taxonomy::make('categories')->save(); - Term::make('one')->taxonomy('categories')->dataForLocale('default', [])->save(); - Term::make('two')->taxonomy('categories')->dataForLocale('default', [])->save(); - - $this->collection = CollectionFacade::make('events') - ->taxonomies(['categories']) - ->save(); }); } } From f60ef1251ddaf3b947884d7e5b8e2fbc21b7ef79 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Thu, 26 Mar 2026 15:18:08 +0100 Subject: [PATCH 14/22] Set and restore locale for calendar generation --- src/Tags/Events.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 52af70e..82ea4b1 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -34,6 +34,9 @@ public function between(): EntryCollection|array public function calendar(): Collection { + $currentLocale = CarbonImmutable::getLocale(); + CarbonImmutable::setLocale(Site::current()->locale()); + $month = $this->params->get('month', now()->englishMonth); $year = $this->params->get('year', now()->year); @@ -46,7 +49,11 @@ public function calendar(): Collection ->groupBy(fn (Entry $occurrence) => $occurrence->start->toDateString()) ->map(fn (EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences)); - return $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); + $days = $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); + + CarbonImmutable::setLocale($currentLocale); + + return $days; } public function daysOfWeek(): Collection From 62fc8f1f56d673ba0991c2de75d5414ca883e9a6 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Thu, 26 Mar 2026 16:44:50 +0100 Subject: [PATCH 15/22] add `event with timezone offset appears on the correct UTC date` test --- tests/EventsTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/EventsTest.php b/tests/EventsTest.php index dade95f..ce03a61 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -422,3 +422,21 @@ expect($events1)->toHaveCount(1); expect($events2)->toHaveCount(1); }); + +test('event with timezone offset appears on the correct UTC date', function () { + $date = CarbonImmutable::createFromDate(2026, 2, 28); + Entry::make() + ->collection('events') + ->data([ + 'start_date' => $date->toDateString(), + 'timezone' => 'America/Los_Angeles', + 'start_time' => '22:00', + 'end_time' => '23:00', + ])->save(); + + $events1 = Events::fromCollection('events')->between($date->startOfDay(), $date->endOfDay()); + $events2 = Events::fromCollection('events')->between($date->startOfDay()->addDay(), $date->endOfDay()->addDay()); + + expect($events1)->toHaveCount(0); + expect($events2)->toHaveCount(1); +}); From 329ac2ac173aeebd554394ecad106dea836b2010 Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 26 Mar 2026 12:22:18 -0700 Subject: [PATCH 16/22] =?UTF-8?q?don=E2=80=99t=20shift=20tz=20when=20query?= =?UTF-8?q?ing=20between?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Types/Event.php | 7 +------ tests/EventsTest.php | 2 +- tests/Pest.php | 6 ++++-- tests/TestCase.php | 1 - tests/Types/SingleDayEventsTest.php | 25 +++++++++++++++++++++---- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index d443f97..8728ae6 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -64,12 +64,7 @@ public function isRecurring(): bool public function occurrencesBetween(string|CarbonInterface $from, string|CarbonInterface $to): Collection { - $tz = $this->timezone['name']; - - return $this->collect($this->rule()->getOccurrencesBetween( - begin: $from->shiftTimezone($tz), - end: $to->shiftTimezone($tz) - )); + return $this->collect($this->rule()->getOccurrencesBetween(begin: $from, end: $to)); } public function occursOnDate(string|CarbonInterface $date): bool diff --git a/tests/EventsTest.php b/tests/EventsTest.php index ce03a61..fe1cbcd 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -421,7 +421,7 @@ expect($events1)->toHaveCount(1); expect($events2)->toHaveCount(1); -}); +})->skip(); test('event with timezone offset appears on the correct UTC date', function () { $date = CarbonImmutable::createFromDate(2026, 2, 28); diff --git a/tests/Pest.php b/tests/Pest.php index de7b364..8b301d3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ collection('events')->data($data); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 41d7f5e..be5d962 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,7 +45,6 @@ protected function getEnvironmentSetUp($app) // Assume the pro edition within tests $app['config']->set('statamic.editions.pro', true); - $app['config']->set('events.timezone', 'UTC'); Statamic::booted(function () { Fieldset::addNamespace('events', __DIR__.'/../resources/fieldsets'); diff --git a/tests/Types/SingleDayEventsTest.php b/tests/Types/SingleDayEventsTest.php index 66c7d0b..f437590 100755 --- a/tests/Types/SingleDayEventsTest.php +++ b/tests/Types/SingleDayEventsTest.php @@ -145,7 +145,25 @@ expect($nextOccurrences[0]->has_end_time)->toBeFalse(); }); -test('app and event in different timezone ', function () { +it('queries occurrences based on timezone', function () { + $utcDate = now('UTC')->setTimeFromTimeString('11:00')->toImmutable(); + $laDate = now('America/Los_Angeles')->setTimeFromTimeString('11:00')->toImmutable(); + + $entry = makeEvent([ + 'start_date' => $utcDate->toDateString(), + 'timezone' => 'America/Los_Angeles', + 'start_time' => '22:00', + 'end_time' => '23:00', + ]); + + $events1 = EventFactory::createFromEntry($entry)->occurrencesBetween($utcDate->startOfDay(), $utcDate->endOfDay()); + $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($laDate->startOfDay(), $laDate->endOfDay()); + + expect($events1)->toHaveCount(0); + expect($events2)->toHaveCount(1); +}); + +it('retrieves occurrences that span days', function () { $date = CarbonImmutable::createFromDate(2026, 2, 28); $entry = Entry::make() ->collection('events') @@ -157,8 +175,7 @@ ]); $events1 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()); - $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); + // $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); expect($events1)->toHaveCount(1); - expect($events2)->toHaveCount(1); -}); +})->skip(); From 4779f9963a4e6cf699db1dbb15cb13c151e59c3b Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 26 Mar 2026 18:11:44 -0700 Subject: [PATCH 17/22] get all tests passing --- src/Types/Event.php | 4 ++-- src/Types/MultiDayEvent.php | 11 +---------- src/Types/RecurringEvent.php | 2 +- src/Types/SingleDayEvent.php | 15 ++++++++++++--- tests/EventsTest.php | 2 +- tests/Types/SingleDayEventsTest.php | 26 +++++++++++++------------- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 8728ae6..f340dac 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -14,7 +14,7 @@ abstract class Event { - abstract protected function rule(): RRuleInterface; + abstract protected function rule(bool $useEnd = false): RRuleInterface; public function __construct(protected Entry $event) {} @@ -76,7 +76,7 @@ public function occursOnDate(string|CarbonInterface $date): bool public function nextOccurrences(int $limit = 1): Collection { - return $this->collect($this->rule()->getOccurrencesAfter(date: now(), inclusive: true, limit: $limit)); + return $this->collect($this->rule(true)->getOccurrencesAfter(date: now(), inclusive: true, limit: $limit)); } public function startTime(): string diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 78177bc..c108876 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -93,17 +93,8 @@ public function toICalendarEvents(): array ->all(); } - protected function rule(bool $collapseDays = false): RRuleInterface + protected function rule(bool $useEnd = false): RRuleInterface { - // if we're collapsing, then return an rrule instead of rset and use start of first day to end of last day - if ($this->collapseMultiDays) { - return new RRule([ - 'count' => 1, - 'dtstart' => $this->end(), - 'freq' => RRule::DAILY, - ]); - } - return tap( new RSet, fn (RSet $rset) => $this->days->each(fn (Day $day) => $rset->addRRule([ diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 773623a..2b5723a 100644 --- a/src/Types/RecurringEvent.php +++ b/src/Types/RecurringEvent.php @@ -52,7 +52,7 @@ public function toICalendarEvents(): array return [$iCalEvent]; } - protected function rule(): RRuleInterface + protected function rule(bool $useEnd = false): RRuleInterface { $rule = [ 'dtstart' => $this->end(), diff --git a/src/Types/SingleDayEvent.php b/src/Types/SingleDayEvent.php index f58aff8..af39d16 100644 --- a/src/Types/SingleDayEvent.php +++ b/src/Types/SingleDayEvent.php @@ -7,11 +7,20 @@ class SingleDayEvent extends Event { - protected function rule(): RRuleInterface + protected function rule(bool $useEnd = false): RRuleInterface { + if ($useEnd) { + return new RRule([ + 'count' => 1, + 'dtstart' => $this->end(), + 'freq' => RRule::DAILY, + ]); + } + return new RRule([ - 'count' => 1, - 'dtstart' => $this->end(), + // 'count' => 1, + 'dtstart' => $this->start(), + 'until' => $this->end(), 'freq' => RRule::DAILY, ]); } diff --git a/tests/EventsTest.php b/tests/EventsTest.php index fe1cbcd..ce03a61 100755 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -421,7 +421,7 @@ expect($events1)->toHaveCount(1); expect($events2)->toHaveCount(1); -})->skip(); +}); test('event with timezone offset appears on the correct UTC date', function () { $date = CarbonImmutable::createFromDate(2026, 2, 28); diff --git a/tests/Types/SingleDayEventsTest.php b/tests/Types/SingleDayEventsTest.php index f437590..3c2032f 100755 --- a/tests/Types/SingleDayEventsTest.php +++ b/tests/Types/SingleDayEventsTest.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; use Carbon\CarbonTimeZone; +use Illuminate\Support\Facades\Date; use Statamic\Facades\Entry; use TransformStudios\Events\EventFactory; use TransformStudios\Events\Types\SingleDayEvent; @@ -146,36 +147,35 @@ }); it('queries occurrences based on timezone', function () { - $utcDate = now('UTC')->setTimeFromTimeString('11:00')->toImmutable(); - $laDate = now('America/Los_Angeles')->setTimeFromTimeString('11:00')->toImmutable(); + $utcDate = Date::parse('2026-03-26 11am')->toImmutable(); + $laDate = $utcDate->shiftTimezone('America/Los_Angeles'); $entry = makeEvent([ - 'start_date' => $utcDate->toDateString(), + 'start_date' => '2026-03-26', 'timezone' => 'America/Los_Angeles', - 'start_time' => '22:00', + 'start_time' => '05:00', 'end_time' => '23:00', ]); $events1 = EventFactory::createFromEntry($entry)->occurrencesBetween($utcDate->startOfDay(), $utcDate->endOfDay()); $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($laDate->startOfDay(), $laDate->endOfDay()); - expect($events1)->toHaveCount(0); + expect($events1)->toHaveCount(1); expect($events2)->toHaveCount(1); }); -it('retrieves occurrences that span days', function () { - $date = CarbonImmutable::createFromDate(2026, 2, 28); +it('retrieves occurrences that span days in different timezone than event', function ($from, $to, $count) { $entry = Entry::make() ->collection('events') ->data([ - 'start_date' => $date->toDateString(), + 'start_date' => now()->toDateString(), 'timezone' => 'America/Los_Angeles', 'start_time' => '05:00', 'end_time' => '23:00', ]); - $events1 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()); - // $events2 = EventFactory::createFromEntry($entry)->occurrencesBetween($date->startOfMonth(), $date->endOfMonth()->endOfWeek()); - - expect($events1)->toHaveCount(1); -})->skip(); + expect(EventFactory::createFromEntry($entry))->occurrencesBetween($from, $to)->toHaveCount($count); +})->with([ + [now()->startOfDay(), now()->endOfDay()->addDay(), 1], + [now()->startOfDay(), now()->endOfDay(), 1], +]); From d1e25edfd0d7b4b9819978cf6a45ed3e7c1809cf Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 27 Mar 2026 11:34:20 +0100 Subject: [PATCH 18/22] group occurrences by utc date and time --- src/Tags/Events.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 82ea4b1..96de51f 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -46,7 +46,12 @@ public function calendar(): Collection $occurrences = $this ->generator() ->between(from: $from, to: $to) - ->groupBy(fn (Entry $occurrence) => $occurrence->start->toDateString()) + ->groupBy(function (Entry $occurrence) { + $start = $occurrence->start->setTimezone('UTC'); + $end = $occurrence->end->setTimezone('UTC'); + + return $start->isSameDay($end) ? $start->toDateString() : [$start->toDateString(), $end->toDateString()]; + }) ->map(fn (EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences)); $days = $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); From fac9f8f3f743a523e1200b17bf87d44e33d3dd00 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 27 Mar 2026 14:26:17 +0100 Subject: [PATCH 19/22] use timezones dictionary for settings --- resources/blueprints/settings.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/blueprints/settings.yaml b/resources/blueprints/settings.yaml index 453c324..4a0e3c6 100644 --- a/resources/blueprints/settings.yaml +++ b/resources/blueprints/settings.yaml @@ -27,9 +27,9 @@ tabs: - handle: timezone field: - mode: select + dictionary: timezones max_items: 1 - type: timezones + default: UTC + type: dictionary display: Timezone full_width_setting: true - default: 'UTC' From bc96e26bf1cf5e6af0a801a19bfc03a052427f57 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 27 Mar 2026 14:27:10 +0100 Subject: [PATCH 20/22] use display_timezone as fallback before app timezone --- src/Events.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Events.php b/src/Events.php index 8950d88..71d9e30 100644 --- a/src/Events.php +++ b/src/Events.php @@ -67,7 +67,7 @@ public static function setting(string $key, $default = null): mixed public static function timezone(): string { - return static::setting('timezone', config('app.timezone')); + return static::setting('timezone', config('statamic.system.display_timezone') ?? config('app.timezone')); } private function __construct() {} From 53460fc50728e4e72218f3cabef88c7e19653818 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 27 Mar 2026 14:28:21 +0100 Subject: [PATCH 21/22] group occurrences based on the default timezone --- src/Tags/Events.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index 96de51f..ea32d57 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -40,19 +40,19 @@ public function calendar(): Collection $month = $this->params->get('month', now()->englishMonth); $year = $this->params->get('year', now()->year); - $from = parse_date($month.' '.$year)->startOfMonth()->startOfWeek(); - $to = parse_date($month.' '.$year)->endOfMonth()->endOfWeek(); + $from = parse_date($month . ' ' . $year)->startOfMonth()->startOfWeek(); + $to = parse_date($month . ' ' . $year)->endOfMonth()->endOfWeek(); $occurrences = $this ->generator() ->between(from: $from, to: $to) ->groupBy(function (Entry $occurrence) { - $start = $occurrence->start->setTimezone('UTC'); - $end = $occurrence->end->setTimezone('UTC'); + $start = $occurrence->start->setTimezone($this->params->get('timezone') ?? Generator::timezone()); + $end = $occurrence->end->setTimezone($this->params->get('timezone') ?? Generator::timezone()); return $start->isSameDay($end) ? $start->toDateString() : [$start->toDateString(), $end->toDateString()]; }) - ->map(fn (EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences)); + ->map(fn(EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences)); $days = $this->output($this->makeEmptyDates(from: $from, to: $to)->merge($occurrences)->values()); From 508c957e0444be0ffdec955f1980b22fff1730cb Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 27 Mar 2026 15:41:04 +0100 Subject: [PATCH 22/22] use period to match all spanning days --- src/Tags/Events.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Tags/Events.php b/src/Tags/Events.php index ea32d57..3bfc413 100755 --- a/src/Tags/Events.php +++ b/src/Tags/Events.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Carbon\CarbonPeriod; +use Carbon\CarbonPeriodImmutable; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Statamic\Contracts\Query\Builder; @@ -47,10 +48,14 @@ public function calendar(): Collection ->generator() ->between(from: $from, to: $to) ->groupBy(function (Entry $occurrence) { - $start = $occurrence->start->setTimezone($this->params->get('timezone') ?? Generator::timezone()); - $end = $occurrence->end->setTimezone($this->params->get('timezone') ?? Generator::timezone()); - - return $start->isSameDay($end) ? $start->toDateString() : [$start->toDateString(), $end->toDateString()]; + $periodInTimezone = CarbonPeriodImmutable::between( + $occurrence->start->setTimezone($this->params->get('timezone') ?? Generator::timezone())->startOfDay(), + $occurrence->end->setTimezone($this->params->get('timezone') ?? Generator::timezone())->endOfDay() + ); + + return collect($periodInTimezone->toArray()) + ->map(fn (CarbonImmutable $date) => $date->toDateString()) + ->all(); }) ->map(fn(EntryCollection $occurrences, string $date) => $this->day(date: $date, occurrences: $occurrences));