diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 8cf575e0..ca53b63e 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -172,6 +172,14 @@ public function fastForward(DateTimeInterface $dt) */ protected $currentDate; + /** + * The number of hours that the next occurrence of an event + * jumped forward, usually because summer time started and + * the requested time-of-day like 0230 did not exist on that + * day. And so the event was scheduled 1 hour later at 0330. + */ + protected $hourJump = 0; + /** * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly, * yearly. @@ -319,12 +327,65 @@ public function fastForward(DateTimeInterface $dt) /* Functions that advance the iterator {{{ */ + /** + * Gets the original start time of the RRULE. + * + * The value is formatted as a string with 24-hour:minute:second + */ + protected function startTime(): string + { + return $this->startDate->format('H:i:s'); + } + + /** + * Advances currentDate by the interval. + * The time is set from the original startDate. + * If the recurrence is on a day when summer time started, then the + * time on that day may have jumped forward, for example, from 0230 to 0330. + * Using the original time means that the next recurrence will be calculated + * based on the original start time and the day/week/month/year interval. + * So the start time of the next occurrence can correctly revert to 0230. + */ + protected function advanceTheDate(string $interval): void + { + $this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime()); + } + + /** + * Does the processing for adjusting the time of multi-hourly events when summer time starts. + */ + protected function adjustForTimeJumpsOfHourlyEvent(DateTimeInterface $previousEventDateTime): void + { + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the next occurrence. + // That happens if the next event time is on a day when summer time starts + // and the event time is in the non-existent hour of the day. + // For example, an event that normally starts at 02:30 will + // have to start at 03:30 on that day. + // If the interval is just 1 hour, then there is no "jumping back" to do. + // The events that day will happen, for example, at 0030 0130 0330 0430 0530... + if ($this->interval > 1) { + $expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24; + $actualHourOfNextDate = (int) $this->currentDate->format('G'); + $this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate; + } + } else { + // The hour "jumped" for the previous occurrence, to avoid the non-existent time. + // currentDate got set ahead by (usually) 1 hour on that day. + // Adjust it back for this next occurrence. + $this->currentDate = $this->currentDate->sub(new \DateInterval('PT'.$this->hourJump.'H')); + $this->hourJump = 0; + } + } + /** * Does the processing for advancing the iterator for hourly frequency. */ protected function nextHourly() { + $previousEventDateTime = clone $this->currentDate; $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); + $this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime); } /** @@ -333,7 +394,7 @@ protected function nextHourly() protected function nextDaily() { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); + $this->advanceTheDate('+'.$this->interval.' days'); return; } @@ -392,7 +453,7 @@ protected function nextDaily() protected function nextWeekly() { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); + $this->advanceTheDate('+'.$this->interval.' weeks'); return; } @@ -414,7 +475,7 @@ protected function nextWeekly() if ($this->byHour) { $this->currentDate = $this->currentDate->modify('+1 hours'); } else { - $this->currentDate = $this->currentDate->modify('+1 days'); + $this->advanceTheDate('+1 days'); } // Current day of the week @@ -449,13 +510,13 @@ protected function nextMonthly() // occur to the next month. We Must skip these invalid // entries. if ($currentDayOfMonth < 29) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' months'); + $this->advanceTheDate('+'.$this->interval.' months'); } else { $increase = 0; do { ++$increase; $tempDate = clone $this->currentDate; - $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months'); + $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime()); } while ($tempDate->format('j') != $currentDayOfMonth); $this->currentDate = $tempDate; } @@ -506,11 +567,15 @@ protected function nextMonthly() } } + // Set the currentDate to the year and month that we are in, and the day of the month that we have selected. + // That day could be a day when summer time starts, and if the time of the event is, for example, 0230, + // then 0230 will not be a valid time on that day. So always apply the start time from the original startDate. + // The "modify" method will set the time forward to 0330, for example, if needed. $this->currentDate = $this->currentDate->setDate( (int) $this->currentDate->format('Y'), (int) $this->currentDate->format('n'), (int) $occurrence - ); + )->modify($this->startTime()); } /** @@ -627,7 +692,7 @@ protected function nextYearly() } // The easiest form - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); + $this->advanceTheDate('+'.$this->interval.' years'); return; } @@ -691,7 +756,7 @@ protected function nextYearly() (int) $currentYear, (int) $currentMonth, (int) $occurrence - ); + )->modify($this->startTime()); return; } else { @@ -708,7 +773,7 @@ protected function nextYearly() (int) $currentYear, (int) $currentMonth, (int) $currentDayOfMonth - ); + )->modify($this->startTime()); return; } diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 3a6cbfe2..d148e27d 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -32,6 +32,122 @@ public function testHourly() ); } + /** + * @dataProvider dst2HourlyTransitionProvider + */ + public function test2HourlyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=HOURLY;INTERVAL=2;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dst2HourlyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2023-03-26 00:00:00', + 'Expected' => [ + '2023-03-26 00:00:00', + '2023-03-26 03:00:00', + '2023-03-26 04:00:00', + '2023-03-26 06:00:00', + '2023-03-26 08:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2023-03-26 00:15:00', + 'Expected' => [ + '2023-03-26 00:15:00', + '2023-03-26 03:15:00', + '2023-03-26 04:15:00', + '2023-03-26 06:15:00', + '2023-03-26 08:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2023-03-26 01:00:00', + 'Expected' => [ + '2023-03-26 01:00:00', + '2023-03-26 03:00:00', + '2023-03-26 05:00:00', + '2023-03-26 07:00:00', + '2023-03-26 09:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2023-03-26 01:15:00', + 'Expected' => [ + '2023-03-26 01:15:00', + '2023-03-26 03:15:00', + '2023-03-26 05:15:00', + '2023-03-26 07:15:00', + '2023-03-26 09:15:00', + ], + ]; + } + + /** + * @dataProvider dst6HourlyTransitionProvider + */ + public function testHourlyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=HOURLY;INTERVAL=6;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dst6HourlyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2023-03-25 20:00:00', + 'Expected' => [ + '2023-03-25 20:00:00', + '2023-03-26 03:00:00', + '2023-03-26 08:00:00', + '2023-03-26 14:00:00', + '2023-03-26 20:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2023-03-25 20:15:00', + 'Expected' => [ + '2023-03-25 20:15:00', + '2023-03-26 03:15:00', + '2023-03-26 08:15:00', + '2023-03-26 14:15:00', + '2023-03-26 20:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2023-03-25 21:00:00', + 'Expected' => [ + '2023-03-25 21:00:00', + '2023-03-26 03:00:00', + '2023-03-26 09:00:00', + '2023-03-26 15:00:00', + '2023-03-26 21:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2023-03-25 21:15:00', + 'Expected' => [ + '2023-03-25 21:15:00', + '2023-03-26 03:15:00', + '2023-03-26 09:15:00', + '2023-03-26 15:15:00', + '2023-03-26 21:15:00', + ], + ]; + } + public function testDaily() { $this->parse( @@ -165,6 +281,64 @@ public function testDailyBySetPosLoop() ); } + /** + * @dataProvider dstDailyTransitionProvider + */ + public function testDailyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=DAILY;INTERVAL=1;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dstDailyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2023-03-24 02:00:00', + 'Expected' => [ + '2023-03-24 02:00:00', + '2023-03-25 02:00:00', + '2023-03-26 03:00:00', + '2023-03-27 02:00:00', + '2023-03-28 02:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2023-03-24 02:15:00', + 'Expected' => [ + '2023-03-24 02:15:00', + '2023-03-25 02:15:00', + '2023-03-26 03:15:00', + '2023-03-27 02:15:00', + '2023-03-28 02:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2023-03-24 03:00:00', + 'Expected' => [ + '2023-03-24 03:00:00', + '2023-03-25 03:00:00', + '2023-03-26 03:00:00', + '2023-03-27 03:00:00', + '2023-03-28 03:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2023-03-24 03:15:00', + 'Expected' => [ + '2023-03-24 03:15:00', + '2023-03-25 03:15:00', + '2023-03-26 03:15:00', + '2023-03-27 03:15:00', + '2023-03-28 03:15:00', + ], + ]; + } + public function testWeekly() { $this->parse( @@ -268,6 +442,110 @@ public function testWeeklyByDaySpecificHour() ); } + public function testWeeklyByDaySpecificHourOnDstTransition(): void + { + $this->parse( + 'FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU', + '2023-03-11 02:30:00', + [ + '2023-03-11 02:30:00', + '2023-03-12 02:30:00', + '2023-03-25 02:30:00', + '2023-03-26 03:30:00', + '2023-04-08 02:30:00', + '2023-04-09 02:30:00', + ], + null, + 'Europe/Zurich' + ); + } + + public function testWeeklyByDayByHourOnDstTransition(): void + { + $this->parse( + 'FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU;WKST=MO;BYHOUR=2,14', + '2023-03-11 02:00:00', + [ + '2023-03-11 02:00:00', + '2023-03-11 14:00:00', + '2023-03-12 02:00:00', + '2023-03-12 14:00:00', + '2023-03-25 02:00:00', + '2023-03-25 14:00:00', + // 02:00:00 does not exist on 2023-03-26 because of summer-time start. + // The current implementation logic does not schedule a recurrence on + // the morning of 2023-03-26. But maybe it should schedule one at 03:00:00. + // The RFC is silent about the required behavior in this case. + // '2023-03-26 03:00:00', + '2023-03-26 14:00:00', + '2023-04-08 02:00:00', + '2023-04-08 14:00:00', + '2023-04-09 02:00:00', + '2023-04-09 14:00:00', + ], + null, + 'Europe/Zurich' + ); + } + + /** + * @dataProvider dstWeeklyTransitionProvider + */ + public function testWeeklyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=WEEKLY;INTERVAL=1;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dstWeeklyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2023-03-12 02:00:00', + 'Expected' => [ + '2023-03-12 02:00:00', + '2023-03-19 02:00:00', + '2023-03-26 03:00:00', + '2023-04-02 02:00:00', + '2023-04-09 02:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2023-03-12 02:15:00', + 'Expected' => [ + '2023-03-12 02:15:00', + '2023-03-19 02:15:00', + '2023-03-26 03:15:00', + '2023-04-02 02:15:00', + '2023-04-09 02:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2023-03-12 03:00:00', + 'Expected' => [ + '2023-03-12 03:00:00', + '2023-03-19 03:00:00', + '2023-03-26 03:00:00', + '2023-04-02 03:00:00', + '2023-04-09 03:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2023-03-12 03:15:00', + 'Expected' => [ + '2023-03-12 03:15:00', + '2023-03-19 03:15:00', + '2023-03-26 03:15:00', + '2023-04-02 03:15:00', + '2023-04-09 03:15:00', + ], + ]; + } + public function testMonthly() { $this->parse( @@ -324,6 +602,26 @@ public function testMonthlyByMonthDay() ); } + public function testMonthlyByMonthDayDstTransition(): void + { + $this->parse( + 'FREQ=MONTHLY;INTERVAL=1;COUNT=8;BYMONTHDAY=1,26', + '2023-01-01 02:15:00', + [ + '2023-01-01 02:15:00', + '2023-01-26 02:15:00', + '2023-02-01 02:15:00', + '2023-02-26 02:15:00', + '2023-03-01 02:15:00', + '2023-03-26 03:15:00', + '2023-04-01 02:15:00', + '2023-04-26 02:15:00', + ], + null, + 'Europe/Zurich' + ); + } + public function testMonthlyByDay() { $this->parse( @@ -366,6 +664,31 @@ public function testMonthlyByDayUntil() ); } + public function testMonthlyByDayOnDstTransition(): void + { + $this->parse( + 'FREQ=MONTHLY;INTERVAL=2;COUNT=13;BYDAY=SU', + '2023-01-01 02:30:00', + [ + '2023-01-01 02:30:00', + '2023-01-08 02:30:00', + '2023-01-15 02:30:00', + '2023-01-22 02:30:00', + '2023-01-29 02:30:00', + '2023-03-05 02:30:00', + '2023-03-12 02:30:00', + '2023-03-19 02:30:00', + '2023-03-26 03:30:00', + '2023-05-07 02:30:00', + '2023-05-14 02:30:00', + '2023-05-21 02:30:00', + '2023-05-28 02:30:00', + ], + null, + 'Europe/Zurich' + ); + } + public function testMonthlyByDayUntilWithImpossibleNextOccurrence() { $this->parse( @@ -417,6 +740,74 @@ public function testMonthlyByDayBySetPos() ); } + /** + * @dataProvider dstMonthlyTransitionProvider + */ + public function testMonthlyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=MONTHLY;INTERVAL=1;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dstMonthlyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2023-01-26 02:00:00', + 'Expected' => [ + '2023-01-26 02:00:00', + '2023-02-26 02:00:00', + '2023-03-26 03:00:00', + '2023-04-26 02:00:00', + '2023-05-26 02:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2023-01-26 02:15:00', + 'Expected' => [ + '2023-01-26 02:15:00', + '2023-02-26 02:15:00', + '2023-03-26 03:15:00', + '2023-04-26 02:15:00', + '2023-05-26 02:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2023-01-26 03:00:00', + 'Expected' => [ + '2023-01-26 03:00:00', + '2023-02-26 03:00:00', + '2023-03-26 03:00:00', + '2023-04-26 03:00:00', + '2023-05-26 03:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2023-01-26 03:15:00', + 'Expected' => [ + '2023-01-26 03:15:00', + '2023-02-26 03:15:00', + '2023-03-26 03:15:00', + '2023-04-26 03:15:00', + '2023-05-26 03:15:00', + ], + ]; + yield 'During transition on 31st day of month' => [ + 'Start' => '2024-01-31 02:15:00', + 'Expected' => [ + '2024-01-31 02:15:00', + '2024-03-31 03:15:00', + '2024-05-31 02:15:00', + '2024-07-31 02:15:00', + '2024-08-31 02:15:00', + ], + ]; + } + public function testYearly() { $this->parse( @@ -468,6 +859,26 @@ public function testYearlyByMonth() ); } + public function testYearlyByMonthOnDstTransition(): void + { + $this->parse( + 'FREQ=YEARLY;COUNT=8;INTERVAL=2;BYMONTH=3,9', + '2019-03-26 02:30:00', + [ + '2019-03-26 02:30:00', + '2019-09-26 02:30:00', + '2021-03-26 02:30:00', + '2021-09-26 02:30:00', + '2023-03-26 03:30:00', + '2023-09-26 02:30:00', + '2025-03-26 02:30:00', + '2025-09-26 02:30:00', + ], + null, + 'Europe/Zurich' + ); + } + public function testYearlyByMonthInvalidValue1() { $this->expectException(InvalidDataException::class); @@ -526,6 +937,31 @@ public function testYearlyByMonthByDay() ); } + public function testYearlyByMonthByDayOnDstTransition(): void + { + $this->parse( + 'FREQ=YEARLY;COUNT=13;INTERVAL=2;BYMONTH=3;BYDAY=SU', + '2021-03-07 02:30:00', + [ + '2021-03-07 02:30:00', + '2021-03-14 02:30:00', + '2021-03-21 02:30:00', + '2021-03-28 03:30:00', + '2023-03-05 02:30:00', + '2023-03-12 02:30:00', + '2023-03-19 02:30:00', + '2023-03-26 03:30:00', + '2025-03-02 02:30:00', + '2025-03-09 02:30:00', + '2025-03-16 02:30:00', + '2025-03-23 02:30:00', + '2025-03-30 03:30:00', + ], + null, + 'Europe/Zurich' + ); + } + public function testYearlyNewYearsDay() { $this->parse( @@ -713,6 +1149,64 @@ public function testYearlyByDayByWeekNo() ); } + /** + * @dataProvider dstYearlyTransitionProvider + */ + public function testYearlyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=YEARLY;INTERVAL=1;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich' + ); + } + + public function dstYearlyTransitionProvider(): iterable + { + yield 'On transition start' => [ + 'Start' => '2021-03-26 02:00:00', + 'Expected' => [ + '2021-03-26 02:00:00', + '2022-03-26 02:00:00', + '2023-03-26 03:00:00', + '2024-03-26 02:00:00', + '2025-03-26 02:00:00', + ], + ]; + yield 'During transition' => [ + 'Start' => '2021-03-26 02:15:00', + 'Expected' => [ + '2021-03-26 02:15:00', + '2022-03-26 02:15:00', + '2023-03-26 03:15:00', + '2024-03-26 02:15:00', + '2025-03-26 02:15:00', + ], + ]; + yield 'On transition end' => [ + 'Start' => '2021-03-26 03:00:00', + 'Expected' => [ + '2021-03-26 03:00:00', + '2022-03-26 03:00:00', + '2023-03-26 03:00:00', + '2024-03-26 03:00:00', + '2025-03-26 03:00:00', + ], + ]; + yield 'After transition end' => [ + 'Start' => '2021-03-26 03:15:00', + 'Expected' => [ + '2021-03-26 03:15:00', + '2022-03-26 03:15:00', + '2023-03-26 03:15:00', + '2024-03-26 03:15:00', + '2025-03-26 03:15:00', + ], + ]; + } + public function testFastForward() { // The idea is that we're fast-forwarding too far in the future, so