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

<img width="1816" height="756" alt="before"
src="https://github.com/user-attachments/assets/ebec81fd-0c4a-4f9f-bbe3-e2b32ef6756e"
/>

## After

<img width="1816" height="756" alt="after"
src="https://github.com/user-attachments/assets/8a2c652d-dddc-4f6b-9074-fbef3411f9ed"
/>
This commit is contained in:
Kristjan ESPERANTO
2026-03-18 00:32:55 +01:00
committed by GitHub
parent d072345775
commit e1c44a86bb
7 changed files with 735 additions and 193 deletions
+224 -124
View File
@@ -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
@@ -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;
@@ -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;
}
@@ -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;
}
+114 -2
View File
@@ -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);
});
});
});
@@ -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
@@ -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