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

#227 emitted TZID with the IANA name and nothing defining it, on the reasoning
that every client resolves those names and that generating a definition would
mean shipping a zone database. Both halves were wrong.

Measured, not assumed. 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. A 09:00
in Phoenix then reads as 09:00 wherever the file is opened, seven hours out,
silently, on every timed event in every export.

    as exported            | zone: floating        | UTC: 09:00Z
    with a VTIMEZONE added | zone: America/Phoenix | UTC: 16:00Z

The database was already here, too. The browser has IANA behind Intl, and an
offset for an instant is a formatting question: format the instant into the
zone, read the clock back, and the difference is the offset. Transitions are
found by walking month by month for the ones where the answer changes and
bisecting inside them -- no rules are known, so none can be got wrong.

Each transition is 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, while a list of dates can only be incomplete at its ends, which is
what the window is for -- the year before the earliest event to ten years past
the latest, an open-ended weekly meeting being the case that needs it.

A zone Intl does not know is left undefined rather than described from
nothing; the TZID stays on the event, which is where it was. TZNAME is dropped
where Intl offers "GMT+9", which only repeats the offset beside it.

Confirmed the same way it was found. Berlin now resolves to +0200 in September
and +0100 in December, so the transitions are being applied and not just an
offset.

Refs #216.
This commit is contained in:
2026-09-02 10:26:43 -07:00
parent 5f35428ddb
commit 0e58b886b5
2 changed files with 274 additions and 13 deletions
+95 -1
View File
@@ -17,7 +17,16 @@ const base: JSCalendarEvent = {
};
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
const find = (e: JSCalendarEvent[], prefix: string) => lines(e).filter((l) => l.startsWith(prefix));
/*
* From the first event onwards. The zone definitions above carry DTSTART and
* TZNAME of their own, and a test asking "what is this event's DTSTART" must
* not be answered by a transition rule.
*/
const eventLines = (e: JSCalendarEvent[]) => {
const all = lines(e);
return all.slice(all.indexOf("BEGIN:VEVENT"));
};
const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l) => l.startsWith(prefix));
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
describe("the document around the events", () => {
@@ -209,3 +218,88 @@ describe("what comes back out of the parser", () => {
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
});
});
/*
* Time zone definitions.
*
* These exist because leaving them out was wrong, and measurably: ical.js --
* Mozilla's library, the one Thunderbird's calendar uses -- reads a TZID with
* nothing defining it as *floating*, so a 09:00 in Phoenix opened anywhere else
* reads as 09:00 there. Seven hours out, silently, on every timed event.
*/
describe("the zones an export names", () => {
const inZone = (uid: string, tz: string, start = "2026-09-02T09:00:00") =>
({ ...base, uid, timeZone: tz, start }) as JSCalendarEvent;
it("defines every zone its events refer to", () => {
const l = lines([inZone("a", "America/Phoenix"), inZone("b", "Asia/Tokyo")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(2);
expect(l).toContain("TZID:America/Phoenix");
expect(l).toContain("TZID:Asia/Tokyo");
});
it("defines a zone once however many events use it", () => {
const l = lines([inZone("a", "Europe/Berlin"), inZone("b", "Europe/Berlin"), inZone("c", "Europe/Berlin")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(1);
});
it("says nothing about UTC, which needs no definition", () => {
expect(lines([inZone("a", "Etc/UTC")]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("says nothing about an all-day event, which has no zone to define", () => {
const e = { ...base, showWithoutTime: true, timeZone: "Europe/Berlin" } as JSCalendarEvent;
expect(lines([e]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("writes a zone that never changes as one standing rule", () => {
// Phoenix keeps MST all year: one sub-component, and the two offsets equal.
const l = lines([inZone("a", "America/Phoenix")]);
expect(l.filter((x) => x === "BEGIN:DAYLIGHT")).toHaveLength(0);
expect(l.filter((x) => x === "BEGIN:STANDARD")).toHaveLength(1);
expect(l).toContain("TZOFFSETFROM:-0700");
expect(l).toContain("TZOFFSETTO:-0700");
expect(l).toContain("TZNAME:MST");
});
it("finds the transitions of a zone that does change", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
// Both directions, and at the hours the EU actually changes at.
expect(l).toContain("DTSTART:20260329T020000");
expect(l).toContain("DTSTART:20261025T030000");
const spring = l.indexOf("DTSTART:20260329T020000");
expect(l[spring - 1]).toBe("BEGIN:DAYLIGHT");
expect(l[spring + 1]).toBe("TZOFFSETFROM:+0100");
expect(l[spring + 2]).toBe("TZOFFSETTO:+0200");
});
it("covers years around the events rather than only the year they fall in", () => {
// An open-ended weekly meeting outlives the year it was created in, so a
// definition that stopped at that year would leave later occurrences
// undefined.
const l = lines([inZone("a", "Europe/Berlin")]);
const years = new Set(l.filter((x) => x.startsWith("DTSTART:")).map((x) => x.slice(8, 12)));
expect(years.size).toBeGreaterThan(5);
expect([...years].some((y) => Number(y) > 2030)).toBe(true);
});
it("leaves out a zone name that only repeats the offset", () => {
// Intl answers "GMT+9" for Tokyo, which says nothing TZOFFSETTO has not.
const l = lines([inZone("a", "Asia/Tokyo")]);
expect(l.some((x) => x.startsWith("TZNAME:GMT"))).toBe(false);
expect(l).toContain("TZOFFSETTO:+0900");
});
it("says nothing at all about a zone the browser does not know", () => {
// Rather than writing a definition made up out of nothing. The TZID stays
// on the event, which is where it was before any of this.
const l = lines([inZone("a", "Mars/Olympus_Mons")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
expect(l).toContain("DTSTART;TZID=Mars/Olympus_Mons:20260902T090000");
});
it("puts the definitions before the events that use them", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
expect(l.indexOf("BEGIN:VTIMEZONE")).toBeLessThan(l.indexOf("BEGIN:VEVENT"));
});
});