From ad1f12dd72b6e08de116d1b024d1c1f2cd70ec43 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:29 +0100 Subject: [PATCH 01/19] chore: update node-ical to version 0.25.4 --- package-lock.json | 27 +++++++++++++++++++++------ package.json | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84eff058f5..fd92837595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "ipaddr.js": "^2.3.0", "moment": "^2.30.1", "moment-timezone": "^0.6.0", - "node-ical": "^0.24.1", + "node-ical": "^0.25.4", "nunjucks": "^3.2.4", "pm2": "^6.0.14", "socket.io": "^4.8.3", @@ -8964,13 +8964,13 @@ } }, "node_modules/node-ical": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.24.2.tgz", - "integrity": "sha512-2wJJZE/X3KKLTBtRHqgzJQkMVpTtvFrdQU2Kq02mCNI4QWshqKSuLRXZl5dPy1gF+7XzpFNxKtxHIwLL2q1BqQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.25.4.tgz", + "integrity": "sha512-nuKpkcy0f8Qh/H1arBjy8X+iQmpb0gvIlKSFOCsPOkCRDCGnEjsr0aPCHAAUckNTv4yoJmCf4ia9jbookZEHSg==", "license": "Apache-2.0", "dependencies": { - "@js-temporal/polyfill": "^0.5.1", - "rrule-temporal": "^1.4.5" + "rrule-temporal": "^1.4.6", + "temporal-polyfill": "^0.3.0" }, "engines": { "node": ">=18" @@ -11374,6 +11374,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz", + "integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz", + "integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==", + "license": "ISC" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 89d9ae83bd..8bc5c4b30b 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "ipaddr.js": "^2.3.0", "moment": "^2.30.1", "moment-timezone": "^0.6.0", - "node-ical": "^0.24.1", + "node-ical": "^0.25.4", "nunjucks": "^3.2.4", "pm2": "^6.0.14", "socket.io": "^4.8.3", From d238a7f4a3aa568770239ec743227d77f2fb4e4b Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:30 +0100 Subject: [PATCH 02/19] refactor(calendar): use node-ical expandRecurringEvent for recurring event expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom getMomentsFromRecurringEvent + expandRecurringEvent with node-ical's built-in expandRecurringEvent which handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events. - Remove getMomentsFromRecurringEvent (no longer needed) - Simplify expandRecurringEvent to a thin wrapper (Date→Moment conversion) - Remove year < 1900 workaround (not needed with rrule-temporal) - Remove unused durationMs variable - Update tests to use new API --- .../calendar/calendarfetcherutils.js | 124 ++++-------------- .../calendar/calendar_fetcher_utils_spec.js | 47 +++---- 2 files changed, 46 insertions(+), 125 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index e0971f5759..e994dd263e 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -2,6 +2,7 @@ * @external Moment */ const moment = require("moment-timezone"); +const ical = require("node-ical"); const Log = require("logger"); @@ -40,44 +41,6 @@ const CalendarFetcherUtils = { return moment.tz.guess(); }, - /** - * This function returns a list of moments for a recurring event. - * @param {object} event the current event which is a recurring event - * @param {moment.Moment} pastLocalMoment The past date to search for recurring events - * @param {moment.Moment} futureLocalMoment The future date to search for recurring events - * @param {number} durationInMs the duration of the event, this is used to take into account currently running events - * @returns {moment.Moment[]} All moments for the recurring event - */ - getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) { - const rule = event.rrule; - const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event); - const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone(); - - // rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars - if (rule.origOptions?.dtstart?.getFullYear() < 1900) { - rule.origOptions.dtstart.setFullYear(1900); - } - if (rule.options?.dtstart?.getFullYear() < 1900) { - rule.options.dtstart.setFullYear(1900); - } - - // Expand search window to include ongoing events - const oneDayInMs = 24 * 60 * 60 * 1000; - const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate(); - const searchToDate = futureLocalMoment.clone().add(1, "days").toDate(); - - const dates = rule.between(searchFromDate, searchToDate, true) || []; - - // Convert dates to moments in the event's timezone. - // Full-day events need UTC component extraction to avoid date shifts across timezone boundaries. - return dates.map((date) => { - if (isFullDayEvent) { - return moment.tz([date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()], eventTimezone); - } - return moment.tz(date, eventTimezone); - }); - }, - /** * Filter the events from ical according to the given config * @param {object} data the calendar data from ical @@ -146,17 +109,13 @@ const CalendarFetcherUtils = { Log.debug(`start: ${eventStartMoment.toDate()}`); Log.debug(`end: ${eventEndMoment.toDate()}`); - // Calculate the duration of the event for use with recurring events. - const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf(); - Log.debug(`duration: ${durationMs}`); - const location = event.location || false; const geo = event.geo || false; const description = event.description || false; let instances = []; if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { - instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); + instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); } else { const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); let end = eventEndMoment; @@ -291,65 +250,36 @@ const CalendarFetcherUtils = { }, /** - * Expands a recurring event into individual event instances. + * Expands a recurring event into individual event instances using node-ical. + * Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events. * @param {object} event The recurring event object * @param {moment.Moment} pastLocalMoment The past date limit * @param {moment.Moment} futureLocalMoment The future date limit - * @param {number} durationMs The duration of the event in milliseconds - * @returns {object[]} Array of event instances + * @returns {object[]} Array of event instances with startMoment/endMoment in the local timezone */ - expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) { - const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); - const instances = []; - - for (const startMoment of moments) { - let curEvent = event; - let showRecurrence = true; - let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone()); - let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms"); - - // For full-day events, use local date components to match node-ical's getDateKey behavior - // For timed events, use UTC to match ISO string slice - const isFullDay = CalendarFetcherUtils.isFullDayEvent(event); - const dateKey = isFullDay - ? recurringEventStartMoment.format("YYYY-MM-DD") - : recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD"); - - // Check for overrides - if (curEvent.recurrences !== undefined) { - if (curEvent.recurrences[dateKey] !== undefined) { - curEvent = curEvent.recurrences[dateKey]; - // Re-calculate start/end based on override - const start = curEvent.start; - const end = curEvent.end; - const localTimezone = CalendarFetcherUtils.getLocalTimezone(); - - recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone); - recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone); - } - } - - // Check for exceptions - if (curEvent.exdate !== undefined) { - if (curEvent.exdate[dateKey] !== undefined) { - showRecurrence = false; + expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) { + const localTimezone = CalendarFetcherUtils.getLocalTimezone(); + + return ical + .expandRecurringEvent(event, { + from: pastLocalMoment.toDate(), + to: futureLocalMoment.toDate(), + includeOverrides: true, + excludeExdates: true, + expandOngoing: true + }) + .map((inst) => { + let startMoment, endMoment; + if (inst.isFullDay) { + startMoment = moment.tz([inst.start.getFullYear(), inst.start.getMonth(), inst.start.getDate()], localTimezone); + endMoment = moment.tz([inst.end.getFullYear(), inst.end.getMonth(), inst.end.getDate()], localTimezone); + } else { + startMoment = moment(inst.start).tz(localTimezone); + endMoment = moment(inst.end).tz(localTimezone); } - } - - if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) { - recurringEventEndMoment = recurringEventEndMoment.endOf("day"); - } - - if (showRecurrence) { - instances.push({ - event: curEvent, - startMoment: recurringEventStartMoment, - endMoment: recurringEventEndMoment, - isRecurring: true - }); - } - } - return instances; + if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day"); + return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring }; + }); }, /** 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 31a179a520..56d9ff2920 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -94,24 +94,18 @@ DTSTART;TZID=Europe/Amsterdam:20250311T090000 DTEND;TZID=Europe/Amsterdam:20250311T091500 RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU DTSTAMP:20250531T091103Z -ORGANIZER;CN=test:mailto:test@test.com UID:67e65a1d-b889-4451-8cab-5518cecb9c66 -CREATED:20230111T114612Z -DESCRIPTION:Test -LAST-MODIFIED:20250528T071312Z -SEQUENCE:1 -STATUS:CONFIRMED SUMMARY:Test -TRANSP:OPAQUE END:VEVENT`); - const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); + const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); - const januaryFirst = moments.filter((m) => m.format("MM-DD") === "01-01"); - const julyFirst = moments.filter((m) => m.format("MM-DD") === "07-01"); + const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01"); + const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01"); - expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00"); - expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00"); + // The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone + expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00"); + expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00"); }); it("should return correct day-of-week for full-day recurring events across DST transitions", () => { @@ -128,32 +122,29 @@ SUMMARY:Weekly Monday Event END:VEVENT END:VCALENDAR`); - const event = data["dst-test@google.com"]; - // Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config) // This is how MagicMirror handles full-day events from calendars with timezones - event.start.tz = "America/Chicago"; + data["dst-test@google.com"].start.tz = "America/Chicago"; const pastMoment = moment("2025-10-01"); const futureMoment = moment("2025-11-30"); - - // Get moments for the recurring event - const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastMoment, futureMoment, 0); + const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment); + const startMoments = instances.map((i) => i.startMoment); // All occurrences should be on Monday (day() === 1) at midnight // Oct 27, 2025 - Before DST ends // Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix) // Nov 10, 2025 - After DST ends - expect(moments).toHaveLength(3); - expect(moments[0].day()).toBe(1); // Monday - expect(moments[0].format("YYYY-MM-DD")).toBe("2025-10-27"); - expect(moments[0].hour()).toBe(0); // Midnight - expect(moments[1].day()).toBe(1); // Monday (not Sunday!) - expect(moments[1].format("YYYY-MM-DD")).toBe("2025-11-03"); - expect(moments[1].hour()).toBe(0); // Midnight - expect(moments[2].day()).toBe(1); // Monday - expect(moments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); - expect(moments[2].hour()).toBe(0); // Midnight + expect(startMoments).toHaveLength(3); + expect(startMoments[0].day()).toBe(1); // Monday + expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27"); + expect(startMoments[0].hour()).toBe(0); // Midnight + expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!) + expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03"); + expect(startMoments[1].hour()).toBe(0); // Midnight + expect(startMoments[2].day()).toBe(1); // Monday + expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); + expect(startMoments[2].hour()).toBe(0); // Midnight }); }); }); From 60f49efdb8d7e12660123f13c678cb7e5cc8a06d Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:30 +0100 Subject: [PATCH 03/19] fix(calendar): correctly destructure eventFilterUntil from shouldEventBeExcluded The return value uses 'until', not 'eventFilterUntil', so the destructuring always yielded undefined, effectively disabling the time-based event filter (excludedEvents with 'until'). --- defaultmodules/calendar/calendarfetcherutils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index e994dd263e..2de2d0dad4 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -74,7 +74,7 @@ const CalendarFetcherUtils = { Log.debug(`title: ${title}`); // Return quickly if event should be excluded. - let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title); + let { excluded, until: eventFilterUntil } = this.shouldEventBeExcluded(config, title); if (excluded) { return; } From 509ddd67d4cbc652136df2a0a6e00ef46e7a8a56 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:30 +0100 Subject: [PATCH 04/19] test(calendar): add tests for excludedEvents with 'until' time filter Cover the scenario where an event matches an excludedEvents filter that has an 'until' property (e.g. {filterBy: 'Payment', until: '3 days'}). The event should be hidden when far away and shown when close to its end. Also add a basic test for simple string-based excludedEvents. --- .../calendar/calendar_fetcher_utils_spec.js | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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 56d9ff2920..66d5bf84d6 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -54,6 +54,58 @@ describe("Calendar fetcher utils test", () => { expect(filteredEvents[1].title).toBe("upcomingEvent"); }); + it("should hide excluded event with 'until' when far away and show it when close", () => { + // An event ending in 10 days with until='3 days' should be hidden now + const farStart = moment().add(9, "days").toDate(); + const farEnd = moment().add(10, "days").toDate(); + // An event ending in 1 day with until='3 days' should be shown (within 3 days of end) + const closeStart = moment().add(1, "hours").toDate(); + const closeEnd = moment().add(1, "days").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: [{ filterBy: "Payment", until: "3 days" }] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" }, + closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" }, + normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" } + }, + config + ); + + // farPayment should be hidden (now < endDate - 3 days) + // closePayment should show (now >= endDate - 3 days) + // normalEvent should show (not matched by filter) + const titles = filteredEvents.map((e) => e.title); + expect(titles).not.toContain("Payment due"); + expect(titles).toContain("Payment reminder"); + expect(titles).toContain("Normal event"); + }); + + it("should fully exclude event when excludedEvents has no 'until'", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: ["Hidden"] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + hidden: { type: "VEVENT", start, end, summary: "Hidden event" }, + visible: { type: "VEVENT", start, end, summary: "Visible event" } + }, + config + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].title).toBe("Visible event"); + }); + it("should return the correct times when recurring events pass through daylight saving time", () => { const data = ical.parseICS(`BEGIN:VEVENT DTSTART;TZID=Europe/Amsterdam:20250311T090000 From b6a92906e052a8a86f794359402adac1a98b287c Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:31 +0100 Subject: [PATCH 05/19] refactor(calendar): remove Facebook birthday workaround, rrule-temporal handles it correctly Previously, Facebook birthday calendars (DTSTART with the birth year, e.g. 1990) triggered a special-case bypass that skipped expandRecurringEvent entirely. This was needed because rrule.js couldn't handle very old dates correctly. rrule-temporal handles pre-1900/old dates via the Temporal API, so the workaround is no longer needed. Facebook birthday events now go through expandRecurringEvent like any other yearly recurring event. Adds a regression test to verify the birthday shows in the current/upcoming year, not in the birth year. --- .../calendar/calendarfetcherutils.js | 23 +++----------- .../calendar/calendar_fetcher_utils_spec.js | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 2de2d0dad4..4b3b1245ab 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -79,15 +79,6 @@ const CalendarFetcherUtils = { return; } - // FIXME: Ugly fix to solve the facebook birthday issue. - // Otherwise, the recurring events only show the birthday for next year. - let isFacebookBirthday = false; - if (typeof event.uid !== "undefined") { - if (event.uid.indexOf("@facebook.com") !== -1) { - isFacebookBirthday = true; - } - } - if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); let eventStartMoment = eventDate(event, "start"); @@ -98,12 +89,8 @@ const CalendarFetcherUtils = { } else if (typeof event.duration !== "undefined") { eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); } else { - if (!isFacebookBirthday) { - // make copy of start date, separate storage area - eventEndMoment = eventStartMoment.clone(); - } else { - eventEndMoment = eventStartMoment.clone().add(1, "days"); - } + // make copy of start date, separate storage area + eventEndMoment = eventStartMoment.clone(); } Log.debug(`start: ${eventStartMoment.toDate()}`); @@ -114,10 +101,10 @@ const CalendarFetcherUtils = { const description = event.description || false; let instances = []; - if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { + if (event.rrule !== undefined) { instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); } else { - const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); + const fullDayEvent = CalendarFetcherUtils.isFullDayEvent(event); let end = eventEndMoment; if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) { end = end.endOf("day"); @@ -144,7 +131,7 @@ const CalendarFetcherUtils = { } const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); - const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); + const fullDay = CalendarFetcherUtils.isFullDayEvent(event); Log.debug(`saving event: ${title}`); newEvents.push({ 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 66d5bf84d6..ae0c9fe741 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -198,5 +198,36 @@ END:VCALENDAR`); expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); expect(startMoments[2].hour()).toBe(0); // Midnight }); + + it("should show Facebook birthday events in the current year, not in the birth year", () => { + // Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990), + // which previously caused rrule.js to return the wrong year occurrence. + // With rrule-temporal this works correctly without any special-casing. + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900215 +RRULE:FREQ=YEARLY +DTSTAMP:20260101T000000Z +UID:birthday_123456789@facebook.com +SUMMARY:Jane Doe's Birthday +END:VEVENT +END:VCALENDAR`); + + const thisYear = moment().year(); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday"); + expect(birthdayEvents.length).toBeGreaterThanOrEqual(1); + + // The event must expand to a recent year — NOT to the birth year 1990. + // It should be the current or next year depending on whether Feb 15 has already passed. + const startYear = moment(birthdayEvents[0].startDate, "x").year(); + expect(startYear).toBeGreaterThanOrEqual(thisYear); + expect(startYear).toBeLessThanOrEqual(thisYear + 1); + }); }); }); From 2560ba01d85ec65454dfca6a287c4a4178f116e6 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:31 +0100 Subject: [PATCH 06/19] refactor(calendar): remove dead code in isFullDayEvent event.start is always a Date object, so event.start.length is always undefined and the length === 8 check never matched. --- defaultmodules/calendar/calendarfetcherutils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 4b3b1245ab..4064b87197 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -179,7 +179,7 @@ const CalendarFetcherUtils = { * @returns {boolean} True if the event is a fullday event, false otherwise */ isFullDayEvent (event) { - if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") { + if (event.start.dateOnly || event.datetype === "date") { return true; } From 9c3152ddb84217057ab71c3f07516bcf267659da Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:32 +0100 Subject: [PATCH 07/19] refactor(calendar): route all events through expandRecurringEvent Remove the manual if/else branch that handled recurring vs. non-recurring events separately. ical.expandRecurringEvent() handles both cases, so the duplicated eventDate() helper, eventStartMoment/eventEndMoment setup, and the manual duration/end fallback are no longer needed. - Remove eventDate() helper function - Remove manual eventStartMoment/eventEndMoment calculation - Replace if (event.rrule) / else branch with a single expandRecurringEvent() call - Merge start/end into the "saving event" debug log (one line per saved instance) Test: fix full-day event fixture to use ICS-compliant DTEND (exclusive next day) and set dateOnly = true so the same code path as real ICS data is exercised. --- .../calendar/calendarfetcherutils.js | 39 +------------------ .../calendar/calendar_fetcher_utils_spec.js | 15 +++++-- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 4064b87197..670c513f81 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -50,11 +50,6 @@ const CalendarFetcherUtils = { filterEvents (data, config) { const newEvents = []; - const eventDate = function (event, time) { - const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone()); - return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment; - }; - Log.debug(`There are ${Object.entries(data).length} calendar entries.`); const now = moment(); @@ -81,42 +76,12 @@ const CalendarFetcherUtils = { if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - let eventStartMoment = eventDate(event, "start"); - let eventEndMoment; - - if (typeof event.end !== "undefined") { - eventEndMoment = eventDate(event, "end"); - } else if (typeof event.duration !== "undefined") { - eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); - } else { - // make copy of start date, separate storage area - eventEndMoment = eventStartMoment.clone(); - } - - Log.debug(`start: ${eventStartMoment.toDate()}`); - Log.debug(`end: ${eventEndMoment.toDate()}`); const location = event.location || false; const geo = event.geo || false; const description = event.description || false; - let instances = []; - if (event.rrule !== undefined) { - instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); - } else { - const fullDayEvent = CalendarFetcherUtils.isFullDayEvent(event); - let end = eventEndMoment; - if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) { - end = end.endOf("day"); - } - - instances.push({ - event: event, - startMoment: eventStartMoment, - endMoment: end, - isRecurring: false - }); - } + const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); for (const instance of instances) { const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance; @@ -133,7 +98,7 @@ const CalendarFetcherUtils = { const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); const fullDay = CalendarFetcherUtils.isFullDayEvent(event); - Log.debug(`saving event: ${title}`); + Log.debug(`saving event: ${title}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`); newEvents.push({ title: title, startDate: startMoment.format("x"), 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 ae0c9fe741..42c20f1988 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -39,12 +39,19 @@ describe("Calendar fetcher utils test", () => { const yesterday = moment().subtract(1, "days").startOf("day").toDate(); const today = moment().startOf("day").toDate(); const tomorrow = moment().add(1, "days").startOf("day").toDate(); - + const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate(); + // Mark as DATE-only (full-day) events per ICS convention + yesterday.dateOnly = true; + today.dateOnly = true; + tomorrow.dateOnly = true; + dayAfterTomorrow.dateOnly = true; + + // ICS convention: DTEND for a full-day event is the exclusive next day const filteredEvents = CalendarFetcherUtils.filterEvents( { - pastEvent: { type: "VEVENT", start: yesterday, end: yesterday, summary: "pastEvent" }, - ongoingEvent: { type: "VEVENT", start: today, end: today, summary: "ongoingEvent" }, - upcomingEvent: { type: "VEVENT", start: tomorrow, end: tomorrow, summary: "upcomingEvent" } + pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" }, + ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" }, + upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" } }, defaultConfig ); From 6c3863a68ee44de4ea44167d85d4e0b17fd82d01 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:32 +0100 Subject: [PATCH 08/19] refactor(calendar): use isFullDay from instance, remove isFullDayEvent() Each instance returned by expandRecurringEvent already carries isFullDay as set by node-ical. Using it directly is more accurate than calling isFullDayEvent() on the base event, which would return the wrong value for RECURRENCE-ID overrides that change a full-day instance to a timed one (or vice versa). isFullDayEvent() is now unused and has been removed. --- .../calendar/calendarfetcherutils.js | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 670c513f81..128c612ef7 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -84,7 +84,7 @@ const CalendarFetcherUtils = { const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); for (const instance of instances) { - const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance; + const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance; // Filter logic if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { @@ -96,14 +96,13 @@ const CalendarFetcherUtils = { } const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); - const fullDay = CalendarFetcherUtils.isFullDayEvent(event); Log.debug(`saving event: ${title}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`); newEvents.push({ title: title, startDate: startMoment.format("x"), endDate: endMoment.format("x"), - fullDayEvent: fullDay, + fullDayEvent: isFullDay, recurringEvent: isRecurring, class: event.class, firstYear: event.start.getFullYear(), @@ -138,27 +137,6 @@ const CalendarFetcherUtils = { return title; }, - /** - * Checks if an event is a fullday event. - * @param {object} event The event object to check. - * @returns {boolean} True if the event is a fullday event, false otherwise - */ - isFullDayEvent (event) { - if (event.start.dateOnly || event.datetype === "date") { - return true; - } - - const start = event.start || 0; - const startDate = new Date(start); - const end = event.end || 0; - if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) { - // Is 24 hours, and starts on the middle of the night. - return true; - } - - return false; - }, - /** * Determines if the user defined time filter should apply * @param {moment.Moment} now Date object using previously created object for consistency @@ -230,7 +208,7 @@ const CalendarFetcherUtils = { endMoment = moment(inst.end).tz(localTimezone); } if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day"); - return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring }; + return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring, isFullDay: inst.isFullDay }; }); }, From 20a7b9afe8b60995226663577f64066f686739f9 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:32 +0100 Subject: [PATCH 09/19] fix(calendar): unwrap ParameterValue objects for description and location node-ical returns {val, params} objects for ICS properties that carry parameters (e.g. DESCRIPTION;LANGUAGE=de:Text, LOCATION;LANGUAGE=de:Ort). Without unwrapping, these were passed as raw objects to the frontend, causing "[object Object]" to be displayed. Add unwrapParameterValue() helper and apply it consistently to summary, description and location in filterEvents() and getTitleFromEvent(). Simplify getTitleFromEvent() to a single expression. Add 9 unit tests covering unwrapParameterValue, getTitleFromEvent, and an integration test for ParameterValue properties in filterEvents. --- .../calendar/calendarfetcherutils.js | 29 +++++---- .../calendar/calendar_fetcher_utils_spec.js | 62 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 128c612ef7..0ee409d60b 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -77,9 +77,9 @@ const CalendarFetcherUtils = { if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - const location = event.location || false; + const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false; const geo = event.geo || false; - const description = event.description || false; + const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false; const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); @@ -106,9 +106,9 @@ const CalendarFetcherUtils = { recurringEvent: isRecurring, class: event.class, firstYear: event.start.getFullYear(), - location: instanceEvent.location || location, + location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location, geo: instanceEvent.geo || geo, - description: instanceEvent.description || description + description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description }); } } @@ -127,14 +127,21 @@ const CalendarFetcherUtils = { * @returns {string} The title of the event, or "Event" if no title is found. */ getTitleFromEvent (event) { - let title = "Event"; - if (event.summary) { - title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary; - } else if (event.description) { - title = event.description; - } + return CalendarFetcherUtils.unwrapParameterValue(event.summary || event.description) || "Event"; + }, - return title; + /** + * Extracts the string value from a node-ical ParameterValue object ({val, params}) + * or returns the value as-is if it is already a plain string. + * This handles ICS properties with parameters, e.g. DESCRIPTION;LANGUAGE=de:Text. + * @param {string|object} value The raw value from node-ical + * @returns {string|object} The unwrapped string value, or the original value if not a ParameterValue + */ + unwrapParameterValue (value) { + if (value && typeof value === "object" && typeof value.val !== "undefined") { + return value.val; + } + return value; }, /** 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 42c20f1988..1c6251c0cf 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -237,4 +237,66 @@ END:VCALENDAR`); expect(startYear).toBeLessThanOrEqual(thisYear + 1); }); }); + + describe("unwrapParameterValue", () => { + it("should return the val of a ParameterValue object", () => { + expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text"); + }); + + it("should return a plain string unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain"); + }); + + it("should return falsy values unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined(); + expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false); + }); + }); + + describe("getTitleFromEvent", () => { + it("should return summary string directly", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event"); + }); + + it("should unwrap ParameterValue summary", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event"); + }); + + it("should fall back to description string", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc"); + }); + + it("should unwrap ParameterValue description as fallback title", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc"); + }); + + it("should return 'Event' when neither summary nor description is present", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event"); + }); + }); + + describe("filterEvents with ParameterValue properties", () => { + it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Test", + description: { val: "Beschreibung", params: { LANGUAGE: "de" } }, + location: { val: "Berlin", params: { LANGUAGE: "de" } } + } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].description).toBe("Beschreibung"); + expect(filteredEvents[0].location).toBe("Berlin"); + }); + }); }); From db4eb1c28cfb2ba5b023376764698141973ec274 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:33 +0100 Subject: [PATCH 10/19] refactor(calendar): replace this with CalendarFetcherUtils in filterEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only use of `this` in the file — consistent with all other method calls and safe when used as a callback. --- defaultmodules/calendar/calendarfetcherutils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 0ee409d60b..d63682035d 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -69,7 +69,7 @@ const CalendarFetcherUtils = { Log.debug(`title: ${title}`); // Return quickly if event should be excluded. - let { excluded, until: eventFilterUntil } = this.shouldEventBeExcluded(config, title); + let { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title); if (excluded) { return; } From 6fa2ad4ed625dc0d5ac382d8bf912c196e7046ed Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:33 +0100 Subject: [PATCH 11/19] docs(calendar): fix @returns type of filterEvents from string[] to object[] --- defaultmodules/calendar/calendarfetcherutils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index d63682035d..7914d4ed08 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -45,7 +45,7 @@ const CalendarFetcherUtils = { * Filter the events from ical according to the given config * @param {object} data the calendar data from ical * @param {object} config The configuration object - * @returns {string[]} the filtered events + * @returns {object[]} the filtered events */ filterEvents (data, config) { const newEvents = []; From 9ae21594d6a8d5e636aae6804c3d43f16670b972 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:33 +0100 Subject: [PATCH 12/19] refactor(calendar): replace deprecated substr and operator comparison - Replace filter.substr(1).slice(0,-1) with filter.slice(1,-1) in titleFilterApplies (substr is deprecated) - Replace now < filterUntil with now.isBefore(filterUntil) in timeFilterApplies (idiomatic Moment.js) --- defaultmodules/calendar/calendarfetcherutils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 7914d4ed08..95560ad196 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -158,7 +158,7 @@ const CalendarFetcherUtils = { increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js filterUntil = moment(endDate.format()).subtract(value, increment); - return now < filterUntil; + return now.isBefore(filterUntil); } return false; @@ -178,7 +178,7 @@ const CalendarFetcherUtils = { // Assume if leading slash, there is also trailing slash if (filter[0] === "/") { // Strip leading and trailing slashes - regexFilter = filter.substr(1).slice(0, -1); + regexFilter = filter.slice(1, -1); } return new RegExp(regexFilter, regexFlags).test(title); } else { From 739bbc8f7548a5065305e123f3a40cee734504fa Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:34 +0100 Subject: [PATCH 13/19] refactor(calendar): guard with early return on non-VEVENT entries Skip getTitleFromEvent and shouldEventBeExcluded for non-VEVENT entries (VTIMEZONE, VCALENDAR, etc.) by returning early. Removes one level of nesting and eliminates the shadowed title variable inside the loop (renamed to instanceTitle). --- .../calendar/calendarfetcherutils.js | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 95560ad196..51a306de5e 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -65,52 +65,54 @@ const CalendarFetcherUtils = { Object.entries(data).forEach(([key, event]) => { Log.debug("Processing entry..."); + if (event.type !== "VEVENT") { + return; + } + const title = CalendarFetcherUtils.getTitleFromEvent(event); Log.debug(`title: ${title}`); // Return quickly if event should be excluded. - let { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title); + const { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title); if (excluded) { return; } - if (event.type === "VEVENT") { - Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - - const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false; - const geo = event.geo || false; - const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false; - - const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); - - for (const instance of instances) { - const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance; - - // Filter logic - if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { - continue; - } - - if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) { - continue; - } - - const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); - - Log.debug(`saving event: ${title}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`); - newEvents.push({ - title: title, - startDate: startMoment.format("x"), - endDate: endMoment.format("x"), - fullDayEvent: isFullDay, - recurringEvent: isRecurring, - class: event.class, - firstYear: event.start.getFullYear(), - location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location, - geo: instanceEvent.geo || geo, - description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description - }); + Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); + + const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false; + const geo = event.geo || false; + const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false; + + const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); + + for (const instance of instances) { + const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance; + + // Filter logic + if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { + continue; } + + if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) { + continue; + } + + const instanceTitle = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); + + Log.debug(`saving event: ${instanceTitle}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`); + newEvents.push({ + title: instanceTitle, + startDate: startMoment.format("x"), + endDate: endMoment.format("x"), + fullDayEvent: isFullDay, + recurringEvent: isRecurring, + class: event.class, + firstYear: event.start.getFullYear(), + location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location, + geo: instanceEvent.geo || geo, + description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description + }); } }); From e3045f2fe499f55f34ab1ad6b7822f2c82b51223 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:34 +0100 Subject: [PATCH 14/19] fix(calendar): skip malformed events without losing the rest of the feed If expandRecurringEvent throws for a single event (e.g. due to corrupted ICS data), catch it in filterEvents, log the error, and continue with the remaining events. expandRecurringEvent itself remains clean and throws through. --- .../calendar/calendarfetcherutils.js | 8 ++++- .../calendar/calendar_fetcher_utils_spec.js | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 51a306de5e..cf133f5b10 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -84,7 +84,13 @@ const CalendarFetcherUtils = { const geo = event.geo || false; const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false; - const instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); + let instances; + try { + instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); + } catch (error) { + Log.error(`Could not expand event "${title}": ${error.message}`); + return; + } for (const instance of instances) { const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance; 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 1c6251c0cf..ec4faf9454 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -238,6 +238,39 @@ END:VCALENDAR`); }); }); + describe("filterEvents error handling", () => { + it("should skip a broken event but still return other valid events", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const icalSpy = vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const result = CalendarFetcherUtils.filterEvents( + { + brokenEvent: { type: "VEVENT", start, end, summary: "Broken" }, + goodEvent: { type: "VEVENT", start, end, summary: "Good" } + }, + defaultConfig + ); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Good"); + icalSpy.mockRestore(); + }); + + it("should let expandRecurringEvent throw through directly", () => { + const icalSpy = vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" }; + expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule"); + icalSpy.mockRestore(); + }); + }); + describe("unwrapParameterValue", () => { it("should return the val of a ParameterValue object", () => { expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text"); From a89e9a5296de627aa8463c1176c5d6e80ba0f1ff Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:34 +0100 Subject: [PATCH 15/19] test(calendar): add MM-specific tests for filterEvents output and no-DTEND fallback - filterEvents output shape: verifies all 9 fields (title, startDate, endDate, fullDayEvent, recurringEvent, class, firstYear, location, geo, description) are correctly populated - expandRecurringEvent no-DTEND: verifies MagicMirror's endOf("day") fallback when node-ical returns end === start --- .../calendar/calendar_fetcher_utils_spec.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 ec4faf9454..b614ea4fc5 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -236,6 +236,59 @@ END:VCALENDAR`); expect(startYear).toBeGreaterThanOrEqual(thisYear); expect(startYear).toBeLessThanOrEqual(thisYear + 1); }); + + it("should produce a correctly shaped event object with all required fields", () => { + const start = moment("2026-03-10T14:00:00").toDate(); + const end = moment("2026-03-10T15:00:00").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Team Meeting", + description: "Agenda TBD", + location: "Room 42", + geo: { lat: 52.52, lon: 13.4 }, + class: "PUBLIC", + uid: "shaped-event@test" + } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(1); + const ev = filteredEvents[0]; + expect(ev.title).toBe("Team Meeting"); + expect(ev.startDate).toBe(moment(start).format("x")); + expect(ev.endDate).toBe(moment(end).format("x")); + expect(ev.fullDayEvent).toBe(false); + expect(ev.recurringEvent).toBe(false); + expect(ev.class).toBe("PUBLIC"); + expect(ev.firstYear).toBe(2026); + expect(ev.location).toBe("Room 42"); + expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 }); + expect(ev.description).toBe("Agenda TBD"); + }); + }); + + describe("expandRecurringEvent", () => { + it("should extend end to end-of-day when event has no DTEND", () => { + // node-ical sets end === start when DTEND is absent; our code extends to endOf("day") + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260222T100000Z +UID:no-end-test@test +SUMMARY:No End Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24")); + + expect(instances).toHaveLength(1); + expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59"); + }); }); describe("filterEvents error handling", () => { From 950c99f6e6ac5b5811af0abb7dec6eda8a1c8611 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:35 +0100 Subject: [PATCH 16/19] refactor(calendar): tighten debug logging and add comments --- defaultmodules/calendar/calendarfetcherutils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index cf133f5b10..7234514d72 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -63,8 +63,6 @@ const CalendarFetcherUtils = { .subtract(1, "seconds"); Object.entries(data).forEach(([key, event]) => { - Log.debug("Processing entry..."); - if (event.type !== "VEVENT") { return; } @@ -78,7 +76,7 @@ const CalendarFetcherUtils = { return; } - Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); + Log.debug(`Event: ${title} | start: ${event.start} | end: ${event.end} | recurring: ${!!event.rrule}`); const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false; const geo = event.geo || false; @@ -222,6 +220,8 @@ const CalendarFetcherUtils = { startMoment = moment(inst.start).tz(localTimezone); endMoment = moment(inst.end).tz(localTimezone); } + // Events without DTEND (e.g. reminders) get start === end from node-ical; + // extend to end-of-day so they remain visible on the calendar. if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day"); return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring, isFullDay: inst.isFullDay }; }); From 7f2cc07f5dd40265300f1e3929612452906dfda8 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:35 +0100 Subject: [PATCH 17/19] test(calendar): add tests for RECURRENCE-ID overrides and DURATION events --- eslint.config.mjs | 3 +- .../calendar/calendar_fetcher_utils_spec.js | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8e1ed23c8a..b8f07d7941 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -144,7 +144,8 @@ export default defineConfig([ ], "vitest/max-nested-describe": ["error", { max: 3 }], "vitest/prefer-to-be": "error", - "vitest/prefer-to-have-length": "error" + "vitest/prefer-to-have-length": "error", + "max-lines-per-function": "off" } }, { 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 b614ea4fc5..9d0c1b8f0a 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -289,6 +289,95 @@ END:VCALENDAR`); expect(instances).toHaveLength(1); expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59"); }); + + it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => { + // A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00 + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260302T100000 +DTEND;TZID=Europe/Berlin:20260302T110000 +RRULE:FREQ=WEEKLY;COUNT=3 +UID:recurrence-override@test +SUMMARY:Weekly Standup +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260310T140000 +DTEND;TZID=Europe/Berlin:20260310T150000 +RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000 +UID:recurrence-override@test +SUMMARY:Moved Standup +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurrence-override@test"], + moment("2026-03-01"), + moment("2026-03-31") + ); + + expect(instances).toHaveLength(3); + + // First occurrence: Monday March 2, 10:00 (unchanged) + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup"); + + // Second occurrence: moved to Tuesday March 10, 14:00 + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup"); + + // Third occurrence: Monday March 16, 10:00 (unchanged) + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00"); + }); + + it("should handle events with DURATION instead of DTEND", () => { + // RFC 5545 allows DURATION as alternative to DTEND + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260315T090000Z +DURATION:PT1H30M +UID:duration-test@test +SUMMARY:Duration Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["duration-test@test"], + moment("2026-03-14"), + moment("2026-03-16") + ); + + expect(instances).toHaveLength(1); + // End should be 90 minutes after start + const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes"); + expect(durationMinutes).toBe(90); + }); + + it("should handle recurring events with DURATION instead of DTEND", () => { + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260301T080000 +DURATION:PT45M +RRULE:FREQ=DAILY;COUNT=3 +UID:recurring-duration@test +SUMMARY:Daily Scrum +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurring-duration@test"], + moment("2026-02-28"), + moment("2026-03-05") + ); + + expect(instances).toHaveLength(3); + for (const inst of instances) { + const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes"); + expect(durationMinutes).toBe(45); + } + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01"); + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02"); + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03"); + }); }); describe("filterEvents error handling", () => { From 29215f38898ff575ec452653017e961363528f02 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:35 +0100 Subject: [PATCH 18/19] test(calendar): add regression guard for firstYear on full-day Jan 1 events --- .../calendar/calendar_fetcher_utils_spec.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 9d0c1b8f0a..a8b45d7d28 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -271,6 +271,30 @@ END:VCALENDAR`); expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 }); expect(ev.description).toBe("Agenda TBD"); }); + + it("should return correct firstYear for a full-day event on January 1st", () => { + // node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day). + // getFullYear() on a locally-constructed date always returns the correct calendar year + // regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear(). + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900101 +DTEND;VALUE=DATE:19900102 +RRULE:FREQ=YEARLY +UID:newyear-birthday@test +SUMMARY:New Year Baby +END:VEVENT +END:VCALENDAR`); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthday = filteredEvents.find((e) => e.title === "New Year Baby"); + expect(birthday).toBeDefined(); + expect(birthday.firstYear).toBe(1990); + }); }); describe("expandRecurringEvent", () => { From 9bd76b1be8a96238c33dc108d7f090ef0760ef87 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:25:36 +0100 Subject: [PATCH 19/19] test: restore mocks automatically via restoreAllMocks in vitest config --- .../modules/default/calendar/calendar_fetcher_utils_spec.js | 6 ++---- vitest.config.mjs | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) 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 a8b45d7d28..20c40efce9 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -409,7 +409,7 @@ END:VCALENDAR`); const start = moment().add(1, "hours").toDate(); const end = moment().add(2, "hours").toDate(); - const icalSpy = vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { throw new TypeError("invalid rrule"); }); @@ -423,17 +423,15 @@ END:VCALENDAR`); expect(result).toHaveLength(1); expect(result[0].title).toBe("Good"); - icalSpy.mockRestore(); }); it("should let expandRecurringEvent throw through directly", () => { - const icalSpy = vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { throw new TypeError("invalid rrule"); }); const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" }; expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule"); - icalSpy.mockRestore(); }); }); diff --git a/vitest.config.mjs b/vitest.config.mjs index 75808c82fc..0829d89278 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -21,6 +21,8 @@ export default defineConfig({ setupFiles: ["./tests/utils/vitest-setup.js"], // Stop test execution on first failure bail: 3, + // Automatically restore all mocks after each test to prevent leaks + restoreAllMocks: true, // Shared exclude patterns exclude: [