From e1c44a86bb961bbb821de670326e128652c2b68c Mon Sep 17 00:00:00 2001
From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Date: Wed, 18 Mar 2026 00:32:55 +0100
Subject: [PATCH] fix(calendar): make showEnd behavior more consistent across
time formats (#4059)
Closes #4053
This started as a small fix.
After feedback and more debugging, I found more issues and
inconsistencies and went a bit down the rabbit hole.
The PR is bigger now, but I think the result is better: behavior is more
predictable, and the output is more consistent.
## Changes
- Multi-day full-day events now show an end date in `relative` and
`dateheaders` when `showEnd` is enabled.
- With `absolute` + `nextDaysRelative`, full-day events now keep the end
date when the start is replaced by TODAY/TOMORROW/etc.
- Timed events in `absolute` now also respect
`showEndsOnlyWithDuration`.
- Tests were expanded and refactored to cover more showEnd cases and
keep the setup easier to maintain.
- I also refactored parts of the calendar time rendering to reduce
duplicate logic.
## Before
## After
---
defaultmodules/calendar/calendar.js | 348 +++++++++++------
.../calendar/calendarShowEndConfigs.js | 361 ++++++++++++++++++
...multiple_days_non_repeating_display_end.js | 33 --
...tiple_days_non_repeating_no_display_end.js | 34 --
tests/electron/modules/calendar_spec.js | 116 +++++-
...nt_with_time_over_multiple_days_yearly.ics | 18 +
.../mocks/event_with_time_same_day_yearly.ics | 18 +
7 files changed, 735 insertions(+), 193 deletions(-)
create mode 100644 tests/configs/modules/calendar/calendarShowEndConfigs.js
delete mode 100644 tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js
delete mode 100644 tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js
create mode 100644 tests/mocks/event_with_time_over_multiple_days_yearly.ics
create mode 100644 tests/mocks/event_with_time_same_day_yearly.ics
diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js
index 0d8575c8..cfc62543 100644
--- a/defaultmodules/calendar/calendar.js
+++ b/defaultmodules/calendar/calendar.js
@@ -384,32 +384,7 @@ Module.register("calendar", {
}
if (this.config.timeFormat === "dateheaders") {
- if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
-
- if (event.fullDayEvent) {
- 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)}`;
- timeWrapper.style.paddingLeft = "2px";
- timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
- timeWrapper.innerHTML = eventStartDateMoment.format("LT");
-
- // Add endDate to dataheaders 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"))}`;
- }
- }
-
- eventWrapper.appendChild(timeWrapper);
-
- if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
- }
- if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
+ this.renderDateHeadersEventTime(eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment);
} else {
const timeWrapper = document.createElement("td");
@@ -417,106 +392,11 @@ Module.register("calendar", {
const now = moment();
if (this.config.timeFormat === "absolute") {
- // Use dateFormat
- timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
- // Add end time if showEnd
- if (this.config.showEnd) {
- // and has a duration
- if (event.startDate !== event.endDate) {
- timeWrapper.innerHTML += "-";
- timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat));
- }
- }
-
- // For full day events we use the fullDayEventDateFormat
- if (event.fullDayEvent) {
- //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
- eventEndDateMoment.subtract(1, "second");
- timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
- // only show end if requested and allowed and the dates are different
- if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) {
- timeWrapper.innerHTML += "-";
- timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat));
- } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
- }
- } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
- // Ongoing and getRelative is set
- timeWrapper.innerHTML = CalendarUtils.capFirst(
- this.translate("RUNNING", {
- fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
- timeUntilEnd: eventEndDateMoment.fromNow(true)
- })
- );
- } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) {
- // Within urgency days
- timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow());
- }
- if (event.fullDayEvent && this.config.nextDaysRelative) {
- // Full days events within the next two days
- if (event.today) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
- } else if (event.yesterday) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
- } else if (event.tomorrow) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
- } else if (event.dayAfterTomorrow) {
- if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
- }
- }
- }
+ timeWrapper.innerHTML = this.buildAbsoluteTimeText(event, eventStartDateMoment, eventEndDateMoment, now);
} else {
- // Show relative times
- if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
- // Use relative time
- if (!this.config.hideTime && !event.fullDayEvent) {
- Log.debug("[calendar] event not hidden and not fullday");
- timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
- } else {
- Log.debug("[calendar] event full day or hidden");
- timeWrapper.innerHTML = `${CalendarUtils.capFirst(
- eventStartDateMoment.calendar(null, {
- sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
- nextDay: `[${this.translate("TOMORROW")}]`,
- nextWeek: "dddd",
- sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
- })
- )}`;
- }
- if (event.fullDayEvent) {
- // Full days events within the next two days
- if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
- } else if (event.dayBeforeYesterday) {
- if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
- }
- } else if (event.yesterday) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
- } else if (event.tomorrow) {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
- } else if (event.dayAfterTomorrow) {
- if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
- timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
- }
- }
- Log.info("[calendar] event fullday");
- } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
- 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 {
- // Ongoing event
- timeWrapper.innerHTML = CalendarUtils.capFirst(
- this.translate("RUNNING", {
- fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
- timeUntilEnd: eventEndDateMoment.fromNow(true)
- })
- );
- }
+ timeWrapper.innerHTML = this.buildRelativeTimeText(event, eventStartDateMoment, eventEndDateMoment, now);
}
+
timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`;
eventWrapper.appendChild(timeWrapper);
}
@@ -793,6 +673,226 @@ Module.register("calendar", {
);
},
+ createDateHeadersTimeWrapper (url) {
+ const timeWrapper = document.createElement("td");
+ timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(url)}`;
+ timeWrapper.style.paddingLeft = "2px";
+ timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
+ return timeWrapper;
+ },
+
+ hasEventDuration (event) {
+ return event.startDate !== event.endDate;
+ },
+
+ shouldShowDateHeadersTimedEnd (event) {
+ return this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event));
+ },
+
+ shouldShowRelativeTimedEnd (event) {
+ return !this.config.hideTime && this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event));
+ },
+
+ getAdjustedFullDayEndMoment (endMoment) {
+ return endMoment.clone().subtract(1, "second");
+ },
+
+ renderDateHeadersEventTime (eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment) {
+ if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
+
+ if (event.fullDayEvent) {
+ const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
+ if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
+ const timeWrapper = this.createDateHeadersTimeWrapper(event.url);
+ timeWrapper.innerHTML = `-${CalendarUtils.capFirst(adjustedEndMoment.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 {
+ const timeWrapper = this.createDateHeadersTimeWrapper(event.url);
+ timeWrapper.innerHTML = eventStartDateMoment.format("LT");
+
+ // In dateheaders mode, keep the end as time-only to avoid redundant date info under a date header.
+ if (this.shouldShowDateHeadersTimedEnd(event)) {
+ timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`;
+ }
+
+ eventWrapper.appendChild(timeWrapper);
+ if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
+ }
+
+ if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
+ },
+
+ buildAbsoluteTimeText (event, eventStartDateMoment, eventEndDateMoment, now) {
+ let timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
+
+ if (this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event))) {
+ const sameDay = this.isSameDay(eventStartDateMoment, eventEndDateMoment);
+ if (sameDay && !this.dateFormatIncludesTime()) {
+ timeText += `, ${eventStartDateMoment.format("LT")}`;
+ }
+ timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`;
+ }
+
+ if (event.fullDayEvent) {
+ const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
+ timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
+
+ if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
+ timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
+ } else if (!eventStartDateMoment.isSame(adjustedEndMoment, "d") && eventStartDateMoment.isBefore(now)) {
+ timeText = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
+ }
+
+ if (this.config.nextDaysRelative) {
+ let relativeLabel = false;
+ if (event.today) {
+ timeText = CalendarUtils.capFirst(this.translate("TODAY"));
+ relativeLabel = true;
+ } else if (event.yesterday) {
+ timeText = CalendarUtils.capFirst(this.translate("YESTERDAY"));
+ relativeLabel = true;
+ } else if (event.tomorrow) {
+ timeText = CalendarUtils.capFirst(this.translate("TOMORROW"));
+ relativeLabel = true;
+ } else if (event.dayAfterTomorrow && this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
+ timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
+ relativeLabel = true;
+ }
+
+ if (relativeLabel && this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
+ timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
+ }
+ }
+
+ return timeText;
+ }
+
+ if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
+ return CalendarUtils.capFirst(
+ this.translate("RUNNING", {
+ fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
+ timeUntilEnd: eventEndDateMoment.fromNow(true)
+ })
+ );
+ }
+
+ if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) {
+ return CalendarUtils.capFirst(eventStartDateMoment.fromNow());
+ }
+
+ return timeText;
+ },
+
+ buildRelativeTimeText (event, eventStartDateMoment, eventEndDateMoment, now) {
+ if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
+ let timeText;
+
+ if (!this.config.hideTime && !event.fullDayEvent) {
+ Log.debug("[calendar] event not hidden and not fullday");
+ timeText = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
+ } else {
+ Log.debug("[calendar] event full day or hidden");
+ timeText = `${CalendarUtils.capFirst(
+ eventStartDateMoment.calendar(null, {
+ sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
+ nextDay: `[${this.translate("TOMORROW")}]`,
+ nextWeek: "dddd",
+ sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
+ })
+ )}`;
+ }
+
+ if (event.fullDayEvent) {
+ if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
+ timeText = CalendarUtils.capFirst(this.translate("TODAY"));
+ } else if (event.dayBeforeYesterday) {
+ if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
+ timeText = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
+ }
+ } else if (event.yesterday) {
+ timeText = CalendarUtils.capFirst(this.translate("YESTERDAY"));
+ } else if (event.tomorrow) {
+ timeText = CalendarUtils.capFirst(this.translate("TOMORROW"));
+ } else if (event.dayAfterTomorrow) {
+ if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
+ timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
+ }
+ }
+
+ if (this.config.showEnd && !this.config.showEndsOnlyWithDuration) {
+ const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
+ if (!eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
+ timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.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");
+ timeText = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;
+ } else if (this.shouldShowRelativeTimedEnd(event)) {
+ if (this.isSameDay(eventStartDateMoment, eventEndDateMoment)) {
+ const sameElseFormat = this.dateFormatIncludesTime() ? this.config.dateFormat : `${this.config.dateFormat}, LT`;
+ timeText = CalendarUtils.capFirst(
+ eventStartDateMoment.calendar(null, { sameElse: sameElseFormat })
+ );
+ }
+ timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`;
+ }
+
+ return timeText;
+ }
+
+ return CalendarUtils.capFirst(
+ this.translate("RUNNING", {
+ fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
+ timeUntilEnd: eventEndDateMoment.fromNow(true)
+ })
+ );
+ },
+
+ /**
+ * Determines whether two moments are on the same day.
+ * @param {moment.Moment} startMoment The start moment.
+ * @param {moment.Moment} endMoment The end moment.
+ * @returns {boolean} True when both moments share the same calendar day.
+ */
+ isSameDay (startMoment, endMoment) {
+ return startMoment.isSame(endMoment, "d");
+ },
+
+ /**
+ * Checks whether the configured dateFormat already contains time components.
+ * @returns {boolean} True when dateFormat includes time tokens.
+ */
+ dateFormatIncludesTime () {
+ const dateFormatWithoutLiterals = this.config.dateFormat.replace(/\[[^\]]*\]/g, "");
+ const localeDateFormat = moment.localeData();
+ const expandedDateFormat = dateFormatWithoutLiterals.replace(
+ /LTS|LT|LLLL|LLL|LL|L|llll|lll|ll|l/g,
+ (token) => localeDateFormat.longDateFormat(token) || token
+ );
+ const expandedDateFormatWithoutLiterals = expandedDateFormat.replace(/\[[^\]]*\]/g, "");
+ return (/(H{1,2}|h{1,2}|k{1,2}|m{1,2}|s{1,2}|a|A)/).test(expandedDateFormatWithoutLiterals);
+ },
+
+ /**
+ * Formats a timed event end value.
+ * Uses time-only for same-day events and dateEndFormat for multi-day events.
+ * @param {moment.Moment} startMoment The event start moment.
+ * @param {moment.Moment} endMoment The event end moment.
+ * @returns {string} The formatted end value.
+ */
+ formatTimedEventEnd (startMoment, endMoment) {
+ const endFormat = this.isSameDay(startMoment, endMoment) ? "LT" : this.config.dateEndFormat;
+ return CalendarUtils.capFirst(endMoment.format(endFormat));
+ },
+
/**
* Retrieves the symbolClass for a specific calendar url.
* @param {string} url The calendar url
diff --git a/tests/configs/modules/calendar/calendarShowEndConfigs.js b/tests/configs/modules/calendar/calendarShowEndConfigs.js
new file mode 100644
index 00000000..129ad973
--- /dev/null
+++ b/tests/configs/modules/calendar/calendarShowEndConfigs.js
@@ -0,0 +1,361 @@
+const calendarShowEndConfigs = {
+ event_with_time_over_multiple_days_non_repeating_display_end: {
+ 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: "absolute",
+ getRelative: 0,
+ showEnd: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_over_multiple_days_non_repeating_display_end_dateheaders: {
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_over_multiple_days_non_repeating_display_end_relative: {
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_over_multiple_days_non_repeating_no_display_end: {
+ 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: "absolute",
+ getRelative: 0,
+ showEnd: true,
+ showEndsOnlyWithDuration: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_absolute: {
+ address: "0.0.0.0",
+ ipWhitelist: [],
+ timeFormat: 24,
+ modules: [
+ {
+ module: "calendar",
+ position: "bottom_bar",
+ config: {
+ fade: false,
+ urgency: 0,
+ dateFormat: "Do.MMM",
+ dateEndFormat: "Do.MMM, HH:mm",
+ fullDayEventDateFormat: "Do.MMM",
+ timeFormat: "absolute",
+ getRelative: 0,
+ showEnd: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_absolute_dateformat_lll: {
+ address: "0.0.0.0",
+ ipWhitelist: [],
+ language: "en",
+ timeFormat: 24,
+ modules: [
+ {
+ module: "calendar",
+ position: "bottom_bar",
+ config: {
+ fade: false,
+ urgency: 0,
+ dateFormat: "LLL",
+ dateEndFormat: "Do.MMM, HH:mm",
+ fullDayEventDateFormat: "Do.MMM",
+ timeFormat: "absolute",
+ getRelative: 0,
+ showEnd: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time: {
+ 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: "absolute",
+ getRelative: 0,
+ showEnd: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_dateheaders: {
+ address: "0.0.0.0",
+ ipWhitelist: [],
+ timeFormat: 24,
+ modules: [
+ {
+ module: "calendar",
+ position: "bottom_bar",
+ config: {
+ fade: false,
+ urgency: 0,
+ dateFormat: "Do.MMM",
+ 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_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_relative: {
+ address: "0.0.0.0",
+ ipWhitelist: [],
+ timeFormat: 24,
+ modules: [
+ {
+ module: "calendar",
+ position: "bottom_bar",
+ config: {
+ fade: false,
+ urgency: 0,
+ dateFormat: "Do.MMM",
+ 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_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ event_with_time_same_day_yearly_display_end_relative_hide_time: {
+ address: "0.0.0.0",
+ ipWhitelist: [],
+ timeFormat: 24,
+ modules: [
+ {
+ module: "calendar",
+ position: "bottom_bar",
+ config: {
+ fade: false,
+ urgency: 0,
+ dateFormat: "Do.MMM",
+ dateEndFormat: "Do.MMM, HH:mm",
+ fullDayEventDateFormat: "Do.MMM",
+ timeFormat: "relative",
+ getRelative: 0,
+ hideTime: true,
+ showEnd: true,
+ calendars: [
+ {
+ maximumEntries: 100,
+ url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ fullday_multiday_showend_dateheaders: {
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ fullday_multiday_showend_nextdaysrelative: {
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ fullday_multiday_showend_relative: {
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ }
+};
+
+const defaultScenario = "event_with_time_over_multiple_days_non_repeating_display_end";
+const selectedScenario = process.env.MM_CALENDAR_SHOWEND_SCENARIO || defaultScenario;
+const config = calendarShowEndConfigs[selectedScenario];
+
+if (!config) {
+ throw new Error(`Unknown MM_CALENDAR_SHOWEND_SCENARIO: ${selectedScenario}`);
+}
+
+module.exports = config;
diff --git a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js
deleted file mode 100644
index 95989648..00000000
--- a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js
+++ /dev/null
@@ -1,33 +0,0 @@
-let 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: "absolute",
- getRelative: 0,
- showEnd: true,
- calendars: [
- {
- maximumEntries: 100,
- url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.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_no_display_end.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js
deleted file mode 100644
index ef60df4c..00000000
--- a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js
+++ /dev/null
@@ -1,34 +0,0 @@
-let 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: "absolute",
- getRelative: 0,
- showEnd: true,
- showEndsOnlyWithDuration: true,
- calendars: [
- {
- maximumEntries: 100,
- url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.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 2a9845ae..1be60c4d 100644
--- a/tests/electron/modules/calendar_spec.js
+++ b/tests/electron/modules/calendar_spec.js
@@ -50,8 +50,29 @@ describe("Calendar module", () => {
return true;
};
+ const defaultCalendarNow = "08 Oct 2024 12:30:00 GMT-07:00";
+ const defaultCalendarTimeZone = "America/Chicago";
+ const showEndConfigPath = "tests/configs/modules/calendar/calendarShowEndConfigs.js";
+
+ const startCalendarShowEndScenario = async (scenario, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone) => {
+ process.env.MM_CALENDAR_SHOWEND_SCENARIO = scenario;
+ await helpers.startApplication(showEndConfigPath, now, [], timeZone);
+ };
+
+ const expectFirstEventTimeCell = async ({ scenario, expectedTime, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone }) => {
+ await startCalendarShowEndScenario(scenario, now, timeZone);
+ await expect(doTestTableContent(".calendar .event", ".time", expectedTime, first)).resolves.toBe(true);
+ };
+
+ const getFirstEventTimeText = async () => {
+ const timeCell = global.page.locator(".calendar .event .time").locator(`nth=${first}`);
+ await timeCell.waitFor({ state: "visible" });
+ return (await timeCell.textContent()) || "";
+ };
+
afterEach(async () => {
await helpers.stopApplication();
+ delete process.env.MM_CALENDAR_SHOWEND_SCENARIO;
});
describe("Test css classes", () => {
@@ -283,7 +304,7 @@ describe("Calendar module", () => {
describe("one event no end display", () => {
it("don't display end", async () => {
- await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
+ await startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_no_display_end");
// just
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00", first)).resolves.toBe(true);
});
@@ -291,12 +312,77 @@ describe("Calendar module", () => {
describe("display end display end", () => {
it("display end", async () => {
- await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
+ await startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_display_end");
// just
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00-26th.Oct, 06:00", first)).resolves.toBe(true);
});
});
+ describe("showEnd for timed multi-day events", () => {
+ const timedMultiDayCases = [
+ {
+ name: "relative timeFormat shows start and end for timed multi-day events",
+ scenario: "event_with_time_over_multiple_days_non_repeating_display_end_relative",
+ expectedTime: "25th.Oct, 20:00-26th.Oct, 06:00"
+ },
+ {
+ name: "dateheaders timeFormat shows end for timed multi-day events",
+ scenario: "event_with_time_over_multiple_days_non_repeating_display_end_dateheaders",
+ expectedTime: "20:00-06:00"
+ }
+ ];
+
+ it.each(timedMultiDayCases)("$name", async (testCase) => {
+ expect.hasAssertions();
+ await expectFirstEventTimeCell(testCase);
+ });
+ });
+
+ describe("showEnd for timed same-day events", () => {
+ const timedSameDaySimpleCases = [
+ {
+ name: "absolute timeFormat shows start and end time without repeating date",
+ scenario: "event_with_time_same_day_yearly_display_end_absolute",
+ expectedTime: "25th.Oct, 20:00-22:00"
+ },
+ {
+ name: "absolute timeFormat with time in dateFormat does not duplicate start time",
+ scenario: "event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time",
+ expectedTime: "25th.Oct, 20:00-22:00"
+ },
+ {
+ name: "relative timeFormat shows start and end time without repeating date",
+ scenario: "event_with_time_same_day_yearly_display_end_relative",
+ expectedTime: "25th.Oct, 20:00-22:00"
+ },
+ {
+ name: "dateheaders timeFormat shows start and end time only",
+ scenario: "event_with_time_same_day_yearly_display_end_dateheaders",
+ expectedTime: "20:00-22:00"
+ }
+ ];
+
+ it.each(timedSameDaySimpleCases)("$name", async (testCase) => {
+ expect.hasAssertions();
+ await expectFirstEventTimeCell(testCase);
+ });
+
+ it("absolute timeFormat with dateFormat LLL does not duplicate start time", async () => {
+ await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_absolute_dateformat_lll");
+ const timeText = await getFirstEventTimeText();
+ const timeTokens = timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || [];
+ expect(timeTokens).toHaveLength(2);
+ expect(timeText).toContain("-");
+ });
+
+ it("relative timeFormat with hideTime does not show start or end times", async () => {
+ await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_relative_hide_time");
+ const timeText = await getFirstEventTimeText();
+ expect(timeText).toContain("25th.Oct");
+ expect(timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || []).toHaveLength(0);
+ });
+ });
+
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");
@@ -314,4 +400,30 @@ describe("Calendar module", () => {
await expect(doTestTableContent(".testNotification", ".elementCount", "12", first)).resolves.toBe(true);
});
});
+
+ describe("showEnd for multi-day full-day events", () => {
+ const fullDayShowEndCases = [
+ {
+ name: "relative timeFormat shows start and end date",
+ scenario: "fullday_multiday_showend_relative",
+ expectedTime: "25th.Oct-30th.Oct"
+ },
+ {
+ name: "dateheaders timeFormat shows end date in time cell",
+ scenario: "fullday_multiday_showend_dateheaders",
+ expectedTime: "-30th.Oct"
+ },
+ {
+ name: "absolute timeFormat with nextDaysRelative shows relative label and end date",
+ scenario: "fullday_multiday_showend_nextdaysrelative",
+ expectedTime: "Tomorrow-30th.Oct",
+ now: "24 Oct 2024 12:30:00 GMT-07:00"
+ }
+ ];
+
+ it.each(fullDayShowEndCases)("$name", async (testCase) => {
+ expect.hasAssertions();
+ await expectFirstEventTimeCell(testCase);
+ });
+ });
});
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 00000000..00f65b2f
--- /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
diff --git a/tests/mocks/event_with_time_same_day_yearly.ics b/tests/mocks/event_with_time_same_day_yearly.ics
new file mode 100644
index 00000000..4e0bb851
--- /dev/null
+++ b/tests/mocks/event_with_time_same_day_yearly.ics
@@ -0,0 +1,18 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//MagicMirror Test//timed-same-day-yearly//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+DTSTART:20241025T200000
+DTEND:20241025T220000
+DTSTAMP:20241024T153358Z
+UID:timed-same-day-yearly@magicmirror
+CREATED:20241024T153313Z
+LAST-MODIFIED:20241024T153330Z
+SEQUENCE:0
+STATUS:CONFIRMED
+RRULE:FREQ=YEARLY
+SUMMARY:Same day timed event
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR