diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index ecf1affeb..f9d3287cd 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -362,6 +362,14 @@ private function jumpForward(\DateTimeInterface $dt): void */ protected float $counter = 0; + /** + * The leap offset in seconds, by iteration. + * + * An occurrence may add an offset, for example if it falls on a DST change. + * It is accounted for in this variable. It should be removed on the next iteration. + */ + protected array $leapOffset = []; + /** * Which weekdays to recur. * @@ -432,7 +440,7 @@ private function jumpForward(\DateTimeInterface $dt): void */ protected function nextHourly($amount = 1): void { - $this->currentDate = $this->currentDate->modify('+'.$amount * $this->interval.' hours'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.$amount * $this->interval.' hours'); } /** @@ -441,7 +449,7 @@ protected function nextHourly($amount = 1): void protected function nextDaily($amount = 1): void { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$amount * $this->interval.' days'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.$amount * $this->interval.' days'); return; } @@ -469,9 +477,9 @@ protected function nextDaily($amount = 1): void $amount = 1; } - $this->currentDate = $this->currentDate->modify('+1 hours'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+1 hours'); } else { - $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' days'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.($amount * $this->interval).' days'); $amount = 1; } @@ -502,7 +510,7 @@ protected function nextDaily($amount = 1): void protected function nextWeekly($amount = 1): void { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' weeks'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.($amount * $this->interval).' weeks'); return; } @@ -522,9 +530,9 @@ protected function nextWeekly($amount = 1): void do { if ($this->byHour) { - $this->currentDate = $this->currentDate->modify('+1 hours'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+1 hours'); } else { - $this->currentDate = $this->currentDate->modify('+1 days'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+1 days'); } // Current day of the week @@ -535,12 +543,12 @@ protected function nextWeekly($amount = 1): void // We need to roll over to the next week if ($currentDay === $firstDay && (!$this->byHour || '0' == $currentHour)) { - $this->currentDate = $this->currentDate->modify('+'.(($amount * $this->interval) - 1).' weeks'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.(($amount * $this->interval) - 1).' weeks'); $amount = 1; // We need to go to the first day of this week, but only if we // are not already on this first day of this week. if ($this->currentDate->format('w') != $firstDay) { - $this->currentDate = $this->currentDate->modify('last '.$this->dayNames[$this->dayMap[$this->weekStart]]); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, 'last '.$this->dayNames[$this->dayMap[$this->weekStart]]); } } @@ -564,13 +572,13 @@ protected function nextMonthly($amount = 1): void // occur to the next month. We Must skip these invalid // entries. if ($currentDayOfMonth < 29) { - $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' months'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.($amount * $this->interval).' months'); } else { $increase = $amount - 1; do { ++$increase; $tempDate = clone $this->currentDate; - $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months'); + $tempDate = $this->getNextIterationDateTime($tempDate, '+ '.($this->interval * $increase).' months'); } while ($tempDate->format('j') != $currentDayOfMonth); $this->currentDate = $tempDate; } @@ -615,7 +623,7 @@ protected function nextMonthly($amount = 1): void 1 ); // end of workaround - $this->currentDate = $this->currentDate->modify('+ '.($amount * $this->interval).' months'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+ '.($amount * $this->interval).' months'); $amount = 1; // This goes to 0 because we need to start counting at the @@ -676,7 +684,7 @@ protected function nextYearly($amount = 1): void // 400. (1800, 1900, 2100). So we just rely on the datetime // functions instead. $nextDate = clone $this->currentDate; - $nextDate = $nextDate->modify('+ '.($this->interval * $counter).' years'); + $nextDate = $this->getNextIterationDateTime($nextDate, '+ '.($this->interval * $counter).' years'); } while (2 != $nextDate->format('n')); $this->currentDate = $nextDate; @@ -767,7 +775,7 @@ protected function nextYearly($amount = 1): void } // The easiest form - $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' years'); + $this->currentDate = $this->getNextIterationDateTime($this->currentDate, '+'.($amount * $this->interval).' years'); return; } @@ -1055,12 +1063,12 @@ protected function getMonthlyOccurrences(): array $checkDate = new \DateTime($startDate->format('Y-m-1')); // workaround modify always advancing the date even if the current day is a $dayName in hhvm if ($checkDate->format('l') !== $dayName) { - $checkDate = $checkDate->modify($dayName); + $checkDate = $this->getNextIterationDateTime($checkDate, $dayName); } do { $dayHits[] = $checkDate->format('j'); - $checkDate = $checkDate->modify('next '.$dayName); + $checkDate = $this->getNextIterationDateTime($checkDate, 'next '.$dayName); } while ($checkDate->format('n') === $startDate->format('n')); // So now we have 'all wednesdays' for month. It is however @@ -1230,4 +1238,100 @@ protected function getMonths(): array return $recurrenceMonths; } + + private function getNextIterationDateTime(\DateTimeImmutable|\DateTime $dateTime, string $modify): \DateTimeImmutable|\DateTime + { + $startTs = $dateTime->getTimestamp(); + $initialDateTime = clone $dateTime; + $modifiedDateTime = $dateTime->modify($modify); + + $this->detectAndSetDstOffset( + $initialDateTime, + $modifiedDateTime, + $modify, + ); + + $modifiedDateTime = $this->revertPastOffset($modifiedDateTime); + + return $modifiedDateTime; + } + + private function detectAndSetDstOffset( + \DateTimeImmutable|\DateTime $initialDateTime, + \DateTimeImmutable|\DateTime $modifiedDateTime, + string $modify, + ) { + $dstTransitionLeap = $this->getDstTransitionLeap($initialDateTime, $modifiedDateTime, $modify); + + if (!is_null($dstTransitionLeap)) { + $this->leapOffset[$this->counter] = $dstTransitionLeap; + } + + } + + private function getDstTransitionLeap( + \DateTimeImmutable|\DateTime $initialDateTime, + \DateTimeImmutable|\DateTime $modifiedDateTime, + string $modify, + ): ?int { + $modifiedDateInterval = $this->substractDates($modifiedDateTime, $initialDateTime); + $modifyInterval = \DateInterval::createFromDateString($modify); + $leap = $this->substractIntervals($modifiedDateInterval, $modifyInterval); + + if ($this->isDstTransitionLeap($leap)) { + return 3_600 * $leap->h; + } + + return null; + } + + private function isDstTransitionLeap(\DateInterval $leap): bool + { + return $leap->y === 0 && $leap->m === 0 && $leap->d === 0 && $leap->h > 0 && $leap->i === 0 && $leap->s === 0; + } + + private function revertPastOffset(\DateTimeImmutable|\DateTime $dateTime): \DateTimeImmutable|\DateTime + { + $previousIterationLeapOffset = $this->leapOffset[$this->counter - 1] ?? null; + + if (!is_int($previousIterationLeapOffset)) { + return $dateTime; + } + + $modifiedDateTime = $dateTime->modify("- $previousIterationLeapOffset seconds"); + + unset($this->leapOffset[$this->counter - 1]); + + return $modifiedDateTime; + } + + private function substractIntervals(\DateInterval $dateIntervalOne, \DateInterval $dateIntervalTwo): \DateInterval + { + $yearsDiff = $dateIntervalOne->y - $dateIntervalTwo->y; + $monthsDiff = $dateIntervalOne->m - $dateIntervalTwo->m; + $daysDiff = $dateIntervalOne->d - $dateIntervalTwo->d; + $hoursDiff = $dateIntervalOne->h - $dateIntervalTwo->h; + $minutesDiff = $dateIntervalOne->i - $dateIntervalTwo->i; + $secondsDiff = $dateIntervalOne->s - $dateIntervalTwo->s; + + return \DateInterval::createFromDateString( + "$yearsDiff years $monthsDiff months $daysDiff days $hoursDiff hours $minutesDiff minutes $secondsDiff seconds", + ); + } + + private function substractDates( + \DateTimeImmutable|\DateTime $dateIntervalOne, + \DateTimeImmutable|\DateTime $dateIntervalTwo, + ): \DateInterval { + $yearsDiff = $dateIntervalOne->format('y') - $dateIntervalTwo->format('y'); + $monthsDiff = $dateIntervalOne->format('m') - $dateIntervalTwo->format('m'); + $daysDiff = $dateIntervalOne->format('d') - $dateIntervalTwo->format('d'); + $hoursDiff = $dateIntervalOne->format('h') - $dateIntervalTwo->format('h'); + $minutesDiff = $dateIntervalOne->format('i') - $dateIntervalTwo->format('i'); + $secondsDiff = $dateIntervalOne->format('s') - $dateIntervalTwo->format('s'); + + return \DateInterval::createFromDateString( + "$yearsDiff years $monthsDiff months $daysDiff days $hoursDiff hours $minutesDiff minutes $secondsDiff seconds", + ); + } } diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index 75222f217..bfe3d36c5 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -48,6 +48,66 @@ public function testDaily(): void ); } + /** + * @dataProvider dstTransitionProvider + */ + public function testRecurrenceWithDstTransition(string $start, array $expected): void + { + $this->parse( + rule:'FREQ=DAILY;INTERVAL=1;COUNT=5', + start: $start, + expected: $expected, + expectedFreq: 'daily', + expectedCount: 5, + expectedInterval: 1, + tz: '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 testDailyByDayByHour(): void { $this->parse(