diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 10269dafb..ff276a697 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -159,6 +159,14 @@ public function fastForward(\DateTimeInterface $dt): void */ protected ?\DateTimeInterface $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 int $hourJump = 0; + /** * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly, * yearly. @@ -276,12 +284,65 @@ public function fastForward(\DateTimeInterface $dt): void /* 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(): void { + $previousEventDateTime = clone $this->currentDate; $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); + $this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime); } /** @@ -290,7 +351,7 @@ protected function nextHourly(): void protected function nextDaily(): void { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); + $this->advanceTheDate('+'.$this->interval.' days'); return; } @@ -349,7 +410,7 @@ protected function nextDaily(): void protected function nextWeekly(): void { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); + $this->advanceTheDate('+'.$this->interval.' weeks'); return; } @@ -371,7 +432,7 @@ protected function nextWeekly(): void 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 @@ -408,13 +469,13 @@ protected function nextMonthly(): void // 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; } @@ -465,11 +526,15 @@ protected function nextMonthly(): void } } + // 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()); } /** @@ -586,7 +651,7 @@ protected function nextYearly(): void } // The easiest form - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); + $this->advanceTheDate('+'.$this->interval.' years'); return; } @@ -650,7 +715,7 @@ protected function nextYearly(): void (int) $currentYear, (int) $currentMonth, (int) $occurrence - ); + )->modify($this->startTime()); return; } else { @@ -667,7 +732,7 @@ protected function nextYearly(): void (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 931a79967..ea049a170 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -29,6 +29,122 @@ public function testHourly(): void ); } + /** + * @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(): void { $this->parse( @@ -162,6 +278,64 @@ public function testDailyBySetPosLoop(): void ); } + /** + * @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(): void { $this->parse( @@ -265,6 +439,110 @@ public function testWeeklyByDaySpecificHour(): void ); } + 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(): void { $this->parse( @@ -321,6 +599,26 @@ public function testMonthlyByMonthDay(): void ); } + 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(): void { $this->parse( @@ -363,6 +661,31 @@ public function testMonthlyByDayUntil(): void ); } + 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(): void { $this->parse( @@ -414,6 +737,74 @@ public function testMonthlyByDayBySetPos(): void ); } + /** + * @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(): void { $this->parse( @@ -465,6 +856,26 @@ public function testYearlyByMonth(): void ); } + 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(): void { $this->expectException(InvalidDataException::class); @@ -523,6 +934,31 @@ public function testYearlyByMonthByDay(): void ); } + 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(): void { $this->parse( @@ -710,6 +1146,64 @@ public function testYearlyByDayByWeekNo(): void ); } + /** + * @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(): void { // The idea is that we're fast-forwarding too far in the future, so