Skip to content

Fix: treat equivalent timezones as a single zone - #246

Merged
alies-dev merged 1 commit into
masterfrom
fix/timezone-alias-equivalence
Aug 20, 2026
Merged

Fix: treat equivalent timezones as a single zone#246
alies-dev merged 1 commit into
masterfrom
fix/timezone-alias-equivalence

Conversation

@alies-dev

Copy link
Copy Markdown
Collaborator

Context and Purposes

Link::hasDistinctTimezones() decided whether an event crosses a timezone by comparing the two zone
names. That is wrong whenever one zone is spelled differently from the other while meaning the same
thing, and it is easy to hit by accident:

$from = new DateTimeImmutable('2026-01-01 10:00:00', new DateTimeZone('UTC'));
$to   = new DateTimeImmutable('2026-01-01 11:00:00', new DateTimeZone('Etc/UTC'));
Link::create('Meeting', $from, $to)->google();
// ...&stz=UTC&etz=Etc/UTC

A Z suffixed ISO string is worse, because PHP names its zone literally Z:

Link::create('Meeting', new DateTimeImmutable('2026-01-01T10:00:00Z'), $to)->google();
// ...&stz=Z&etz=UTC          Google does not accept `Z` as an identifier
// ICS: DTSTART;TZID=Z:20260101T100000

The event never leaves a single zone, yet the generators took the two zone path and emitted
identifiers the calendar services reject.

The rule this uses, and why it is not plain offset equality

The obvious fix is to compare the offsets of $from and $to at their own instants instead of the
names. That handles aliases and offset style zones alike, but on its own it is too eager: two
genuinely different places can share an offset. Europe/London and Europe/Lisbon are both on +00:00
the whole year, and Europe/Berlin and Africa/Lagos are both on +01:00 all winter. A flight from
London to Lisbon should still be able to name where it lands.

So hasDistinctTimezones() now collapses a pair of zones into one only when both of these hold.

  1. The offsets agree, each zone read at the instant that belongs to it. Reading each zone at its own
    instant is what lets an event running across a daylight saving change still see the two offsets it
    really spans.
  2. At least one of the two zones names no place. A bare offset (+00:00), an abbreviation (Z) and
    every member of the UTC family (UTC, Etc/UTC, Etc/GMT+5) are offsets wearing a label: they
    carry no country in the timezone database, so there is no location to lose by describing the event
    in the zone opposite them. A region zone does carry one, and two of them stay distinct even when
    their offsets happen to coincide.

Name equality is still checked first as a short circuit, since identical names are the same zone by
definition, daylight saving included.

The constructor

The constructor also branches on the zone names when it normalises $to, and that comparison is left
as it is on purpose. It answers a narrower question: not whether the two zones are worth naming
separately, but whether $to has to move at all. Equal names mean there is nothing to move, and for
every other pair the conversion is either meaningful or a no-op on the instant. Keeping it also keeps
the existing invariant that $to always carries $fromTimezone, which Yahoo and the single zone
paths of Google and ICS rely on when they render both endpoints under one zone label.

Scope

This fixes the equivalence comparison only. ctz=Z is still not an identifier Google accepts, so a
Z suffixed event is now described as single zone but not yet named in a form the services
understand. That part is #234 and is handled separately.

Fixes #238

@alies-dev alies-dev self-assigned this Aug 20, 2026
@alies-dev
alies-dev force-pushed the fix/timezone-alias-equivalence branch 3 times, most recently from 55872c9 to 090d148 Compare August 20, 2026 15:49
hasDistinctTimezones() compared zone names, so UTC against Etc/UTC, or the
bare Z that PHP leaves on a Z suffixed ISO string, took the two zone path and
emitted identifiers the calendar services reject for an event that never
crossed a zone.

The zones now collapse into one when their offsets agree, each read at its own
instant, and at least one of them names no place. Equal offsets alone are not
enough: Europe/London and Europe/Lisbon share one all year, and both are still
worth naming.

A zone that names no place is identified by the offset family itself, not by a
missing country in the timezone database. Every IANA backward alias reports the
same `??` country as the UTC family, so reading that placeholder as "no place"
would fold Japan, Singapore, US/Eastern and GB into whatever zone they were
paired with and lose the destination of the flight.
@alies-dev
alies-dev force-pushed the fix/timezone-alias-equivalence branch from 090d148 to 704bd3a Compare August 20, 2026 16:13
@alies-dev
alies-dev merged commit 3ca8e53 into master Aug 20, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hasDistinctTimezones() treats equivalent timezone aliases as distinct

1 participant