From 42f8239eeab45c820330b27700abd50d10302fad Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:30:44 +0100 Subject: [PATCH 1/4] fix(calendar): show end date for multi-day full-day events in all timeFormats showEnd: true now works for full-day events spanning multiple days in 'relative' and 'dateheaders' mode, not just 'absolute'. Also, when nextDaysRelative replaces the start with 'Tomorrow' etc., the end date is appended (e.g. 'Tomorrow - 17th Mar'). showEndsOnlyWithDuration suppresses the end date as expected. Closes #4053 --- defaultmodules/calendar/calendar.js | 36 +++++++++++++++++-- .../fullday_multiday_showend_dateheaders.js | 31 ++++++++++++++++ ...llday_multiday_showend_nextdaysrelative.js | 32 +++++++++++++++++ .../fullday_multiday_showend_relative.js | 31 ++++++++++++++++ tests/electron/modules/calendar_spec.js | 17 +++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/configs/modules/calendar/fullday_multiday_showend_dateheaders.js create mode 100644 tests/configs/modules/calendar/fullday_multiday_showend_nextdaysrelative.js create mode 100644 tests/configs/modules/calendar/fullday_multiday_showend_relative.js diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js index 0d8575c810..755b0dfa70 100644 --- a/defaultmodules/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -387,8 +387,24 @@ Module.register("calendar", { if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); if (event.fullDayEvent) { - titleWrapper.colSpan = "2"; - titleWrapper.classList.add("align-left"); + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration) { + const endMomentAdjusted = eventEndDateMoment.clone().subtract(1, "second"); + if (!eventStartDateMoment.isSame(endMomentAdjusted, "d")) { + const timeWrapper = document.createElement("td"); + timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; + timeWrapper.style.paddingLeft = "2px"; + timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; + timeWrapper.innerHTML = `-${CalendarUtils.capFirst(endMomentAdjusted.format(this.config.fullDayEventDateFormat))}`; + eventWrapper.appendChild(timeWrapper); + if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); + } else { + titleWrapper.colSpan = "2"; + titleWrapper.classList.add("align-left"); + } + } else { + titleWrapper.colSpan = "2"; + titleWrapper.classList.add("align-left"); + } } else { const timeWrapper = document.createElement("td"); timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; @@ -454,17 +470,26 @@ Module.register("calendar", { } if (event.fullDayEvent && this.config.nextDaysRelative) { // Full days events within the next two days + let relativeLabel = false; if (event.today) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); + relativeLabel = true; } else if (event.yesterday) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); + relativeLabel = true; } else if (event.tomorrow) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); + relativeLabel = true; } else if (event.dayAfterTomorrow) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + relativeLabel = true; } } + // Append end date only if a relative label replaced the start date + if (relativeLabel && this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat))}`; + } } } else { // Show relative times @@ -501,6 +526,13 @@ Module.register("calendar", { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } + // Show end date for multi-day full-day events if showEnd is configured + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration) { + const endMomentAdjusted = eventEndDateMoment.clone().subtract(1, "second"); + if (!eventStartDateMoment.isSame(endMomentAdjusted, "d")) { + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(endMomentAdjusted.format(this.config.fullDayEventDateFormat))}`; + } + } Log.info("[calendar] event fullday"); } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { Log.info("[calendar] not full day but within getRelative size"); diff --git a/tests/configs/modules/calendar/fullday_multiday_showend_dateheaders.js b/tests/configs/modules/calendar/fullday_multiday_showend_dateheaders.js new file mode 100644 index 0000000000..2033d55d60 --- /dev/null +++ b/tests/configs/modules/calendar/fullday_multiday_showend_dateheaders.js @@ -0,0 +1,31 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/fullday_multiday_showend_nextdaysrelative.js b/tests/configs/modules/calendar/fullday_multiday_showend_nextdaysrelative.js new file mode 100644 index 0000000000..32ca4ec2b1 --- /dev/null +++ b/tests/configs/modules/calendar/fullday_multiday_showend_nextdaysrelative.js @@ -0,0 +1,32 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + nextDaysRelative: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/fullday_multiday_showend_relative.js b/tests/configs/modules/calendar/fullday_multiday_showend_relative.js new file mode 100644 index 0000000000..c4dc08cc2d --- /dev/null +++ b/tests/configs/modules/calendar/fullday_multiday_showend_relative.js @@ -0,0 +1,31 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index 2a9845ae18..0defb5a477 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -314,4 +314,21 @@ describe("Calendar module", () => { await expect(doTestTableContent(".testNotification", ".elementCount", "12", first)).resolves.toBe(true); }); }); + + describe("showEnd for multi-day full-day events", () => { + it("relative timeFormat shows start and end date", async () => { + await helpers.startApplication("tests/configs/modules/calendar/fullday_multiday_showend_relative.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct-30th.Oct", first)).resolves.toBe(true); + }); + + it("dateheaders timeFormat shows end date in time cell", async () => { + await helpers.startApplication("tests/configs/modules/calendar/fullday_multiday_showend_dateheaders.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await expect(doTestTableContent(".calendar .event", ".time", "-30th.Oct", first)).resolves.toBe(true); + }); + + it("absolute timeFormat with nextDaysRelative shows relative label and end date", async () => { + await helpers.startApplication("tests/configs/modules/calendar/fullday_multiday_showend_nextdaysrelative.js", "24 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await expect(doTestTableContent(".calendar .event", ".time", "Tomorrow-30th.Oct", first)).resolves.toBe(true); + }); + }); }); From 7c0cd384c690ccd930711efe757e9d5bb464c986 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:39:03 +0100 Subject: [PATCH 2/4] fix(calendar): apply showEnd to timed events in relative/dateheaders --- defaultmodules/calendar/calendar.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js index 755b0dfa70..cb70308d36 100644 --- a/defaultmodules/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -412,12 +412,12 @@ Module.register("calendar", { timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; timeWrapper.innerHTML = eventStartDateMoment.format("LT"); - // Add endDate to dataheaders if showEnd is enabled + // Add endDate to dateheaders if showEnd is enabled if (this.config.showEnd) { if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { // no duration here, don't display end } else { - timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat))}`; } } @@ -538,6 +538,9 @@ Module.register("calendar", { Log.info("[calendar] not full day but within getRelative size"); // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; + } else if (this.config.showEnd && (!this.config.showEndsOnlyWithDuration || event.startDate !== event.endDate)) { + // Show end time for timed events + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat))}`; } } else { // Ongoing event From 32033971f6be2ea7c3eb7465789b4fec35a0d58c Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:22:28 +0100 Subject: [PATCH 3/4] test(calendar): cover timed multi-day showEnd in relative/dateheaders Add electron test coverage for timed (non-full-day) multi-day events with showEnd enabled in `relative` and `dateheaders` modes. Use a yearly recurring mock ICS so tests stay stable over time and avoid "no upcoming events" failures as dates move on. --- ...s_non_repeating_display_end_dateheaders.js | 33 +++++++++++++++++++ ...days_non_repeating_display_end_relative.js | 33 +++++++++++++++++++ tests/electron/modules/calendar_spec.js | 12 +++++++ ...nt_with_time_over_multiple_days_yearly.ics | 18 ++++++++++ 4 files changed, 96 insertions(+) create mode 100644 tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_dateheaders.js create mode 100644 tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_relative.js create mode 100644 tests/mocks/event_with_time_over_multiple_days_yearly.ics diff --git a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_dateheaders.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_dateheaders.js new file mode 100644 index 0000000000..b7dd24218c --- /dev/null +++ b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_dateheaders.js @@ -0,0 +1,33 @@ +const config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_relative.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_relative.js new file mode 100644 index 0000000000..92afb5f184 --- /dev/null +++ b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_relative.js @@ -0,0 +1,33 @@ +const config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index 0defb5a477..6436940d77 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -297,6 +297,18 @@ describe("Calendar module", () => { }); }); + describe("showEnd for timed multi-day events", () => { + it("relative timeFormat shows start and end for timed multi-day events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_relative.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00-26th.Oct, 06:00", first)).resolves.toBe(true); + }); + + it("dateheaders timeFormat shows end for timed multi-day events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end_dateheaders.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await expect(doTestTableContent(".calendar .event", ".time", "-26th.Oct, 06:00", first)).resolves.toBe(true); + }); + }); + describe("count and check symbols", () => { it("in array", async () => { await helpers.startApplication("tests/configs/modules/calendar/symboltest.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); diff --git a/tests/mocks/event_with_time_over_multiple_days_yearly.ics b/tests/mocks/event_with_time_over_multiple_days_yearly.ics new file mode 100644 index 0000000000..00f65b2f1d --- /dev/null +++ b/tests/mocks/event_with_time_over_multiple_days_yearly.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagicMirror Test//timed-multiday-yearly//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20241026T010000Z +DTEND:20241026T110000Z +DTSTAMP:20241024T153358Z +UID:4maud6s79m41a99pj2g7j5km0a@google.com +CREATED:20241024T153313Z +LAST-MODIFIED:20241024T153330Z +SEQUENCE:0 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +SUMMARY:Sleep over at Bobs +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR From d1d43c2c3af11f79efbb55398f3e988ed1ca347d Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:59:46 +0100 Subject: [PATCH 4/4] fix(calendar): use time-only format for same-day timed event ends --- defaultmodules/calendar/calendar.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js index cb70308d36..89a29cf14c 100644 --- a/defaultmodules/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -417,7 +417,8 @@ Module.register("calendar", { if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { // no duration here, don't display end } else { - timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat))}`; + const endFormat = eventStartDateMoment.isSame(eventEndDateMoment, "d") ? "LT" : this.config.dateEndFormat; + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(endFormat))}`; } } @@ -439,8 +440,9 @@ Module.register("calendar", { if (this.config.showEnd) { // and has a duration if (event.startDate !== event.endDate) { + const endFormat = eventStartDateMoment.isSame(eventEndDateMoment, "d") ? "LT" : this.config.dateEndFormat; timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat)); + timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(endFormat)); } } @@ -540,7 +542,8 @@ Module.register("calendar", { timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; } else if (this.config.showEnd && (!this.config.showEndsOnlyWithDuration || event.startDate !== event.endDate)) { // Show end time for timed events - timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat))}`; + const endFormat = eventStartDateMoment.isSame(eventEndDateMoment, "d") ? "LT" : this.config.dateEndFormat; + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format(endFormat))}`; } } else { // Ongoing event