@@ -70,6 +70,17 @@ public function generate(Link $link): string
7070 'VERSION:2.0 ' , // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.4
7171 'PRODID: ' .($ this ->options ['PRODID ' ] ?? 'Spatie calendar-links ' ), // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.3
7272 ...$ this ->additionalCalendarProperties ($ link ),
73+ ];
74+
75+ // Properties precede components at the VCALENDAR level, which is why this sits after the
76+ // calendar properties and before the event.
77+ // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
78+ if ($ this ->shouldDefineTimezones ($ link )) {
79+ $ url = [...$ url , ...$ this ->generateTimezoneComponents ($ link )];
80+ }
81+
82+ $ url = [
83+ ...$ url ,
7384 'BEGIN:VEVENT ' ,
7485 'UID: ' .($ this ->options ['UID ' ] ?? $ this ->generateEventUid ($ link )),
7586 'SUMMARY: ' .$ this ->escapeString ($ link ->title ),
@@ -302,6 +313,167 @@ protected function additionalEventProperties(Link $link): array
302313 return [];
303314 }
304315
316+ /**
317+ * Whether the file needs VTIMEZONE components at all.
318+ *
319+ * This must stay in step with the condition in generate() that decides whether the endpoints are
320+ * written with a TZID parameter, because a VTIMEZONE is only meaningful when a property in the
321+ * file references it. Narrowing that condition has to narrow this one with it, or the file ends
322+ * up carrying an orphan component that defines a zone nothing names.
323+ *
324+ * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
325+ */
326+ protected function shouldDefineTimezones (Link $ link ): bool
327+ {
328+ return $ link ->hasDistinctTimezones ();
329+ }
330+
331+ /**
332+ * Extension point: the zones the generated file names with a TZID parameter, in the order their
333+ * VTIMEZONE components should appear. Repeated names are collapsed by the caller, so an override
334+ * is free to list a zone it cannot rule out being there already.
335+ *
336+ * @return list<\DateTimeZone>
337+ */
338+ protected function referencedTimezones (Link $ link ): array
339+ {
340+ return [$ link ->fromTimezone , $ link ->toTimezone ];
341+ }
342+
343+ /**
344+ * "An individual VTIMEZONE calendar component MUST be specified for each unique TZID parameter
345+ * value specified in the iCalendar object." Without them the file is invalid, and a client that
346+ * does not resolve bare IANA identifiers (older Outlook desktop) reads the endpoints as floating
347+ * local times, which shifts the event.
348+ *
349+ * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
350+ * @return list<string>
351+ */
352+ protected function generateTimezoneComponents (Link $ link ): array
353+ {
354+ $ components = [];
355+ $ definedTimezones = [];
356+
357+ foreach ($ this ->referencedTimezones ($ link ) as $ timezone ) {
358+ $ tzid = $ timezone ->getName ();
359+
360+ // Unique is per TZID value, not per referencing property, so an event that departs and
361+ // lands in one zone still gets a single component.
362+ if (isset ($ definedTimezones [$ tzid ])) {
363+ continue ;
364+ }
365+
366+ $ definedTimezones [$ tzid ] = true ;
367+
368+ $ components = [...$ components , ...$ this ->generateTimezoneComponent ($ timezone , $ link )];
369+ }
370+
371+ return $ components ;
372+ }
373+
374+ /**
375+ * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
376+ * @return list<string>
377+ */
378+ protected function generateTimezoneComponent (\DateTimeZone $ timezone , Link $ link ): array
379+ {
380+ $ component = ['BEGIN:VTIMEZONE ' ];
381+ $ component [] = 'TZID: ' .$ timezone ->getName ();
382+
383+ foreach ($ this ->generateTimezoneObservances ($ timezone , $ link ) as $ observance ) {
384+ $ component = [...$ component , ...$ observance ];
385+ }
386+
387+ $ component [] = 'END:VTIMEZONE ' ;
388+
389+ return $ component ;
390+ }
391+
392+ /**
393+ * At most one STANDARD and one DAYLIGHT observance, read from the zone's own transition table
394+ * rather than a hardcoded rule set, so the file needs no maintenance when a country changes its
395+ * mind about summer time. The window reaches a year either side of the event, which is wide
396+ * enough for a zone that changes its clocks to contribute both observances while staying small
397+ * and free of the current date, so the output stays the same on every run.
398+ *
399+ * An observance carries no RRULE here, so it applies from its DTSTART onwards until the next one
400+ * begins. Keeping the latest onset that has already happened by the end of the event therefore
401+ * describes exactly the period the event falls in. A zone that holds one offset all year round
402+ * has no daylight saving to describe and contributes STANDARD alone.
403+ *
404+ * That last case has no real transition to point at, so its onset is the opening of the window,
405+ * which is where PHP reports the offset already in effect. The epoch such a zone is often given
406+ * instead would claim the offset has held since 1970, and for a zone that settled only recently
407+ * that is false: Europe/Moscow kept summer time until 2011 and moved from +0400 to +0300 in
408+ * 2014, so an epoch onset would misplace every date in between, which a recurrence resolved
409+ * against this component can reach. Opening at the window claims less and stays true.
410+ *
411+ * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
412+ * @return list<list<string>>
413+ */
414+ private function generateTimezoneObservances (\DateTimeZone $ timezone , Link $ link ): array
415+ {
416+ $ transitions = $ timezone ->getTransitions (
417+ $ link ->from ->modify ('-1 year ' )->getTimestamp (),
418+ $ link ->to ->modify ('+1 year ' )->getTimestamp (),
419+ );
420+
421+ $ eventEnd = $ link ->to ->getTimestamp ();
422+ $ observances = [];
423+ $ previousOffset = null ;
424+
425+ foreach ($ transitions as $ transition ) {
426+ // The first entry describes the offset already in effect when the window opens instead of
427+ // a change, so there is no earlier offset to move away from.
428+ $ offsetFrom = $ previousOffset ?? $ transition ['offset ' ];
429+ $ previousOffset = $ transition ['offset ' ];
430+ $ type = $ transition ['isdst ' ] ? 'DAYLIGHT ' : 'STANDARD ' ;
431+
432+ // Transitions come back in chronological order, so overwriting until the event is passed
433+ // leaves the latest onset in effect, or the earliest one when a whole group lies ahead.
434+ if (isset ($ observances [$ type ]) && $ transition ['ts ' ] > $ eventEnd ) {
435+ continue ;
436+ }
437+
438+ $ observances [$ type ] = [
439+ 'BEGIN: ' .$ type ,
440+ // An onset is a local time read against the offset being left behind.
441+ // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.2.4
442+ 'DTSTART: ' .gmdate (self ::LOCAL_DATETIME_FORMAT , $ transition ['ts ' ] + $ offsetFrom ),
443+ 'TZOFFSETFROM: ' .$ this ->formatUtcOffset ($ offsetFrom ),
444+ 'TZOFFSETTO: ' .$ this ->formatUtcOffset ($ transition ['offset ' ]),
445+ 'TZNAME: ' .$ this ->escapeString ($ transition ['abbr ' ]),
446+ 'END: ' .$ type ,
447+ ];
448+ }
449+
450+ // A fixed order, rather than the order the transitions happened to arrive in, keeps one zone
451+ // rendering identically whichever side of a clock change the event sits on.
452+ return array_values (array_filter ([
453+ $ observances ['STANDARD ' ] ?? null ,
454+ $ observances ['DAYLIGHT ' ] ?? null ,
455+ ]));
456+ }
457+
458+ /**
459+ * A UTC offset is signed hours and minutes, with seconds appended only when a zone needs them,
460+ * which in practice means the local mean times that predate standardised zones.
461+ *
462+ * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.14
463+ */
464+ private function formatUtcOffset (int $ offsetInSeconds ): string
465+ {
466+ $ absoluteOffset = abs ($ offsetInSeconds );
467+ $ seconds = $ absoluteOffset % 60 ;
468+
469+ return sprintf (
470+ '%s%02d%02d ' ,
471+ $ offsetInSeconds < 0 ? '- ' : '+ ' ,
472+ intdiv ($ absoluteOffset , 3600 ),
473+ intdiv ($ absoluteOffset % 3600 , 60 ),
474+ ).($ seconds !== 0 ? sprintf ('%02d ' , $ seconds ) : '' );
475+ }
476+
305477 /**
306478 * @param \Spatie\CalendarLinks\Link $link
307479 * @return list<string>
0 commit comments