Skip to content

Commit f699c2c

Browse files
committed
refactor(ics): tidy the timezone rule derivation
Four cleanups over the code the previous commits added, with the output byte for byte unchanged. The no-transitions branch built a whole observance by hand rather than letting the synthetic transition fall through the loop that builds every other one. Restoring the fall-through drops the duplicate, and the rule derivation is skipped by counting the table instead, which also stops a fixed offset zone reading years it has no changes in. observesAChange() was answering the same question three times per link, since generate() reaches shouldNameTimezones() once directly and twice more through referencedTimezones(). Reading a zone's transition table is the most expensive thing this class does, so the answer is kept. annualRecurrenceRules() walked the whole table twice, once per kind of observance, carrying a parallel offset variable as it went. One pass keyed by kind says the same thing, and lastOnsetOfEachKind() gives the step a name. annualRuleFor() used one variable as both the agreed week of the month and the flag for there being no agreement, and `??=` then re-seeded it after it had been cleared: onsets in weeks 2, 3 and 4 came back out as week 4 rather than as no rule at all. Collecting the parts and comparing them at the end removes the state that made that possible.
1 parent 62114d9 commit f699c2c

1 file changed

Lines changed: 93 additions & 59 deletions

File tree

src/Generators/Ics.php

Lines changed: 93 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -577,16 +577,31 @@ protected function shouldNameTimezones(Link $link): bool
577577
* Whether the zone moves its clocks anywhere near the event, over the same stretch the VTIMEZONE
578578
* would describe. A zone that does not is one offset from end to end, which a UTC instant already
579579
* carries.
580+
*
581+
* generate() asks this through shouldNameTimezones(), which it reaches three times over one link:
582+
* once to choose how to write the endpoints, and twice more through referencedTimezones(). The
583+
* answer is a function of the zone and the event's two instants and nothing else, so it is worked
584+
* out once and kept. Reading a zone's table is the most expensive thing this class does.
585+
*
586+
* @var array<string, bool>
580587
*/
588+
private array $observedChanges = [];
589+
581590
private function observesAChange(\DateTimeZone $timezone, Link $link): bool
582591
{
583-
$transitions = $timezone->getTransitions(
584-
$link->from->modify('-1 year')->getTimestamp(),
585-
$link->to->modify('+1 year')->getTimestamp() + self::RULE_PROBE_YEARS * self::SECONDS_PER_YEAR,
586-
);
592+
$windowStart = $link->from->modify('-1 year')->getTimestamp();
593+
$windowEnd = $link->to->modify('+1 year')->getTimestamp() + self::RULE_PROBE_YEARS * self::SECONDS_PER_YEAR;
594+
595+
$key = $timezone->getName()."\0".$windowStart."\0".$windowEnd;
587596

588-
// The first entry is the offset already in force when the stretch opens, not a change.
589-
return is_array($transitions) && count($transitions) > 1;
597+
if (! isset($this->observedChanges[$key])) {
598+
$transitions = $timezone->getTransitions($windowStart, $windowEnd);
599+
600+
// The first entry is the offset already in force when the stretch opens, not a change.
601+
$this->observedChanges[$key] = is_array($transitions) && count($transitions) > 1;
602+
}
603+
604+
return $this->observedChanges[$key];
590605
}
591606

