mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
fix: keep daily recurring events going past a DST gap
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user