Skip to content

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

Merged
rejas merged 19 commits intoMagicMirrorOrg:developfrom
KristjanESPERANTO:calendar
Mar 2, 2026
Merged

[calendar] refactor: delegate event expansion to node-ical's expandRecurringEvent#4047
rejas merged 19 commits intoMagicMirrorOrg:developfrom
KristjanESPERANTO:calendar

Conversation

@KristjanESPERANTO
Copy link
Copy Markdown
Collaborator

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 workedshouldEventBeExcluded() 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).

…event expansion

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
…tBeExcluded

The return value uses 'until', not 'eventFilterUntil', so the
destructuring always yielded undefined, effectively disabling
the time-based event filter (excludedEvents with 'until').
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.
…al 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.
event.start is always a Date object, so event.start.length is always
undefined and the length === 8 check never matched.
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.
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.
…tion

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.
…vents

The only use of `this` in the file — consistent with all other method
calls and safe when used as a callback.
- 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)
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).
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.
…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
@rejas rejas merged commit ab3108f into MagicMirrorOrg:develop Mar 2, 2026
9 checks passed
@KristjanESPERANTO KristjanESPERANTO deleted the calendar branch March 6, 2026 23:16
@khassel khassel mentioned this pull request Apr 1, 2026
khassel added a commit that referenced this pull request Apr 1, 2026
## Release Notes
Thanks to: @angeldeejay, @in-voker, @JHWelch, @khassel,
@KristjanESPERANTO, @rejas, @sdetweil
> ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change
to previous release)

[Compare to previous Release
v2.34.0](v2.34.0...v2.25.0)

> ⚠️ We introduced some internal changes with this release, please read
[this forum
post](https://forum.magicmirror.builders/topic/20138/upcoming-release-april-1-2026-breaking-changes-some-operational-changes)
before upgrading!

### [core]
- Prepare Release 2.35.0 (#4071)
- docs: add security policy and vulnerability reporting guidelines
(#4069)
- refactor: simplify internal `require()` calls (#4056)
- allow environment variables in cors urls (#4033)
- fix cors proxy getting binary data (e.g. png, webp) (#4030)
- fix: correct secret redaction and optimize loadConfig (#4031)
- change loading config.js, allow variables in config.js and try to
protect sensitive data (#4029)
- remove kioskmode (#4027)
- Add dark theme logo (#4026)
- move custom.css from css to config (#4020)
- move default modules from /modules/default to /defaultmodules (#4019)
- update node versions in workflows (#4018)
- [core] refactor: extract and centralize HTTP fetcher (#4016)
- fix systeminformation not displaying electron version (#4012)
- Update node-ical and support it's rrule-temporal changes (#4010)
- Change default start scripts from X11 to Wayland (#4011)
- refactor: unify favicon for index.html and Electron (#4006)
- [core] run systeminformation in subprocess so the info is always
displayed (#4002)
- set next release dev number (#4000)

### [dependencies]
- update dependencies (#4068)
- update dependencies incl. electron to v41 (#4058)
- chore: upgrade ESLint to v10 and fix newly surfaced issues (#4057)
- chore: update ESLint and plugins, simplify config, apply new rules
(#4052)
- chore: update dependencies + add exports, files, and sideEffects
fields to package.json (#4040)
- [core] refactor: enable ESLint rule require-await and handle detected
issues (#4038)
- Update node-ical and other deps (#4025)
- chore: update dependencies (#4021)
- chore(eslint): migrate from eslint-plugin-vitest to
@vitest/eslint-plugin and run rules only on test files (#4014)
- Update deps as requested by dependabot (#4008)
- update Collaboration.md and dependencies (#4001)

### [logging]
- refactor: further logger clean-up (#4050)
- Fix Node.js v25 logging prefix and modernize logger (#4049)

### [modules/calendar]
- fix(calendar): make showEnd behavior more consistent across time
formats (#4059)
- test(calendar): fix hardcoded date in event shape test (#4055)
- [calendar] refactor: delegate event expansion to node-ical's
expandRecurringEvent (#4047)
- calendar.js: remove useless hasCalendarURL function (#4028)
- fix(calendar): update to node-ical 0.23.1 and fix full-day recurrence
lookup (#4013)
- fix(calendar): correct day-of-week for full-day recurring events
across all timezones (#4004)

### [modules/newsfeed]
- fix(newsfeed): fix full article view and add framing check (#4039)
- [newsfeed] refactor: migrate to centralized HTTPFetcher (#4023)

### [modules/weather]
- fix(weather): fix openmeteo forecast stuck in the past (#4064)
- fix(weather): fix weathergov forecast day labels off by one (#4065)
- weather: fixes for templates (#4054)
- weather: add possibility to override njk's and css (#4051)
- Use getDateString in openmeteo (#4046)
- [weather] refactor: migrate to server-side providers with centralized
HTTPFetcher (#4032)
- [weather] feat: add Weather API Provider  (#4036)

### [testing]
- chore: remove obsolete Jest config and unit test global setup (#4044)
- replace template_spec test with config_variables test (#4034)
- refactor(clientonly): modernize code structure and add comprehensive
tests (#4022)
- Switch to undici Agent for HTTPS requests (#4015)
- chore: migrate CI workflows to ubuntu-slim for faster startup times
(#4007)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: dathbe <github@beffa.us>
Co-authored-by: Marcel <m-idler@users.noreply.github.com>
Co-authored-by: Kevin G. <crazylegstoo@gmail.com>
Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com>
Co-authored-by: Jboucly <contact@jboucly.fr>
Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com>
Co-authored-by: Jordan Welch <JordanHWelch@gmail.com>
Co-authored-by: Blackspirits <blackspirits@gmail.com>
Co-authored-by: Samed Ozdemir <samed@xsor.io>
Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com>
Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants