From 6bb13c255ec2c9fa076adff443775b13aba9b653 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 24 Oct 2026 10:01:52 -0400 Subject: [PATCH 1/3] Preserve the start and timezone of RRULE schedules --- README.md | 4 ++ src/Drivers/RecurrSchedule.php | 16 +++++- src/Drivers/RruleSchedule.php | 16 +++++- src/Support/RruleExpression.php | 8 +++ tests/Drivers/RecurrScheduleTest.php | 4 +- tests/Drivers/RruleScheduleTest.php | 4 +- tests/Drivers/RruleStartTest.php | 77 +++++++++++++++++++++++++++ tests/ScheduleTest.php | 56 +++++++++++++++++++ tests/Support/RruleExpressionTest.php | 13 +++++ 9 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/Drivers/RruleStartTest.php diff --git a/README.md b/README.md index a19aed0..a60381a 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,10 @@ new RecurrSchedule('FREQ=WEEKLY;BYDAY=MO,WE,FR'); new RecurrSchedule('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1'); ``` +Both RRULE drivers add a `DTSTART` when the expression is first evaluated or serialized. The start uses the schedule's timezone, or the application's default timezone when none is provided. `addSchedule()` stores the start and timezone so `COUNT` and `INTERVAL` stay anchored when the schedule is reloaded. + +To choose a specific start, include `DTSTART` in the expression. Explicit starts are preserved. Existing stored expressions without `DTSTART` should be updated with the intended start; their original implicit start was not recorded. + ### Custom Drivers Create a class that extends the base `Schedule` driver: diff --git a/src/Drivers/RecurrSchedule.php b/src/Drivers/RecurrSchedule.php index 339c4ac..3ba168e 100644 --- a/src/Drivers/RecurrSchedule.php +++ b/src/Drivers/RecurrSchedule.php @@ -13,6 +13,20 @@ class RecurrSchedule extends Schedule { + /** + * Serialize the expression with a stable start in the schedule's timezone. + */ + public function toExpression(): string + { + if (! RruleExpression::hasDtstart($this->expression)) { + $this->timezone ??= date_default_timezone_get(); + + $this->expression .= ';DTSTART='.now($this->timezone)->format('Ymd\THis'); + } + + return $this->expression; + } + /** * Resolve the next occurrence after the given date. */ @@ -40,7 +54,7 @@ protected function resolveNextOccurrence(CarbonInterface $after): ?CarbonInterfa */ protected function newRule(): Rule { - [$expression, $dtstart] = RruleExpression::extractDtstart($this->expression); + [$expression, $dtstart] = RruleExpression::extractDtstart($this->toExpression()); if ($dtstart) { $timezone = $this->timezone ? new DateTimeZone($this->timezone) : null; diff --git a/src/Drivers/RruleSchedule.php b/src/Drivers/RruleSchedule.php index e1e3dd4..6be6f49 100644 --- a/src/Drivers/RruleSchedule.php +++ b/src/Drivers/RruleSchedule.php @@ -9,6 +9,20 @@ class RruleSchedule extends Schedule { + /** + * Serialize the expression with a stable start in the schedule's timezone. + */ + public function toExpression(): string + { + if (! RruleExpression::hasDtstart($this->expression)) { + $this->timezone ??= date_default_timezone_get(); + + $this->expression .= ';DTSTART='.now($this->timezone)->format('Ymd\THis'); + } + + return $this->expression; + } + /** * Resolve the next occurrence after the given date. */ @@ -36,7 +50,7 @@ protected function resolveNextOccurrence(CarbonInterface $after): ?CarbonInterfa */ protected function toRfcString(): string { - [$expression, $dtstart] = RruleExpression::extractDtstart($this->expression); + [$expression, $dtstart] = RruleExpression::extractDtstart($this->toExpression()); if ($dtstart) { $tzid = $this->timezone ? ";TZID={$this->timezone}" : ''; diff --git a/src/Support/RruleExpression.php b/src/Support/RruleExpression.php index 98db7ef..fc2acb3 100644 --- a/src/Support/RruleExpression.php +++ b/src/Support/RruleExpression.php @@ -4,6 +4,14 @@ class RruleExpression { + /** + * Determine if the expression includes an explicit start. + */ + public static function hasDtstart(string $expression): bool + { + return (bool) preg_match('/(?:^|[;\r\n])DTSTART[=;:]/i', $expression); + } + /** * Extract the DTSTART value from an RRULE expression. * diff --git a/tests/Drivers/RecurrScheduleTest.php b/tests/Drivers/RecurrScheduleTest.php index 739c620..98b9e75 100644 --- a/tests/Drivers/RecurrScheduleTest.php +++ b/tests/Drivers/RecurrScheduleTest.php @@ -60,7 +60,9 @@ }); it('serializes to expression', function () { - expect((new RecurrSchedule('FREQ=DAILY;COUNT=5'))->toExpression())->toBe('FREQ=DAILY;COUNT=5'); + Carbon::setTestNow('2026-05-02 10:00:00'); + + expect((new RecurrSchedule('FREQ=DAILY;COUNT=5'))->toExpression())->toBe('FREQ=DAILY;COUNT=5;DTSTART=20260502T100000'); }); it('reconstitutes from expression', function () { diff --git a/tests/Drivers/RruleScheduleTest.php b/tests/Drivers/RruleScheduleTest.php index 6d0cb0e..77a0b34 100644 --- a/tests/Drivers/RruleScheduleTest.php +++ b/tests/Drivers/RruleScheduleTest.php @@ -57,7 +57,9 @@ }); it('serializes to expression', function () { - expect((new RruleSchedule('FREQ=DAILY;COUNT=5'))->toExpression())->toBe('FREQ=DAILY;COUNT=5'); + Carbon::setTestNow('2026-05-02 10:00:00'); + + expect((new RruleSchedule('FREQ=DAILY;COUNT=5'))->toExpression())->toBe('FREQ=DAILY;COUNT=5;DTSTART=20260502T100000'); }); it('reconstitutes from expression', function () { diff --git a/tests/Drivers/RruleStartTest.php b/tests/Drivers/RruleStartTest.php new file mode 100644 index 0000000..01a4ee5 --- /dev/null +++ b/tests/Drivers/RruleStartTest.php @@ -0,0 +1,77 @@ +getNextOccurrence(now())?->toDateTimeString())->toBe('2026-05-02 10:00:01'); + + Carbon::setTestNow('2026-05-02 10:00:05'); + + $expression = $driver->toExpression(); + $restored = $driverClass::fromExpression($expression)->setTimezone($driver->getTimezone()); + + expect($expression)->toBe('FREQ=SECONDLY;COUNT=2;DTSTART=20260502T100000') + ->and($driver->getNextOccurrence(now()))->toBeNull() + ->and($restored->getNextOccurrence(now()))->toBeNull(); +})->with('rrule drivers'); + +it('uses the configured timezone for a default start', function ($driverClass, $date, $expected) { + Carbon::setTestNow(Carbon::parse($date, 'UTC')); + + $driver = (new $driverClass('FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0')) + ->setTimezone('America/New_York'); + + expect($driver->getNextOccurrence(now())?->utc()->toDateTimeString())->toBe($expected); +})->with('rrule drivers')->with([ + 'daylight saving time' => ['2026-05-02 12:00:00', '2026-05-02 13:00:00'], + 'standard time' => ['2026-12-02 12:00:00', '2026-12-02 14:00:00'], +]); + +it('leaves an explicit start unchanged', function ($driverClass) { + Carbon::setTestNow('2026-05-02 10:00:00'); + + $expression = 'DTSTART=20260501T090000;FREQ=DAILY;COUNT=3'; + $driver = new $driverClass($expression, 'America/New_York'); + + expect($driver->toExpression())->toBe($expression) + ->and($driver->getNextOccurrence(now())?->utc()->toDateTimeString())->toBe('2026-05-02 13:00:00'); +})->with('rrule drivers'); + +it('honours until when adding a default start', function ($driverClass) { + Carbon::setTestNow('2026-05-02 10:00:00'); + + $driver = new $driverClass('FREQ=DAILY;UNTIL=20260503T100000Z'); + + expect($driver->getNextOccurrence(now())?->toDateTimeString())->toBe('2026-05-03 10:00:00') + ->and($driver->getNextOccurrence(Carbon::parse('2026-05-03 10:00:00')))->toBeNull(); +})->with('rrule drivers'); + +it('keeps the schedule timezone across daylight saving changes', function ($driverClass) { + Carbon::setTestNow(Carbon::parse('2026-10-31 12:00:00', 'UTC')); + + $driver = new $driverClass('FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0', 'America/New_York'); + $expression = $driver->toExpression(); + + Carbon::setTestNow(Carbon::parse('2026-11-01 12:00:00', 'UTC')); + + $restored = $driverClass::fromExpression($expression)->setTimezone($driver->getTimezone()); + + expect($restored->getNextOccurrence(now())?->utc()->toDateTimeString())->toBe('2026-11-01 14:00:00'); +})->with('rrule drivers'); + +it('preserves explicit RFC expressions', function () { + $expression = "DTSTART;TZID=America/New_York:20260501T090000\nRRULE:FREQ=DAILY;COUNT=3"; + $driver = new RruleSchedule($expression); + + expect($driver->toExpression())->toBe($expression) + ->and($driver->getNextOccurrence(Carbon::parse('2026-05-02 12:00:00', 'UTC'))?->utc()->toDateTimeString()) + ->toBe('2026-05-02 13:00:00'); +}); diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php index 4e2589e..757896f 100644 --- a/tests/ScheduleTest.php +++ b/tests/ScheduleTest.php @@ -3,6 +3,8 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use DirectoryTree\Cadence\Drivers\CronSchedule; +use DirectoryTree\Cadence\Drivers\RecurrSchedule; +use DirectoryTree\Cadence\Drivers\RruleSchedule; use DirectoryTree\Cadence\Drivers\ScheduleDriver; use DirectoryTree\Cadence\Schedule; use DirectoryTree\Cadence\Tests\Fixtures\SchedulableModel; @@ -37,6 +39,60 @@ expect($schedule->next_run_at->format('Y-m-d H:i:s'))->toBe('2026-05-02 12:00:00'); }); +it('stores the default recurrence start and timezone for later evaluations', function ($driverClass) { + $schedule = SchedulableModel::create()->addSchedule( + new $driverClass('FREQ=DAILY;INTERVAL=3;BYHOUR=9;BYMINUTE=0;BYSECOND=0', 'America/New_York') + ); + + expect($schedule->expression)->toBe('FREQ=DAILY;INTERVAL=3;BYHOUR=9;BYMINUTE=0;BYSECOND=0;DTSTART=20260502T060000') + ->and($schedule->next_run_at->toDateTimeString())->toBe('2026-05-02 13:00:00'); + + Carbon::setTestNow('2026-05-06 10:00:00'); + + $schedule->refresh()->advance(now()); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-08 13:00:00'); +})->with([RruleSchedule::class, RecurrSchedule::class]); + +it('does not restart the count when a recurrence is reloaded', function ($driverClass) { + $schedule = SchedulableModel::create()->addSchedule(new $driverClass('FREQ=SECONDLY;COUNT=2')); + + expect($schedule->next_run_at->toDateTimeString())->toBe('2026-05-02 10:00:01'); + + Carbon::setTestNow('2026-05-02 10:00:05'); + + $schedule->refresh()->advance(now()); + + expect($schedule->refresh()->next_run_at)->toBeNull() + ->and($schedule->timezone)->toBe('UTC'); +})->with([RruleSchedule::class, RecurrSchedule::class]); + +it('preserves the application timezone for an implicit recurrence start', function ($driverClass) { + $originalTimezone = date_default_timezone_get(); + + config(['app.timezone' => 'America/Toronto']); + date_default_timezone_set('America/Toronto'); + Carbon::setTestNow(Carbon::parse('2026-05-02 08:00:00', 'America/Toronto')); + + try { + $schedule = SchedulableModel::create()->addSchedule( + new $driverClass('FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0') + ); + + expect($schedule->timezone)->toBe('America/Toronto') + ->and($schedule->expression)->toBe('FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0;DTSTART=20260502T080000') + ->and($schedule->next_run_at->toDateTimeString())->toBe('2026-05-02 09:00:00'); + + Carbon::setTestNow(Carbon::parse('2026-05-02 10:00:00', 'America/Toronto')); + + $schedule->refresh()->advance(now()); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-03 09:00:00'); + } finally { + date_default_timezone_set($originalTimezone); + } +})->with([RruleSchedule::class, RecurrSchedule::class]); + it('stores timezone on creation', function () { $schedule = SchedulableModel::create()->addSchedule( new CronSchedule('0 9 * * *', 'America/New_York') diff --git a/tests/Support/RruleExpressionTest.php b/tests/Support/RruleExpressionTest.php index 123715e..4ecb903 100644 --- a/tests/Support/RruleExpressionTest.php +++ b/tests/Support/RruleExpressionTest.php @@ -2,6 +2,19 @@ use DirectoryTree\Cadence\Support\RruleExpression; +it('recognizes explicit starts in inline and RFC expressions', function ($expression) { + expect(RruleExpression::hasDtstart($expression))->toBeTrue(); +})->with([ + 'FREQ=DAILY;DTSTART=20260501T090000', + 'dtstart=20260501T090000;FREQ=DAILY', + "DTSTART:20260501T090000Z\nRRULE:FREQ=DAILY", + "DTSTART;TZID=America/New_York:20260501T090000\nRRULE:FREQ=DAILY", +]); + +it('recognizes expressions without an explicit start', function () { + expect(RruleExpression::hasDtstart('FREQ=DAILY;COUNT=5'))->toBeFalse(); +}); + it('extracts dtstart from expression', function () { [$expression, $dtstart] = RruleExpression::extractDtstart('FREQ=DAILY;DTSTART=20260501T090000'); From 63662cc591a0df9ecbab0d3a58ed2aca2a45473d Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 24 Oct 2026 10:12:02 -0400 Subject: [PATCH 2/3] Recalculate the next run when a schedule timezone changes --- README.md | 8 +++++ src/Schedule.php | 14 ++++++++ tests/ScheduleTest.php | 80 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/README.md b/README.md index a60381a..6a641b2 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,14 @@ $report->addSchedule($schedule); The schedule's timezone only determines when occurrences happen. Cadence converts computed run dates to your application's timezone before storing them, so the `due()` scope and `schedules:run` command fire at the correct moment. +Saving a timezone change on a schedule recalculates `next_run_at` from the current time. It preserves `last_run_at`, and disabled schedules keep `next_run_at` set to `null`. + +```php +$schedule->update(['timezone' => 'America/Los_Angeles']); +``` + +For RRULE schedules with a local `DTSTART`, the start keeps the same local date and time in the new timezone. Changing the timezone does not reset the start or the rule's `COUNT` or `INTERVAL`. + ### Disabling Schedules Schedules may be disabled without deleting them: diff --git a/src/Schedule.php b/src/Schedule.php index dd7af67..442d50c 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -16,6 +16,20 @@ class Schedule extends Model */ protected $guarded = []; + /** + * Boot the model. + */ + protected static function booted(): void + { + static::updating(function (self $schedule) { + if ($schedule->isDirty('timezone')) { + $schedule->next_run_at = $schedule->isEnabled() + ? $schedule->toDriver()->getNextOccurrence(now())?->setTimezone(config('app.timezone')) + : null; + } + }); + } + /** * Get the attributes that should be cast. * diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php index 757896f..0392fb5 100644 --- a/tests/ScheduleTest.php +++ b/tests/ScheduleTest.php @@ -107,6 +107,86 @@ expect($schedule->timezone)->toBeNull(); }); +it('recalculates the next run when the timezone changes', function ($driverClass, $expression) { + $schedule = SchedulableModel::create()->addSchedule(new $driverClass($expression, 'America/New_York')); + $schedule->update(['last_run_at' => Carbon::parse('2026-05-01 13:01:00')]); + $storedExpression = $schedule->expression; + + Carbon::setTestNow('2026-05-02 13:30:00'); + + $schedule->update(['timezone' => 'America/Los_Angeles']); + $schedule->refresh(); + + expect($schedule->next_run_at->toDateTimeString())->toBe('2026-05-02 16:00:00') + ->and($schedule->last_run_at->toDateTimeString())->toBe('2026-05-01 13:01:00') + ->and($schedule->expression)->toBe($storedExpression) + ->and($schedule->isEnabled())->toBeTrue(); +})->with([ + 'cron' => [CronSchedule::class, '0 9 * * *'], + 'rrule' => [RruleSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], + 'recurr' => [RecurrSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], +]); + +it('keeps a schedule disabled when its timezone changes', function ($driverClass, $expression) { + $schedule = SchedulableModel::create()->addSchedule(new $driverClass($expression, 'America/New_York')); + $schedule->update(['last_run_at' => Carbon::parse('2026-05-01 13:01:00')]); + $schedule->disable(); + $disabledAt = $schedule->disabled_at->toDateTimeString(); + $storedExpression = $schedule->expression; + + Carbon::setTestNow('2026-05-02 13:30:00'); + + $schedule->update(['timezone' => 'America/Los_Angeles']); + $schedule->refresh(); + + expect($schedule->next_run_at)->toBeNull() + ->and($schedule->disabled_at->toDateTimeString())->toBe($disabledAt) + ->and($schedule->last_run_at->toDateTimeString())->toBe('2026-05-01 13:01:00') + ->and($schedule->expression)->toBe($storedExpression); + + $schedule->enable(); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-02 16:00:00'); +})->with([ + 'cron' => [CronSchedule::class, '0 9 * * *'], + 'rrule' => [RruleSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], + 'recurr' => [RecurrSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], +]); + +it('recalculates the next run in the default timezone when the timezone is cleared', function ($driverClass, $expression) { + $schedule = SchedulableModel::create()->addSchedule(new $driverClass($expression, 'America/New_York')); + + $schedule->update(['timezone' => null]); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-03 09:00:00'); +})->with([ + 'cron' => [CronSchedule::class, '0 9 * * *'], + 'rrule' => [RruleSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], + 'recurr' => [RecurrSchedule::class, 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0'], +]); + +it('does not recalculate the next run when the timezone is unchanged', function () { + $schedule = SchedulableModel::create()->addSchedule(new CronSchedule('0 9 * * *', 'America/New_York')); + $schedule->update(['next_run_at' => Carbon::parse('2026-05-10 13:00:00')]); + + $schedule->update(['timezone' => 'America/New_York']); + + expect($schedule->refresh()->next_run_at->toDateTimeString())->toBe('2026-05-10 13:00:00'); +}); + +it('preserves the recurrence count when the timezone changes', function ($driverClass) { + $schedule = SchedulableModel::create()->addSchedule( + new $driverClass('FREQ=DAILY;COUNT=2;DTSTART=20260501T090000', 'America/New_York') + ); + + Carbon::setTestNow('2026-05-02 18:00:00'); + + $schedule->update(['timezone' => 'America/Los_Angeles']); + + expect($schedule->refresh()->next_run_at)->toBeNull() + ->and($schedule->expression)->toBe('FREQ=DAILY;COUNT=2;DTSTART=20260501T090000'); +})->with([RruleSchedule::class, RecurrSchedule::class]); + it('resolves the driver from a stored schedule', function () { $schedule = SchedulableModel::create()->addSchedule(new CronSchedule('0 12 * * *')); From 3d96a806b1117895e5024efe0e2420c2017603e9 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 24 Oct 2026 10:19:41 -0400 Subject: [PATCH 3/3] Extract the schedule next-run calculation --- src/Schedule.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Schedule.php b/src/Schedule.php index 442d50c..677253e 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -24,7 +24,7 @@ protected static function booted(): void static::updating(function (self $schedule) { if ($schedule->isDirty('timezone')) { $schedule->next_run_at = $schedule->isEnabled() - ? $schedule->toDriver()->getNextOccurrence(now())?->setTimezone(config('app.timezone')) + ? $schedule->nextRunAt(now()) : null; } }); @@ -88,9 +88,7 @@ public function enable(): static { $this->fill([ 'disabled_at' => null, - 'next_run_at' => $this->toDriver() - ->getNextOccurrence(now()) - ?->setTimezone(config('app.timezone')), + 'next_run_at' => $this->nextRunAt(now()), ])->save(); return $this; @@ -104,14 +102,22 @@ public function advance(CarbonInterface $date): static $this->fill([ 'last_run_at' => $date->avoidMutation() ->setTimezone(config('app.timezone')), - 'next_run_at' => $this->toDriver() - ->getNextOccurrence($date) - ?->setTimezone(config('app.timezone')), + 'next_run_at' => $this->nextRunAt($date), ])->save(); return $this; } + /** + * Calculate the next run in the application's timezone. + */ + protected function nextRunAt(CarbonInterface $after): ?CarbonInterface + { + return $this->toDriver() + ->getNextOccurrence($after) + ?->setTimezone(config('app.timezone')); + } + /** * Get the parent schedulable model. */