From 54f4d788d80cd38bd3ecbaba8625cbb24dce6701 Mon Sep 17 00:00:00 2001 From: Pascal Pfammatter <304897782+pascalpfammatter@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:04:23 +0200 Subject: [PATCH] fix(calendar): keep yearly events in DTSTART's month when BYMONTH is missing Several calendar clients export a yearly event as DTSTART;VALUE=DATE:20231002 RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2 restating DTSTART's day-of-month in BYMONTHDAY but omitting BYMONTH. Since BYMONTHDAY is an expanding rule part for FREQ=YEARLY, the recurrence expands to the 2nd of every month, so the event shows up twelve times a year instead of once. Google Calendar and the clients that emit this render it once a year on DTSTART's date. Confine such a rule to DTSTART's month, but only when it is provably a redundant restatement of DTSTART rather than a real expansion: BYMONTHDAY must hold exactly one value, that value must equal DTSTART's day-of-month, and no other BYxxx part may shape the recurrence. Rules that genuinely expand, such as FREQ=YEARLY;BYMONTHDAY=1,3 or FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FR, are untouched. Refs #2547, #3047 --- .../calendar/calendarfetcherutils.js | 54 +++++++++++++ .../calendar/calendar_fetcher_utils_spec.js | 79 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 59d0823520..f6b9dda196 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -222,6 +222,56 @@ const CalendarFetcherUtils = { } }, + /** + * Detects yearly rules that restate DTSTART's day-of-month in BYMONTHDAY but omit BYMONTH, + * as exported by several calendar clients (typically for birthdays), e.g. + * + * DTSTART;VALUE=DATE:20231002 + * RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2 + * + * RFC 5545 makes BYMONTHDAY an *expanding* rule part for FREQ=YEARLY, so a conforming + * expander returns the 2nd of every month - the event shows up twelve times a year + * instead of once. The clients that emit this, and the web UIs that render it, all show + * it once a year on DTSTART's date, so we follow the author's evident intent. + * + * The test is deliberately narrow, so rules that genuinely expand are left alone: + * BYMONTHDAY must carry exactly one value, that value must equal DTSTART's day-of-month + * (i.e. it merely restates DTSTART), and no other BYxxx part may shape the recurrence. + * @param {object} event The recurring event object + * @returns {boolean} True if the rule should be confined to DTSTART's month + */ + isYearlyRuleMissingByMonth (event) { + const rule = event.rrule; + if (!rule) { + return false; + } + + const options = typeof rule.options === "function" ? rule.options() : rule.options; + // node-ical >= 0.26 reports freq as a string, earlier versions used rrule.js' numeric RRule.YEARLY. + if (!options || (options.freq !== "YEARLY" && options.freq !== 0)) { + return false; + } + + const byMonthDay = options.byMonthDay ?? options.bymonthday; + if (!Array.isArray(byMonthDay) || byMonthDay.length !== 1) { + return false; + } + + // Any further BYxxx part means the rule shapes the recurrence on purpose. + const shapingParts = [ + options.byMonth ?? options.bymonth, + options.byDay ?? options.byweekday, + options.byYearDay ?? options.byyearday, + options.byWeekNo ?? options.byweekno, + options.bySetPos ?? options.bysetpos + ]; + if (shapingParts.some((part) => (Array.isArray(part) ? part.length > 0 : part !== undefined && part !== null))) { + return false; + } + + return byMonthDay[0] === event.start.getDate(); + }, + /** * Expands a recurring event into individual event instances using node-ical. * Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events. @@ -232,6 +282,9 @@ const CalendarFetcherUtils = { */ expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) { const localTimezone = CalendarFetcherUtils.getLocalTimezone(); + // Drop the eleven spurious months produced by a yearly rule that is missing BYMONTH. + const confineToStartMonth = CalendarFetcherUtils.isYearlyRuleMissingByMonth(event); + const startMonth = event.start.getMonth(); return ical .expandRecurringEvent(event, { @@ -241,6 +294,7 @@ const CalendarFetcherUtils = { excludeExdates: true, expandOngoing: true }) + .filter((inst) => !confineToStartMonth || inst.start.getMonth() === startMonth) .map((inst) => { let startMoment, endMoment; if (inst.isFullDay) { diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js index 89af3a6392..657c52eee0 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -517,4 +517,83 @@ END:VCALENDAR`); expect(filteredEvents[0].location).toBe("Berlin"); }); }); + + describe("yearly events that restate DTSTART's day in BYMONTHDAY but omit BYMONTH", () => { + // See GitHub issues #2547 and #3047: several calendar clients export a yearly + // event (typically a birthday) as FREQ=YEARLY;BYMONTHDAY= without + // a BYMONTH part. RFC 5545 makes BYMONTHDAY an *expanding* rule part for YEARLY, + // so a conforming expander returns that day in every month - the event then shows + // up twelve times a year instead of once. + + const yearConfig = { ...defaultConfig, maximumNumberOfDays: 365 }; + + const buildEvent = function (rrule, dtstart = "20231002", dtend = "20231003") { + return ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:${dtstart} +DTEND;VALUE=DATE:${dtend} +RRULE:${rrule} +DTSTAMP:20230425T111027Z +UID:yearly-bymonthday@example.com +SUMMARY:Ted Birthday +END:VEVENT +END:VCALENDAR`); + }; + + const monthDaysOf = (events) => events.map((event) => moment(event.startDate, "x").format("MM-DD")); + + it("should occur only in DTSTART's month when BYMONTH is missing", () => { + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays.length).toBeGreaterThan(0); + expect(monthDays).toEqual(monthDays.map(() => "10-02")); + }); + + it("should still expand a rule that lists several days of the month", () => { + // FREQ=YEARLY;BYMONTHDAY=1,3 legitimately expands across the whole year. + const data = buildEvent("FREQ=YEARLY;BYMONTHDAY=1,3"); + + const months = new Set(monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)).map((md) => md.slice(0, 2))); + + expect(months.size).toBe(12); + }); + + it("should still expand a rule that also constrains the weekday", () => { + // "Every Friday the 13th" - BYDAY shapes the recurrence, so it must expand. + const data = buildEvent("FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FR"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays.length).toBeGreaterThan(0); + expect(monthDays.every((md) => md.endsWith("-13"))).toBe(true); + expect(monthDays.some((md) => !md.startsWith("10"))).toBe(true); + }); + + it("should still expand when BYMONTHDAY does not match DTSTART's day", () => { + // The day was not simply restated from DTSTART, so the rule means something else. + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=7"); + + const months = new Set(monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)).map((md) => md.slice(0, 2))); + + expect(months.size).toBe(12); + }); + + it("should keep a well-formed yearly rule with BYMONTH on its single date", () => { + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2;BYMONTH=10"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays).toEqual(["10-02"]); + }); + + it("should keep a plain yearly rule on its single date", () => { + const data = buildEvent("FREQ=YEARLY"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays).toEqual(["10-02"]); + }); + }); });