Skip to content

Commit 0f77ce7

Browse files
committed
feat: emit VTIMEZONE components for referenced TZIDs
An event with distinct start and end timezones is written with a TZID parameter on each endpoint, but the file carried no definition of those zones. RFC 5545 section 3.6.5 requires one VTIMEZONE per unique TZID. Clients that resolve bare IANA identifiers imported the event anyway, while older Outlook desktop versions read the endpoints as floating local times and shifted the event. The observances are derived from DateTimeZone::getTransitions() over a window reaching a year either side of the event, so a zone that changes its clocks contributes both STANDARD and DAYLIGHT while a fixed offset zone contributes STANDARD alone. The window comes from the event's own dates rather than the current date, keeping the output deterministic.
1 parent d732f15 commit 0f77ce7

4 files changed

Lines changed: 305 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ DTSTART;TZID=Asia/Tokyo:20270315T090000
133133
DTEND;TZID=America/Los_Angeles:20270315T093000
134134
```
135135

136-
`DTSTAMP` stays in UTC, as RFC 5545 requires.
136+
`DTSTAMP` stays in UTC, as RFC 5545 requires. Each zone named this way also gets its own `VTIMEZONE` component, placed before the event and holding the offsets in force around it, so the file stands on its own for a client that does not resolve bare IANA names (older Outlook desktop, for instance).
137137

138138
Nothing needs switching on. An event whose two ends share a zone is generated exactly as before, and so is an all-day event, which has no clock time to place in a zone. Yahoo has no timezone parameter and Outlook accepts only UTC or the viewer's own zone, so both keep their current output.
139139

src/Generators/Ics.php

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

tests/Generators/IcsGeneratorTest.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,114 @@ public function it_rejects_a_dtstamp_that_is_not_a_date_time_through_the_link():
186186
$link->ics(['DTSTAMP' => '2026-01-01'], ['format' => Ics::FORMAT_FILE]);
187187
}
188188

189+
#[Test]
190+
public function it_defines_a_vtimezone_for_every_referenced_tzid(): void
191+
{
192+
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5
193+
$output = $this->generator()->generate($this->createFlightWithDistinctTimezonesLink());
194+
195+
$this->assertSame(2, substr_count($output, 'BEGIN:VTIMEZONE'));
196+
$this->assertStringContainsString("BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo\r\n", $output);
197+
$this->assertStringContainsString("BEGIN:VTIMEZONE\r\nTZID:America/Los_Angeles\r\n", $output);
198+
199+
// A component is only useful to a client that reads it before the event that leans on it.
200+
$this->assertLessThan(strpos($output, 'BEGIN:VEVENT'), strpos($output, 'BEGIN:VTIMEZONE'));
201+
$this->assertLessThan(strpos($output, 'BEGIN:VEVENT'), strpos($output, 'END:VTIMEZONE'));
202+
}
203+
204+
#[Test]
205+
public function it_describes_both_observances_of_a_zone_that_changes_its_clocks(): void
206+
{
207+
// The flight lands on 2027-03-15, the day after Los Angeles moved to daylight saving time,
208+
// so the pair of observances brackets the event and the later of the two applies to it.
209+
$output = $this->generator()->generate($this->createFlightWithDistinctTimezonesLink());
210+
211+
$this->assertStringContainsString(
212+
implode("\r\n", [
213+
'BEGIN:STANDARD',
214+
'DTSTART:20261101T020000',
215+
'TZOFFSETFROM:-0700',
216+
'TZOFFSETTO:-0800',
217+
'TZNAME:PST',
218+
'END:STANDARD',
219+
'BEGIN:DAYLIGHT',
220+
'DTSTART:20270314T020000',
221+
'TZOFFSETFROM:-0800',
222+
'TZOFFSETTO:-0700',
223+
'TZNAME:PDT',
224+
'END:DAYLIGHT',
225+
]),
226+
$output
227+
);
228+
}
229+
230+
#[Test]
231+
public function it_describes_a_zone_that_keeps_one_offset_with_a_standard_observance_alone(): void
232+
{
233+
// Neither zone observes daylight saving time, so there is no DAYLIGHT rule to state.
234+
$link = Link::create(
235+
'Bengaluru to Reykjavik',
236+
new DateTime('2027-07-01 09:00', new DateTimeZone('Asia/Kolkata')),
237+
new DateTime('2027-07-01 15:30', new DateTimeZone('Atlantic/Reykjavik')),
238+
);
239+
240+
$output = $this->generator()->generate($link);
241+
242+
$this->assertSame(2, substr_count($output, 'BEGIN:VTIMEZONE'));
243+
$this->assertSame(2, substr_count($output, 'BEGIN:STANDARD'));
244+
$this->assertStringNotContainsString('BEGIN:DAYLIGHT', $output);
245+
246+
// A zone that never changes leaves and arrives at the same offset.
247+
$this->assertStringContainsString("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530\r\nTZNAME:IST", $output);
248+
$this->assertStringContainsString("TZOFFSETFROM:+0000\r\nTZOFFSETTO:+0000\r\nTZNAME:GMT", $output);
249+
}
250+
251+
#[Test]
252+
public function it_defines_a_zone_referenced_twice_only_once(): void
253+
{
254+
// The rule is one component per unique TZID, not one per property that names a zone.
255+
$generator = new class ([], ['format' => Ics::FORMAT_FILE]) extends Ics {
256+
#[\Override]
257+
protected function referencedTimezones(Link $link): array
258+
{
259+
return [new DateTimeZone('Asia/Tokyo'), new DateTimeZone('Asia/Tokyo')];
260+
}
261+
};
262+
263+
$output = $generator->generate($this->createFlightWithDistinctTimezonesLink());
264+
265+
$this->assertSame(1, substr_count($output, 'BEGIN:VTIMEZONE'));
266+
$this->assertSame(1, substr_count($output, 'TZID:Asia/Tokyo'));
267+
}
268+
269+
#[Test]
270+
public function it_defines_no_timezone_when_the_gate_is_closed(): void
271+
{
272+
// The components and the TZID parameters that reference them are gated together, so a
273+
// narrower gate must leave no component behind naming a zone the file never mentions.
274+
$generator = new class ([], ['format' => Ics::FORMAT_FILE]) extends Ics {
275+
#[\Override]
276+
protected function shouldDefineTimezones(Link $link): bool
277+
{
278+
return false;
279+
}
280+
};
281+
282+
$output = $generator->generate($this->createFlightWithDistinctTimezonesLink());
283+
284+
$this->assertStringNotContainsString('VTIMEZONE', $output);
285+
}
286+
287+
#[Test]
288+
public function it_defines_no_timezone_when_the_endpoints_share_a_zone(): void
289+
{
290+
// Both endpoints are written as UTC instants, so no TZID is referenced and nothing needs defining.
291+
$output = $this->generator()->generate($this->createShortEventLink());
292+
293+
$this->assertStringNotContainsString('VTIMEZONE', $output);
294+
$this->assertStringContainsString('DTSTART:20180201T090000Z', $output);
295+
}
296+
189297
#[Test]
190298
public function it_can_generate_a_recurring_event(): void
191299
{

tests/Generators/__snapshots__/IcsGeneratorTest__it_can_generate_an_event_with_separate_start_and_end_timezones__1.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
BEGIN:VCALENDAR
22
VERSION:2.0
33
PRODID:Spatie calendar-links
4+
BEGIN:VTIMEZONE
5+
TZID:Asia/Tokyo
6+
BEGIN:STANDARD
7+
DTSTART:20260315T090000
8+
TZOFFSETFROM:+0900
9+
TZOFFSETTO:+0900
10+
TZNAME:JST
11+
END:STANDARD
12+
END:VTIMEZONE
13+
BEGIN:VTIMEZONE
14+
TZID:America/Los_Angeles
15+
BEGIN:STANDARD
16+
DTSTART:20261101T020000
17+
TZOFFSETFROM:-0700
18+
TZOFFSETTO:-0800
19+
TZNAME:PST
20+
END:STANDARD
21+
BEGIN:DAYLIGHT
22+
DTSTART:20270314T020000
23+
TZOFFSETFROM:-0800
24+
TZOFFSETTO:-0700
25+
TZNAME:PDT
26+
END:DAYLIGHT
27+
END:VTIMEZONE
428
BEGIN:VEVENT
529
UID:950b3e39f0e8344ccd3a11a310eb193c
630
SUMMARY:NH 106 Tokyo to Los Angeles

0 commit comments

Comments
 (0)