Skip to content

Commit a5e7ec1

Browse files
authored
Merge pull request #248 from spatie/fix/offset-style-timezones
Fix: fall back to UTC for timezones a service cannot resolve
2 parents 3ca8e53 + f825338 commit a5e7ec1

8 files changed

Lines changed: 288 additions & 11 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20270315T10000
2828

2929
Give both dates an explicit timezone, as above. The `ctz` parameter is taken from the start date, so without one your links follow the `date.timezone` of whichever machine generated them.
3030

31+
Name a place (`Europe/Brussels`, or an alias of one such as `Japan` or `US/Pacific`) rather than an offset. Parsing an ISO 8601 string leaves you with a zone named `+01:00`, and neither an offset nor an abbreviation (`CEST`) gives a calendar service a place to resolve. The same goes for the IANA entries that stand for no place: the POSIX rule sets (`EST`, `MST7MDT`), the other spellings of UTC, and the whole `Etc/GMT±N` family, which Google rejects outright and whose sign runs the opposite way from the offset it names. Those events are written in UTC instead (`dates=20270315T090000Z/20270315T160000Z`, with no `ctz`, and a UTC `DTSTART` in the ics file), so they land at the right instant everywhere, but the calendar has no zone to follow when the daylight saving rules of that place change. The `Etc/` tree is refused in full, `Etc/UTC` included, so pass `UTC` when you want a UTC endpoint alongside a named zone, since a refused zone at either end of a two zone event sends both ends to the fallback.
32+
3133
If you follow that link (and are authenticated with Google), you’ll see a screen to add the event to your calendar.
3234

3335
The package can also generate ics files that you can open in several email and calendar programs, including Microsoft Outlook, Google Calendar, and Apple Calendar.
@@ -137,6 +139,8 @@ DTEND;TZID=America/Los_Angeles:20270315T093000
137139

138140
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.
139141

142+
Both ends have to name a place for this. If either one does not, the pair is written in UTC together, since naming only one end would leave the other to be read in whichever zone the viewer sits in.
143+
140144
`$link->from` and `$link->to` are unchanged too: `$to` is still normalised into `$from`'s zone, so the two are directly comparable. The zones are recorded separately, on `$link->fromTimezone` and `$link->toTimezone`.
141145

142146
### Guests

src/Generators/Google.php

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class Google implements Generator
2020
/** @see https://www.php.net/manual/en/function.date.php */
2121
private const string DATETIME_FORMAT = 'Ymd\THis';
2222

23+
/**
24+
* An instant, with the Z suffix that marks a UTC value. Used when no zone can be named alongside.
25+
* @see https://www.php.net/manual/en/function.date.php
26+
*/
27+
private const string UTC_DATETIME_FORMAT = 'Ymd\THis\Z';
28+
2329
/** @psalm-var GoogleUrlParameters */
2430
protected array $urlParameters = [];
2531

