Skip to content

Commit

Permalink
Fix dst-transition-leap spreading to subsequent events
Browse files Browse the repository at this point in the history
  • Loading branch information
cyrilvanschreven-proton committed Apr 19, 2024
1 parent a9edab8 commit 6dbb10b
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 16 deletions.
136 changes: 120 additions & 16 deletions lib/Recur/RRuleIterator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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');
}

/**
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand All @@ -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]]);
}
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
);
}
}
60 changes: 60 additions & 0 deletions tests/VObject/Recur/RRuleIteratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit 6dbb10b

Please sign in to comment.