Skip to content

Conversation

@KristjanESPERANTO
Copy link
Collaborator

@KristjanESPERANTO KristjanESPERANTO commented Nov 29, 2025

The bottom line of this PR is, that it fixes an issue and simplifies the code by dealing with the TODOs in the code.

For review, I suggest looking at each commit individually. If there are too many changes for a PR, let me know and I'll split it up 🙂

1. fix(calendar): prevent excessive fetching with smart refresh strategy

  • Add lastFetch timestamp tracking to CalendarFetcher
  • Add shouldRefetch() method with configurable minimum interval (default: 3 minutes)
  • When reusing existing fetcher: fetch if data is stale (>3 min), otherwise broadcast cached events
  • Prevents double broadcasts to consuming modules while maintaining fresh data
  • Balances rate limit prevention (Issue [Bug] CALENDAR_EVENTS are being resent all the time with no updates #3971) with data freshness on user reload
  • Prevents excessive fetching during rapid reloads (e.g., Fully Kiosk screensaver use case)
  • Allows fresh calendar data when enough time has passed since last fetch

2. refactor(calendar): simplify event exclusion logic

  • Extract filtering logic from shouldEventBeExcluded into new helper checkEventAgainstFilter
  • Simplify the main loop in `shouldEventBeExcluded

It resolves a TODO from the comments in the code:

  • This seems like an overly complicated way to exclude events based on the title.

3. refactor(calendar): extract recurring event expansion logic

This change separates the expansion of recurring events from the main filtering loop into a new helper function 'expandRecurringEvent'.

It resolves two TODOs from the comments in the code:

  • This should be a separate function
  • This should create an event per moment so we can change anything we want

This improves code readability, reduces complexity in 'filterEvents', and allows for cleaner handling of individual recurrence instances.

4. refactor(calendar): simplify recurring event handling

  • Simplify 'getMomentsFromRecurringEvent' using modern syntax
  • Improve handling of full-day events across different timezones

5. test(calendar): fix UNTIL date in fullday_until.ics fixture

The issue was with the UNTIL date being May 4th while DTSTART was May 5th. This created an invalid recurrence rule where the end date came before the start date.

The fix only adjusts the UNTIL date from May 4th to May 5th, so it matches the start date.

…ezone support

- Simplify 'getMomentsFromRecurringEvent' using modern syntax
- Improve handling of full-day events across different timezones
This change separates the expansion of recurring events from the main filtering loop into a new helper function 'expandRecurringEvent'.

It resolves two TODOs:
- 'This should be a separate function'
- 'This should create an event per moment so we can change anything we want'

This improves code readability, reduces complexity in 'filterEvents', and allows for cleaner handling of individual recurrence instances.
- Extract filtering logic from `shouldEventBeExcluded` into new helper `checkEventAgainstFilter`
- Simplify the main loop in `shouldEventBeExcludedrefactor(calendar): simplify event exclusion logic
@KristjanESPERANTO KristjanESPERANTO marked this pull request as draft November 29, 2025 12:51
@sdetweil
Copy link
Collaborator

But 1 is how we get calendar updates. Resending the cached events doesn’t keep the calendar up to date

@KristjanESPERANTO
Copy link
Collaborator Author

But 1 is how we get calendar updates. Resending the cached events doesn’t keep the calendar up to date

Very good catch! Many thanks! In the meantime I've updated the implementation in commit 182d3dd to address exactly that concern.

Now when the calendar is reloaded, it always broadcasts the cached events immediately (for fast UI response), and then checks if the data is older than 3 minutes - if so, it fetches fresh data in the background. This way you get instant display with cached data, plus automatic updates when needed, while still preventing rate limit issues from rapid reloads. I couldn't think of a simpler approach.


I'm thinking about that I should split up this PR since it touches several points. Then we could look at each individual point more easily and decide whether we merge them or not. What do you think?

@KristjanESPERANTO KristjanESPERANTO marked this pull request as ready for review November 30, 2025 00:14
@sdetweil
Copy link
Collaborator

Do you know what the impact of the multiple broadcasts does to the using modules? Ext3, calendarMonthly

ext3 has a 10 min (default) refresh cycle

- Add lastFetch timestamp tracking to CalendarFetcher
- Add shouldRefetch() method with configurable minimum interval (default: 3 minutes)
- When reusing existing fetcher: fetch if data is stale (>3 min), otherwise broadcast cached events
- Prevents double broadcasts to consuming modules while maintaining fresh data
- Balances rate limit prevention (Issue MagicMirrorOrg#3971) with data freshness on user reload
- Prevents excessive fetching during rapid reloads (e.g., Fully Kiosk screensaver use case)
- Allows fresh calendar data when enough time has passed since last fetch

Fixes MagicMirrorOrg#3971
@KristjanESPERANTO
Copy link
Collaborator Author

KristjanESPERANTO commented Nov 30, 2025

Do you know what the impact of the multiple broadcasts does to the using modules? Ext3, calendarMonthly

Again a good point! I've just made a small adjustment to optimize this. As it stands now, it shouldn't have any noticeable effect on ext3 and similar modules.

Calendar Fetch Strategy Comparison

Key Differences

Aspect Before After (< 3 min) After (> 3 min)
Fetch on reload ✅ Always ❌ No ✅ Yes
Broadcasts per reload 1 1 1
Rate limit risk ❌ High ✅ None ✅ Very low
Data freshness ✅ Always fresh ⚠️ Cached ✅ Fresh
Ext3 processing 1x 1x 1x
Network requests Every reload None When needed

Impact on Ext3 and Other Modules

Before:

  • Every page reload → 1 fetch → 1 broadcast → 1x processing (but too many fetches)

After:

  • Every page reload → 0 or 1 fetch → 1 broadcast → 1x processing
  • Ext3's 10-minute refresh cycle remains independent and unaffected

@KristjanESPERANTO
Copy link
Collaborator Author

Since calendars don't change that often, we could even set the default cache limit time higher. Maybe 5 or 10 minutes. And perhaps even offer it as an option that users can adjust.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On this
if (isFullDayEvent && rule.options?.until) {
rule.options.until = moment(rule.options.until).endOf("day").toDate();
}
I used start of day vs end of, cause end of could appear as tomorrow in some timezones. Start of day will never be wrong.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point about timezones. But I think endOf("day") shouldn't cause issues in this case because it operates on the same calendar day - it just moves the time to 23:59:59 of that same day, never into the next day.

So even if someone is in a timezone like UTC+12, endOf("day") on May 5th stays May 5th 23:59:59 - it can't "appear as tomorrow" because we're not adding time, just extending to the end of the current day.

That said, this is admittedly a workaround for a deeper issue in rrule.js itself. The library has known problems with UNTIL and timezone handling (see rrule#596, open since Aug 2023), and unfortunately rrule.js appears to be unmaintained at this point (~180 open issues).

I'm working on replacing rrule.js with rrule-temporal in node-ical to solve these problems at the root. rrule-temporal is built on the Temporal API which handles timezones correctly by design. Since this is a major change, it will take a while to implement it, so unfortunately I think we will need to use workarounds like this for the time being.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember full day doesn’t have time, I ran into that here and in the front end

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's still necessary. Here's a test script that demonstrates the bug:

const { RRule } = require('rrule');
const moment = require('moment-timezone');

// Full-day event: daily at 00:00 UTC, starting March 10
const dtstartUTC = new Date(Date.UTC(2024, 2, 10, 0, 0, 0));

// UNTIL=20240315 parsed in Europe/Berlin (UTC+1)
// "March 15 00:00 Berlin" = "March 14 23:00 UTC"
const untilRaw = moment.tz('2024-03-15', 'Europe/Berlin').toDate();
const untilFixed = moment.tz('2024-03-15', 'Europe/Berlin').endOf('day').toDate();

const rule1 = new RRule({ freq: RRule.DAILY, dtstart: dtstartUTC, until: untilRaw });
const rule2 = new RRule({ freq: RRule.DAILY, dtstart: dtstartUTC, until: untilFixed });

console.log('WITHOUT endOf("day"):', rule1.all().length, 'days, last:', rule1.all().pop()?.toISOString().slice(0,10));
console.log('WITH endOf("day"):   ', rule2.all().length, 'days, last:', rule2.all().pop()?.toISOString().slice(0,10));

Output:

WITHOUT endOf("day"): 5 days, last: 2024-03-14
WITH endOf("day"):    6 days, last: 2024-03-15

The issue: When rrule.js checks if March 15 should be included, it compares:

  • Event time: 00:00:00 UTC on March 15
  • UNTIL: 23:00:00 UTC on March 14 (because of timezone conversion)

Since 23:00 < 00:00, March 15th is excluded. The endOf("day") fix ensures UNTIL is always after the event time on that day.

Remove unnecessary parameter from shouldRefetch() and use the fetcher's
reloadInterval directly. This respects the user's configured fetchInterval
as the threshold for rate limit protection.
@sdetweil
Copy link
Collaborator

sdetweil commented Dec 7, 2025

Seems ok.

@KristjanESPERANTO
Copy link
Collaborator Author

Seems ok.

Great. It would be awesome if you'd merge it 🙂

@rejas rejas merged commit c2ec6fc into MagicMirrorOrg:develop Dec 8, 2025
9 checks passed
@KristjanESPERANTO KristjanESPERANTO deleted the calendar branch December 10, 2025 09:51
@sdetweil sdetweil mentioned this pull request Jan 1, 2026
sdetweil added a commit that referenced this pull request Jan 1, 2026
Thanks to: @Blackspirits, @Crazylegstoo, @jarnoml, @jboucly, @JHWelch,
@khassel, @KristjanESPERANTO, @rejas, @sdetweil, @xsorifc28

⚠️ This release needs nodejs version >=22.21.1 <23 || >=24

[Compare to previous Release
v2.33.0](v2.33.0...develop)

[core]
Prepare Release 2.34.0
(#3998)
dependency update + adjust Playwright setup + fix linter issue
(#3994)
demo with gif (#3995)
[core] fix: allow browser globals in config files
(#3992)
[core] fix: restore --ozone-platform=wayland flag for reliable Wayland
support (#3989)
[core] auto create release notes with every push on develop
(#3985)
[core] chore: simplify Wayland start script
(#3974)
[gitignore] restore the old Git behavior for the default modules
(#3968)
[core] configure cspell to check default modules only and fix typos
(#3955)
[gitignore] restoring the old Git behavior for the CSS directory
(#3954)
feat(core): add server:watch script with automatic restart on file
changes (#3920)
[check_config] refactor: improve error handling
(#3927)
refactor: replace express-ipfilter with lightweight custom middleware
(#3917)
refactor: replace module-alias dependency with internal alias resolver
(#3893)

[dependencies]

[chore] update dependencies and min. node version
(#3986)
[core] bump dependencies into december
(#3982)
Bump actions/checkout from 5 to 6
(#3972)
Update deps, unpin parse5
(#3934)
[core] Update deps and pin jsdom to v27.0.0
(#3925)
chore: update dependencies
(#3921)
update deps, exclude node v23
(#3916)
remove eslint warnings, add npm publish process to Collaboration.md
(#3913)
feat: add ESlint rule no-sparse-arrays for config check
(#3911)
chore: bump dependencies into october
(#3909)

[logging]

logger: add calling filename as prefix on server side
(#3926)
[logger] Add prefixes to most Log messages
(#3923)

[modules/alert]
Add new pt and pt-BR translations for Alert module and update global PT
strings (#3965)

[modules/calendar]
add checksum to test whether calendar event list changed
(#3988)
[calendar] fix: prevent excessive fetching on client reload and refactor
calendarfetcherutils.js
(#3976)
[calendar] refactor: migrate CalendarFetcher to ES6 class and improve
error handling (#3959)
[calendar] Show repeatingCountTitle only if yearDiff > 0
(#3949)
[tests] suppress debug logs in CI environment + improve calendar symbol
test stability (#3941)
[calendar] chore: remove requiresVersion: "2.1.0"
(#3932)
[calendar] test: remove "Recurring event per timezone" test
(#3929)

[modules/compliments]
[compliments] refactor: optimize loadComplimentFile method and add unit
tests (#3969)
fix: set compliments remote file minimum delay to 15 minutes
(#3970)
[compliments] fix: duplicate query param "?" in compliments module
refresh url (#3967)

[modules/newsfeed]
[newsfeed] fix header layout issue
(#3946)

[modules/weather]
[weatherprovider] update subclass language use override
(#3914)
[weather] fix wind-icon not showing in pirateweather
(#3957)
[weather] add error handling to weather fetch functions, including cors
(#3791)
remove deprecated ukmetoffice datapoint provider, cleanup .gitignore
(#3952)
fixes problems with daylight-saving-time in weather provider openmeteo
(#3931)
Fix for envcanada Provider to use updated Env Canada URL
(#3919)
[weather] feat: add configurable forecast date format option
(#3918)

[testing]
testing: update "Enforce Pull-Request Rules" workflow
(#3987)
[core] refactor: replace XMLHttpRequest with fetch and migrate e2e tests
to Playwright (#3950)
[test] replace node-libgpiod with serialport in electron-rebuild
workflow (#3945)
[ci] Add concurrency to automated tests workflow to cancel outdated runs
(#3943)
[tests] migrate from jest to vitest
(#3940)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Karsten Hassel <hassel@gmx.de>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: HeikoGr <20295490+HeikoGr@users.noreply.github.com>
Co-authored-by: Pedro Lamas <pedrolamas@gmail.com>
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>
sdetweil pushed a commit that referenced this pull request Jan 4, 2026
…ss all timezones (#4004)

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 c2ec6fc (#3976), which fixed the
time but broke the date. This PR fixes both.

| | Result | Day | Time | Notes |
|----------|--------|-----|------|-------|
| **Before c2ec6fc** | `2025-11-03 05:00:00 Monday` | ✅ | ❌ | Wrong
time, but correct day |
| **Current (c2ec6fc)** | `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
```
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