fix: keep daily recurring events going past a DST gap

This commit is contained in:
Linus Rath
2026-10-29 03:18:00 +01:00
parent 6f475c0c52
commit 4de856923b
2 changed files with 58 additions and 5 deletions
@@ -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'));
});
});
+18 -5
View File
@@ -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;
}