[Bug] Calendar shows wrong time for recurring events created before DST
Environment
MagicMirror² version: 2.31.0 Node version: 22.16.0 npm version: 10.9.2 Platform: Debian Linux Docker container (karsten13/magicmirror:latest)
Which start option are you using?
npm run server
Are you using PM2?
No
Module
calendar
Have you tried disabling other modules?
- [ ] Yes
- [x] No
Have you searched if someone else has already reported the issue on the forum or in the issues?
- [x] Yes
What did you do?
I'm loading a .ics calendar with all default config except maximumNumberOfDays (2) and fetchInterval of 15 minutes.
When I create a recurring event in March, before daylight saving time then after daylight saving time it will show 1 hour too late (10 instead of 9 for example)
What did you expect to happen?
I expected the calendar module to work with recurring events combined with DST
What actually happened?
It shows 1 hour too late for every recurring event that is created in the winter period (before DST).
Additional comments
The problem lies in the interpretation of the results from RRule, as per the logging below (I have seperated them to place comments on relevant lines):
[2025-05-31 11:45:54.434] [DEBUG] start: Tue Mar 11 2025 09:00:00 GMT+0100 (Central European Standard Time)
[2025-05-31 11:45:54.434] [DEBUG] end:: Tue Mar 11 2025 09:15:00 GMT+0100 (Central European Standard Time)
This seems to be interpreted fine, the event first occurence was on tuesday the 11th of March 2025 at 09:00 in the morning at Europe/Brussels time. So I would expect to see an event on monday the 2nd of May 2025 at 09:00 as this only occurs on workdays.
[2025-05-31 11:45:54.434] [DEBUG] duration: 900000
[2025-05-31 11:45:54.434] [DEBUG] title: xxxxxxxxxxxxxxxxxxx
[2025-05-31 11:45:54.435] [DEBUG] Search for recurring events between: Sat May 31 2025 11:45:53 GMT+0200 (Central European Summer Time) and Mon Jun 02 2025 23:59:59 GMT+0200 (Central European Summer Time)
This shows that the calendar module searching for the correct range of recurring events. Below we can see how that was passed to RRule.
[2025-05-31 11:45:54.435] [DEBUG] RRule: DTSTART;TZID=Europe/Brussels:20250311T090000
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE
[2025-05-31 11:45:54.435] [DEBUG] Title: xxxxxxxxxxxxxxxx, with dates: ["2025-06-02T09:00:00.000Z","2025-06-03T09:00:00.000Z"]
RRule got the original DTStart from the .ics file correct, as you can see there is a timezone Europe/Brussels that is not part of the ISO-8601 datetime after it. As you can see RRule returns 2 datetimes both in UTC as per this documentation on RRule: https://github.com/jkbrzt/rrule?tab=readme-ov-file#important-use-utc-dates
On this line the timezone option is removed from RRule and therefor it will return the new datetimes in UTC format and it will not do anthing with the timezone provided in DTStart: https://github.com/MagicMirrorOrg/MagicMirror/blob/master/modules/default/calendar/calendarfetcherutils.js#L302
[2025-05-31 11:45:54.435] [DEBUG] Rule has byweekday, checking for correction
[2025-05-31 11:45:54.435] [DEBUG] initial tz=Europe/Brussels
[2025-05-31 11:45:54.435] [DEBUG] corrected tz=Europe/Brussels
[2025-05-31 11:45:54.435] [DEBUG] start date/time=Tue Mar 11 2025 09:00:00 GMT+0100 (Central European Standard Time)
[2025-05-31 11:45:54.435] [DEBUG] start offset=60
[2025-05-31 11:45:54.435] [DEBUG] start date/time w tz =Tue Mar 11 2025 09:00:00 GMT+0100 (Central European Standard Time)
This seems fine but it is really weird that the calendar module tries to do anything with the original timezone etc.
[2025-05-31 11:45:54.435] [DEBUG] event date=Mon Jun 02 2025 11:00:00 GMT+0200 (Central European Summer Time)
[2025-05-31 11:45:54.435] [DEBUG] event offset=120 hour=11 event date=Mon Jun 02 2025 11:00:00 GMT+0200 (Central European Summer Time)
[2025-05-31 11:45:54.435] [DEBUG] offset
[2025-05-31 11:45:54.435] [DEBUG] adjust down 1 hour dst change
[2025-05-31 11:45:54.435] [DEBUG] adjustHours=-1
[2025-05-31 11:45:54.435] [DEBUG] Applying timezone adjustment hours=-1 to Mon Jun 02 2025 11:00:00 GMT+0200 (Central European Summer Time)
This is where it is broken, it has interpreted the result of RRule as if the datetime returned has the UTC timezone, therefor it has corrected the time to 11:00 and then for some reason there is a -1 hour adjustment???
What I think should happen is remove the Z from the returned datetime from RRule and then add the local timezone, not convert it to the local timezone. Don't ever work with just the offset if you are calculating with datetimes, always use the zone.
Participation
- [ ] I am willing to submit a pull request for this change.
Also as a sidenote, there is a huge amount of magic inside a function called filterEvents that isn't properly tested at all. The tests all use moment now and not specific datetimes before/after DST.
Please provide the ical event that exhibits this issue
curl -sL calendar_Url >somefile.txt
find the vevent in somefile.txt edit xxx the personal info, dont remove any lines
BEGIN:VEVENT
DTSTART;TZID=Europe/Brussels:20250311T090000
DTEND;TZID=Europe/Brussels:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE
DTSTAMP:20250531T091103Z
ORGANIZER;CN=xxxxxxxxxxxxxxxx
UID:xxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=xxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxxxx
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxxxxxxxxxxxxxxxxxxxxxxxx;X-NUM-GUESTS=0:mailto:xxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-GOOGLE-CONFERENCE:xxxxxxxxxxxxxxxxxxxxxxxxxx
CREATED:20230111T114612Z
DESCRIPTION:xxxxxxxxxxxxxxxxxxxxxxxxxxx
LAST-MODIFIED:20250528T071312Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:xxxxxxxxxxxxxxxxxxxxxxx
TRANSP:OPAQUE
END:VEVENT
Here is the event from the .ics file, the relevant information from the event is also logged btw.
I created a simple test and added it to calendar_fetcher_utils_spec.js:
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
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
ORGANIZER;CN=test:mailto:[email protected]
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
CREATED:20230111T114612Z
DESCRIPTION:Test
LAST-MODIFIED:20250528T071312Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Test
TRANSP:OPAQUE
END:VEVENT`);
const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);
const januaryFirst = filteredEvents.filter((event) => moment.unix(event.startDate / 1000).format("MM-DD") === "01-01");
console.log(januaryFirst);
const novemberFirst = filteredEvents.filter((event) => moment.unix(event.startDate / 1000).format("MM-DD") === "07-01");
console.log(novemberFirst);
});
This returns the following data:
{
title: 'Test',
startDate: '1767265200000',
endDate: '1767266100000',
fullDayEvent: false,
recurringEvent: true,
class: undefined,
firstYear: 2025,
location: false,
geo: false,
description: 'Test'
}
{
title: 'Test',
startDate: '1751367600000',
endDate: '1751368500000',
fullDayEvent: false,
recurringEvent: true,
class: undefined,
firstYear: 2025,
location: false,
geo: false,
description: 'Test'
}
Both events have the wrong startDate
RRULE cannot handle non-local dates, so the MagicMirror code converts to local date/time before rrule.between
and converts back to timezone based after
so if the date/time returned from rrule.between is incorrect, in the dates= list the final result will be wrong
i am away from my system til tomorrow
I made an ics from the above event and see this on my system set to Brussels timezone , 9am as expected
I made an ics from the above event and see this on my system set to Brussels timezone , 9am as expected
What if you change the start date to February? It might have something to do with DST in America for you as this event was created after DST in New York.
I'm currently trying to refactor the code in the filterEvents method to remove all of the different calculations and conversions between dates.
@plebcity
I'm currently trying to refactor the code in the filterEvents method to remove all of the different calculations and conversions between dates.
good luck with that. date/time/timezone/moment and RRULE all want different things.. RRULE is the biggest pain
moved to Feb 11, and see the same results.. also adjusted to my Chicago tz and see 02:00 as expected
what tz is the docker container running?
moved to Feb 11, and see the same results.. also adjusted to my Chicago tz and see 02:00 as expected
what tz is the docker container running?
Everything is running in Europe/Amsterdam (same rules as Brussels), docker container, host machine and client browser.
good luck with that. date/time/timezone/moment and RRULE all want different things.. RRULE is the biggest pain
I'm trying to figure out what all these weird calculations do but I can't get my head around this method:
fixEventtoLocal (event) {
// if there are excluded dates, their date is incorrect and possibly key as well.
if (event.exdate !== undefined) {
Object.keys(event.exdate).forEach((dateKey) => {
// get the date
let exdate = event.exdate[dateKey];
Log.debug("exdate w key=", exdate);
//exdate=CalendarFetcherUtils.convertDateToLocalTime(exdate, event.end.tz)
exdate = new Date(new Date(exdate.valueOf() - ((120 * 60 * 1000))).getTime());
Log.debug("new exDate item=", exdate, " with old key=", dateKey);
let newkey = exdate.toISOString().slice(0, 10);
if (newkey !== dateKey) {
Log.debug("new exDate item=", exdate, ` key=${newkey}`);
event.exdate[newkey] = exdate;
//delete event.exdate[dateKey]
}
});
Log.debug("updated exdate list=", event.exdate);
}
if (event.recurrences) {
Object.keys(event.recurrences).forEach((dateKey) => {
let exdate = event.recurrences[dateKey];
//exdate=new Date(new Date(exdate.valueOf()-(60*60*1000)).getTime())
Log.debug("new recurrence item=", exdate, " with old key=", dateKey);
exdate.start = CalendarFetcherUtils.convertDateToLocalTime(exdate.start, exdate.start.tz);
exdate.end = CalendarFetcherUtils.convertDateToLocalTime(exdate.end, exdate.end.tz);
Log.debug("adjusted recurringEvent start=", exdate.start, " end=", exdate.end);
});
}
Log.debug("modified recurrences before rrule.between", event.recurrences);
},
the excluded and recurrence dates in the list also need to be changed to local for rrule
rrule will return the full list of events possible, we have to process and adjust/remove events moved or excluded so they are not sent as from rule
the recurrences are events that would be returned but have been moved from their original positions
there are none for this event so it doesnt matter
both of these objects, exdates and recurrences, use data keyed off the date/time, these are in UTC time, not TZ time. because we fixed the between and start dates for RRULE, the events come back with non-utc dates and don't match the info from the event entry
@sdetweil I created a PR to refactor the calendarfetcherutil and added some tests to make sure we get the correct moments and events back from the methods. Please review the changes when you have the time. I opted to comment out the fixEventtoLocal method call because I'm not sure if it is still needed. If you have a testcase that shows how it should work then I could add it back in and refactor it aswell.
there are testcases do
npm run test:electron
npm run test:e2e
there are testcases do
npm run test:electron npm run test:e2e
How did I miss those?! I just looked inside unit to find the unittests for these methods. I will check out the e2e and electron tests tomorrow and see if I missed anything in the refactoring. The unit tests are all green atleast and the 2 tests I added give the expected result.
there are testcases do
npm run test:electron npm run test:e2e
I see some tests are failing, I will check those failures tomorrow, I see some exdates failures and some failures that probably have to do with the conversion from the unix timestamp back to a readable datetime string in the dom.