From 6e697baa463f732a22e19018c6d6f30a3485c3e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:54:52 +0100 Subject: [PATCH] fix: keep the recurrence id of a daily series in another zone on the device's DST day --- src/lib/__tests__/recurrence-dst-gap.test.ts | 42 +++++++++ src/lib/recurrence-expansion.ts | 99 +++++++++++++++++--- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/lib/__tests__/recurrence-dst-gap.test.ts b/src/lib/__tests__/recurrence-dst-gap.test.ts index e568091e..9ad8f8a2 100644 --- a/src/lib/__tests__/recurrence-dst-gap.test.ts +++ b/src/lib/__tests__/recurrence-dst-gap.test.ts @@ -38,3 +38,45 @@ describe('daily recurrence across a DST gap', () => { expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00')); }); }); + +describe("a daily series in another zone on the device's DST day", () => { + // Berlin skips 02:00-03:00 on 28 March 2027; New York changed on 14 March. + it("keeps the series' 02:30 as recurrence id and time on the night the device skips it", () => { + process.env.TZ = 'Europe/Berlin'; + const out = expandRecurringEvents( + [daily('2027-03-20T02:30:00', false, 'America/New_York')], + '2027-03-27T00:00:00', '2027-03-30T00:00:00', + ); + const day = out.find((o) => o.recurrenceId?.startsWith('2027-03-28')); + expect(day?.recurrenceId).toBe('2027-03-28T02:30:00'); + expect(day?.start).toBe('2027-03-28T02:30:00'); + expect(day?.id).toBe('E1:2027-03-28T02:30:00'); + // 02:30 in New York (EDT, -4) is 06:30 UTC. + expect(day?.utcStart).toBe('2027-03-28T06:30:00.000Z'); + expect(out.map((o) => o.recurrenceId)).toEqual([ + '2027-03-27T02:30:00', '2027-03-28T02:30:00', '2027-03-29T02:30:00', + ]); + }); + + it('applies the override stored for that night', () => { + process.env.TZ = 'Europe/Berlin'; + const event = { + ...daily('2027-03-20T02:30:00', false, 'America/New_York'), + recurrenceOverrides: { '2027-03-28T02:30:00': { title: 'Moved' } }, + } as CalendarEvent; + const out = expandRecurringEvents([event], '2027-03-28T00:00:00', '2027-03-29T00:00:00'); + expect(out).toHaveLength(1); + expect(out[0].title).toBe('Moved'); + }); + + it('gives the same occurrences whichever zone the device is in', () => { + const run = (tz: string) => { + process.env.TZ = tz; + return expandRecurringEvents( + [daily('2027-03-10T02:30:00', false, 'America/New_York')], + '2027-03-10T00:00:00', '2027-04-01T00:00:00', + ).map((o) => [o.recurrenceId, o.utcStart]); + }; + expect(run('Europe/Berlin')).toEqual(run('America/New_York')); + }); +}); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index fae990ec..04d3d15f 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -97,6 +97,59 @@ export function seriesOccursAt( return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime())); } +/** + * Dates stand for wall-clock times through their device-local fields. A + * wall time the device's clock skips (its own DST gap) can't be one: the + * Date moves past the gap. Such a Date carries the wall time it stands for + * here, so a series in another zone keeps its times on the device's DST day. + */ +const intendedWall = new WeakMap(); + +/** The LocalDateTime `date` stands for. */ +function wallString(date: Date): string { + return intendedWall.get(date) ?? format(date, "yyyy-MM-dd'T'HH:mm:ss"); +} + +/** A Date for the LocalDateTime `wall` ("yyyy-MM-ddTHH:mm:ss"), remembering it when the device's clock skips it. */ +function dateForWall(wall: string): Date { + const date = parseISO(wall); + const wallMs = localDateTimeAsUtc(wall); + if (!isNaN(date.getTime()) && wallMs !== null && wallAsUtc(date) !== wallMs) { + intendedWall.set(date, utcFieldsString(wallMs)); + } + return date; +} + +/** The UTC fields of `ms` as a LocalDateTime. */ +function utcFieldsString(ms: number): string { + const w = new Date(ms); + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + return `${pad(w.getUTCFullYear(), 4)}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}` + + `T${pad(w.getUTCHours())}:${pad(w.getUTCMinutes())}:${pad(w.getUTCSeconds())}`; +} + +/** The wall-clock fields of a LocalDateTime as a UTC timestamp, read from its digits. */ +function localDateTimeAsUtc(value: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value); + if (!m) return null; + return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)); +} + +/** + * The wall time `y-m-d h:mi:s` names in `timeZone`: itself, or past the gap + * when that zone's clock skips it (as a device-local Date would move). Null + * for an unknown zone. + */ +function wallInZone( + fields: [number, number, number, number, number, number], + timeZone: string, +): string | null { + const formatter = getTzFormatter(timeZone); + if (!formatter) return null; + const instant = zonedWallMsToUtc(Date.UTC(...fields), formatter); + return utcFieldsString(wallClockAsUtcTimestamp(formatter, instant)); +} + function occurrenceDateKey(master: CalendarEvent, date: Date): string { return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString(); } @@ -106,8 +159,10 @@ function expandEvent( rangeStart: Date, rangeEnd: Date, ): CalendarEvent[] { - const eventStart = parseISO(master.start); + const eventStart = master.showWithoutTime ? parseISO(master.start) : dateForWall(master.start.slice(0, 19)); if (isNaN(eventStart.getTime())) return []; + // Daily occurrences are built on the event's own clock (see wallInZone). + const zone = !master.showWithoutTime && master.timeZone ? master.timeZone : undefined; const rules = master.recurrenceRules || []; const overrides = master.recurrenceOverrides || {}; @@ -120,13 +175,13 @@ function expandEvent( for (const exRule of master.excludedRecurrenceRules || []) { // includeStartDate=false: "the series start is always an occurrence" // applies to recurrence rules, not to exclusion rules. - for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) { + for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false, undefined, zone)) { excludedDates.add(occurrenceDateKey(master, date)); } } for (const rule of rules) { - const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); + const dates = generateDates(eventStart, rule, rangeStart, rangeEnd, true, undefined, zone); for (const date of dates) { const dateKey = occurrenceDateKey(master, date); @@ -136,7 +191,7 @@ function expandEvent( const recurrenceId = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") - : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + : wallString(date); const override = overrides[recurrenceId] as | (Partial & { excluded?: boolean }) @@ -153,7 +208,7 @@ function expandEvent( for (const [recurrenceId, rawOverride] of Object.entries(overrides)) { const override = rawOverride as Partial & { excluded?: boolean }; if (override.excluded) continue; - const overrideDate = parseISO(recurrenceId); + const overrideDate = master.showWithoutTime ? parseISO(recurrenceId) : dateForWall(recurrenceId.slice(0, 19)); if (isNaN(overrideDate.getTime())) continue; // Overrides take precedence over excludedRecurrenceRules (they re-add @@ -288,12 +343,16 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + return new Date(zonedWallMsToUtc(wallAsUtc(wall), formatter)); +} + +/** The instant of the wall clock `wallMs` (its fields read as UTC) in the formatter's zone. */ +function zonedWallMsToUtc(wallMs: number, formatter: Intl.DateTimeFormat): number { let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); } - return new Date(guess); + return guess; } function createOccurrence( @@ -304,7 +363,7 @@ function createOccurrence( ): CalendarEvent { const startStr = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") - : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + : wallString(date); // The occurrence as the rule generates it, with its override applied as a // patch: a moved or resized occurrence has its own start, time zone and @@ -340,7 +399,10 @@ function createOccurrence( // occurrences on the other side of a DST transition in that zone keep // their wall time. Reusing the master's fixed UTC offset (the fallback // below) would shift them by the DST delta. - const zoned = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null; + // From the digits: a wall time the device's clock skips is still this one. + const formatter = occurrence.timeZone ? getTzFormatter(occurrence.timeZone) : null; + const wallMs = localDateTimeAsUtc(start); + const zoned = formatter && wallMs !== null ? new Date(zonedWallMsToUtc(wallMs, formatter)) : null; if (zoned) { utcStart = zoned.toISOString(); if (durationMs !== null) { @@ -430,6 +492,8 @@ function generateDates( rangeEnd: Date, includeStartDate = true, maxOccurrences = 500, + // The series' time zone: daily occurrences are built on its clock. + timeZone?: string, ): Date[] { const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; @@ -465,7 +529,7 @@ function generateDates( break; } - const candidates = generateCandidatesForPeriod(current, rule, eventStart); + const candidates = generateCandidatesForPeriod(current, rule, eventStart, timeZone); const filtered = rule.bySetPosition?.length ? applyBySetPosition(candidates, rule.bySetPosition) @@ -509,6 +573,7 @@ function generateCandidatesForPeriod( periodStart: Date, rule: RecurrenceRule, eventStart: Date, + timeZone?: string, ): Date[] { const freq = rule.frequency; let candidates: Date[]; @@ -523,17 +588,27 @@ function generateCandidatesForPeriod( case 'weekly': candidates = expandWeekly(periodStart, rule, eventStart); break; - case 'daily': + case 'daily': { // The start's wall-clock time on this day. The period anchor is // advanced day by day and keeps whatever time a DST gap pushed it to // (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using // it made every later day fail the implicit byHour and the series // stopped at the gap. Mirrors the webmail (34fb9d247). - candidates = [new Date( + // With a time zone, on that zone's clock: the device's own DST gap + // must not move an occurrence of a series in another zone. + const time = timeZone ? /T(\d{2}):(\d{2}):(\d{2})/.exec(wallString(eventStart)) : null; + const zoned = time && timeZone + ? wallInZone([ + periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), + Number(time[1]), Number(time[2]), Number(time[3]), + ], timeZone) + : null; + candidates = [zoned ? dateForWall(zoned) : new Date( periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(), )]; break; + } case 'hourly': case 'minutely': case 'secondly':