Skip to content

Commit 68a1e14

Browse files
refactor(calendar): improve recurring event handling clarity and robustness
- enhance comments for better understanding of timezone conversions and recurrence logic - replace `arrow functions` with `named functions` for improved readability - compact `dtstart` normalization code to reduce duplication - add conditional `tzid` reset to prevent errors on missing options - snap all-day recurrences to event timezone midnight using `startOf("day")` to fix date drift
1 parent fbca0a0 commit 68a1e14

File tree

1 file changed

+42
-24
lines changed

1 file changed

+42
-24
lines changed

modules/default/calendar/calendarfetcherutils.js

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -84,47 +84,65 @@ const CalendarFetcherUtils = {
8484
*/
8585
getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {
8686
const rule = event.rrule;
87-
88-
// can cause problems with e.g. birthdays before 1900
89-
if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
87+
const isFullDay = CalendarFetcherUtils.isFullDayEvent(event);
88+
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
89+
const eventTimezone = event.start && event.start.tz ? event.start.tz : localTimezone;
90+
91+
// rrule.js interprets years < 1900 as offsets from 1900 which breaks parsing for
92+
// some imported calendars (notably Google birthday calendars). Normalise those
93+
// values before we expand the recurrence window.
94+
if (rule.origOptions?.dtstart instanceof Date && rule.origOptions.dtstart.getFullYear() < 1900) {
9095
rule.origOptions.dtstart.setYear(1900);
96+
}
97+
if (rule.options?.dtstart instanceof Date && rule.options.dtstart.getFullYear() < 1900) {
9198
rule.options.dtstart.setYear(1900);
9299
}
93100

94-
// subtract the max of the duration of this event or 1 day to find events in the past that are currently still running and should therefor be displayed.
95-
const oneDayInMs = 24 * 60 * 60000;
96-
let searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
97-
let searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
101+
// Expand the search window by the event duration (or a full day) so ongoing recurrences are included.
102+
// Without this buffer a long-running recurrence that already started before "pastLocalMoment"
103+
// would be skipped even though it is still active.
104+
const oneDayInMs = 24 * 60 * 60 * 1000;
105+
const searchWindowMs = Math.max(durationInMs, oneDayInMs);
106+
const searchFromDate = pastLocalMoment.clone().subtract(searchWindowMs, "milliseconds").toDate();
107+
const searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
98108
Log.debug(`Search for recurring events between: ${searchFromDate} and ${searchToDate}`);
99109

100-
// if until is set, and its a full day event, force the time to midnight. rrule gets confused with non-00 offset
101-
// looks like MS Outlook sets the until time incorrectly for fullday events
102-
if ((rule.options.until !== undefined) && CalendarFetcherUtils.isFullDayEvent(event)) {
110+
if (isFullDay && rule.options && rule.options.until) {
111+
// node-ical supplies "until" in UTC for all-day events; push the date to the end of
112+
// that day so the last occurrence is part of the set we request from rrule.js.
103113
Log.debug("fixup rrule until");
104114
rule.options.until = moment(rule.options.until).clone().startOf("day").add(1, "day")
105115
.toDate();
106116
}
107117

108-
Log.debug("fix rrule start=", rule.options.dtstart);
118+
Log.debug("fix rrule start=", rule.options?.dtstart);
109119
Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate);
110-
111120
Log.debug(`RRule: ${rule.toString()}`);
112-
rule.options.tzid = null; // RRule gets *very* confused with timezones
113121

114-
let dates = rule.between(searchFromDate, searchToDate, true, () => {
115-
return true;
116-
});
122+
if (rule.options) {
123+
// Let moment.js handle the timezone conversion afterwards. Keeping tzid here lets
124+
// rrule.js double adjust the times which causes one-day drifts.
125+
rule.options.tzid = null;
126+
}
117127

118-
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(dates)}\n`);
128+
const rawDates = rule.between(searchFromDate, searchToDate, true, () => true) || [];
129+
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(rawDates)}\n`);
119130

120-
// shouldn't need this anymore, as RRULE not passed junk
121-
dates = dates.filter((d) => {
122-
return JSON.stringify(d) !== "null";
131+
const validDates = rawDates.filter(Boolean);
132+
return validDates.map((date) => {
133+
const baseUtcMoment = moment.tz(date, "UTC");
134+
if (isFullDay) {
135+
// Convert the UTC timestamp into the configured event timezone and clamp to the
136+
// start of that day so the calendar date stays consistent across viewer timezones.
137+
return baseUtcMoment.clone().tz(eventTimezone).startOf("day");
138+
}
139+
if (event.start && event.start.tz) {
140+
// Preserve the original start timezone when the ICS explicitly defines one.
141+
return baseUtcMoment.clone().tz(event.start.tz, true);
142+
}
143+
// Fallback: render in the viewer's local timezone while keeping the absolute instant.
144+
return baseUtcMoment.clone().tz(localTimezone, true);
123145
});
124-
125-
// Dates are returned in UTC timezone but with localdatetime because tzid is null.
126-
// So we map the date to a moment using the original timezone of the event.
127-
return dates.map((d) => (event.start.tz ? moment.tz(d, "UTC").tz(event.start.tz, true) : moment.tz(d, "UTC").tz(CalendarFetcherUtils.getLocalTimezone(), true)));
128146
},
129147

130148
/**

0 commit comments

Comments
 (0)