Skip to content
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20270315T10000

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.

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.

If you follow that link (and are authenticated with Google), you’ll see a screen to add the event to your calendar.

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.
Expand Down Expand Up @@ -137,6 +139,8 @@ DTEND;TZID=America/Los_Angeles:20270315T093000

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.

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.

`$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`.

### Guests
Expand Down
38 changes: 30 additions & 8 deletions src/Generators/Google.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class Google implements Generator
/** @see https://www.php.net/manual/en/function.date.php */
private const string DATETIME_FORMAT = 'Ymd\THis';

/**
* An instant, with the Z suffix that marks a UTC value. Used when no zone can be named alongside.
* @see https://www.php.net/manual/en/function.date.php
*/
private const string UTC_DATETIME_FORMAT = 'Ymd\THis\Z';

/** @psalm-var GoogleUrlParameters */
protected array $urlParameters = [];

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

$dateTimeFormat = $link->allDay ? self::DATE_FORMAT : self::DATETIME_FORMAT;

// Each endpoint is written as a local time in the zone that names it below.
$to = $link->hasDistinctTimezones() ? $link->to->setTimezone($link->toTimezone) : $link->to;
$url .= '&dates='.$link->from->format($dateTimeFormat).'/'.$to->format($dateTimeFormat);
// The branches below write the two endpoints and, where they can, name the zone those times
// belong to. A zone name goes in unencoded: every name that gets this far is a TZDB name,
// spelled with unreserved characters and at most a `/`, and RFC 3986 lets that one stand as
// itself in a query.
// @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
if ($link->allDay) {
// An all-day event is a pair of calendar dates rather than instants, so there is no clock
// time to move between zones and the dates are written as they were given either way.
$url .= '&dates='.$link->from->format(self::DATE_FORMAT).'/'.$link->to->format(self::DATE_FORMAT);

if ($link->hasResolvableTimezones()) {
$url .= '&ctz='.$link->fromTimezone->getName();
}
} elseif (! $link->hasResolvableTimezones()) {
// Google silently ignores a ctz, stz or etz it cannot resolve, which leaves the local
// times in `dates` to be read in whichever zone the viewer sits in, so the event lands at
// the wrong instant for everyone else. UTC instants leave nothing to be interpreted.
$url .= '&dates='.gmdate(self::UTC_DATETIME_FORMAT, $link->from->getTimestamp()).'/'.gmdate(self::UTC_DATETIME_FORMAT, $link->to->getTimestamp());
} elseif ($link->hasDistinctTimezones()) {
// Each endpoint is written as a local time in the zone that names it.
$url .= '&dates='.$link->from->format(self::DATETIME_FORMAT).'/'.$link->to->setTimezone($link->toTimezone)->format(self::DATETIME_FORMAT);

// Not URL-encoded intentionally: Google Calendar handles unencoded timezone names (e.g. Etc/GMT+5) correctly.
if ($link->hasDistinctTimezones()) {
// stz takes priority over ctz, so ctz is not emitted alongside the pair.
$url .= '&stz='.$link->fromTimezone->getName();
$url .= '&etz='.$link->toTimezone->getName();
} else {
$url .= '&ctz='.$link->from->getTimezone()->getName();
$url .= '&dates='.$link->from->format(self::DATETIME_FORMAT).'/'.$link->to->format(self::DATETIME_FORMAT);
$url .= '&ctz='.$link->fromTimezone->getName();
}

$url .= '&text='.urlencode($link->title);

if ($link->description !== '') {
Expand Down
10 changes: 7 additions & 3 deletions src/Generators/Ics.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,13 @@ public function generate(Link $link): string
if ($link->allDay) {
$url[] = 'DTSTART;VALUE=DATE:'.$link->from->format($this->dateFormat);
$url[] = 'DURATION:P'.(max(1, (int) $link->from->diff($link->to)->days)).'D';
} elseif ($link->hasDistinctTimezones()) {
// Both endpoints are written as local times, each named by its own TZID, so the file
// shows the event's own zones rather than flattening them to UTC.
} elseif ($link->hasDistinctTimezones() && $link->hasResolvableTimezones()) {
// Both endpoints are written as local times, each named by its own TZID, so the file shows
// the event's own zones rather than flattening them to UTC. A TZID may only name a zone the
// client can look up, and a param-value carries no unquoted `:`, so a zone that is only an
// offset (`+02:00`) would fail to resolve and cut the property value in half. Those events
// take the UTC branch below instead.
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.19
$url[] = 'DTSTART;TZID='.$link->fromTimezone->getName().':'.$link->from->format(self::LOCAL_DATETIME_FORMAT);
$url[] = 'DTEND;TZID='.$link->toTimezone->getName().':'.$link->to->setTimezone($link->toTimezone)->format(self::LOCAL_DATETIME_FORMAT);
Expand Down
76 changes: 76 additions & 0 deletions src/Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ class Link
/** @psalm-var list<LinkGuest> */
public array $guests = [];

/**
* The TZDB entries that stand for no place, so no calendar can hold an event in one. They fall
* into three groups: the POSIX rule sets kept for compatibility with older systems (`EST`,
* `MST7MDT`), the spellings of UTC under its other names (`GMT`, `Greenwich`, `Zulu`, and the
* whole `Etc/` tree, whose `Etc/GMT±N` members run their sign the opposite way from the offset
* they name), and `Factory`, the placeholder that ships to make an unconfigured system complain
* rather than quietly guess.
*
* `UTC` is deliberately absent: it is the one placeless name every service resolves, and it is
* what the generators fall back to in any case.
*
* @see https://data.iana.org/time-zones/tzdb/etcetera
* @see https://data.iana.org/time-zones/tzdb/factory
*/
private const array PLACELESS_TIMEZONE_NAMES = [
'CET', 'CST6CDT', 'EET', 'EST', 'EST5EDT', 'Factory', 'GMT', 'GMT+0', 'GMT-0', 'GMT0',
'Greenwich', 'HST', 'MET', 'MST', 'MST7MDT', 'PST8PDT', 'UCT', 'Universal', 'WET', 'Zulu',
];

/**
* Every TZDB name, keyed for lookup. The list is fixed for the lifetime of the process, so it is
* built once instead of on every generated link.
* @psalm-var array<string, true>|null
*/
private static ?array $timezoneIdentifiers = null;

final public function __construct(string $title, \DateTimeInterface $from, \DateTimeInterface $to, bool $allDay = false)
{
$this->title = $title;
Expand Down Expand Up @@ -267,6 +293,56 @@ private static function namesAPlace(\DateTimeZone $timezone): bool
return ! in_array($name, self::UTC_ZONE_NAMES, true) && ! str_starts_with($name, 'etc/');
}

/**
* Whether a generator can name the zones this event carries instead of writing it in UTC. Only
* the zones that actually reach the output are judged, which is a different set on each path.
*
* On the distinct path both ends are named, so both have to resolve: naming one and not the
* other would leave the pair inconsistent, with half the event pinned to a zone and half of it
* loose. When the two zones collapse into one, the end zone is a label the generators discard
* anyway, so only the start zone is asked about. That is what keeps `UTC` to `Etc/UTC` coming
* out as `ctz=UTC` rather than losing its name to a spelling nothing was going to emit.
*
* Parsing an ISO 8601 string with an offset (`2026-01-01T10:00:00+02:00`) is the common way to
* end up with a zone that cannot be named, since the resulting zone is named `+02:00`.
*/
public function hasResolvableTimezones(): bool
{
return self::isResolvableTimezone($this->fromTimezone)
&& (! $this->hasDistinctTimezones() || self::isResolvableTimezone($this->toTimezone));
}

/**
* A calendar service can only hold an event in a zone that stands for a place. Every name the
* TZDB ships for one qualifies, the backward names (`US/Pacific`, `Japan`, `GB`) included: they
* are aliases of a region and resolve exactly like the region they point at. `UTC` is accepted
* alongside them as the one placeless name every service understands.
*
* Two kinds of name are turned down. The placeless TZDB entries above are one. The other is
* anything DateTimeZone accepts that the TZDB does not ship at all, which is to say an offset
* (`+02:00`) or an abbreviation (`CEST`): neither names a place or carries daylight saving
* rules, so a service has nothing to look up.
*/
private static function isResolvableTimezone(\DateTimeZone $timezone): bool
{
$name = $timezone->getName();

if ($name === 'UTC') {
return true;
}

if (str_starts_with($name, 'Etc/') || in_array($name, self::PLACELESS_TIMEZONE_NAMES, true)) {
return false;
}

// Whatever is left still has to be a name the TZDB ships, which rules out the offsets and the
// abbreviations. The deprecated list is included so that a backward name is judged on the
// region it points at rather than on being deprecated.
self::$timezoneIdentifiers ??= array_fill_keys(\DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC), true);

return isset(self::$timezoneIdentifiers[$name]);
}

public function formatWith(Generator $generator): string
{
return $generator->generate($this);
Expand Down
60 changes: 60 additions & 0 deletions tests/Generators/GoogleGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@

namespace Spatie\CalendarLinks\Tests\Generators;

use DateTime;
use DateTimeZone;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Spatie\CalendarLinks\Generator;
use Spatie\CalendarLinks\Generators\Google;
use Spatie\CalendarLinks\Link;
use Spatie\CalendarLinks\Tests\TestCase;

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

/** @param non-empty-string $timezone */
#[Test]
#[TestWith(['+02:00', '20260101T080000Z/20260101T090000Z'])]
#[TestWith(['-05:00', '20260101T150000Z/20260101T160000Z'])]
#[TestWith(['Etc/GMT+5', '20260101T150000Z/20260101T160000Z'])]
#[TestWith(['CEST', '20260101T080000Z/20260101T090000Z'])]
#[TestWith(['EST', '20260101T150000Z/20260101T160000Z'])]
#[TestWith(['Factory', '20260101T100000Z/20260101T110000Z'])]
public function it_falls_back_to_utc_for_a_timezone_google_cannot_resolve(string $timezone, string $expectedDates): void
{
// Google ignores a ctz it cannot resolve, so local times would be read in the viewer's zone.
$url = $this->generator()->generate($this->createEventLinkInTimezone($timezone));

$this->assertStringContainsString('&dates='.$expectedDates, $url);
$this->assertStringNotContainsString('ctz=', $url);
}

/** @param non-empty-string $timezone */
#[Test]
#[TestWith(['Japan'])]
#[TestWith(['GB'])]
#[TestWith(['US/Pacific'])]
public function it_names_a_backward_timezone_the_same_as_any_other(string $timezone): void
{
// A backward name is an alias of a region, so Google resolves it and the times stay local.
$url = $this->generator()->generate($this->createEventLinkInTimezone($timezone));

$this->assertStringContainsString('&dates=20260101T100000/20260101T110000', $url);
$this->assertStringContainsString('&ctz='.$timezone, $url);
}

#[Test]
public function it_falls_back_to_utc_when_only_one_end_of_a_flight_names_a_zone(): void
{
// Naming just the departure would leave the arrival to be read in the viewer's zone, so the
// pair goes to UTC together.
$url = $this->generator()->generate($this->createFlightWithUnresolvableEndTimezoneLink());

$this->assertStringContainsString('&dates=20270315T000000Z/20270315T163000Z', $url);
$this->assertStringNotContainsString('stz=', $url);
$this->assertStringNotContainsString('etz=', $url);
$this->assertStringNotContainsString('ctz=', $url);
}

#[Test]
public function it_keeps_the_calendar_dates_of_an_all_day_event_in_an_unresolvable_timezone(): void
{
// An all-day event has no clock time to convert, so only the zone naming is dropped.
$link = Link::createAllDay('Holiday', new DateTime('2026-01-01 00:00', new DateTimeZone('+02:00')));

$url = $this->generator()->generate($link);

$this->assertStringContainsString('&dates=20260101/20260102', $url);
$this->assertStringNotContainsString('ctz=', $url);
}

#[Test]
public function it_emits_a_single_timezone_for_two_spellings_of_utc(): void
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Generators/IcsGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTimeZone;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Spatie\CalendarLinks\Exceptions\InvalidLink;
use Spatie\CalendarLinks\Generator;
use Spatie\CalendarLinks\Generators\Ics;
Expand Down Expand Up @@ -114,6 +115,33 @@ public function it_keeps_dtstamp_in_utc_next_to_zoned_endpoints(): void
$this->assertStringContainsString('DTEND;TZID=America/Los_Angeles:20270315T093000', $output);
}

/** @param non-empty-string $timezone */
#[Test]
#[TestWith(['+02:00', '20260101T080000Z', '20260101T090000Z'])]
#[TestWith(['-05:00', '20260101T150000Z', '20260101T160000Z'])]
#[TestWith(['Etc/GMT+5', '20260101T150000Z', '20260101T160000Z'])]
#[TestWith(['CEST', '20260101T080000Z', '20260101T090000Z'])]
public function it_writes_utc_endpoints_for_a_timezone_that_cannot_be_named(string $timezone, string $start, string $end): void
{
$output = $this->generator()->generate($this->createEventLinkInTimezone($timezone));

$this->assertStringContainsString('DTSTART:'.$start, $output);
$this->assertStringContainsString('DTEND:'.$end, $output);
$this->assertStringNotContainsString('TZID', $output);
}

#[Test]
public function it_falls_back_to_utc_when_only_one_end_of_a_flight_names_a_zone(): void
{
// An unquoted `:` inside a param-value would cut `DTSTART;TZID=+02:00:20270315T090000` in
// half, and naming only the departure would leave the pair inconsistent anyway.
$output = $this->generator()->generate($this->createFlightWithUnresolvableEndTimezoneLink());

$this->assertStringContainsString('DTSTART:20270315T000000Z', $output);
$this->assertStringContainsString('DTEND:20270315T163000Z', $output);
$this->assertStringNotContainsString('TZID', $output);
}

#[Test]
public function it_stamps_a_timed_event_with_its_start_by_default(): void
{
Expand Down
54 changes: 54 additions & 0 deletions tests/LinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DateTime;
use DateTimeZone;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Spatie\CalendarLinks\Exceptions\InvalidLink;
use Spatie\CalendarLinks\Generators\Ics;
use Spatie\CalendarLinks\Link;
Expand Down Expand Up @@ -99,6 +100,59 @@ public function it_still_normalises_the_end_date_into_the_start_timezone(): void
$this->assertSame('2027-03-15T09:30:00-07:00', $flight->to->setTimezone($flight->toTimezone)->format('c'));
}

/** @param non-empty-string $timezone */
#[Test]
#[TestWith(['UTC'])]
#[TestWith(['Asia/Tokyo'])]
#[TestWith(['Europe/Brussels'])]
// A backward name is an alias of a region, whether or not it is spelled with a slash.
#[TestWith(['US/Pacific'])]
#[TestWith(['Japan'])]
#[TestWith(['GB'])]
public function it_reports_a_name_that_stands_for_a_place_as_resolvable(string $timezone): void
{
$this->assertTrue($this->createEventLinkInTimezone($timezone)->hasResolvableTimezones());
}

/** @param non-empty-string $timezone */
#[Test]
#[TestWith(['+02:00'])]
#[TestWith(['-05:00'])]
#[TestWith(['CEST'])]
// The TZDB entries that stand for no place: a POSIX rule set, a spelling of UTC, the placeholder.
#[TestWith(['EST'])]
#[TestWith(['MST7MDT'])]
#[TestWith(['GMT'])]
#[TestWith(['Universal'])]
#[TestWith(['Etc/GMT+5'])]
#[TestWith(['Etc/UTC'])]
#[TestWith(['Factory'])]
public function it_does_not_report_a_placeless_name_as_resolvable(string $timezone): void
{
$this->assertFalse($this->createEventLinkInTimezone($timezone)->hasResolvableTimezones());
}

#[Test]
public function it_still_names_the_start_zone_when_the_other_end_is_a_refused_spelling(): void
{
// `UTC` to `Etc/UTC` collapses to a single zone, so the end zone is a label the generators
// discard. Refusing that spelling must not drag the event into the UTC fallback and cost the
// start zone the name it was going to be written under.
$link = $this->createEventAcrossUtcAliasesLink();

$this->assertFalse($link->hasDistinctTimezones());
$this->assertTrue($link->hasResolvableTimezones());
$this->assertStringContainsString('&ctz=UTC', $link->google());
}

#[Test]
public function it_does_not_report_resolvable_timezones_when_only_one_end_names_a_place(): void
{
// Naming one end and not the other would leave the pair inconsistent, so both go to UTC.
$this->assertFalse($this->createFlightWithUnresolvableEndTimezoneLink()->hasResolvableTimezones());
$this->assertTrue($this->createFlightWithDistinctTimezonesLink()->hasResolvableTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_for_a_single_zone_event(): void
{
Expand Down
Loading