diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a024297a..6842a60592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,13 @@ _This release is scheduled to be released on 2024-04-01._ - Unneeded file headers (#3358) +## [2.27.0] - UNRELEASED + +### Fixed + +- Worked around several issues in the RRULE library that were causing deleted calender events to still show, some + initial and recurring events to not show, and some event times to be off an hour. (#3291) + ## [2.26.0] - 01-01-2024 Thanks to: @bnitkin, @bugsounet, @dependabot, @jkriegshauser, @kaennchenstruggle, @KristjanESPERANTO and @Ybbet. diff --git a/modules/default/calendar/calendar.js b/modules/default/calendar/calendar.js index fe08a883af..3b10b922d1 100644 --- a/modules/default/calendar/calendar.js +++ b/modules/default/calendar/calendar.js @@ -36,6 +36,7 @@ Module.register("calendar", { hideDuplicates: true, showTimeToday: false, colored: false, + forceUseCurrentTime: false, tableClass: "small", calendars: [ { @@ -567,9 +568,16 @@ Module.register("calendar", { const ONE_HOUR = ONE_MINUTE * 60; const ONE_DAY = ONE_HOUR * 24; - const now = new Date(); - const today = moment().startOf("day"); - const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + let now, today, future; + if (this.config.forceUseCurrentTime || this.defaults.forceUseCurrentTime) { + now = new Date(); + today = moment().startOf("day"); + future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + } else { + now = new Date(Date.now()); // Can use overridden time + today = moment(now).startOf("day"); + future = moment(now).startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + } let events = []; for (const calendarUrl in this.calendarData) { diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js index c2067296cd..4be451eb51 100644 --- a/modules/default/calendar/calendarfetcherutils.js +++ b/modules/default/calendar/calendarfetcherutils.js @@ -56,7 +56,7 @@ const CalendarFetcherUtils = { event.start.tz = ""; Log.debug(`ical offset=${current_offset} date=${date}`); mm = moment(date); - let x = parseInt(moment(new Date()).utcOffset()); + let x = moment(new Date()).utcOffset(); Log.debug(`net mins=${current_offset * 60 - x}`); mm = mm.add(x - current_offset * 60, "minutes"); @@ -128,24 +128,26 @@ const CalendarFetcherUtils = { }; const eventDate = function (event, time) { - return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); + return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time]).startOf("day") : moment(event[time]); }; Log.debug(`There are ${Object.entries(data).length} calendar entries.`); + + const now = new Date(Date.now()); + const todayLocal = moment(now).startOf("day").toDate(); + const futureLocalDate + = moment(now) + .startOf("day") + .add(config.maximumNumberOfDays, "days") + .subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. + .toDate(); + Object.entries(data).forEach(([key, event]) => { Log.debug("Processing entry..."); - const now = new Date(); - const today = moment().startOf("day").toDate(); - const future - = moment() - .startOf("day") - .add(config.maximumNumberOfDays, "days") - .subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. - .toDate(); - let past = today; + let pastLocalDate = todayLocal; if (config.includePastEvents) { - past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + pastLocalDate = moment(now).startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); } // FIXME: Ugly fix to solve the facebook birthday issue. @@ -159,33 +161,33 @@ const CalendarFetcherUtils = { if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event)}`); - let startDate = eventDate(event, "start"); - let endDate; + let startMoment = eventDate(event, "start"); + let endMoment; if (typeof event.end !== "undefined") { - endDate = eventDate(event, "end"); + endMoment = eventDate(event, "end"); } else if (typeof event.duration !== "undefined") { - endDate = startDate.clone().add(moment.duration(event.duration)); + endMoment = startMoment.clone().add(moment.duration(event.duration)); } else { if (!isFacebookBirthday) { // make copy of start date, separate storage area - endDate = moment(startDate.format("x"), "x"); + endMoment = moment(startMoment.valueOf()); } else { - endDate = moment(startDate).add(1, "days"); + endMoment = moment(startMoment).add(1, "days"); } } - Log.debug(`start: ${startDate.toDate()}`); - Log.debug(`end:: ${endDate.toDate()}`); + Log.debug(`start: ${startMoment.toDate()}`); + Log.debug(`end:: ${endMoment.toDate()}`); // Calculate the duration of the event for use with recurring events. - let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); - Log.debug(`duration: ${duration}`); + const durationMs = endMoment.valueOf() - startMoment.valueOf(); + Log.debug(`duration: ${durationMs}`); // FIXME: Since the parsed json object from node-ical comes with time information // this check could be removed (?) if (event.start.length === 8) { - startDate = startDate.startOf("day"); + startMoment = startMoment.startOf("day"); } const title = CalendarFetcherUtils.getTitleFromEvent(event); @@ -245,11 +247,11 @@ const CalendarFetcherUtils = { const geo = event.geo || false; const description = event.description || false; - if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { + if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { const rule = event.rrule; - const pastMoment = moment(past); - const futureMoment = moment(future); + const pastMoment = moment(pastLocalDate); + const futureMoment = moment(futureLocalDate); // can cause problems with e.g. birthdays before 1900 if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) { @@ -260,8 +262,8 @@ const CalendarFetcherUtils = { // For recurring events, get the set of start dates that fall within the range // of dates we're looking for. // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time - let pastLocal = 0; - let futureLocal = 0; + let pastLocal; + let futureLocal; if (CalendarFetcherUtils.isFullDayEvent(event)) { Log.debug("fullday"); // if full day event, only use the date part of the ranges @@ -277,17 +279,51 @@ const CalendarFetcherUtils = { pastLocal = pastMoment.toDate(); } else { // otherwise use NOW.. cause we shouldn't use any before now - pastLocal = moment().toDate(); //now + pastLocal = moment(now).toDate(); //now } futureLocal = futureMoment.toDate(); // future } Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`); - let dates = rule.between(pastLocal, futureLocal, true, limitFunction); + const hasByWeekdayRule = rule.options.byweekday !== undefined && rule.options.byweekday !== null; + const oneDayInMs = 24 * 60 * 60 * 1000; + Log.debug(`RRule: ${rule.toString()}`); + rule.options.tzid = null; // RRule gets *very* confused with timezones + let dates = rule.between(new Date(pastLocal.valueOf() - oneDayInMs), new Date(futureLocal.valueOf() + oneDayInMs), true, () => { return true; }); Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`); dates = dates.filter((d) => { if (JSON.stringify(d) === "null") return false; else return true; }); + + // RRule can generate dates with an incorrect recurrence date. Process the array here and apply date correction. + if (hasByWeekdayRule) { + Log.debug("Rule has byweekday, checking for correction"); + dates.forEach((date, index, arr) => { + // NOTE: getTimezoneOffset() is negative of the expected value. For America/Los_Angeles under DST (GMT-7), + // this value is +420. For Australia/Sydney under DST (GMT+11), this value is -660. + const tzOffset = date.getTimezoneOffset() / 60; + const hour = date.getHours(); + if ((tzOffset < 0) && (hour < -tzOffset)) { // east of GMT + Log.debug(`East of GMT (tzOffset: ${tzOffset}) and hour=${hour} < ${-tzOffset}, Subtracting 1 day from ${date}`); + arr[index] = new Date(date.valueOf() - oneDayInMs); + } else if ((tzOffset > 0) && (hour >= (24 - tzOffset))) { // west of GMT + Log.debug(`West of GMT (tzOffset: ${tzOffset}) and hour=${hour} >= 24-${tzOffset}, Adding 1 day to ${date}`); + arr[index] = new Date(date.valueOf() + oneDayInMs); + } + }); + } + + // The dates array from rrule can be confused by DST. If the event was created during DST and we + // are querying a date range during non-DST, rrule can have the incorrect time for the date range. + // Reprocess the array here computing and applying the time offset. + dates.forEach((date, index, arr) => { + let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + if (adjustHours !== 0) { + Log.debug(`Applying timezone adjustment hours=${adjustHours} to ${date}`); + arr[index] = new Date(date.valueOf() + (adjustHours * 60 * 60 * 1000)); + } + }); + // The "dates" array contains the set of dates within our desired date range range that are valid // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that // had its date changed from outside the range to inside the range. For the time being, @@ -297,108 +333,35 @@ const CalendarFetcherUtils = { // Would be great if there was a better way to handle this. Log.debug(`event.recurrences: ${event.recurrences}`); if (event.recurrences !== undefined) { - for (let r in event.recurrences) { + for (let dateKey in event.recurrences) { // Only add dates that weren't already in the range we added from the rrule so that - // we don"t double-add those events. - if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { - dates.push(new Date(r)); + // we don't double-add those events. + let d = new Date(dateKey); + if (!moment(d).isBetween(pastMoment, futureMoment)) { + dates.push(d); } } } + + // Lastly, sometimes rrule doesn't include the event.start even if it is in the requested range. Ensure + // inclusion here. Unfortunately dates.includes() doesn't find it so we have to do forEach(). + { + let found = false; + dates.forEach((d) => { if (d.valueOf() === event.start.valueOf()) found = true; }); + if (!found) { + Log.debug(`event.start=${event.start} was not included in results from rrule; adding`); + dates.splice(0, 0, event.start); + } + } + // Loop through the set of date entries to see which recurrences should be added to our event list. for (let d in dates) { let date = dates[d]; let curEvent = event; + let curDurationMs = durationMs; let showRecurrence = true; - // set the time information in the date to equal the time information in the event - date.setUTCHours(curEvent.start.getUTCHours(), curEvent.start.getUTCMinutes(), curEvent.start.getUTCSeconds(), curEvent.start.getUTCMilliseconds()); - - // Get the offset of today where we are processing - // This will be the correction, we need to apply. - let nowOffset = new Date().getTimezoneOffset(); - // For full day events, the time might be off from RRULE/Luxon problem - // Get time zone offset of the rule calculated event - let dateoffset = date.getTimezoneOffset(); - - // Reduce the time by the following offset. - Log.debug(` recurring date is ${date} offset is ${dateoffset}`); - - let dh = moment(date).format("HH"); - Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`); - - if (CalendarFetcherUtils.isFullDayEvent(event)) { - Log.debug("Fullday"); - // If the offset is negative (east of GMT), where the problem is - if (dateoffset < 0) { - if (dh < Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - // reduce the time by the offset - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 fulldate is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 fulldate is ${date}`); - } - //} - } - } else { - // not full day, but luxon can still screw up the date on the rule processing - // we need to correct the date to get back to the right event for - if (dateoffset < 0) { - // if the date hour is less than the offset - if (dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Reduce the time by t: - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 is ${date}`); - } - //} - } - } - startDate = moment(date); - Log.debug(`Corrected startDate: ${startDate.toDate()}`); - - let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + startMoment = moment(date); // Remove the time information of each date by using its substring, using the following method: // .toISOString().substring(0,10). @@ -411,30 +374,30 @@ const CalendarFetcherUtils = { if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { // We found an override, so for this recurrence, use a potentially different title, start date, and duration. curEvent = curEvent.recurrences[dateKey]; - startDate = moment(curEvent.start); - duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); + startMoment = moment(curEvent.start); + curDurationMs = curEvent.end.valueOf() - startMoment.valueOf(); } // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { // This date is an exception date, which means we should skip it in the recurrence pattern. showRecurrence = false; } - Log.debug(`duration: ${duration}`); + Log.debug(`duration: ${curDurationMs}`); - endDate = moment(parseInt(startDate.format("x")) + duration, "x"); - if (startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); + endMoment = moment(startMoment.valueOf() + curDurationMs); + if (startMoment.valueOf() === endMoment.valueOf()) { + endMoment = endMoment.endOf("day"); } const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add // it to the event list. - if (endDate.isBefore(past) || startDate.isAfter(future)) { + if (endMoment.isBefore(pastLocal) || startMoment.isAfter(futureLocal)) { showRecurrence = false; } - if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { showRecurrence = false; } @@ -442,8 +405,8 @@ const CalendarFetcherUtils = { Log.debug(`saving event: ${description}`); newEvents.push({ title: recurrenceTitle, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + startDate: startMoment.format("x"), + endDate: endMoment.format("x"), fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), recurringEvent: true, class: event.class, @@ -461,43 +424,47 @@ const CalendarFetcherUtils = { // Log.debug("full day event") // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) - if (fullDayEvent && startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); + if (fullDayEvent && startMoment.valueOf() === endMoment.valueOf()) { + endMoment = endMoment.endOf("day"); } if (config.includePastEvents) { // Past event is too far in the past, so skip. - if (endDate < past) { + if (endMoment < pastLocalDate) { return; } } else { // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && endDate < new Date()) { + if (!fullDayEvent && endMoment < now) { return; } // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && endDate <= today) { + if (fullDayEvent && endMoment <= todayLocal) { return; } } // It exceeds the maximumNumberOfDays limit, so skip. - if (startDate > future) { + if (startMoment > futureLocalDate) { return; } - if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { return; } // get correction for date saving and dst change between now and then - let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate()); + let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startMoment.toDate()); + // This shouldn't happen + if (adjustHours) { + Log.warn(`Unexpected timezone adjustment of ${adjustHours} hours on non-recurring event`); + } // Every thing is good. Add it to the list. newEvents.push({ title: title, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + startDate: startMoment.add(adjustHours, "hours").format("x"), + endDate: endMoment.add(adjustHours, "hours").format("x"), fullDayEvent: fullDayEvent, class: event.class, location: location, @@ -578,7 +545,7 @@ const CalendarFetcherUtils = { increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js filterUntil = moment(endDate.format()).subtract(value, increment); - return now < filterUntil.format("x"); + return now < filterUntil.toDate(); } return false; diff --git a/tests/configs/modules/calendar/custom.js b/tests/configs/modules/calendar/custom.js index 4c6deb7838..8552bcbe5c 100644 --- a/tests/configs/modules/calendar/custom.js +++ b/tests/configs/modules/calendar/custom.js @@ -7,6 +7,7 @@ let config = { position: "bottom_bar", config: { customEvents: [{ keyword: "CustomEvent", symbol: "dice", eventClass: "undo" }], + forceUseCurrentTime: true, calendars: [ { maximumEntries: 5, diff --git a/tests/configs/modules/calendar/exdate.js b/tests/configs/modules/calendar/exdate.js deleted file mode 100644 index bb52561aa0..0000000000 --- a/tests/configs/modules/calendar/exdate.js +++ /dev/null @@ -1,31 +0,0 @@ -/* NOTE: calendar_test_exdate.ics has exdate entries for the next 20 years, but without some - * way to set a debug date for tests, this test may become flaky on specific days (i.e. could - * not test easily on leap-years, the BYDAY specified in exdate, etc.) or when the 20 years - * elapses if this project is still in active development ;) - * See issue #3250 - */ -let config = { - timeFormat: 12, - - modules: [ - { - module: "calendar", - position: "bottom_bar", - config: { - maximumEntries: 100, - calendars: [ - { - maximumEntries: 100, - maximumNumberOfDays: 364, - url: "http://localhost:8080/tests/mocks/calendar_test_exdate.ics" - } - ] - } - } - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = config; -} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js new file mode 100644 index 0000000000..608c71da96 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_dst.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_std.js b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js new file mode 100644 index 0000000000..6128c97f50 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_std.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_la_before_midnight.js b/tests/configs/modules/calendar/exdate_la_before_midnight.js new file mode 100644 index 0000000000..8c886f775a --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_before_midnight.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_before_midnight.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js new file mode 100644 index 0000000000..f98b75f4c7 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_dst.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js new file mode 100644 index 0000000000..06c7ff5319 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_std.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_before_midnight.js b/tests/configs/modules/calendar/exdate_syd_before_midnight.js new file mode 100644 index 0000000000..849c1f5363 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_before_midnight.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_before_midnight.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/electron/helpers/global-setup.js b/tests/electron/helpers/global-setup.js index 164c7f6630..71dfd0ddbe 100644 --- a/tests/electron/helpers/global-setup.js +++ b/tests/electron/helpers/global-setup.js @@ -3,11 +3,11 @@ // https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser const { _electron: electron } = require("playwright"); -exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"]) => { +exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"], timezone = "GMT") => { global.electronApp = null; global.page = null; process.env.MM_CONFIG_FILE = configFilename; - process.env.TZ = "GMT"; + process.env.TZ = timezone; global.electronApp = await electron.launch({ args: electronParams }); await global.electronApp.firstWindow(); @@ -20,7 +20,7 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar if (systemDate) { await global.page.evaluate((systemDate) => { Date.now = () => { - return new Date(systemDate); + return new Date(systemDate).valueOf(); }; }, systemDate); } diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index 2af61a93df..3dcbd36f68 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -44,17 +44,96 @@ describe("Calendar module", () => { }); }); - describe("Exdate check", () => { - it("should show the recurring event 51 times (excluded once) in a 364-day (inclusive) period", async () => { - // test must run on a Thursday - await helpers.startApplication("tests/configs/modules/calendar/exdate.js", "14 Dec 2023 12:30:00 GMT"); + /****************************/ + // LOS ANGELES TESTS: + // In 2023, DST (GMT-7) was until 5 Nov, after which is standard (STD) (GMT-8) time. + // Test takes place on Thu 19 Oct, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be + // 4 events (25 Oct, 1 Nov, (switch to STD), 8 Nov, Nov 15), but 1 Nov and 8 Nov are excluded. + // There are three separate tests: + // * before midnight GMT (3pm local time) + // * at midnight GMT in STD time (4pm local time) + // * at midnight GMT in DST time (5pm local time) + describe("Exdate: LA crossover DST before midnight GMT", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_before_midnight.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); expect(global.page).not.toBeNull(); const loc = await global.page.locator(".calendar .event"); const elem = loc.first(); await elem.waitFor(); expect(elem).not.toBeNull(); const cnt = await loc.count(); - expect(cnt).toBe(51); + expect(cnt).toBe(2); + }); + }); + + describe("Exdate: LA crossover DST at midnight GMT local STD", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_std.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: LA crossover DST at midnight GMT local DST", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_dst.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + + /****************************/ + // SYDNEY TESTS: + // In 2023, standard time (STD) (GMT+10) was until 1 Oct, after which is DST (GMT+11). + // Test takes place on Thu 14 Sep, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be + // 4 events (20 Sep, 27 Sep, (switch to DST), 4 Oct, 11 Oct), but 27 Sep and 4 Oct are excluded. + // There are three separate tests: + // * before midnight GMT (9am local time) + // * at midnight GMT in STD time (10am local time) + // * at midnight GMT in DST time (11am local time) + describe("Exdate: SYD crossover DST before midnight GMT", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_before_midnight.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: SYD crossover DST at midnight GMT local STD", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_std.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: SYD crossover DST at midnight GMT local DST", () => { + it("SYD crossover DST at midnight GMT local DST should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); }); }); }); diff --git a/tests/mocks/calendar_test_exdate.ics b/tests/mocks/calendar_test_exdate.ics deleted file mode 100644 index 8f1f8b9f32..0000000000 --- a/tests/mocks/calendar_test_exdate.ics +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN:VEVENT -DTSTART;TZID=UTC:20231025T181000 -DTEND;TZID=UTC:20231025T195000 -RRULE:FREQ=WEEKLY;BYDAY=WE -EXDATE;TZID=UTC:20231101T181000 -EXDATE;TZID=UTC:20241030T181000 -EXDATE;TZID=UTC:20251029T181000 -EXDATE;TZID=UTC:20261028T181000 -EXDATE;TZID=UTC:20271027T181000 -EXDATE;TZID=UTC:20281025T181000 -EXDATE;TZID=UTC:20291024T181000 -EXDATE;TZID=UTC:20301023T181000 -EXDATE;TZID=UTC:20311022T181000 -EXDATE;TZID=UTC:20321020T181000 -EXDATE;TZID=UTC:20331019T181000 -EXDATE;TZID=UTC:20341018T181000 -EXDATE;TZID=UTC:20351017T181000 -EXDATE;TZID=UTC:20361015T181000 -EXDATE;TZID=UTC:20371014T181000 -EXDATE;TZID=UTC:20381013T181000 -EXDATE;TZID=UTC:20391012T181000 -EXDATE;TZID=UTC:20401010T181000 -EXDATE;TZID=UTC:20411009T181000 -EXDATE;TZID=UTC:20421008T181000 -EXDATE;TZID=UTC:20431007T181000 -DTSTAMP:20231025T233434Z -UID:sdflbkasuhdb5fkauglkb@google.com -CREATED:20230306T193128Z -LAST-MODIFIED:20231024T222515Z -SEQUENCE:0 -STATUS:CONFIRMED -SUMMARY:My Event -TRANSP:OPAQUE -END:VEVENT diff --git a/tests/mocks/exdate_la_at_midnight_dst.ics b/tests/mocks/exdate_la_at_midnight_dst.ics new file mode 100644 index 0000000000..6f8575f5d7 --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_dst.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T170000 +DTEND;TZID=America/Los_Angeles:20231025T180000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T170000 +EXDATE;TZID=America/Los_Angeles:20231108T170000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_la_at_midnight_std.ics b/tests/mocks/exdate_la_at_midnight_std.ics new file mode 100644 index 0000000000..553ca10eef --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_std.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T160000 +DTEND;TZID=America/Los_Angeles:20231025T170000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T160000 +EXDATE;TZID=America/Los_Angeles:20231108T160000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_la_before_midnight.ics b/tests/mocks/exdate_la_before_midnight.ics new file mode 100644 index 0000000000..5cdb069b45 --- /dev/null +++ b/tests/mocks/exdate_la_before_midnight.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T150000 +DTEND;TZID=America/Los_Angeles:20231025T160000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T150000 +EXDATE;TZID=America/Los_Angeles:20231108T150000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_dst.ics b/tests/mocks/exdate_syd_at_midnight_dst.ics new file mode 100644 index 0000000000..9a88a10b6d --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_dst.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T110000 +DTEND;TZID=Australia/Sydney:20230920T111000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T110000 +EXDATE;TZID=Australia/Sydney:20231004T110000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_std.ics b/tests/mocks/exdate_syd_at_midnight_std.ics new file mode 100644 index 0000000000..af444e8cc0 --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_std.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T100000 +DTEND;TZID=Australia/Sydney:20230920T110000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T100000 +EXDATE;TZID=Australia/Sydney:20231004T100000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_before_midnight.ics b/tests/mocks/exdate_syd_before_midnight.ics new file mode 100644 index 0000000000..5b5e53d2a2 --- /dev/null +++ b/tests/mocks/exdate_syd_before_midnight.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T090000 +DTEND;TZID=Australia/Sydney:20230920T100000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T090000 +EXDATE;TZID=Australia/Sydney:20231004T090000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file