Skip to content

Commit 1b388aa

Browse files
authored
Merge pull request #653 from phil-davis/dst-leap-648
Handle summer time jumps in event recurrences
2 parents 227f681 + 1d0d0bd commit 1b388aa

File tree

2 files changed

+568
-9
lines changed

2 files changed

+568
-9
lines changed

lib/Recur/RRuleIterator.php

+74-9
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ public function fastForward(\DateTimeInterface $dt): void
159159
*/
160160
protected ?\DateTimeInterface $currentDate;
161161

162+
/**
163+
* The number of hours that the next occurrence of an event
164+
* jumped forward, usually because summer time started and
165+
* the requested time-of-day like 0230 did not exist on that
166+
* day. And so the event was scheduled 1 hour later at 0330.
167+
*/
168+
protected int $hourJump = 0;
169+
162170
/**
163171
* Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
164172
* yearly.
@@ -276,12 +284,65 @@ public function fastForward(\DateTimeInterface $dt): void
276284

277285
/* Functions that advance the iterator {{{ */
278286

287+
/**
288+
* Gets the original start time of the RRULE.
289+
*
290+
* The value is formatted as a string with 24-hour:minute:second
291+
*/
292+
protected function startTime(): string
293+
{
294+
return $this->startDate->format('H:i:s');
295+
}
296+
297+
/**
298+
* Advances currentDate by the interval.
299+
* The time is set from the original startDate.
300+
* If the recurrence is on a day when summer time started, then the
301+
* time on that day may have jumped forward, for example, from 0230 to 0330.
302+
* Using the original time means that the next recurrence will be calculated
303+
* based on the original start time and the day/week/month/year interval.
304+
* So the start time of the next occurrence can correctly revert to 0230.
305+
*/
306+
protected function advanceTheDate(string $interval): void
307+
{
308+
$this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime());
309+
}
310+
311+
/**
312+
* Does the processing for adjusting the time of multi-hourly events when summer time starts.
313+
*/
314+
protected function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousEventDateTime): void
315+
{
316+
if (0 === $this->hourJump) {
317+
// Remember if the clock time jumped forward on the next occurrence.
318+
// That happens if the next event time is on a day when summer time starts
319+
// and the event time is in the non-existent hour of the day.
320+
// For example, an event that normally starts at 02:30 will
321+
// have to start at 03:30 on that day.
322+
// If the interval is just 1 hour, then there is no "jumping back" to do.
323+
// The events that day will happen, for example, at 0030 0130 0330 0430 0530...
324+
if ($this->interval > 1) {
325+
$expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24;
326+
$actualHourOfNextDate = (int) $this->currentDate->format('G');
327+
$this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate;
328+
}
329+
} else {
330+
// The hour "jumped" for the previous occurrence, to avoid the non-existent time.
331+
// currentDate got set ahead by (usually) 1 hour on that day.
332+
// Adjust it back for this next occurrence.
333+
$this->currentDate = $this->currentDate->sub(new \DateInterval('PT'.$this->hourJump.'H'));
334+
$this->hourJump = 0;
335+
}
336+
}
337+
279338
/**
280339
* Does the processing for advancing the iterator for hourly frequency.
281340
*/
282341
protected function nextHourly(): void
283342
{
343+
$previousEventDateTime = clone $this->currentDate;
284344
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours');
345+
$this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime);
285346
}
286347

287348
/**
@@ -290,7 +351,7 @@ protected function nextHourly(): void
290351
protected function nextDaily(): void
291352
{
292353
if (!$this->byHour && !$this->byDay) {
293-
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' days');
354+
$this->advanceTheDate('+'.$this->interval.' days');
294355

295356
return;
296357
}
@@ -349,7 +410,7 @@ protected function nextDaily(): void
349410
protected function nextWeekly(): void
350411
{
351412
if (!$this->byHour && !$this->byDay) {
352-
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks');
413+
$this->advanceTheDate('+'.$this->interval.' weeks');
353414

354415
return;
355416
}
@@ -371,7 +432,7 @@ protected function nextWeekly(): void
371432
if ($this->byHour) {
372433
$this->currentDate = $this->currentDate->modify('+1 hours');
373434
} else {
374-
$this->currentDate = $this->currentDate->modify('+1 days');
435+
$this->advanceTheDate('+1 days');
375436
}
376437

377438
// Current day of the week
@@ -408,13 +469,13 @@ protected function nextMonthly(): void
408469
// occur to the next month. We Must skip these invalid
409470
// entries.
410471
if ($currentDayOfMonth < 29) {
411-
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' months');
472+
$this->advanceTheDate('+'.$this->interval.' months');
412473
} else {
413474
$increase = 0;
414475
do {
415476
++$increase;
416477
$tempDate = clone $this->currentDate;
417-
$tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months');
478+
$tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime());
418479
} while ($tempDate->format('j') != $currentDayOfMonth);
419480
$this->currentDate = $tempDate;
420481
}
@@ -465,11 +526,15 @@ protected function nextMonthly(): void
465526
}
466527
}
467528

529+
// Set the currentDate to the year and month that we are in, and the day of the month that we have selected.
530+
// That day could be a day when summer time starts, and if the time of the event is, for example, 0230,
531+
// then 0230 will not be a valid time on that day. So always apply the start time from the original startDate.
532+
// The "modify" method will set the time forward to 0330, for example, if needed.
468533
$this->currentDate = $this->currentDate->setDate(
469534
(int) $this->currentDate->format('Y'),
470535
(int) $this->currentDate->format('n'),
471536
(int) $occurrence
472-
);
537+
)->modify($this->startTime());
473538
}
474539

475540
/**
@@ -586,7 +651,7 @@ protected function nextYearly(): void
586651
}
587652

588653
// The easiest form
589-
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' years');
654+
$this->advanceTheDate('+'.$this->interval.' years');
590655

591656
return;
592657
}
@@ -650,7 +715,7 @@ protected function nextYearly(): void
650715
(int) $currentYear,
651716
(int) $currentMonth,
652717
(int) $occurrence
653-
);
718+
)->modify($this->startTime());
654719

655720
return;
656721
} else {
@@ -667,7 +732,7 @@ protected function nextYearly(): void
667732
(int) $currentYear,
668733
(int) $currentMonth,
669734
(int) $currentDayOfMonth
670-
);
735+
)->modify($this->startTime());
671736

672737
return;
673738
}

0 commit comments

Comments
 (0)