@@ -38,20 +44,36 @@ public function generate(Link $link): string
3844
{
3945
$url = static::BASE_URL;
4046

41-
$dateTimeFormat = $link->allDay ? self::DATE_FORMAT : self::DATETIME_FORMAT;
42-
43-
// Each endpoint is written as a local time in the zone that names it below.
44-
$to = $link->hasDistinctTimezones() ? $link->to->setTimezone($link->toTimezone) : $link->to;
45-
$url .= '&dates='.$link->from->format($dateTimeFormat).'/'.$to->format($dateTimeFormat);
47+
// The branches below write the two endpoints and, where they can, name the zone those times
48+
// belong to. A zone name goes in unencoded: every name that gets this far is a TZDB name,
49+
// spelled with unreserved characters and at most a `/`, and RFC 3986 lets that one stand as
50+
// itself in a query.
51+
// @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
52+
if ($link->allDay) {
53+
// An all-day event is a pair of calendar dates rather than instants, so there is no clock
54+
// time to move between zones and the dates are written as they were given either way.
55+
$url .= '&dates='.$link->from->format(self::DATE_FORMAT).'/'.$link->to->format(self::DATE_FORMAT);
56+
57+
if ($link->hasResolvableTimezones()) {
58+
$url .= '&ctz='.$link->fromTimezone->getName();
59+
}
60+
} elseif (! $link->hasResolvableTimezones()) {
61+
// Google silently ignores a ctz, stz or etz it cannot resolve, which leaves the local
62+
// times in `dates` to be read in whichever zone the viewer sits in, so the event lands at
63+
// the wrong instant for everyone else. UTC instants leave nothing to be interpreted.
64+
$url .= '&dates='.gmdate(self::UTC_DATETIME_FORMAT, $link->from->getTimestamp()).'/'.gmdate(self::UTC_DATETIME_FORMAT, $link->to->getTimestamp());
65+
} elseif ($link->hasDistinctTimezones()) {
66+
// Each endpoint is written as a local time in the zone that names it.
67+
$url .= '&dates='.$link->from->format(self::DATETIME_FORMAT).'/'.$link->to->setTimezone($link->toTimezone)->format(self::DATETIME_FORMAT);
4668

47-
// Not URL-encoded intentionally: Google Calendar handles unencoded timezone names (e.g. Etc/GMT+5) correctly.
48-
if ($link->hasDistinctTimezones()) {
4969
// stz takes priority over ctz, so ctz is not emitted alongside the pair.
5070
$url .= '&stz='.$link->fromTimezone->getName();
5171
$url .= '&etz='.$link->toTimezone->getName();
5272
} else {
53-
$url .= '&ctz='.$link->from->getTimezone()->getName();
73+
$url .= '&dates='.$link->from->format(self::DATETIME_FORMAT).'/'.$link->to->format(self::DATETIME_FORMAT);
74+
$url .= '&ctz='.$link->fromTimezone->getName();
5475
}
76+
5577
$url .= '&text='.urlencode($link->title);
5678

5779
if ($link->description !== '') {

src/Generators/Ics.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,13 @@ public function generate(Link $link): string
222222
if ($link->allDay) {
223223
$url[] = 'DTSTART;VALUE=DATE:'.$link->from->format($this->dateFormat);
224224
$url[] = 'DURATION:P'.(max(1, (int) $link->from->diff($link->to)->days)).'D';
225-
} elseif ($link->hasDistinctTimezones()) {
226-
// Both endpoints are written as local times, each named by its own TZID, so the file
227-
// shows the event's own zones rather than flattening them to UTC.
225+
} elseif ($link->hasDistinctTimezones() && $link->hasResolvableTimezones()) {
226+
// Both endpoints are written as local times, each named by its own TZID, so the file shows
227+
// the event's own zones rather than flattening them to UTC. A TZID may only name a zone the
228+
// client can look up, and a param-value carries no unquoted `:`, so a zone that is only an
229+
// offset (`+02:00`) would fail to resolve and cut the property value in half. Those events
230+
// take the UTC branch below instead.
231+
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1
228232
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.19
229233
$url[] = 'DTSTART;TZID='.$link->fromTimezone->getName().':'.$link->from->format(self::LOCAL_DATETIME_FORMAT);
230234
$url[] = 'DTEND;TZID='.$link->toTimezone->getName().':'.$link->to->setTimezone($link->toTimezone)->format(self::LOCAL_DATETIME_FORMAT);

src/Link.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ class Link
4343
/** @psalm-var list<LinkGuest> */
4444
public array $guests = [];
4545

46+
/**
47+
* The TZDB entries that stand for no place, so no calendar can hold an event in one. They fall
48+
* into three groups: the POSIX rule sets kept for compatibility with older systems (`EST`,
49+
* `MST7MDT`), the spellings of UTC under its other names (`GMT`, `Greenwich`, `Zulu`, and the
50+
* whole `Etc/` tree, whose `Etc/GMT±N` members run their sign the opposite way from the offset
51+
* they name), and `Factory`, the placeholder that ships to make an unconfigured system complain
52+
* rather than quietly guess.
53+
*
54+
* `UTC` is deliberately absent: it is the one placeless name every service resolves, and it is
55+
* what the generators fall back to in any case.
56+
*
57+
* @see https://data.iana.org/time-zones/tzdb/etcetera
58+
* @see https://data.iana.org/time-zones/tzdb/factory
59+
*/
60+
private const array PLACELESS_TIMEZONE_NAMES = [
61+
'CET', 'CST6CDT', 'EET', 'EST', 'EST5EDT', 'Factory', 'GMT', 'GMT+0', 'GMT-0', 'GMT0',
62+
'Greenwich', 'HST', 'MET', 'MST', 'MST7MDT', 'PST8PDT', 'UCT', 'Universal', 'WET', 'Zulu',
63+
];
64+
65+
/**
66+
* Every TZDB name, keyed for lookup. The list is fixed for the lifetime of the process, so it is
67+
* built once instead of on every generated link.
68+
* @psalm-var array<string, true>|null
69+
*/
70+
private static ?array $timezoneIdentifiers = null;
71+
4672
final public function __construct(string $title, \DateTimeInterface $from, \DateTimeInterface $to, bool $allDay = false)
4773
{
4874
$this->title = $title;
@@ -267,6 +293,56 @@ private static function namesAPlace(\DateTimeZone $timezone): bool
267293
return ! in_array($name, self::UTC_ZONE_NAMES, true) && ! str_starts_with($name, 'etc/');
268294
}
269295

296+
/**
297+
* Whether a generator can name the zones this event carries instead of writing it in UTC. Only
298+
* the zones that actually reach the output are judged, which is a different set on each path.
299+
*
300+
* On the distinct path both ends are named, so both have to resolve: naming one and not the
301+
* other would leave the pair inconsistent, with half the event pinned to a zone and half of it
302+
* loose. When the two zones collapse into one, the end zone is a label the generators discard
303+
* anyway, so only the start zone is asked about. That is what keeps `UTC` to `Etc/UTC` coming
304+
* out as `ctz=UTC` rather than losing its name to a spelling nothing was going to emit.
305+
*
306+
* Parsing an ISO 8601 string with an offset (`2026-01-01T10:00:00+02:00`) is the common way to
307+
* end up with a zone that cannot be named, since the resulting zone is named `+02:00`.
308+
*/
309+
public function hasResolvableTimezones(): bool
310+
{
311+
return self::isResolvableTimezone($this->fromTimezone)
312+
&& (! $this->hasDistinctTimezones() || self::isResolvableTimezone($this->toTimezone));
313+
}
314+
315+
/**
316+
* A calendar service can only hold an event in a zone that stands for a place. Every name the
317+
* TZDB ships for one qualifies, the backward names (`US/Pacific`, `Japan`, `GB`) included: they
318+
* are aliases of a region and resolve exactly like the region they point at. `UTC` is accepted
319+
* alongside them as the one placeless name every service understands.
320+
*
321+
* Two kinds of name are turned down. The placeless TZDB entries above are one. The other is
322+
* anything DateTimeZone accepts that the TZDB does not ship at all, which is to say an offset
323+
* (`+02:00`) or an abbreviation (`CEST`): neither names a place or carries daylight saving
324+
* rules, so a service has nothing to look up.
325+
*/
326+
private static function isResolvableTimezone(\DateTimeZone $timezone): bool
327+
{
328+
$name = $timezone->getName();
329+
330+
if ($name === 'UTC') {
331+
return true;
332+
}
333+
334+
if (str_starts_with($name, 'Etc/') || in_array($name, self::PLACELESS_TIMEZONE_NAMES, true)) {
335+
return false;
336+
}
337+
338+
// Whatever is left still has to be a name the TZDB ships, which rules out the offsets and the
339+
// abbreviations. The deprecated list is included so that a backward name is judged on the
340+
// region it points at rather than on being deprecated.
341+
self::$timezoneIdentifiers ??= array_fill_keys(\DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC), true);
342+
343+
return isset(self::$timezoneIdentifiers[$name]);
344+
}
345+
270346
public function formatWith(Generator $generator): string
271347
{
272348
return $generator->generate($this);

tests/Generators/GoogleGeneratorTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
namespace Spatie\CalendarLinks\Tests\Generators;
66

7+
use DateTime;
8+
use DateTimeZone;
79
use PHPUnit\Framework\Attributes\Test;
10+
use PHPUnit\Framework\Attributes\TestWith;
811
use Spatie\CalendarLinks\Generator;
912
use Spatie\CalendarLinks\Generators\Google;
13+
use Spatie\CalendarLinks\Link;
1014
use Spatie\CalendarLinks\Tests\TestCase;
1115

1216
final class GoogleGeneratorTest extends TestCase
@@ -37,6 +41,62 @@ public function it_does_not_emit_ctz_alongside_stz_and_etz(): void
3741
$this->assertStringNotContainsString('ctz=', $url);
3842
}
3943

44+
/** @param non-empty-string $timezone */
45+
#[Test]
46+
#[TestWith(['+02:00', '20260101T080000Z/20260101T090000Z'])]
47+
#[TestWith(['-05:00', '20260101T150000Z/20260101T160000Z'])]
48+
#[TestWith(['Etc/GMT+5', '20260101T150000Z/20260101T160000Z'])]
49+
#[TestWith(['CEST', '20260101T080000Z/20260101T090000Z'])]
50+
#[TestWith(['EST', '20260101T150000Z/20260101T160000Z'])]
51+
#[TestWith(['Factory', '20260101T100000Z/20260101T110000Z'])]
52+
public function it_falls_back_to_utc_for_a_timezone_google_cannot_resolve(string $timezone, string $expectedDates): void
53+
{
54+
// Google ignores a ctz it cannot resolve, so local times would be read in the viewer's zone.
55+
$url = $this->generator()->generate($this->createEventLinkInTimezone($timezone));
56+
57+
$this->assertStringContainsString('&dates='.$expectedDates, $url);
58+
$this->assertStringNotContainsString('ctz=', $url);
59+
}
60+
61+
/** @param non-empty-string $timezone */
62+
#[Test]
63+
#[TestWith(['Japan'])]
64+
#[TestWith(['GB'])]
65+
#[TestWith(['US/Pacific'])]
66+
public function it_names_a_backward_timezone_the_same_as_any_other(string $timezone): void
67+
{
68+
// A backward name is an alias of a region, so Google resolves it and the times stay local.
69+
$url = $this->generator()->generate($this->createEventLinkInTimezone($timezone));
70+
71+
$this->assertStringContainsString('&dates=20260101T100000/20260101T110000', $url);
72+
$this->assertStringContainsString('&ctz='.$timezone, $url);
73+
}
74+
75+
#[Test]
76+
public function it_falls_back_to_utc_when_only_one_end_of_a_flight_names_a_zone(): void
77+
{
78+
// Naming just the departure would leave the arrival to be read in the viewer's zone, so the
79+
// pair goes to UTC together.
80+
$url = $this->generator()->generate($this->createFlightWithUnresolvableEndTimezoneLink());
81+
82+
$this->assertStringContainsString('&dates=20270315T000000Z/20270315T163000Z', $url);
83+
$this->assertStringNotContainsString('stz=', $url);
84+
$this->assertStringNotContainsString('etz=', $url);
85+
$this->assertStringNotContainsString('ctz=', $url);
86+
}
87+
88+
#[Test]
89+
public function it_keeps_the_calendar_dates_of_an_all_day_event_in_an_unresolvable_timezone(): void
90+
{
91+
// An all-day event has no clock time to convert, so only the zone naming is dropped.
92+
$link = Link::createAllDay('Holiday', new DateTime('2026-01-01 00:00', new DateTimeZone('+02:00')));
93+
94+
$url = $this->generator()->generate($link);
95+
96+
$this->assertStringContainsString('&dates=20260101/20260102', $url);
97+
$this->assertStringNotContainsString('ctz=', $url);
98+
}
99+
40100
#[Test]
41101
public function it_emits_a_single_timezone_for_two_spellings_of_utc(): void
42102
{

tests/Generators/IcsGeneratorTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use DateTimeZone;
99
use PHPUnit\Framework\Attributes\DataProvider;
1010
use PHPUnit\Framework\Attributes\Test;
11+
use PHPUnit\Framework\Attributes\TestWith;
1112
use Spatie\CalendarLinks\Exceptions\InvalidLink;
1213
use Spatie\CalendarLinks\Generator;
1314
use Spatie\CalendarLinks\Generators\Ics;
@@ -114,6 +115,33 @@ public function it_keeps_dtstamp_in_utc_next_to_zoned_endpoints(): void
114115
$this->assertStringContainsString('DTEND;TZID=America/Los_Angeles:20270315T093000', $output);
115116
}
116117

118+
/** @param non-empty-string $timezone */
119+
#[Test]
120+
#[TestWith(['+02:00', '20260101T080000Z', '20260101T090000Z'])]
121+
#[TestWith(['-05:00', '20260101T150000Z', '20260101T160000Z'])]
122+
#[TestWith(['Etc/GMT+5', '20260101T150000Z', '20260101T160000Z'])]
123+
#[TestWith(['CEST', '20260101T080000Z', '20260101T090000Z'])]
124+
public function it_writes_utc_endpoints_for_a_timezone_that_cannot_be_named(string $timezone, string $start, string $end): void
125+
{
126+
$output = $this->generator()->generate($this->createEventLinkInTimezone($timezone));
127+
128+
$this->assertStringContainsString('DTSTART:'.$start, $output);
129+
$this->assertStringContainsString('DTEND:'.$end, $output);
130+
$this->assertStringNotContainsString('TZID', $output);
131+
}
132+
133+
#[Test]
134+
public function it_falls_back_to_utc_when_only_one_end_of_a_flight_names_a_zone(): void
135+
{
136+
// An unquoted `:` inside a param-value would cut `DTSTART;TZID=+02:00:20270315T090000` in
137+
// half, and naming only the departure would leave the pair inconsistent anyway.
138+
$output = $this->generator()->generate($this->createFlightWithUnresolvableEndTimezoneLink());
139+
140+
$this->assertStringContainsString('DTSTART:20270315T000000Z', $output);
141+
$this->assertStringContainsString('DTEND:20270315T163000Z', $output);
142+
$this->assertStringNotContainsString('TZID', $output);
143+
}
144+
117145
#[Test]
118146
public function it_stamps_a_timed_event_with_its_start_by_default(): void
119147
{

tests/LinkTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use DateTime;
88
use DateTimeZone;
99
use PHPUnit\Framework\Attributes\Test;
10+
use PHPUnit\Framework\Attributes\TestWith;
1011
use Spatie\CalendarLinks\Exceptions\InvalidLink;
1112
use Spatie\CalendarLinks\Generators\Ics;
1213
use Spatie\CalendarLinks\Link;
@@ -99,6 +100,59 @@ public function it_still_normalises_the_end_date_into_the_start_timezone(): void
99100
$this->assertSame('2027-03-15T09:30:00-07:00', $flight->to->setTimezone($flight->toTimezone)->format('c'));
100101
}
101102

103+
/** @param non-empty-string $timezone */
104+
#[Test]
105+
#[TestWith(['UTC'])]
106+
#[TestWith(['Asia/Tokyo'])]
107+
#[TestWith(['Europe/Brussels'])]
108+
// A backward name is an alias of a region, whether or not it is spelled with a slash.
109+
#[TestWith(['US/Pacific'])]
110+
#[TestWith(['Japan'])]
111+
#[TestWith(['GB'])]
112+
public function it_reports_a_name_that_stands_for_a_place_as_resolvable(string $timezone): void
113+
{
114+
$this->assertTrue($this->createEventLinkInTimezone($timezone)->hasResolvableTimezones());
115+
}
116+
117+
/** @param non-empty-string $timezone */
118+
#[Test]
119+
#[TestWith(['+02:00'])]
120+
#[TestWith(['-05:00'])]
121+
#[TestWith(['CEST'])]
122+
// The TZDB entries that stand for no place: a POSIX rule set, a spelling of UTC, the placeholder.
123+
#[TestWith(['EST'])]
124+
#[TestWith(['MST7MDT'])]
125+
#[TestWith(['GMT'])]
126+
#[TestWith(['Universal'])]
127+
#[TestWith(['Etc/GMT+5'])]
128+
#[TestWith(['Etc/UTC'])]
129+
#[TestWith(['Factory'])]
130+
public function it_does_not_report_a_placeless_name_as_resolvable(string $timezone): void
131+
{
132+
$this->assertFalse($this->createEventLinkInTimezone($timezone)->hasResolvableTimezones());
133+
}
134+
135+
#[Test]
136+
public function it_still_names_the_start_zone_when_the_other_end_is_a_refused_spelling(): void
137+
{
138+
// `UTC` to `Etc/UTC` collapses to a single zone, so the end zone is a label the generators
139+
// discard. Refusing that spelling must not drag the event into the UTC fallback and cost the
140+
// start zone the name it was going to be written under.
141+
$link = $this->createEventAcrossUtcAliasesLink();
142+
143+
$this->assertFalse($link->hasDistinctTimezones());
144+
$this->assertTrue($link->hasResolvableTimezones());
145+
$this->assertStringContainsString('&ctz=UTC', $link->google());
146+
}
147+
148+
#[Test]
149+
public function it_does_not_report_resolvable_timezones_when_only_one_end_names_a_place(): void
150+
{
151+
// Naming one end and not the other would leave the pair inconsistent, so both go to UTC.
152+
$this->assertFalse($this->createFlightWithUnresolvableEndTimezoneLink()->hasResolvableTimezones());
153+
$this->assertTrue($this->createFlightWithDistinctTimezonesLink()->hasResolvableTimezones());
154+
}
155+
102156
#[Test]
103157
public function it_does_not_report_distinct_timezones_for_a_single_zone_event(): void
104158
{

0 commit comments

Comments
 (0)