From 78287b1e5fadbb556a2a2d4c254c195abfa6885c Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 24 Oct 2026 10:27:16 -0400 Subject: [PATCH] Preserve UTC starts in timezone-aware RRULE schedules --- src/Drivers/RruleSchedule.php | 4 +++- tests/Drivers/RruleScheduleTest.php | 20 ++++++++++++++++++++ tests/ScheduleTest.php | 21 +++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Drivers/RruleSchedule.php b/src/Drivers/RruleSchedule.php index 75db91f..720682f 100644 --- a/src/Drivers/RruleSchedule.php +++ b/src/Drivers/RruleSchedule.php @@ -53,7 +53,9 @@ protected function toRfcString(): string [$expression, $dtstart] = RruleExpression::extractDtstart($this->toExpression()); if ($dtstart) { - $tzid = $this->timezone ? ";TZID={$this->timezone}" : ''; + $tzid = $this->timezone && ! str_ends_with($dtstart, 'Z') + ? ";TZID={$this->timezone}" + : ''; return "DTSTART{$tzid}:{$dtstart}\nRRULE:{$expression}"; } diff --git a/tests/Drivers/RruleScheduleTest.php b/tests/Drivers/RruleScheduleTest.php index 77a0b34..a60ce7b 100644 --- a/tests/Drivers/RruleScheduleTest.php +++ b/tests/Drivers/RruleScheduleTest.php @@ -56,6 +56,26 @@ expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-05-02 13:00:00'); }); +it('preserves a UTC start when a timezone is configured', function ($timezone, $limit) { + $expression = "FREQ=DAILY;{$limit};DTSTART=20260501T090000Z"; + $driver = new RruleSchedule($expression, $timezone); + + $next = $driver->getNextOccurrence(Carbon::parse('2026-05-02 08:00:00', 'UTC')); + + expect($next?->utc()->toDateTimeString())->toBe('2026-05-02 09:00:00') + ->and($driver->toExpression())->toBe($expression) + ->and($driver->getNextOccurrence(Carbon::parse('2026-05-03 09:00:00', 'UTC')))->toBeNull(); +})->with(['America/New_York', 'Australia/Sydney'])->with(['COUNT=3', 'UNTIL=20260503T090000Z']); + +it('preserves a UTC start when the timezone is set via method', function () { + $driver = (new RruleSchedule('FREQ=DAILY;DTSTART=20260501T090000Z')) + ->setTimezone('America/New_York'); + + $next = $driver->getNextOccurrence(Carbon::parse('2026-05-02 08:00:00', 'UTC')); + + expect($next?->utc()->toDateTimeString())->toBe('2026-05-02 09:00:00'); +}); + it('serializes to expression', function () { Carbon::setTestNow('2026-05-02 10:00:00'); diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php index 0392fb5..4f82ace 100644 --- a/tests/ScheduleTest.php +++ b/tests/ScheduleTest.php @@ -187,6 +187,27 @@ ->and($schedule->expression)->toBe('FREQ=DAILY;COUNT=2;DTSTART=20260501T090000'); })->with([RruleSchedule::class, RecurrSchedule::class]); +it('preserves an explicit UTC recurrence start across reloads and timezone changes', function () { + $expression = 'FREQ=DAILY;DTSTART=20260501T090000Z'; + $schedule = SchedulableModel::create()->addSchedule( + new RruleSchedule($expression, 'America/New_York') + ); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-03 09:00:00') + ->and($schedule->expression)->toBe($expression); + + $schedule->update(['timezone' => 'Australia/Sydney']); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-03 09:00:00') + ->and($schedule->expression)->toBe($expression); + + Carbon::setTestNow('2026-05-03 09:01:00'); + + $schedule->advance(now()); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-04 09:00:00'); +}); + it('resolves the driver from a stored schedule', function () { $schedule = SchedulableModel::create()->addSchedule(new CronSchedule('0 12 * * *'));