[calendar] refactor: delegate event expansion to node-ical's expandRecurringEvent (#4047)

node-ical 0.25.x added `expandRecurringEvent()` — a proper API for
expanding both recurring and non-recurring events, including EXDATE
filtering and RECURRENCE-ID overrides. This PR replaces our hand-rolled
equivalent with it.

`calendarfetcherutils.js` loses ~125 lines of code. What's left only
deals with MagicMirror-specific concerns: timezone conversion,
config-based filtering, and output formatting. The extra lines in the
diff come from new tests.

## What was removed

- `getMomentsFromRecurringEvent()` — manual rrule.js wrapping with
custom date extraction
- `isFullDayEvent()` — heuristic with multiple fallback checks
- `isFacebookBirthday` workaround — patched years < 1900 and
special-cased `@facebook.com` UIDs
- The `if (event.rrule) / else` split — all events now go through a
single code path

## Bugs fixed along the way

Both were subtle enough to go unnoticed before:

- **`[object Object]` in event titles/description/location** — node-ical
represents ICS properties with parameters (e.g.
`DESCRIPTION;LANGUAGE=de:Text`) as `{val, params}` objects. The old code
passed them straight through. Mainly affected multilingual Exchange/O365
setups. Fixed with `unwrapParameterValue()`.

- **`excludedEvents` with `until` never worked** —
`shouldEventBeExcluded()` returned `{ excluded, until }` but the caller
destructured it as `{ excluded, eventFilterUntil }`, so the until date
was always `undefined` and events were never hidden. Fixed by correcting
the destructuring key.

The expansion loop also gets error isolation: a single broken event is
logged and skipped instead of aborting the whole feed.

## Other clean-ups

- Replaced `this.shouldEventBeExcluded` with
`CalendarFetcherUtils.shouldEventBeExcluded` — avoids context-loss bugs
when the method is destructured or called indirectly.
- Replaced deprecated `substr()` with `slice()`.
- Replaced `now < filterUntil` (operator overloading) with
`now.isBefore(filterUntil)` — idiomatic Moment.js comparison.
- Fixed `@returns` JSDoc: `string[]` → `object[]`.
- Moved verbose `Log.debug("Processing entry...")` after the `VEVENT`
type guard to reduce log noise from non-event entries.
- Replaced `JSON.stringify(event)` debug log with a lightweight summary
to avoid unnecessary serialization cost.
- Added comment explaining the 0-duration → end-of-day fallback for
events without DTEND.

## Tests

24 unit tests, all passing (`npx vitest run
tests/unit/modules/default/calendar/`).

New coverage: `excludedEvents` with/without `until`, Facebook birthday
year expansion, output object shape, no-DTEND fallback, error isolation,
`unwrapParameterValue`, `getTitleFromEvent`, ParameterValue properties,
RECURRENCE-ID overrides, DURATION (single and recurring).
This commit is contained in:
Kristjan ESPERANTO
2026-03-02 21:31:32 +01:00
committed by GitHub
parent 06b1361457
commit ab3108fc14
6 changed files with 474 additions and 241 deletions

View File

@@ -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}`);
const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false;
const geo = event.geo || false;
const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false;
let instances;
try {
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment);
} catch (error) {
Log.error(`Could not expand event "${title}": ${error.message}`);
return;
}
if (event.type === "VEVENT") {
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
let eventStartMoment = eventDate(event, "start");
let eventEndMoment;
for (const instance of instances) {
const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance;
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");
}
// Filter logic
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
continue;
}
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);
} 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
});
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
continue;
}
for (const instance of instances) {
const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;
const instanceTitle = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
// 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
});
}
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;
unwrapParameterValue (value) {
if (value && typeof value === "object" && typeof value.val !== "undefined") {
return value.val;
}
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;
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 = [];
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) {
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
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);
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);
}
}
// Check for exceptions
if (curEvent.exdate !== undefined) {
if (curEvent.exdate[dateKey] !== undefined) {
showRecurrence = false;
}
}
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 };
});
},
/**

View File

@@ -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"
}
},
{

27
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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");
});
});
});

View File

@@ -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: [