From 4de856923b7a5cb0ad7fd109548834458ad00d0f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:18:00 +0100 Subject: [PATCH] fix: keep daily recurring events going past a DST gap --- src/lib/__tests__/recurrence-dst-gap.test.ts | 40 ++++++++++++++++++++ src/lib/recurrence-expansion.ts | 23 ++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/recurrence-dst-gap.test.ts diff --git a/src/lib/__tests__/recurrence-dst-gap.test.ts b/src/lib/__tests__/recurrence-dst-gap.test.ts new file mode 100644 index 00000000..e568091e --- /dev/null +++ b/src/lib/__tests__/recurrence-dst-gap.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { expandRecurringEvents } from '../recurrence-expansion'; + +// Daily expansion advanced a Date across the device's DST gap and kept the +// shifted time (02:30 became 03:30, midnight became 01:00); the implicit +// byHour then rejected every later day, so the series ended at the gap. + +function daily(start: string, allDay: boolean, timeZone?: string): CalendarEvent { + return { + id: 'E1', uid: 'u1', title: 'Daily', calendarIds: { c: true }, + start, duration: allDay ? 'P1D' : 'PT30M', showWithoutTime: allDay, + ...(timeZone ? { timeZone } : {}), + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }], + } as unknown as CalendarEvent; +} + +let originalTZ: string | undefined; +beforeEach(() => { + originalTZ = process.env.TZ; +}); +afterEach(() => { + process.env.TZ = originalTZ; +}); + +describe('daily recurrence across a DST gap', () => { + it('keeps a 02:30 series going past the spring-forward night (Berlin)', () => { + process.env.TZ = 'Europe/Berlin'; + const out = expandRecurringEvents([daily('2026-03-01T02:30:00', false, 'Europe/Berlin')], '2026-03-01T00:00:00', '2026-04-01T00:00:00'); + expect(out).toHaveLength(31); + expect(out.at(-1)!.start.slice(0, 16)).toBe('2026-03-31T02:30'); + }); + + it('keeps an all-day series going after a midnight gap (Santiago)', () => { + process.env.TZ = 'America/Santiago'; + const out = expandRecurringEvents([daily('2026-08-20T00:00:00', true)], '2026-09-07T00:00:00', '2026-09-14T00:00:00'); + expect(out).toHaveLength(7); + expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00')); + }); +}); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index cdef6eba..fae990ec 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -524,6 +524,16 @@ function generateCandidatesForPeriod( candidates = expandWeekly(periodStart, rule, eventStart); break; 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( + periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), + eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(), + )]; + break; case 'hourly': case 'minutely': case 'secondly': @@ -532,7 +542,10 @@ function generateCandidatesForPeriod( break; } - candidates = candidates.filter((d) => matchesByX(d, rule)); + // A daily candidate is judged by the time it was built for: on the day of + // a DST gap that time does not exist and the occurrence moves past the gap + // (RFC 5545 3.3.5), which is still this occurrence. + candidates = candidates.filter((d) => matchesByX(d, rule, freq === 'daily' ? eventStart : undefined)); candidates.sort((a, b) => a.getTime() - b.getTime()); return candidates; } @@ -660,7 +673,7 @@ function expandWeekly( return dates.sort((a, b) => a.getTime() - b.getTime()); } -function matchesByX(date: Date, rule: RecurrenceRule): boolean { +function matchesByX(date: Date, rule: RecurrenceRule, timeOf: Date = date): boolean { if (rule.byMonth?.length) { const month = String(date.getMonth() + 1); if (!rule.byMonth.some((m) => m.replace('L', '') === month)) return false; @@ -706,9 +719,9 @@ function matchesByX(date: Date, rule: RecurrenceRule): boolean { }) ) return false; } - if (rule.byHour?.length && !rule.byHour.includes(date.getHours())) return false; - if (rule.byMinute?.length && !rule.byMinute.includes(date.getMinutes())) return false; - if (rule.bySecond?.length && !rule.bySecond.includes(date.getSeconds())) return false; + if (rule.byHour?.length && !rule.byHour.includes(timeOf.getHours())) return false; + if (rule.byMinute?.length && !rule.byMinute.includes(timeOf.getMinutes())) return false; + if (rule.bySecond?.length && !rule.bySecond.includes(timeOf.getSeconds())) return false; return true; }