Define the zones an export names, instead of only naming them #229

Closed
opened 2026-09-02 17:26:58 +00:00 by jcoffey-dev · 0 comments
Owner

Refs #216. This corrects a decision I made in #227 and defended in that issue's comments. It was wrong, and I have the measurement.

What was wrong

#227 emitted TZID with the IANA name and nothing defining it, on two claims: that every client resolves IANA names, and that generating a definition would mean shipping a zone database to the browser.

Run an export through ical.js — Mozilla's own iCalendar library, the one Thunderbird's calendar uses — and a TZID with no VTIMEZONE beside it does not resolve. It falls back to floating time:

as exported            | zone: floating        | UTC: 2026-09-02T09:00:00Z
with a VTIMEZONE added | zone: America/Phoenix | UTC: 2026-09-02T16:00:00Z

Same file, same parser, seven hours apart. Silently, on every timed event in every export — and against the client the reporter's colleagues actually use.

The second claim was wrong too. The browser has the IANA database already, behind Intl.

How the definitions are built

An offset for an instant is a formatting question: format the instant into the zone, read the clock back, and the difference between the two readings is the offset. Transitions are then found by walking month by month for the ones where the answer changes and bisecting inside them, down to the millisecond. No rules are known, so none can be got wrong.

Each transition is written as its own dated sub-component rather than an RRULE. More lines and no cleverness: a derived rule that is subtly wrong moves somebody's meeting, whereas a list of dates can only be incomplete at its ends — which is what the window is for. The window runs from the year before the earliest event to ten years past the latest; an open-ended weekly meeting is the case that needs the tail.

Two deliberate silences: a zone Intl does not know is left undefined rather than described from nothing (the TZID stays on the event, exactly as before), and TZNAME is dropped where Intl offers GMT+9, which only repeats the offset beside it.

Confirmed the same way it was found

Berlin resolves to +0200 in September and +0100 in December, so the transitions are being applied rather than a single offset. Phoenix, which has no DST, comes out as one standing rule. All-day events still carry no zone, correctly.

Tests

Eleven new: one definition per zone however many events use it, none for UTC or all-day, a non-changing zone as a single rule, both EU transitions at the right hours and in the right direction, a window spanning more than five years and reaching past 2030, no GMT+n names, an unknown zone left undefined, and definitions ordered before the events.

One existing helper needed narrowing — a test asking "what is this event's DTSTART" was being answered by a transition rule.

npm run typecheck, npm test (956 web + 122 server), npm run build pass.

Still to do

Correct the record on #216, where I told the reporter the opposite with some confidence.

Merged 2026-09-02 as coffey-labs/ihasmail@a01e1874d8

Rebuilt from: git history, session transcript.

Refs #216. This corrects a decision I made in #227 and defended in that issue's comments. It was wrong, and I have the measurement. ## What was wrong #227 emitted `TZID` with the IANA name and nothing defining it, on two claims: that every client resolves IANA names, and that generating a definition would mean shipping a zone database to the browser. Run an export through **ical.js — Mozilla's own iCalendar library, the one Thunderbird's calendar uses** — and a `TZID` with no VTIMEZONE beside it does not resolve. It falls back to *floating* time: ``` as exported | zone: floating | UTC: 2026-09-02T09:00:00Z with a VTIMEZONE added | zone: America/Phoenix | UTC: 2026-09-02T16:00:00Z ``` Same file, same parser, seven hours apart. Silently, on every timed event in every export — and against the client the reporter's colleagues actually use. The second claim was wrong too. The browser has the IANA database already, behind `Intl`. ## How the definitions are built An offset for an instant is a formatting question: format the instant into the zone, read the clock back, and the difference between the two readings is the offset. Transitions are then found by walking month by month for the ones where the answer changes and bisecting inside them, down to the millisecond. No rules are known, so none can be got wrong. Each transition is written as **its own dated sub-component rather than an RRULE**. More lines and no cleverness: a derived rule that is subtly wrong moves somebody's meeting, whereas a list of dates can only be incomplete at its ends — which is what the window is for. The window runs from the year before the earliest event to ten years past the latest; an open-ended weekly meeting is the case that needs the tail. Two deliberate silences: a zone `Intl` does not know is left undefined rather than described from nothing (the `TZID` stays on the event, exactly as before), and `TZNAME` is dropped where `Intl` offers `GMT+9`, which only repeats the offset beside it. ## Confirmed the same way it was found Berlin resolves to **+0200 in September and +0100 in December**, so the transitions are being applied rather than a single offset. Phoenix, which has no DST, comes out as one standing rule. All-day events still carry no zone, correctly. ## Tests Eleven new: one definition per zone however many events use it, none for UTC or all-day, a non-changing zone as a single rule, both EU transitions at the right hours and in the right direction, a window spanning more than five years and reaching past 2030, no `GMT+n` names, an unknown zone left undefined, and definitions ordered before the events. One existing helper needed narrowing — a test asking "what is this event's DTSTART" was being answered by a transition rule. `npm run typecheck`, `npm test` (956 web + 122 server), `npm run build` pass. ## Still to do Correct the record on #216, where I told the reporter the opposite with some confidence. **Merged** 2026-09-02 as coffey-labs/ihasmail@a01e1874d831 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.