Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DRAFT: Fix dst-transition-leap spreading to subsequent events #101

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 122 additions & 15 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, modify: '+'.$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, modify: '+'.$amount * $this->interval.' days');

return;
}
Expand All @@ -465,13 +473,13 @@ protected function nextDaily($amount = 1): void
if ($this->byHour) {
if ('23' == $this->currentDate->format('G')) {
// to obey the interval rule
$this->currentDate = $this->currentDate->modify('+'.(($amount * $this->interval) - 1).' days');
$this->currentDate = $this->getNextIterationDateTime($this->currentDate, modify: '+'.(($amount * $this->interval) - 1).' days');
$amount = 1;
}

$this->currentDate = $this->currentDate->modify('+1 hours');
$this->currentDate = $this->getNextIterationDateTime($this->currentDate, modify: '+1 hours');
} else {
$this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' days');
$this->currentDate = $this->getNextIterationDateTime($this->currentDate, modify: '+'.($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, modify: '+'.($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, modify: '+1 hours');
} else {
$this->currentDate = $this->currentDate->modify('+1 days');
$this->currentDate = $this->getNextIterationDateTime($this->currentDate, modify: '+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, modify: '+'.(($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, modify: '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, modify: '+'.($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, modify: '+ '.($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, modify: '+ '.($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, modify: '+ '.($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, modify: '+'.($amount * $this->interval).' years');

return;
}
Expand Down Expand Up @@ -1230,4 +1238,103 @@ 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->detectAndSaveDstOffset(
$initialDateTime,
$modifiedDateTime,
$modify,
);

$modifiedDateTime = $this->revertPastOffset($modifiedDateTime);
schreven marked this conversation as resolved.
Show resolved Hide resolved

return $modifiedDateTime;
}

private function detectAndSaveDstOffset(
\DateTimeImmutable|\DateTime $initialDateTime,
\DateTimeImmutable|\DateTime $modifiedDateTime,
string $modify,
) {
$dstTransitionLeap = $this->getDstTransitionLeap($initialDateTime, $modifiedDateTime, $modify);
if (!is_null($dstTransitionLeap)) {
$this->leapOffset[$this->counter] = ($this->leapOffset[$this->counter] ?? 0) + $dstTransitionLeap;
}

}

private function getDstTransitionLeap(
\DateTimeImmutable|\DateTime $initialDateTime,
\DateTimeImmutable|\DateTime $modifiedDateTime,
string $modify,
): ?int {
$modifiedDateInterval = $this->substractDates($modifiedDateTime, $initialDateTime);
$modifyInterval = \DateInterval::createFromDateString($modify);
giuseppe-arcuti marked this conversation as resolved.
Show resolved Hide resolved
$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
{
if (!is_numeric($this->counter)) {
return $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
Loading