@@ -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