Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions src/Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ final public function __construct(string $title, \DateTimeInterface $from, \Date

$immutableTo = \DateTimeImmutable::createFromInterface($to);

// Ensures timezones match.
// Ensures timezones match. This asks a narrower question than hasDistinctTimezones(): not
// whether the two zones are worth naming separately, but whether $to needs moving at all.
// Equal names mean there is nothing to move, and for every other pair the conversion is
// either meaningful or a no-op, so the cheap comparison is the right one here. It leaves
// $to always carried in $fromTimezone, which the generators rely on when they render both
// endpoints under a single zone.
if ($this->fromTimezone->getName() !== $this->toTimezone->getName()) {
$immutableTo = $allDay
? self::reinterpretIn($immutableTo, $this->fromTimezone)
Expand Down Expand Up @@ -199,10 +204,67 @@ private static function validateGuestEmail(string $email): string
/**
* Whether the event genuinely starts and ends in different timezones, so a generator that can
* express both should. An all-day event has no clock time to place in a zone, so it never does.
*
* Two spellings of one zone do not count. `UTC`, `Etc/UTC` and the bare `Z` that PHP leaves on a
* Z suffixed ISO string all describe the same zone, and emitting them as a pair hands the
* services identifiers they reject for an event that never crossed a zone at all.
*
* Equal offsets alone are not the test though. Europe/London and Europe/Lisbon share an offset
* the whole year, as do Europe/Berlin and Africa/Lagos in winter, yet each pair is two places a
* flight may legitimately want to name. So the zones collapse into one only when their offsets
* agree and at least one of them names no place: a bare offset, an abbreviation, and every
* member of the UTC family are all just an offset wearing a label, with no location to lose.
*/
public function hasDistinctTimezones(): bool
{
return ! $this->allDay && $this->fromTimezone->getName() !== $this->toTimezone->getName();
if ($this->allDay || $this->fromTimezone->getName() === $this->toTimezone->getName()) {
return false;
}

// Each zone is read at the instant that belongs to it, so an event running across a DST
// change still sees the two offsets it actually spans.
if ($this->fromTimezone->getOffset($this->from) !== $this->toTimezone->getOffset($this->to)) {
return true;
}

return self::namesAPlace($this->fromTimezone) && self::namesAPlace($this->toTimezone);
}

/**
* Spellings of UTC that the timezone database lists as identifiers in their own right. The
* `Etc/` tree is matched by prefix instead, since every zone in it is a fixed offset. PHP hands
* back whatever case the caller wrote, so these are compared lowercased.
*/
private const array UTC_ZONE_NAMES = ['utc', 'zulu', 'universal', 'greenwich', 'gmt0'];

/**
* Whether a zone stands for a place rather than a plain offset.
*
* `getLocation()` returns false for the zones the database holds no location row for at all: a
* bare offset (`+01:00`), the abbreviation PHP leaves on a Z suffixed ISO string, and the
* standalone abbreviations (`GMT`, `UCT`, `CET`, `EST`, `MST`, `HST`, `MET`, `WET`, `EET`).
*
* The country code deliberately plays no part in the decision. Every IANA backward alias reports
* the same `??` placeholder as the UTC family does, so reading `??` as "no place" would quietly
* classify `Japan`, `Singapore`, `US/Eastern`, `GB` and `Asia/Calcutta` as offsets, and a Tokyo
* to Seoul flight booked with `new DateTimeZone('Japan')` would lose its destination. The rest of
* the offset family is therefore named outright rather than inferred.
*
* `EST5EDT`, `CST6CDT`, `MST7MDT`, `PST8PDT` and `Factory` are left out of that set on purpose.
* They name no place either, but they carry daylight saving rules rather than one fixed offset,
* so they are more than a label on an offset. Counting them as places costs at most a second
* TZID that every service still resolves, where counting them as placeless would drop the real
* zone they are paired with, which is the failure this guards against.
*/
private static function namesAPlace(\DateTimeZone $timezone): bool
{
if ($timezone->getLocation() === false) {
return false;
}

$name = strtolower($timezone->getName());

return ! in_array($name, self::UTC_ZONE_NAMES, true) && ! str_starts_with($name, 'etc/');
}

public function formatWith(Generator $generator): string
Expand Down
29 changes: 29 additions & 0 deletions tests/Generators/GoogleGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ public function it_does_not_emit_ctz_alongside_stz_and_etz(): void
$this->assertStringNotContainsString('ctz=', $url);
}

#[Test]
public function it_emits_a_single_timezone_for_two_spellings_of_utc(): void
{
$url = $this->generator()->generate($this->createEventAcrossUtcAliasesLink());

$this->assertStringContainsString('&ctz=UTC', $url);
$this->assertStringNotContainsString('stz=', $url);
$this->assertStringNotContainsString('etz=', $url);
}

#[Test]
public function it_emits_a_single_timezone_for_a_z_suffixed_start(): void
{
$url = $this->generator()->generate($this->createEventWithZSuffixedStartLink());

// `Z` is still not an identifier Google accepts, but the event is at least no longer
// described as crossing a zone. Naming it properly is the subject of #234.
$this->assertStringNotContainsString('stz=', $url);
$this->assertStringNotContainsString('etz=', $url);
}

#[Test]
public function it_still_names_both_zones_when_two_places_share_an_offset(): void
{
$url = $this->generator()->generate($this->createFlightBetweenPlacesSharingAnOffsetLink());

$this->assertStringContainsString('&stz=Europe/London&etz=Europe/Lisbon', $url);
}

#[Test]
public function it_can_generate_an_url_with_custom_parameters(): void
{
Expand Down
39 changes: 39 additions & 0 deletions tests/Generators/IcsGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,45 @@ public function it_rejects_a_dtstamp_that_is_not_a_date_time_through_the_link():
$link->ics(['DTSTAMP' => '2026-01-01'], ['format' => Ics::FORMAT_FILE]);
}

#[Test]
public function it_emits_plain_utc_endpoints_for_two_spellings_of_utc(): void
{
$output = $this->generator()->generate($this->createEventAcrossUtcAliasesLink());

$this->assertStringContainsString('DTSTART:20260101T100000Z', $output);
$this->assertStringContainsString('DTEND:20260101T110000Z', $output);
$this->assertStringNotContainsString('TZID=', $output);
}

#[Test]
public function it_emits_plain_utc_endpoints_for_a_z_suffixed_start(): void
{
// `Z` is not a TZID any calendar can resolve, so it must never reach the output.
$output = $this->generator()->generate($this->createEventWithZSuffixedStartLink());

$this->assertStringContainsString('DTSTART:20260101T100000Z', $output);
$this->assertStringContainsString('DTEND:20260101T110000Z', $output);
$this->assertStringNotContainsString('TZID=', $output);
}

#[Test]
public function it_still_names_both_zones_when_two_places_share_an_offset(): void
{
$output = $this->generator()->generate($this->createFlightBetweenPlacesSharingAnOffsetLink());

$this->assertStringContainsString('DTSTART;TZID=Europe/London:20260115T090000', $output);
$this->assertStringContainsString('DTEND;TZID=Europe/Lisbon:20260115T114500', $output);
}

#[Test]
public function it_still_names_both_zones_when_the_start_uses_a_legacy_alias(): void
{
$output = $this->generator()->generate($this->createFlightBookedWithALegacyZoneAliasLink());

$this->assertStringContainsString('DTSTART;TZID=Japan:20260115T090000', $output);
$this->assertStringContainsString('DTEND;TZID=Asia/Seoul:20260115T114500', $output);
}

#[Test]
public function it_can_generate_a_recurring_event(): void
{
Expand Down
134 changes: 134 additions & 0 deletions tests/LinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,140 @@ public function it_does_not_report_distinct_timezones_for_an_all_day_event(): vo
$this->assertFalse($this->createEventMultipleDaysViaStartEndWithDiffTimezoneLink()->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_for_two_spellings_of_utc(): void
{
$this->assertFalse($this->createEventAcrossUtcAliasesLink()->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_for_a_z_suffixed_start(): void
{
$link = $this->createEventWithZSuffixedStartLink();

$this->assertSame('Z', $link->fromTimezone->getName());
$this->assertFalse($link->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_for_an_offset_matching_the_start_zone(): void
{
// A bare offset names no place, so it adds nothing to the zone it is paired with.
$link = Link::create(
'Meeting',
new DateTime('2026-01-01 10:00', new DateTimeZone('UTC')),
new DateTime('2026-01-01T11:00:00+00:00'),
);

$this->assertFalse($link->hasDistinctTimezones());
}

#[Test]
public function it_reports_distinct_timezones_for_two_places_sharing_an_offset(): void
{
// Equal offsets are not enough to merge two real places: the destination is still worth naming.
$flight = $this->createFlightBetweenPlacesSharingAnOffsetLink();

$this->assertSame(0, $flight->fromTimezone->getOffset($flight->from));
$this->assertSame(0, $flight->toTimezone->getOffset($flight->to));
$this->assertTrue($flight->hasDistinctTimezones());
}

#[Test]
public function it_reports_distinct_timezones_for_a_legacy_alias_naming_a_place(): void
{
$flight = $this->createFlightBookedWithALegacyZoneAliasLink();

$this->assertSame(32400, $flight->fromTimezone->getOffset($flight->from));
$this->assertSame(32400, $flight->toTimezone->getOffset($flight->to));
$this->assertTrue($flight->hasDistinctTimezones());
}

#[Test]
public function it_reports_distinct_timezones_for_every_legacy_alias_sharing_an_offset(): void
{
// The IANA backward names are ordinary places wearing an old label, and the timezone
// database gives them the same `??` country as the UTC family, so they need naming outright.
$pairs = [
['Singapore', 'Asia/Kuala_Lumpur'],
['Poland', 'Europe/Berlin'],
['Hongkong', 'Asia/Shanghai'],
['US/Eastern', 'America/Toronto'],
['GB', 'Europe/Lisbon'],
];

foreach ($pairs as [$from, $to]) {
$link = Link::create(
'Flight',
new DateTime('2026-01-15 09:00', new DateTimeZone($from)),
new DateTime('2026-01-15 11:45', new DateTimeZone($to)),
);

$this->assertSame(
$link->fromTimezone->getOffset($link->from),
$link->toTimezone->getOffset($link->to),
"$from and $to should share an offset, otherwise this proves nothing",
);
$this->assertTrue($link->hasDistinctTimezones(), "$from to $to should keep both zones");
}
}

#[Test]
public function it_reports_distinct_timezones_for_a_posix_rule_zone(): void
{
// EST5EDT names no place, but it carries daylight saving rules rather than one fixed offset,
// so it counts as a zone of its own. Folding it away would drop America/New_York instead.
$link = Link::create(
'Meeting',
new DateTime('2026-01-15 09:00', new DateTimeZone('EST5EDT')),
new DateTime('2026-01-15 11:00', new DateTimeZone('America/New_York')),
);

$this->assertTrue($link->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_when_only_one_side_names_a_place(): void
{
// Etc/GMT+5 is a fixed offset with no place of its own, so at a matching offset it adds
// nothing to the zone opposite it.
$link = Link::create(
'Meeting',
new DateTime('2026-01-15 09:00', new DateTimeZone('America/New_York')),
new DateTime('2026-01-15 11:00', new DateTimeZone('Etc/GMT+5')),
);

$this->assertFalse($link->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_across_a_daylight_saving_change_in_one_zone(): void
{
// Both endpoints are named `Europe/London`, so the zones match even though the offsets do not.
$link = Link::create(
'Clocks going forward',
new DateTime('2026-03-29 00:30', new DateTimeZone('Europe/London')),
new DateTime('2026-03-29 02:30', new DateTimeZone('Europe/London')),
);

$this->assertNotSame($link->fromTimezone->getOffset($link->from), $link->toTimezone->getOffset($link->to));
$this->assertFalse($link->hasDistinctTimezones());
}

#[Test]
public function it_does_not_report_distinct_timezones_for_an_all_day_event_across_utc_spellings(): void
{
$link = new Link(
'New Year break',
new DateTime('2026-01-01', new DateTimeZone('UTC')),
new DateTime('2026-01-02', new DateTimeZone('Etc/UTC')),
true,
);

$this->assertFalse($link->hasDistinctTimezones());
$this->assertStringContainsString('dates=20260101/20260103', $link->google());
}

#[Test]
public function it_keeps_the_inclusive_end_date_of_a_cross_timezone_all_day_event(): void
{
Expand Down
41 changes: 41 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,47 @@ protected function createFlightWithDistinctTimezonesLink(): Link
);
}

protected function createEventAcrossUtcAliasesLink(): Link
{
// Two spellings of one zone, so the event never leaves UTC.
return Link::create(
'Meeting',
new DateTime('2026-01-01 10:00', new DateTimeZone('UTC')),
new DateTime('2026-01-01 11:00', new DateTimeZone('Etc/UTC')),
);
}

protected function createEventWithZSuffixedStartLink(): Link
{
// PHP names the zone of a Z suffixed ISO string literally `Z`, which no calendar service accepts.
return Link::create(
'Meeting',
new DateTime('2026-01-01T10:00:00Z'),
new DateTime('2026-01-01 11:00', new DateTimeZone('UTC')),
);
}

protected function createFlightBetweenPlacesSharingAnOffsetLink(): Link
{
// London and Lisbon are both on +00:00 in January, yet they are two places worth naming.
return Link::create(
'BA 500 London to Lisbon',
new DateTime('2026-01-15 09:00', new DateTimeZone('Europe/London')),
new DateTime('2026-01-15 11:45', new DateTimeZone('Europe/Lisbon')),
);
}

protected function createFlightBookedWithALegacyZoneAliasLink(): Link
{
// `Japan` is an IANA backward alias for Asia/Tokyo. Both ends sit on +09:00 all year, so
// only the two zone names tell Tokyo from Seoul.
return Link::create(
'NH 867 Tokyo to Seoul',
new DateTime('2026-01-15 09:00', new DateTimeZone('Japan')),
new DateTime('2026-01-15 11:45', new DateTimeZone('Asia/Seoul')),
);
}

protected function createEventWithGuestsLink(): Link
{
return Link::create(
Expand Down