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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user