MagicMirror icon indicating copy to clipboard operation
MagicMirror copied to clipboard

[Bug] Calendar shows wrong time for recurring events created before DST

Open plebcity opened this issue 7 months ago • 16 comments

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.

plebcity avatar Jun 01 '25 08:06 plebcity

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.

plebcity avatar Jun 01 '25 09:06 plebcity

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

sdetweil avatar Jun 01 '25 10:06 sdetweil

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.

plebcity avatar Jun 01 '25 10:06 plebcity

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

plebcity avatar Jun 01 '25 11:06 plebcity

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

sdetweil avatar Jun 01 '25 13:06 sdetweil

I made an ics from the above event and see this on my system set to Brussels timezone , 9am as expected

Image

sdetweil avatar Jun 02 '25 21:06 sdetweil

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 avatar Jun 02 '25 21:06 plebcity

@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

sdetweil avatar Jun 02 '25 21:06 sdetweil

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?

sdetweil avatar Jun 02 '25 21:06 sdetweil

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.

plebcity avatar Jun 02 '25 21:06 plebcity

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);
	},

plebcity avatar Jun 02 '25 21:06 plebcity

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 avatar Jun 02 '25 21:06 sdetweil

@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.

plebcity avatar Jun 03 '25 20:06 plebcity

there are testcases do

npm run test:electron
npm run test:e2e

sdetweil avatar Jun 03 '25 20:06 sdetweil

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.

plebcity avatar Jun 03 '25 20:06 plebcity

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.

plebcity avatar Jun 03 '25 21:06 plebcity