From 3fad0d3bd26e3c8a58343040bffdf79745bca809 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:30:23 +0300 Subject: [PATCH] fix: count negative week numbers from the end of the year --- lib/Recur/RRuleIterator.php | 24 ++++- tests/VObject/Recur/RRuleIteratorTest.php | 116 ++++++++++++++++++++-- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 6f1f28ae3..860b4c4bb 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -572,22 +572,40 @@ protected function nextYearly(): void $dayOffsets = []; if ($this->byDay) { foreach ($this->byDay as $byDay) { - $dayOffsets[] = $this->dayMap[$byDay]; + $dayOffsets[] = 'SU' === $byDay ? 7 : $this->dayMap[$byDay]; } } else { // default is Monday $dayOffsets[] = 1; } - $currentYear = $this->currentDate->format('Y'); + // A selected week can extend into the next calendar year. + $currentYear = (int) $this->currentDate->format(0 === $this->counter ? 'Y' : 'o'); while (true) { $checkDates = []; + // December 28 always belongs to the last ISO week of its year. + $lastWeekDate = clone $this->currentDate; + $lastWeekDate = $lastWeekDate->setDate($currentYear, 12, 28); + if ($lastWeekDate->getTimestamp() > self::dateUpperLimit) { + $this->currentDate = null; + + return; + } + $lastWeek = (int) $lastWeekDate->format('W'); // loop through all WeekNo and Days to check all the combinations foreach ($this->byWeekNo as $byWeekNo) { + $weekNo = (int) $byWeekNo; + if ($weekNo < 0) { + $weekNo += $lastWeek + 1; + if ($weekNo < 1) { + continue; + } + } + foreach ($dayOffsets as $dayOffset) { $date = clone $this->currentDate; - $date = $date->setISODate($currentYear, $byWeekNo, $dayOffset); + $date = $date->setISODate($currentYear, $weekNo, $dayOffset); if ($date > $this->currentDate) { $checkDates[] = $date; diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 0d14cbae5..f55c039c3 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -1298,21 +1298,115 @@ public function testNegativeValidByWeekNo(): void '2011-09-02 00:00:00', [ '2011-09-02 00:00:00', - '2012-08-07 00:00:00', - '2012-08-10 00:00:00', - '2013-08-06 00:00:00', - '2013-08-09 00:00:00', - '2014-08-05 00:00:00', - '2014-08-08 00:00:00', - '2015-08-11 00:00:00', - '2015-08-14 00:00:00', - '2016-08-09 00:00:00', - '2016-08-12 00:00:00', - '2017-08-08 00:00:00', + '2012-08-14 00:00:00', + '2012-08-17 00:00:00', + '2013-08-13 00:00:00', + '2013-08-16 00:00:00', + '2014-08-12 00:00:00', + '2014-08-15 00:00:00', + '2015-08-18 00:00:00', + '2015-08-21 00:00:00', + '2016-08-16 00:00:00', + '2016-08-19 00:00:00', + '2017-08-15 00:00:00', ] ); } + #[DataProvider('negativeWeekNoProvider')] + public function testNegativeWeekNo(string $rule, string $start, array $expected): void + { + $this->parse($rule, $start, $expected); + } + + public static function negativeWeekNoProvider(): iterable + { + yield 'three year interval with multiple weekdays' => [ + 'FREQ=YEARLY;INTERVAL=3;BYDAY=MO,TU;BYWEEKNO=-2', + '2026-03-17 17:00:00', + [ + '2026-03-17 17:00:00', + '2026-12-21 17:00:00', + '2026-12-22 17:00:00', + '2029-12-17 17:00:00', + '2029-12-18 17:00:00', + '2032-12-20 17:00:00', + '2032-12-21 17:00:00', + ], + ]; + yield 'last week extends into January' => [ + 'FREQ=YEARLY;INTERVAL=3;BYDAY=MO,SU;BYWEEKNO=-1', + '2020-12-28 09:00:00', + [ + '2020-12-28 09:00:00', + '2021-01-03 09:00:00', + '2023-12-25 09:00:00', + '2023-12-31 09:00:00', + '2026-12-28 09:00:00', + '2027-01-03 09:00:00', + ], + ]; + yield 'start date belongs to the previous ISO year' => [ + 'FREQ=YEARLY;INTERVAL=3;BYDAY=MO;BYWEEKNO=-1', + '2021-01-01 09:00:00', + [ + '2021-01-01 09:00:00', + '2021-12-27 09:00:00', + '2024-12-23 09:00:00', + '2027-12-27 09:00:00', + ], + ]; + yield 'negative week 53 skips years with 52 weeks' => [ + 'FREQ=YEARLY;BYDAY=TH;BYWEEKNO=-53', + '2014-01-01 09:00:00', + [ + '2014-01-01 09:00:00', + '2015-01-01 09:00:00', + '2020-01-02 09:00:00', + '2026-01-01 09:00:00', + '2032-01-01 09:00:00', + ], + ]; + yield 'mixed and repeated week numbers' => [ + 'FREQ=YEARLY;BYDAY=MO;BYWEEKNO=-1,1,-1', + '2021-01-04 09:00:00', + [ + '2021-01-04 09:00:00', + '2021-12-27 09:00:00', + '2022-01-03 09:00:00', + '2022-12-26 09:00:00', + '2023-01-02 09:00:00', + ], + ]; + } + + public function testNegativeWeekNoWithoutMatchingYear(): void + { + $this->parse( + 'FREQ=YEARLY;INTERVAL=400;BYWEEKNO=-53;BYDAY=TH;COUNT=2', + '2014-01-01 09:00:00', + ['2014-01-01 09:00:00'], + ); + } + + public function testNegativeWeekNoImmutable(): void + { + $start = new \DateTimeImmutable('2026-03-17 17:00:00', new \DateTimeZone('Europe/Brussels')); + $iterator = new RRuleIterator('FREQ=YEARLY;INTERVAL=3;BYDAY=MO,TU;BYWEEKNO=-2;COUNT=3', $start); + + $dates = array_map( + static fn (\DateTimeInterface $date): string => $date->format('Y-m-d H:i:s e'), + iterator_to_array($iterator), + ); + + self::assertSame([ + '2026-03-17 17:00:00 Europe/Brussels', + '2026-12-21 17:00:00 Europe/Brussels', + '2026-12-22 17:00:00 Europe/Brussels', + ], $dates); + self::assertSame('2026-03-17 17:00:00', $start->format('Y-m-d H:i:s')); + } + public function testTwoValidByWeekNo(): void { $this->parse(