While looking into #4053 I noticed that one of the calendar tests had
stopped working. The cause was simple: the test had `2026-03-10`
hardcoded, and since that date is now in the past, the event was
silently filtered out by `includePastEvents: false`.
Instead of bumping the date to some future value (which would only delay
the same problem), I made it relative so it always lies in the future.
Command to test:
```bash
npx vitest run tests/unit/modules/default/calendar/
```
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).
Since the project's inception, I've missed a clear separation between
default and third-party modules.
This increases complexity within the project (exclude `modules`, but not
`modules/default`), but the mixed use is particularly problematic in
Docker setups.
Therefore, with this pull request, I'm moving the default modules to a
different directory.
~~I've chosen `default/modules`, but I'm not bothered about it;
`defaultmodules` or something similar would work just as well.~~
Changed to `defaultmodules`.
Let me know if there's a majority in favor of this change.
Fixes **full-day recurring events showing on wrong day** in timezones
west of UTC (reported in #4003).
**Root cause**: `moment.tz(date, eventTimezone).startOf("day")`
interprets UTC midnight as local time:
- `2025-11-03T00:00:00.000Z` in America/Chicago (UTC-6)
- → Converts to `2025-11-02 18:00:00` (6 hours back)
- → `.startOf("day")` → `2025-11-02 00:00:00` ❌ **Wrong day!**
**Impact**: The bug affects:
- All timezones west of UTC (UTC-1 through UTC-12): Americas, Pacific
- Timezones east of UTC (UTC+1 through UTC+12): Europe, Asia, Africa -
work correctly
- UTC itself - works correctly
The issue was introduced with commit c2ec6fc2 (#3976), which fixed the
time but broke the date. This PR fixes both.
| | Result | Day | Time | Notes |
|----------|--------|-----|------|-------|
| **Before c2ec6fc2** | `2025-11-03 05:00:00 Monday` | ✅ | ❌ | Wrong
time, but correct day |
| **Current (c2ec6fc2)** | `2025-11-02 00:00:00 Sunday` | ❌ (west of
UTC)<br>✅ (east of UTC) | ✅ | Wrong day - visible bug! |
| **This fix** | `2025-11-03 00:00:00 Monday` | ✅ | ✅ | Correct in all
timezones |
Note: While the old logic had incorrect timing, it produced the correct
calendar day due to how it handled UTC offsets. The current logic fixed
the timing issue but introduced the more visible calendar day bug.
### Solution
Extract UTC date components and interpret as local calendar dates:
```javascript
const utcYear = date.getUTCFullYear();
const utcMonth = date.getUTCMonth();
const utcDate = date.getUTCDate();
return moment.tz([utcYear, utcMonth, utcDate], eventTimezone);
```
### Testing
To prevent this from happening again in future refactorings, I wrote a
test for it.
```bash
npm test -- tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js
```
### 1. Replace `XMLHttpRequest` with the modern `fetch` API for loading
translation files
#### Changes
- **translator.js**: Use `fetch` with `async/await` instead of XHR
callbacks
- **loader.js**: Align URL handling and add error handling (follow-up to
fetch migration)
- **Tests**: Update infrastructure for `fetch` compatibility
#### Benefits
- Modern standard API
- Cleaner, more readable code
- Better error handling and fallback mechanisms
### 2. Migrate e2e tests to Playwright
This wasn't originally planned for this PR, but is related. While
investigating suspicious log entries which surfaced after the fetch
migration I kept running into JSDOM’s limitations. That pushed me to
migrate the E2E suite to Playwright instead.
#### Changes
- switch e2e harness to Playwright (`tests/e2e/helpers/global-setup.js`)
- rewrite specs to use Playwright locators + shared `expectTextContent`
- install Chromium via `npx playwright install --with-deps` in CI
#### Benefits
- much closer to real browser behaviour
- and no more fighting JSDOM’s quirks
This is a big change, but I think it's a good move, as `vitest` is much
more modern than `jest`.
I'm excited about the UI watch feature (run `npm run test:ui`), for
example - it's really helpful and saves time when debugging tests. I had
to adjust a few tests because they had time related issues, but
basically we are now testing the same things - even a bit better and
less flaky (I hope).
What do you think?
Refactored calendarfetcherutils to remove as many of the date
conversions as possible and use moment tz when calculating recurring
events, this will make debugging a lot easier and fixes problems from
the past with offsets and DST not being handled properly. Also added
some tests to test the behavior of the refactored methodes to make sure
the correct event dates are returned.
Refactored calendar.js aswell to make sure the unix UTC start and end
date of events are properly converted to a local timezone and displayed
correctly for the user.
This PR relates to:
https://github.com/MagicMirrorOrg/MagicMirror/issues/3797
---------
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>