Skip to content

Fix: fall back to UTC for timezones a service cannot resolve - #248

Merged
alies-dev merged 8 commits into
masterfrom
fix/offset-style-timezones
Aug 20, 2026
Merged

Fix: fall back to UTC for timezones a service cannot resolve#248
alies-dev merged 8 commits into
masterfrom
fix/offset-style-timezones

Conversation

@alies-dev

Copy link
Copy Markdown
Collaborator

Context and Purposes

A DateTimeZone does not have to name a place. Parsing any ISO 8601 string with an offset gives you one that does not:

$from = new DateTimeImmutable('2026-01-01T10:00:00+02:00'); // zone name: "+02:00"

The generators passed that name straight through, and both services broke on it.

Google received &dates=20260101T100000/20260101T110000&ctz=+02:00. Live testing against calendar.google.com shows ctz=+02:00 is ignored in every encoding, so the wall clock pair in dates was reinterpreted in the viewer's own timezone. The event silently landed at the wrong absolute time for every viewer outside +02:00, and the stz/etz pair on the distinct timezone path failed the same way. The same testing disproved the comment in Google.php claiming Google handles unencoded names such as Etc/GMT+5: that family is rejected both raw and fully percent encoded, so no encoding makes it work.

ICS received DTSTART;TZID=+02:00:20260101T100000. RFC 5545 defines a param-value as text that cannot carry an unquoted : (section 3.1), so a conforming parser reads the parameter as TZID=+02 and takes 00:20260101T100000 as the property value, which is not a valid DATE-TIME. The event was corrupt in every client, and negative offsets failed identically. A TZID also has to name a zone the client can look up (section 3.2.19), which an offset never does.

Both are the same underlying question, so the answer lives in one place. Link::hasResolvableTimezones() reports whether both ends carry a name a calendar service can resolve, and both generators ask it:

  • a TZDB region name (Europe/Amsterdam, including the backward names such as US/Pacific) resolves, and so does UTC;
  • an offset (+02:00) or an abbreviation (CEST) names no region and carries no daylight saving rules;
  • Etc/GMT±N is a real TZDB identifier, but its sign is inverted from the offset it names and Google rejects it outright;
  • the region-less legacy names (EST, MST7MDT) are POSIX rules rather than places.

When the answer is no, the generators write the unambiguous UTC form instead. Google gets dates=20260101T080000Z/20260101T090000Z and no ctz, stz or etz. The ics file gets UTC DTSTART/DTEND and no TZID. The instant is preserved in both, which is what was being lost.

Both ends are judged together. Naming only the departure of a flight whose arrival zone is an offset would leave the two halves of one event on different footings, so an unresolvable zone at either end sends the pair to UTC.

An all-day event is the one case that is not converted. Its endpoints are calendar dates rather than instants, and rewriting them in UTC would shift a midnight in +02:00 back onto the previous day. Those dates stay as they were given and only the ctz naming is dropped.

Nothing changes for an event that already names a region: every existing snapshot is untouched.

Fixes #234

@alies-dev alies-dev self-assigned this Aug 20, 2026
@alies-dev
alies-dev force-pushed the fix/offset-style-timezones branch from 92f72eb to 6b252b1 Compare August 20, 2026 15:33
@alies-dev

Copy link
Copy Markdown
Collaborator Author

Merge-order note: #249 adds VTIMEZONE components gated on Ics::shouldDefineTimezones(), which currently returns $link->hasDistinctTimezones(). This PR narrows the condition for writing TZID to hasDistinctTimezones() && hasResolvableTimezones(). Whichever lands second should bring the two gates back in step, otherwise an event with an offset style zone gets VTIMEZONE components that nothing in the file references.

@alies-dev
alies-dev force-pushed the fix/offset-style-timezones branch from d742dbe to 05d8e7d Compare August 20, 2026 15:49
An offset (`+02:00`), an abbreviation (`CEST`) and the `Etc/GMT±N` family
are all names DateTimeZone accepts and no calendar service can resolve.
Both generators need the same answer, so the check lives on Link and
judges the two ends together.
Google ignores a ctz, stz or etz that is not a zone it knows, so the local
times in `dates` were left to be read in the viewer's own zone and the
event landed at the wrong instant for everyone else. Those events are now
written as UTC instants with no zone parameter.

Corrects the comment claiming Google handles `Etc/GMT+5` unencoded: it is
rejected raw and percent encoded alike.
RFC 5545 forbids an unquoted `:` in a param-value, so `TZID=+02:00` cut
`DTSTART` in half and left a value no parser could read. The distinct
timezone path now only names the zones when both of them resolve, and
writes UTC endpoints otherwise.
The `str_contains($name, '/')` gate was standing in for two separate
things and got one of them wrong. `Japan`, `GB`, `Singapore`, `Eire`,
`W-SU` and the rest of the slashless backward names are aliases of a
region and resolve exactly like `US/Pacific`, which the same code kept.
They were being downgraded to UTC, which held the instant but dropped
the zone the caller named, silently.

The TZDB list is now the only positive test. The entries that stand for
no place are excluded by name instead: the POSIX rule sets, the other
spellings of UTC, the `Etc/` tree and `Factory`.

Also corrects the README, which called `Etc/GMT+5` unresolvable. It is a
real IANA identifier; the reasons to turn it down are that Google rejects
it and that its sign runs the opposite way from the offset it names.
`Etc/UTC` goes with the rest of the tree, and because both ends are
judged together, refusing it drops the other end's zone as well. Naming
`UTC` instead keeps a UTC endpoint paired with a named zone.
An event from `UTC` to `Etc/UTC` is two spellings of one zone, so it
collapses and only the start zone is ever emitted. Judging both ends
unconditionally let the discarded end zone refuse the event and drop it
to the UTC fallback, costing the start zone the name it was going to be
written under.

The distinct path still needs both ends, since naming one and not the
other leaves half the event pinned to a zone and half of it loose.
@alies-dev
alies-dev force-pushed the fix/offset-style-timezones branch from 0673b19 to f825338 Compare August 20, 2026 16:16
@alies-dev
alies-dev merged commit a5e7ec1 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.

Offset-style timezones produce wrong Google times and corrupt ICS output

1 participant