diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index e0971f5759..7234514d72 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,58 +41,15 @@ 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 * @param {object} config The configuration object - * @returns {string[]} the filtered events + * @returns {object[]} the filtered events */ 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(); @@ -105,102 +63,60 @@ const CalendarFetcherUtils = { .subtract(1, "seconds"); 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, eventFilterUntil } = this.shouldEventBeExcluded(config, title); + const { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title); if (excluded) { 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; - } - } + Log.debug(`Event: ${title} | start: ${event.start} | end: ${event.end} | recurring: ${!!event.rrule}`); - if (event.type === "VEVENT") { - Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - let eventStartMoment = eventDate(event, "start"); - let eventEndMoment; + const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false; + const geo = event.geo || false; + const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false; - if (typeof event.end !== "undefined") { - eventEndMoment = eventDate(event, "end"); - } 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"); - } - } - - 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}`); + let instances; + try { + instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment); + } catch (error) { + Log.error(`Could not expand event "${title}": ${error.message}`); + return; + } - const location = event.location || false; - const geo = event.geo || false; - const description = event.description || false; + for (const instance of instances) { + const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance; - let instances = []; - if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { - instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); - } else { - const fullDayEvent = isFacebookBirthday ? true : 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 - }); + // Filter logic + if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { + continue; } - for (const instance of instances) { - const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance; - - // Filter logic - if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { - continue; - } - - if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) { - continue; - } - - const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); - const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); - - Log.debug(`saving event: ${title}`); - newEvents.push({ - title: title, - startDate: startMoment.format("x"), - endDate: endMoment.format("x"), - fullDayEvent: fullDay, - recurringEvent: isRecurring, - class: event.class, - firstYear: event.start.getFullYear(), - location: instanceEvent.location || location, - geo: instanceEvent.geo || geo, - description: instanceEvent.description || description - }); + 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 + }); } }); @@ -217,35 +133,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 title; + return CalendarFetcherUtils.unwrapParameterValue(event.summary || event.description) || "Event"; }, /** - * 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 + * 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 */ - isFullDayEvent (event) { - if (event.start.length === 8 || 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; + unwrapParameterValue (value) { + if (value && typeof value === "object" && typeof value.val !== "undefined") { + return value.val; } - - return false; + return value; }, /** @@ -262,7 +164,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; @@ -282,7 +184,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 { @@ -291,65 +193,38 @@ 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; + // 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 }; + }); }, /** 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/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", 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..20c40efce9 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 ); @@ -54,6 +61,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 @@ -94,24 +153,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 +181,319 @@ 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 + }); + + 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); + }); + + 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"); + }); + + 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", () => { + 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"); + }); + + 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", () => { + 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(); + + 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"); + }); + + it("should let expandRecurringEvent throw through directly", () => { + 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"); + }); + }); + + 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"); }); }); }); 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: [