Merge pull request #229 from Coffey-Labs/fix/export-vtimezone

Define the zones an export names, instead of only naming them
This commit is contained in:
Coffey Labs
2026-09-02 10:29:45 -07:00
committed by GitHub
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"));
});
});
+179 -12
View File
@@ -281,29 +281,51 @@ function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): Ics
*
* What is deliberately not here, stated rather than discovered:
*
* - **No VTIMEZONE components.** A `TZID` is emitted with the IANA name the
* server holds -- "Europe/Berlin" -- and no definition of that zone beside
* it. Generating one means shipping a zone database to the browser to
* describe rules the reader's own system already knows. Every client that
* matters resolves IANA names; a strict validator will complain, and the
* alternative -- converting everything to UTC -- would be worse, because a
* weekly 09:00 that becomes 08:00 for half the year is a wrong calendar
* rather than a pedantic one.
* - **Overrides are applied at the top level only.** A recurrence override is a
* JSON patch, and a patch addressing `locations/x/name` is not something this
* flattens; those paths are left on the master's value. Plain overridden
* properties -- a moved time, a changed title -- come across.
* - **Overrides are applied at the top level only.** (See below.)
* A recurrence override is a JSON patch, and a patch addressing
* `locations/x/name` is not something this flattens; those paths are left on
* the master's value. Plain overridden properties -- a moved time, a changed
* title -- come across.
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
* in ihasmail sets them.
*/
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
for (const zone of zonesUsed(events)) lines.push(...vtimezone(zone, ...windowFor(events)));
for (const e of events) lines.push(...vevent(e));
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
/** Every named zone the events refer to; UTC needs no definition. */
function zonesUsed(events: JSCalendarEvent[]): string[] {
const zones = new Set<string>();
for (const e of events) {
if (e.showWithoutTime) continue;
const tz = e.timeZone;
if (tz && tz !== "Etc/UTC" && tz !== "UTC") zones.add(tz);
}
return [...zones].sort();
}
/**
* The years a definition has to cover.
*
* A zone's rules are not a fact, they are a decision somebody makes and
* changes, so a VTIMEZONE states them for a span rather than for ever. From the
* year before the earliest event -- an event can be moved earlier by an
* override -- to ten years past the latest, which covers an open-ended weekly
* meeting for as long as anyone plans around one.
*/
function windowFor(events: JSCalendarEvent[]): [number, number] {
const years = events.map((e) => Number(e.start.slice(0, 4))).filter((y) => Number.isFinite(y) && y > 1000);
const now = new Date().getUTCFullYear();
const first = years.length ? Math.min(...years) : now;
const last = Math.max(now, years.length ? Math.max(...years) : now);
return [first - 1, last + 10];
}
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
function escText(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
@@ -480,3 +502,148 @@ function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
return parts.join(";");
}
/* ------------------------------------------------------------------ */
/* Time zones */
/* ------------------------------------------------------------------ */
/**
* A zone's definition, worked out from the one the browser already has.
*
* This exists because leaving it out was wrong, and provably so. A `TZID`
* naming an IANA zone with nothing defining it is not resolved by ical.js --
* Mozilla's own iCalendar library, and the one Thunderbird's calendar uses --
* which 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.
* Measured, not assumed.
*
* The reason it was left out -- that generating one means shipping a zone
* database -- was also wrong. The browser has the IANA database already, behind
* `Intl`, and an offset for an instant is a formatting question. Transitions
* are then found by looking for the months where the answer changes and
* bisecting inside them, rather than by knowing any rules.
*
* Each transition is written as its own dated sub-component instead of as an
* RRULE. It is more lines and no cleverness: a rule has to be *derived*, and a
* derived rule that is subtly wrong moves somebody's meeting, while a list of
* dates can only be incomplete at the ends -- which is what the window is for.
*/
export function vtimezone(tzid: string, fromYear: number, toYear: number): string[] {
let offsetAt: (d: Date) => number;
try {
offsetAt = offsetFinder(tzid);
} catch {
/* A zone `Intl` does not know: say nothing rather than say something wrong.
The TZID stays on the events, which is where it was before this. */
return [];
}
const start = Date.UTC(fromYear, 0, 1);
const end = Date.UTC(toYear, 11, 31);
const MONTH = 30 * 24 * 3600 * 1000;
const transitions: Array<{ at: number; from: number; to: number }> = [];
let prev = offsetAt(new Date(start));
const firstOffset = prev;
for (let t = start; t < end; t += MONTH) {
const next = Math.min(t + MONTH, end);
const here = offsetAt(new Date(next));
if (here === prev) continue;
// Somewhere in this month. Bisect to the minute, which is finer than any
// transition anybody has ever scheduled.
let lo = t;
let hi = next;
// All the way down, rather than to the nearest second and rounded: rounding
// the wrong way writes a 02:00 change as 02:00:01, and thirty more halvings
// of a range that is already one month is nothing.
while (hi - lo > 1) {
const mid = lo + Math.floor((hi - lo) / 2);
if (offsetAt(new Date(mid)) === prev) lo = mid;
else hi = mid;
}
transitions.push({ at: hi, from: prev, to: here });
prev = here;
}
const out = ["BEGIN:VTIMEZONE", `TZID:${tzid}`];
if (!transitions.length) {
/* A zone that does not change -- Phoenix, Tokyo, UTC+X -- is one standing
rule, and RFC 5545 still wants a sub-component to hang it on. */
out.push("BEGIN:STANDARD", `DTSTART:${localStamp(new Date(start), firstOffset)}`,
`TZOFFSETFROM:${offsetText(firstOffset)}`, `TZOFFSETTO:${offsetText(firstOffset)}`,
...tzNameLine(tzid, new Date(start)), "END:STANDARD");
} else {
for (const tr of transitions) {
/* Daylight is the side with the larger offset from UTC; the names are
only labels, but a reader that shows them should not show them
backwards. */
const kind = tr.to > tr.from ? "DAYLIGHT" : "STANDARD";
out.push(`BEGIN:${kind}`,
/* DTSTART is local time read in the *old* offset, which is what
TZOFFSETFROM is there to say. */
`DTSTART:${localStamp(new Date(tr.at), tr.from)}`,
`TZOFFSETFROM:${offsetText(tr.from)}`,
`TZOFFSETTO:${offsetText(tr.to)}`,
...tzNameLine(tzid, new Date(tr.at + 60_000)),
`END:${kind}`);
}
}
out.push("END:VTIMEZONE");
return out;
}
/**
* Minutes east of UTC at an instant, from the zone database `Intl` carries.
*
* Formatting the instant into the zone and reading the clock back is the
* portable way to ask this: `timeZoneName: "longOffset"` is newer than some
* browsers this has to run in, and the difference between the two readings is
* the offset by definition.
*/
function offsetFinder(tzid: string): (d: Date) => number {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: tzid, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
// Throws RangeError here, on construction, if the zone is not known.
dtf.format(new Date());
return (d: Date) => {
const p: Record<string, string> = {};
for (const part of dtf.formatToParts(d)) p[part.type] = part.value;
const asUTC = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));
return Math.round((asUTC - d.getTime()) / 60_000);
};
}
/** TZNAME, or nothing at all where there is no name worth writing. */
function tzNameLine(tzid: string, at: Date): string[] {
const name = zoneName(tzid, at);
return name ? [`TZNAME:${name}`] : [];
}
/** The zone's short label at an instant -- "MST", "CEST" -- or "" if it has none. */
function zoneName(tzid: string, at: Date): string {
try {
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tzid, timeZoneName: "short" }).formatToParts(at);
const name = parts.find((p) => p.type === "timeZoneName")?.value.replace(/[^A-Za-z0-9+-]/g, "") ?? "";
/* Where a zone has no abbreviation in common use, `Intl` answers "GMT+9",
which repeats the offset beside it and reads as a mistake. */
return /^(GMT|UTC)[+-]?/.test(name) ? "" : name;
} catch {
return tzid;
}
}
/** "+0200" / "-0700", which is how iCalendar writes an offset. */
function offsetText(minutes: number): string {
const sign = minutes < 0 ? "-" : "+";
const abs = Math.abs(minutes);
return `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}${String(abs % 60).padStart(2, "0")}`;
}
/** An instant written as the wall clock it shows at a given offset. */
function localStamp(at: Date, offsetMinutes: number): string {
const shifted = new Date(at.getTime() + offsetMinutes * 60_000);
return shifted.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15);
}