Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -300,6 +308,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:
Expand Down
16 changes: 15 additions & 1 deletion src/Drivers/RecurrSchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion src/Drivers/RruleSchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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}" : '';
Expand Down
32 changes: 26 additions & 6 deletions src/Schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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->nextRunAt(now())
: null;
}
});
}

/**
* Get the attributes that should be cast.
*
Expand Down Expand Up @@ -74,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;
Expand All @@ -90,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.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/Support/RruleExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
4 changes: 3 additions & 1 deletion tests/Drivers/RecurrScheduleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
4 changes: 3 additions & 1 deletion tests/Drivers/RruleScheduleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
77 changes: 77 additions & 0 deletions tests/Drivers/RruleStartTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use Carbon\Carbon;
use DirectoryTree\Cadence\Drivers\RecurrSchedule;
use DirectoryTree\Cadence\Drivers\RruleSchedule;

dataset('rrule drivers', [RruleSchedule::class, RecurrSchedule::class]);

it('preserves the default start across repeated evaluations and serialization', function ($driverClass) {
Carbon::setTestNow('2026-05-02 10:00:00');

$driver = new $driverClass('FREQ=SECONDLY;COUNT=2');

expect($driver->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');
});
Loading
Loading