592607
/**
@@ -712,19 +727,22 @@ private function generateTimezoneObservances(\DateTimeZone $timezone, Link $link
712727
// A zone named by a bare abbreviation or a fixed offset (EST, CET, +05:30) has no transition
713728
// table for PHP to return. RFC 5545 still wants at least one subcomponent, so the one offset
714729
// such a zone has stands in as the state at the window opening and runs through the loop
715-
// below like any other. It never changes, so it needs no rule to carry it past the window.
730+
// below like any other.
716731
if ($transitions === false || $transitions === []) {
717-
return [[
718-
'BEGIN:STANDARD',
719-
'DTSTART:'.gmdate(self::LOCAL_DATETIME_FORMAT, $windowStart + $timezone->getOffset($link->from)),
720-
'TZOFFSETFROM:'.$this->formatUtcOffset($timezone->getOffset($link->from)),
721-
'TZOFFSETTO:'.$this->formatUtcOffset($timezone->getOffset($link->from)),
722-
'TZNAME:'.$this->escapeString($link->from->setTimezone($timezone)->format('T')),
723-
'END:STANDARD',
732+
$transitions = [[
733+
'ts' => $windowStart,
734+
'offset' => $timezone->getOffset($link->from),
735+
'isdst' => false,
736+
'abbr' => $link->from->setTimezone($timezone)->format('T'),
724737
]];
725738
}
726739

727-
$recurrenceRules = $this->annualRecurrenceRules($timezone, $transitions, $windowEnd);
740+
// The entry at index 0 is the offset already in force when the window opened rather than a
741+
// change the zone makes, so a table holding nothing else has no change for a rule to repeat
742+
// and no reason to read the years beyond the window looking for one.
743+
$recurrenceRules = count($transitions) > 1
744+
? $this->annualRecurrenceRules($timezone, $transitions, $windowEnd)
745+
: [];
728746

729747
$observances = [];
730748
$previousOffset = null;
@@ -779,25 +797,11 @@ private function annualRecurrenceRules(\DateTimeZone $timezone, array $transitio
779797

780798
$rules = [];
781799

782-
foreach ([true, false] as $isDaylight) {
783-
// The entry at index 0 is the offset already in force when the window opened rather than a
784-
// change the zone makes, so it is never something a yearly rule can repeat.
785-
$lastIndex = null;
786-
$offsetFrom = null;
787-
foreach ($transitions as $index => $transition) {
788-
if ($index > 0 && $transition['isdst'] === $isDaylight) {
789-
$lastIndex = $index;
790-
$offsetFrom = $transitions[$index - 1]['offset'];
791-
}
792-
}
800+
foreach ($this->lastOnsetOfEachKind($transitions) as $isDaylight => $lastOnset) {
801+
$onsets = [$lastOnset];
793802

794-
if ($lastIndex === null || $offsetFrom === null) {
795-
continue;
796-
}
797-
798-
$onsets = [['ts' => $transitions[$lastIndex]['ts'], 'offsetFrom' => $offsetFrom]];
799803
foreach ($laterOnsets as $onset) {
800-
if ($onset['isdst'] === $isDaylight) {
804+
if ($onset['isdst'] === (bool) $isDaylight) {
801805
$onsets[] = $onset;
802806
}
803807
}
@@ -809,13 +813,42 @@ private function annualRecurrenceRules(\DateTimeZone $timezone, array $transitio
809813
$rule = $this->annualRuleFor($onsets);
810814

811815
if ($rule !== null) {
812-
$rules[$lastIndex] = $rule;
816+
$rules[$lastOnset['index']] = $rule;
813817
}
814818
}
815819

816820
return $rules;
817821
}
818822

823+
/**
824+
* The last change of each kind the window holds, keyed by whether it starts daylight saving, with
825+
* the offset it moves away from and the index of the observance it belongs to.
826+
*
827+
* The entry at index 0 is the offset already in force when the window opened rather than a change
828+
* the zone makes, so it is skipped: a yearly rule has nothing to repeat there.
829+
*
830+
* @param non-empty-list<array{ts: int, offset: int, isdst: bool, ...}> $transitions
831+
* @return array<int, array{ts: int, offsetFrom: int, index: int}>
832+
*/
833+
private function lastOnsetOfEachKind(array $transitions): array
834+
{
835+
$lastOnsets = [];
836+
837+
foreach ($transitions as $index => $transition) {
838+
if ($index === 0) {
839+
continue;
840+
}
841+
842+
$lastOnsets[(int) $transition['isdst']] = [
843+
'ts' => $transition['ts'],
844+
'offsetFrom' => $transitions[$index - 1]['offset'],
845+
'index' => $index,
846+
];
847+
}
848+
849+
return $lastOnsets;
850+
}
851+
819852
/**
820853
* The zone's changes over the years that follow the window, each paired with the offset it moves
821854
* away from, which is what its onset is a local time in.
@@ -833,9 +866,9 @@ private function onsetsAfter(\DateTimeZone $timezone, int $from): array
833866
$onsets = [];
834867
$previousOffset = null;
835868

836-
foreach ($transitions as $index => $transition) {
869+
foreach ($transitions as $transition) {
837870
// As in the window itself, the first entry is the state at the start rather than a change.
838-
if ($index > 0 && $previousOffset !== null) {
871+
if ($previousOffset !== null) {
839872
$onsets[] = ['ts' => $transition['ts'], 'offsetFrom' => $previousOffset, 'isdst' => $transition['isdst']];
840873
}
841874

@@ -856,46 +889,47 @@ private function annualRuleFor(array $onsets): ?string
856889
{
857890
$weekdays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
858891

859-
$month = null;
860-
$weekday = null;
861-
$localTime = null;
862-
$weekOfMonth = null;
892+
$months = [];
893+
$weekdayNumbers = [];
894+
$localTimes = [];
895+
$weeksOfMonth = [];
863896
$isAlwaysLastOfMonth = true;
864897

865898
foreach ($onsets as $onset) {
866899
$local = $onset['ts'] + $onset['offsetFrom'];
867900
$dayOfMonth = (int) gmdate('j', $local);
868901

869-
$currentMonth = (int) gmdate('n', $local);
870-
$currentWeekday = (int) gmdate('w', $local);
871-
$currentLocalTime = gmdate('His', $local);
872-
$currentWeekOfMonth = intdiv($dayOfMonth - 1, 7) + 1;
873-
874-
$month ??= $currentMonth;
875-
$weekday ??= $currentWeekday;
876-
$localTime ??= $currentLocalTime;
877-
$weekOfMonth ??= $currentWeekOfMonth;
878-
879-
if ($currentMonth !== $month || $currentWeekday !== $weekday || $currentLocalTime !== $localTime) {
880-
return null;
881-
}
882-
883-
if ($currentWeekOfMonth !== $weekOfMonth) {
884-
$weekOfMonth = null;
885-
}
902+
$months[] = (int) gmdate('n', $local);
903+
$weekdayNumbers[] = (int) gmdate('w', $local);
904+
$localTimes[] = gmdate('His', $local);
905+
$weeksOfMonth[] = intdiv($dayOfMonth - 1, 7) + 1;
886906

887907
$isAlwaysLastOfMonth = $isAlwaysLastOfMonth && $dayOfMonth + 7 > (int) gmdate('t', $local);
888908
}
889909

890-
if ($isAlwaysLastOfMonth) {
891-
$weekOfMonth = -1;
910+
// A yearly rule states one month, one weekday and one time, so onsets that disagree on any of
911+
// the three follow no rule this can write.
912+
if (! self::allTheSame($months) || ! self::allTheSame($weekdayNumbers) || ! self::allTheSame($localTimes)) {
913+
return null;
892914
}
893915

916+
$weekOfMonth = match (true) {
917+
$isAlwaysLastOfMonth => -1,
918+
self::allTheSame($weeksOfMonth) => $weeksOfMonth[0],
919+
default => null,
920+
};
921+
894922
if ($weekOfMonth === null) {
895923
return null;
896924
}
897925

898-
return 'RRULE:FREQ=YEARLY;BYMONTH='.$month.';BYDAY='.$weekOfMonth.$weekdays[$weekday];
926+
return 'RRULE:FREQ=YEARLY;BYMONTH='.$months[0].';BYDAY='.$weekOfMonth.$weekdays[$weekdayNumbers[0]];
927+
}
928+
929+
/** @param non-empty-list<int|string> $values */
930+
private static function allTheSame(array $values): bool
931+
{
932+
return count(array_unique($values)) === 1;
899933
}
900934

901935
/**

0 commit comments

Comments
 (0)