diff --git a/src/lib/__tests__/ics.test.ts b/src/lib/__tests__/ics.test.ts new file mode 100644 index 0000000..58c17f8 --- /dev/null +++ b/src/lib/__tests__/ics.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseIcs } from "../ics"; + +describe("parseIcs", () => { + it("parses TZID datetime values into the correct UTC instant", () => { + const event = parseIcs([ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "METHOD:REQUEST", + "BEGIN:VEVENT", + "UID:test-uid", + "SUMMARY:Team meeting", + "DTSTART;TZID=America/Los_Angeles:20260602T150000", + "DTEND;TZID=America/Los_Angeles:20260602T160000", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n")); + + assert.ok(event); + assert.equal(event.dtStart?.toISOString(), "2026-06-02T22:00:00.000Z"); + assert.equal(event.dtEnd?.toISOString(), "2026-06-02T23:00:00.000Z"); + }); + + it("keeps UTC datetimes unchanged", () => { + const event = parseIcs([ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "METHOD:REQUEST", + "BEGIN:VEVENT", + "UID:test-uid-utc", + "SUMMARY:UTC meeting", + "DTSTART:20260602T220000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n")); + + assert.ok(event); + assert.equal(event.dtStart?.toISOString(), "2026-06-02T22:00:00.000Z"); + }); +}); diff --git a/src/lib/ics.ts b/src/lib/ics.ts index 06463b7..a271729 100644 --- a/src/lib/ics.ts +++ b/src/lib/ics.ts @@ -73,8 +73,86 @@ function parseIcsDatetime( const m = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/); if (!m) return { date: null, allDay: false }; const [, y, mo, d, h, min, s, z] = m; - const iso = `${y}-${mo}-${d}T${h}:${min}:${s}${z === "Z" ? "Z" : ""}`; - return { date: new Date(iso), allDay: false }; + if (z === "Z") { + return { date: new Date(`${y}-${mo}-${d}T${h}:${min}:${s}Z`), allDay: false }; + } + + const tzid = params.TZID; + if (!tzid) { + return { + date: new Date( + Number(y), + Number(mo) - 1, + Number(d), + Number(h), + Number(min), + Number(s) + ), + allDay: false, + }; + } + + const date = convertWallClockToUtc( + Number(y), + Number(mo), + Number(d), + Number(h), + Number(min), + Number(s), + tzid + ); + if (!date) return { date: null, allDay: false }; + return { date, allDay: false }; +} + +function getTimeZoneOffsetMs(date: Date, timeZone: string): number { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const parts = formatter.formatToParts(date); + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])); + const asUtc = Date.UTC( + Number(byType.year), + Number(byType.month) - 1, + Number(byType.day), + Number(byType.hour), + Number(byType.minute), + Number(byType.second) + ); + return asUtc - date.getTime(); +} + +function convertWallClockToUtc( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + timeZone: string +): Date | null { + const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, second); + let timestamp = wallClockUtc; + + try { + for (let i = 0; i < 3; i++) { + const offset = getTimeZoneOffsetMs(new Date(timestamp), timeZone); + const next = wallClockUtc - offset; + if (next === timestamp) break; + timestamp = next; + } + } catch { + return null; + } + + return new Date(timestamp); } function unescape(v: string): string {