From b9c39da1674b2812155070450fb3ee895d21c225 Mon Sep 17 00:00:00 2001 From: Cyril van Schreven Date: Mon, 22 Apr 2024 14:48:32 +0200 Subject: [PATCH 01/19] Reproduce bug where dst leap is passed on to subsequent occurences --- tests/VObject/Recur/RRuleIteratorTest.php | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 931a7996..f0f00ad4 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -162,6 +162,64 @@ public function testDailyBySetPosLoop(): void ); } + /** + * @dataProvider dstTransitionProvider + */ + public function testDailyOnDstTransition(string $start, array $expected): void + { + $this->parse( + 'FREQ=DAILY;INTERVAL=1;COUNT=5', + $start, + $expected, + null, + 'Europe/Zurich', + ); + } + + public function dstTransitionProvider(): 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( From b37ef3d72352e26e9e181bc1441c21a05c251fca Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 14:05:30 +0545 Subject: [PATCH 02/19] Fix test code format --- tests/VObject/Recur/RRuleIteratorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index f0f00ad4..f850aa77 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -162,7 +162,7 @@ public function testDailyBySetPosLoop(): void ); } - /** + /** * @dataProvider dstTransitionProvider */ public function testDailyOnDstTransition(string $start, array $expected): void From 963189bb893d5b28fa6be75afa91233c41a3daf5 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 15:13:10 +0545 Subject: [PATCH 03/19] Handle summer time start for daily recurrences --- lib/Recur/RRuleIterator.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 10269daf..4f25f806 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. @@ -290,7 +298,23 @@ protected function nextHourly(): void protected function nextDaily(): void { if (!$this->byHour && !$this->byDay) { + $hourOfCurrentDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); + $hourOfNextDate = (int) $this->currentDate->format('G'); + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the nextDate. + // That happens if nextDate is 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. + $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + } else { + // The hour "jumped" for the previous date, to avoid the non-existent time. + // currentDate got set ahead by (usually) one 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; + } return; } From 2feab3f2e78a1240e6afcc3ac9224890a62ea043 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 15:37:29 +0545 Subject: [PATCH 04/19] Handle summer time start for weekly recurrences --- lib/Recur/RRuleIterator.php | 16 ++++++ tests/VObject/Recur/RRuleIteratorTest.php | 62 ++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 4f25f806..1506f7c2 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -373,7 +373,23 @@ protected function nextDaily(): void protected function nextWeekly(): void { if (!$this->byHour && !$this->byDay) { + $hourOfCurrentDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); + $hourOfNextDate = (int) $this->currentDate->format('G'); + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the nextDate. + // That happens if nextDate is 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. + $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + } else { + // The hour "jumped" for the previous date, to avoid the non-existent time. + // currentDate got set ahead by (usually) one 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; + } return; } diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index f850aa77..a2518f52 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -163,7 +163,7 @@ public function testDailyBySetPosLoop(): void } /** - * @dataProvider dstTransitionProvider + * @dataProvider dstDailyTransitionProvider */ public function testDailyOnDstTransition(string $start, array $expected): void { @@ -176,7 +176,7 @@ public function testDailyOnDstTransition(string $start, array $expected): void ); } - public function dstTransitionProvider(): iterable + public function dstDailyTransitionProvider(): iterable { yield 'On transition start' => [ 'Start' => '2023-03-24 02:00:00', @@ -323,6 +323,64 @@ public function testWeeklyByDaySpecificHour(): void ); } + /** + * @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( From 3b37fbc6a57312eaa55f200ce6d5fbeadc436f74 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 15:45:34 +0545 Subject: [PATCH 05/19] Handle summer time start for monthly recurrences --- lib/Recur/RRuleIterator.php | 16 +++++++ tests/VObject/Recur/RRuleIteratorTest.php | 58 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 1506f7c2..dccb4fc5 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -448,7 +448,23 @@ protected function nextMonthly(): void // occur to the next month. We Must skip these invalid // entries. if ($currentDayOfMonth < 29) { + $hourOfCurrentDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' months'); + $hourOfNextDate = (int) $this->currentDate->format('G'); + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the nextDate. + // That happens if nextDate is 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. + $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + } else { + // The hour "jumped" for the previous date, to avoid the non-existent time. + // currentDate got set ahead by (usually) one 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; + } } else { $increase = 0; do { diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index a2518f52..21391b77 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -530,6 +530,64 @@ 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', + ], + ]; + } + public function testYearly(): void { $this->parse( From f4a0bba64f5de9ae98fdf9f93fca0e1df88afade Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 15:53:12 +0545 Subject: [PATCH 06/19] Handle summer time start for yearly recurrences --- lib/Recur/RRuleIterator.php | 16 +++++++ tests/VObject/Recur/RRuleIteratorTest.php | 58 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index dccb4fc5..508ac002 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -642,7 +642,23 @@ protected function nextYearly(): void } // The easiest form + $hourOfCurrentDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); + $hourOfNextDate = (int) $this->currentDate->format('G'); + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the nextDate. + // That happens if nextDate is 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. + $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + } else { + // The hour "jumped" for the previous date, to avoid the non-existent time. + // currentDate got set ahead by (usually) one 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; + } return; } diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 21391b77..144f042a 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -884,6 +884,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 From 778177c996c9c8145b5ffc72339669f24247b1b1 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 16:07:36 +0545 Subject: [PATCH 07/19] Refactor summer time start logic into advanceTheDate function --- lib/Recur/RRuleIterator.php | 99 ++++++++++++------------------------- 1 file changed, 31 insertions(+), 68 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 508ac002..eca07f73 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -284,6 +284,33 @@ public function fastForward(\DateTimeInterface $dt): void /* Functions that advance the iterator {{{ */ + /** + * Advances currentDate by the interval. + * Takes into account the case where summer time starts and + * the event time on that day may have had to be advanced, + * usually by 1 hour. + */ + protected function advanceTheDate(string $interval): void + { + $hourOfCurrentDate = (int) $this->currentDate->format('G'); + $this->currentDate = $this->currentDate->modify($interval); + $hourOfNextDate = (int) $this->currentDate->format('G'); + if (0 === $this->hourJump) { + // Remember if the clock time jumped forward on the next date. + // That happens if the next date is 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. + $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + } else { + // The hour "jumped" for the previous date, 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. */ @@ -298,23 +325,7 @@ protected function nextHourly(): void protected function nextDaily(): void { if (!$this->byHour && !$this->byDay) { - $hourOfCurrentDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); - $hourOfNextDate = (int) $this->currentDate->format('G'); - if (0 === $this->hourJump) { - // Remember if the clock time jumped forward on the nextDate. - // That happens if nextDate is 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. - $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; - } else { - // The hour "jumped" for the previous date, to avoid the non-existent time. - // currentDate got set ahead by (usually) one 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; - } + $this->advanceTheDate('+'.$this->interval.' days'); return; } @@ -373,23 +384,7 @@ protected function nextDaily(): void protected function nextWeekly(): void { if (!$this->byHour && !$this->byDay) { - $hourOfCurrentDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); - $hourOfNextDate = (int) $this->currentDate->format('G'); - if (0 === $this->hourJump) { - // Remember if the clock time jumped forward on the nextDate. - // That happens if nextDate is 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. - $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; - } else { - // The hour "jumped" for the previous date, to avoid the non-existent time. - // currentDate got set ahead by (usually) one 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; - } + $this->advanceTheDate('+'.$this->interval.' weeks'); return; } @@ -448,23 +443,7 @@ protected function nextMonthly(): void // occur to the next month. We Must skip these invalid // entries. if ($currentDayOfMonth < 29) { - $hourOfCurrentDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' months'); - $hourOfNextDate = (int) $this->currentDate->format('G'); - if (0 === $this->hourJump) { - // Remember if the clock time jumped forward on the nextDate. - // That happens if nextDate is 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. - $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; - } else { - // The hour "jumped" for the previous date, to avoid the non-existent time. - // currentDate got set ahead by (usually) one 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; - } + $this->advanceTheDate('+'.$this->interval.' months'); } else { $increase = 0; do { @@ -642,23 +621,7 @@ protected function nextYearly(): void } // The easiest form - $hourOfCurrentDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); - $hourOfNextDate = (int) $this->currentDate->format('G'); - if (0 === $this->hourJump) { - // Remember if the clock time jumped forward on the nextDate. - // That happens if nextDate is 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. - $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; - } else { - // The hour "jumped" for the previous date, to avoid the non-existent time. - // currentDate got set ahead by (usually) one 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; - } + $this->advanceTheDate('+'.$this->interval.' years'); return; } From fb5689a29390488b5dc3934514b503d2859004d5 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 17:16:55 +0545 Subject: [PATCH 08/19] Handle summer time start for hourly recurrences --- lib/Recur/RRuleIterator.php | 21 ++++ tests/VObject/Recur/RRuleIteratorTest.php | 116 ++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index eca07f73..2f320036 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -316,7 +316,28 @@ protected function advanceTheDate(string $interval): void */ protected function nextHourly(): void { + $hourOfCurrentDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); + 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 0130 0330 0430 0530... + if ($this->interval > 1) { + $expectedHourOfNextDate = ($hourOfCurrentDate + $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; + } } /** diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 144f042a..51640a95 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( From 018789e4456d07dd953133b1102ef3dee66950ad Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 9 May 2024 17:21:14 +0545 Subject: [PATCH 09/19] Refactor advanceTheDate --- lib/Recur/RRuleIterator.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 2f320036..f19ef059 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -292,16 +292,16 @@ public function fastForward(\DateTimeInterface $dt): void */ protected function advanceTheDate(string $interval): void { - $hourOfCurrentDate = (int) $this->currentDate->format('G'); + $hourOfPreviousDate = (int) $this->currentDate->format('G'); $this->currentDate = $this->currentDate->modify($interval); - $hourOfNextDate = (int) $this->currentDate->format('G'); if (0 === $this->hourJump) { // Remember if the clock time jumped forward on the next date. // That happens if the next date is 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. - $this->hourJump = $hourOfNextDate - $hourOfCurrentDate; + $hourOfNextDate = (int) $this->currentDate->format('G'); + $this->hourJump = $hourOfNextDate - $hourOfPreviousDate; } else { // The hour "jumped" for the previous date, to avoid the non-existent time. // currentDate got set ahead by (usually) 1 hour on that day. From d0cb455e70ff05a85dac55fe83e6b56de77cd395 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Fri, 17 May 2024 14:23:41 +0545 Subject: [PATCH 10/19] fix: refactor advanceTheDate --- lib/Recur/RRuleIterator.php | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index f19ef059..f562d750 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -286,29 +286,16 @@ public function fastForward(\DateTimeInterface $dt): void /** * Advances currentDate by the interval. - * Takes into account the case where summer time starts and - * the event time on that day may have had to be advanced, - * usually by 1 hour. + * 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 { - $hourOfPreviousDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify($interval); - if (0 === $this->hourJump) { - // Remember if the clock time jumped forward on the next date. - // That happens if the next date is 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. - $hourOfNextDate = (int) $this->currentDate->format('G'); - $this->hourJump = $hourOfNextDate - $hourOfPreviousDate; - } else { - // The hour "jumped" for the previous date, 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; - } + $this->currentDate = $this->currentDate->modify($interval.' '.$this->startDate->format('H:i:s')); } /** From eef9fa6003d7d000d8836bc2d8dc11dc726ace63 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Fri, 17 May 2024 15:28:30 +0545 Subject: [PATCH 11/19] Handle case when BYMONTHDAY falls on summer time start --- lib/Recur/RRuleIterator.php | 6 +++++- tests/VObject/Recur/RRuleIteratorTest.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index f562d750..0c810d94 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -508,11 +508,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->startDate->format('H:i:s')); } /** diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 51640a95..d8dda554 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -553,6 +553,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( From 9b20d5e927a632a7ce6dc6a61eaf4bd18c05671e Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Fri, 17 May 2024 15:50:44 +0545 Subject: [PATCH 12/19] Handle case when day at or near end of month falls on summer time start --- lib/Recur/RRuleIterator.php | 2 +- tests/VObject/Recur/RRuleIteratorTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 0c810d94..4b19f77c 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -457,7 +457,7 @@ protected function nextMonthly(): void do { ++$increase; $tempDate = clone $this->currentDate; - $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months'); + $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startDate->format('H:i:s')); } while ($tempDate->format('j') != $currentDayOfMonth); $this->currentDate = $tempDate; } diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index d8dda554..ee40c370 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -722,6 +722,16 @@ public function dstMonthlyTransitionProvider(): iterable '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 From 102909efa694abd1e8574f0d2a3e9e71dcd099a0 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Mon, 27 May 2024 14:08:12 +0545 Subject: [PATCH 13/19] refactor hourly time jump logic into adjustForTimeJumpsOfHourlyEvent private method --- lib/Recur/RRuleIterator.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 4b19f77c..aa626196 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -299,12 +299,10 @@ protected function advanceTheDate(string $interval): void } /** - * Does the processing for advancing the iterator for hourly frequency. + * Does the processing for adjusting the time of multi-hourly events when summer time starts. */ - protected function nextHourly(): void + private function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousEventDateTime): void { - $hourOfCurrentDate = (int) $this->currentDate->format('G'); - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); 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 @@ -312,9 +310,9 @@ protected function nextHourly(): void // 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 0130 0330 0430 0530... + // The events that day will happen, for example, at 0030 0130 0330 0430 0530... if ($this->interval > 1) { - $expectedHourOfNextDate = ($hourOfCurrentDate + $this->interval) % 24; + $expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24; $actualHourOfNextDate = (int) $this->currentDate->format('G'); $this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate; } @@ -327,6 +325,16 @@ protected function nextHourly(): void } } + /** + * 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); + } + /** * Does the processing for advancing the iterator for daily frequency. */ From 85d72e0d4d3e7d31149c5cc90d402c7f4fd05534 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Mon, 27 May 2024 14:31:48 +0545 Subject: [PATCH 14/19] refactor original start time calculation into startTime method --- lib/Recur/RRuleIterator.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index aa626196..f1fd03e3 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -284,6 +284,16 @@ 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. @@ -295,7 +305,7 @@ public function fastForward(\DateTimeInterface $dt): void */ protected function advanceTheDate(string $interval): void { - $this->currentDate = $this->currentDate->modify($interval.' '.$this->startDate->format('H:i:s')); + $this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime()); } /** @@ -465,7 +475,7 @@ protected function nextMonthly(): void do { ++$increase; $tempDate = clone $this->currentDate; - $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startDate->format('H:i:s')); + $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime()); } while ($tempDate->format('j') != $currentDayOfMonth); $this->currentDate = $tempDate; } @@ -524,7 +534,7 @@ protected function nextMonthly(): void (int) $this->currentDate->format('Y'), (int) $this->currentDate->format('n'), (int) $occurrence - )->modify($this->startDate->format('H:i:s')); + )->modify($this->startTime()); } /** From cc112fbde94450699a957bfc4be48caa8e699f77 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Mon, 27 May 2024 14:33:03 +0545 Subject: [PATCH 15/19] refactor adjustForTimeJumpsOfHourlyEvent to be protected --- lib/Recur/RRuleIterator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index f1fd03e3..84f761bc 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -311,7 +311,7 @@ protected function advanceTheDate(string $interval): void /** * Does the processing for adjusting the time of multi-hourly events when summer time starts. */ - private function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousEventDateTime): void + protected function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousEventDateTime): void { if (0 === $this->hourJump) { // Remember if the clock time jumped forward on the next occurrence. From 9d68c7aff72db1a78330dc13e3ca175874b27ac4 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 30 May 2024 12:31:10 +0545 Subject: [PATCH 16/19] Handle summer time start for weekly BYDAY recurrences --- lib/Recur/RRuleIterator.php | 2 +- tests/VObject/Recur/RRuleIteratorTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 84f761bc..a90ae908 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -432,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 diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index ee40c370..08060a45 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -439,6 +439,24 @@ 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', + ); + } + /** * @dataProvider dstWeeklyTransitionProvider */ From 5a3dd88c084c20ee87334ba7e54434f16449dad0 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 30 May 2024 12:52:42 +0545 Subject: [PATCH 17/19] Add test case for Weekly BYDAY with BYHOUR on summer-time --- tests/VObject/Recur/RRuleIteratorTest.php | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 08060a45..e56d0c63 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -457,6 +457,34 @@ public function testWeeklyByDaySpecificHourOnDstTransition(): void ); } + 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 */ From 9039f90fc6d2ee4a54ba53e841f9da651eb7bb92 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 30 May 2024 13:49:01 +0545 Subject: [PATCH 18/19] Add test cases and fix YEARLY with BYMONTH on summer-time transition --- lib/Recur/RRuleIterator.php | 2 +- tests/VObject/Recur/RRuleIteratorTest.php | 45 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index a90ae908..6ce3e609 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -732,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 e56d0c63..2a206e56 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -661,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( @@ -831,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); From 1d0d0bdd2ca4ca3cc71d36145c50c340d92561f8 Mon Sep 17 00:00:00 2001 From: Phil Davis Date: Thu, 30 May 2024 14:01:25 +0545 Subject: [PATCH 19/19] Add test cases and fix YEARLY with BYMONTH BYDAY on summer-time transition --- lib/Recur/RRuleIterator.php | 2 +- tests/VObject/Recur/RRuleIteratorTest.php | 25 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 6ce3e609..ff276a69 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -715,7 +715,7 @@ protected function nextYearly(): void (int) $currentYear, (int) $currentMonth, (int) $occurrence - ); + )->modify($this->startTime()); return; } else { diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 2a206e56..ea049a17 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -934,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(