diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js index 7885b59751..c1de8b949b 100644 --- a/defaultmodules/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -392,9 +392,9 @@ Module.register("calendar", { const now = moment(); if (this.config.timeFormat === "absolute") { - timeWrapper.innerHTML = this.buildAbsoluteTimeText(event, eventStartDateMoment, eventEndDateMoment, now); + timeWrapper.innerHTML = CalendarUtils.buildAbsoluteTimeText(event, eventStartDateMoment, eventEndDateMoment, now, this.config, this.translate.bind(this)); } else { - timeWrapper.innerHTML = this.buildRelativeTimeText(event, eventStartDateMoment, eventEndDateMoment, now); + timeWrapper.innerHTML = CalendarUtils.buildRelativeTimeText(event, eventStartDateMoment, eventEndDateMoment, now, this.config, this.translate.bind(this)); } timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; @@ -688,27 +688,15 @@ Module.register("calendar", { 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"); + return this.config.showEnd && (!this.config.showEndsOnlyWithDuration || CalendarUtils.hasEventDuration(event)); }, renderDateHeadersEventTime (eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment) { if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); if (event.fullDayEvent) { - const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment); + const adjustedEndMoment = CalendarUtils.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))}`; @@ -734,172 +722,6 @@ Module.register("calendar", { 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/defaultmodules/calendar/calendarutils.mjs b/defaultmodules/calendar/calendarutils.mjs index 2c9dbb891d..35484050b8 100644 --- a/defaultmodules/calendar/calendarutils.mjs +++ b/defaultmodules/calendar/calendarutils.mjs @@ -128,6 +128,212 @@ const CalendarUtils = { } } return transformedTitle; + }, + + /** + * Checks whether a calendar event has a non-zero duration. + * @param {object} event The calendar event. + * @returns {boolean} True if the event has a duration. + */ + hasEventDuration (event) { + return event.startDate !== event.endDate; + }, + + /** + * Adjusts an exclusive full-day end to the final second of the event. + * @param {moment.Moment} endMoment The exclusive event end. + * @returns {moment.Moment} The adjusted end moment. + */ + getAdjustedFullDayEndMoment (endMoment) { + return endMoment.clone().subtract(1, "second"); + }, + + /** + * 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 given dateFormat already contains time components. + * @param {string} dateFormat The moment.js date format. + * @returns {boolean} True when dateFormat includes time tokens. + */ + dateFormatIncludesTime (dateFormat) { + const dateFormatWithoutLiterals = dateFormat.replace(/\[[^\]]*\]/g, ""); + const localeDateFormat = globalThis.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. + * @param {string} dateEndFormat The moment.js format used for multi-day ends. + * @returns {string} The formatted end value. + */ + formatTimedEventEnd (startMoment, endMoment, dateEndFormat) { + const endFormat = CalendarUtils.isSameDay(startMoment, endMoment) ? "LT" : dateEndFormat; + return CalendarUtils.capFirst(endMoment.format(endFormat)); + }, + + /** + * Builds the time text for `timeFormat: "absolute"`. + * @param {object} event The calendar event. + * @param {moment.Moment} eventStartDateMoment The event start. + * @param {moment.Moment} eventEndDateMoment The event end. + * @param {moment.Moment} now The current moment. + * @param {object} config The calendar module config. + * @param {(key: string, variables?: object) => string} translate The module translate function. + * @returns {string} The time text. + */ + buildAbsoluteTimeText (event, eventStartDateMoment, eventEndDateMoment, now, config, translate) { + let timeText = CalendarUtils.capFirst(eventStartDateMoment.format(config.dateFormat)); + + if (config.showEnd && (!config.showEndsOnlyWithDuration || CalendarUtils.hasEventDuration(event))) { + const sameDay = CalendarUtils.isSameDay(eventStartDateMoment, eventEndDateMoment); + if (sameDay && !CalendarUtils.dateFormatIncludesTime(config.dateFormat)) { + timeText += `, ${eventStartDateMoment.format("LT")}`; + } + timeText += `-${CalendarUtils.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment, config.dateEndFormat)}`; + } + + if (event.fullDayEvent) { + const adjustedEndMoment = CalendarUtils.getAdjustedFullDayEndMoment(eventEndDateMoment); + timeText = CalendarUtils.capFirst(eventStartDateMoment.format(config.fullDayEventDateFormat)); + + if (config.showEnd && !config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(config.fullDayEventDateFormat))}`; + } else if (!eventStartDateMoment.isSame(adjustedEndMoment, "d") && eventStartDateMoment.isBefore(now)) { + timeText = CalendarUtils.capFirst(now.format(config.fullDayEventDateFormat)); + } + + if (config.nextDaysRelative) { + let relativeLabel = false; + if (event.today) { + timeText = CalendarUtils.capFirst(translate("TODAY")); + relativeLabel = true; + } else if (event.yesterday) { + timeText = CalendarUtils.capFirst(translate("YESTERDAY")); + relativeLabel = true; + } else if (event.tomorrow) { + timeText = CalendarUtils.capFirst(translate("TOMORROW")); + relativeLabel = true; + } else if (event.dayAfterTomorrow && translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeText = CalendarUtils.capFirst(translate("DAYAFTERTOMORROW")); + relativeLabel = true; + } + + if (relativeLabel && config.showEnd && !config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(config.fullDayEventDateFormat))}`; + } + } + + return timeText; + } + + if (config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { + return CalendarUtils.capFirst( + translate("RUNNING", { + fallback: `${translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); + } + + if (config.urgency > 0 && eventStartDateMoment.diff(now, "d") < config.urgency) { + return CalendarUtils.capFirst(eventStartDateMoment.fromNow()); + } + + return timeText; + }, + + /** + * Builds the time text for `timeFormat: "relative"`. + * @param {object} event The calendar event. + * @param {moment.Moment} eventStartDateMoment The event start. + * @param {moment.Moment} eventEndDateMoment The event end. + * @param {moment.Moment} now The current moment. + * @param {object} config The calendar module config. + * @param {(key: string, variables?: object) => string} translate The module translate function. + * @returns {string} The time text. + */ + buildRelativeTimeText (event, eventStartDateMoment, eventEndDateMoment, now, config, translate) { + if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + let timeText; + + if (!config.hideTime && !event.fullDayEvent) { + Log.debug("[calendar] event not hidden and not fullday"); + timeText = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: config.dateFormat }))}`; + } else { + Log.debug("[calendar] event full day or hidden"); + timeText = `${CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { + sameDay: config.showTimeToday ? "LT" : `[${translate("TODAY")}]`, + nextDay: `[${translate("TOMORROW")}]`, + nextWeek: "dddd", + sameElse: event.fullDayEvent ? config.fullDayEventDateFormat : config.dateFormat + }) + )}`; + } + + if (event.fullDayEvent) { + if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + timeText = CalendarUtils.capFirst(translate("TODAY")); + } else if (event.dayBeforeYesterday) { + if (translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { + timeText = CalendarUtils.capFirst(translate("DAYBEFOREYESTERDAY")); + } + } else if (event.yesterday) { + timeText = CalendarUtils.capFirst(translate("YESTERDAY")); + } else if (event.tomorrow) { + timeText = CalendarUtils.capFirst(translate("TOMORROW")); + } else if (event.dayAfterTomorrow) { + if (translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeText = CalendarUtils.capFirst(translate("DAYAFTERTOMORROW")); + } + } + + if (config.showEnd && !config.showEndsOnlyWithDuration) { + const adjustedEndMoment = CalendarUtils.getAdjustedFullDayEndMoment(eventEndDateMoment); + if (!eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(config.fullDayEventDateFormat))}`; + } + } + + Log.info("[calendar] event fullday"); + } else if (eventStartDateMoment.diff(now, "h") < config.getRelative) { + Log.info("[calendar] not full day but within getRelative size"); + timeText = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; + } else if (!config.hideTime && config.showEnd && (!config.showEndsOnlyWithDuration || CalendarUtils.hasEventDuration(event))) { + if (CalendarUtils.isSameDay(eventStartDateMoment, eventEndDateMoment)) { + const sameElseFormat = CalendarUtils.dateFormatIncludesTime(config.dateFormat) ? config.dateFormat : `${config.dateFormat}, LT`; + timeText = CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { sameElse: sameElseFormat }) + ); + } + timeText += `-${CalendarUtils.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment, config.dateEndFormat)}`; + } + + return timeText; + } + + return CalendarUtils.capFirst( + translate("RUNNING", { + fallback: `${translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); } }; diff --git a/tests/configs/modules/calendar/calendarShowEndConfigs.js b/tests/configs/modules/calendar/calendarShowEndConfigs.js index b92b38482a..bc2a638f8d 100644 --- a/tests/configs/modules/calendar/calendarShowEndConfigs.js +++ b/tests/configs/modules/calendar/calendarShowEndConfigs.js @@ -80,116 +80,6 @@ const calendarShowEndConfigs = { } ] }, - 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: [], @@ -217,61 +107,6 @@ const calendarShowEndConfigs = { } ] }, - 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: [], @@ -297,57 +132,6 @@ const calendarShowEndConfigs = { } ] }, - 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" - } - ] - } - } - ] - }, slice_multiday_timed_start_midnight: { address: "0.0.0.0", ipWhitelist: [], diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index ea9d898db8..7367793fdf 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -46,17 +46,11 @@ describe("Calendar module", () => { await helpers.startApplication(showEndConfigPath, now, [], timeZone); }; - const expectFirstEventTimeCell = async ({ scenario, expectedTime, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone }) => { - await startCalendarShowEndScenario(scenario, now, timeZone); + const expectFirstEventTimeCell = async ({ scenario, expectedTime }) => { + await startCalendarShowEndScenario(scenario); 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; @@ -325,14 +319,6 @@ describe("Calendar module", () => { }); }); - describe("one event no end display", () => { - it("don't display end", async () => { - 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); - }); - }); - describe("display end display end", () => { it("display end", async () => { await startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_display_end"); @@ -341,6 +327,7 @@ describe("Calendar module", () => { }); }); + // Text formatting of showEnd is unit-tested in calendar_utils_spec.js; the cases below keep the UTC to local conversion and the dateheaders DOM. describe("showEnd for timed multi-day events", () => { const timedMultiDayCases = [ { @@ -362,47 +349,9 @@ describe("Calendar module", () => { }); 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) => { + it("dateheaders timeFormat shows start and end time only", async () => { 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); + await expectFirstEventTimeCell({ scenario: "event_with_time_same_day_yearly_display_end_dateheaders", expectedTime: "20:00-22:00" }); }); }); @@ -425,28 +374,9 @@ describe("Calendar module", () => { }); 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) => { + it("dateheaders timeFormat shows end date in time cell", async () => { expect.hasAssertions(); - await expectFirstEventTimeCell(testCase); + await expectFirstEventTimeCell({ scenario: "fullday_multiday_showend_dateheaders", expectedTime: "-30th.Oct" }); }); }); }); diff --git a/tests/unit/modules/default/calendar/calendar_utils_spec.js b/tests/unit/modules/default/calendar/calendar_utils_spec.js index 88e7649df2..40232a0b00 100644 --- a/tests/unit/modules/default/calendar/calendar_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_utils_spec.js @@ -1,4 +1,4 @@ -import moment from "moment"; +import moment from "moment-timezone"; import CalendarUtils from "../../../../../defaultmodules/calendar/calendarutils.mjs"; globalThis.moment = moment; @@ -160,4 +160,230 @@ describe("Calendar utils tests", () => { expect(transformedTitle).toBe("Test Birthday"); }); }); + + describe("time text", () => { + const now = moment("2030-01-01T12:00:00Z").utc(); + const translations = { + TODAY: "Today", + TOMORROW: "Tomorrow", + YESTERDAY: "Yesterday", + DAYAFTERTOMORROW: "DAYAFTERTOMORROW", + DAYBEFOREYESTERDAY: "DAYBEFOREYESTERDAY", + RUNNING: "Running" + }; + const translate = (key, variables) => (variables?.fallback ? `${translations[key]} ${variables.timeUntilEnd}` : translations[key]); + const baseConfig = { + dateFormat: "MMM Do", + dateEndFormat: "LT", + fullDayEventDateFormat: "MMM Do", + showEnd: false, + showEndsOnlyWithDuration: false, + hideTime: false, + showTimeToday: false, + nextDaysRelative: false, + getRelative: 6, + urgency: 7 + }; + const makeEvent = (start, end, extra = {}) => ({ + startDate: start.format("x"), + endDate: end.format("x"), + fullDayEvent: false, + ...extra + }); + + beforeEach(() => { + globalThis.Log = { debug () {}, info () {} }; + // moment's fromNow() reads the system clock, not the `now` argument + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now.toDate()); + }); + + afterEach(() => { + vi.useRealTimers(); + Reflect.deleteProperty(globalThis, "Log"); + }); + + describe("dateFormatIncludesTime", () => { + it("detects time tokens, also via locale tokens", () => { + expect(CalendarUtils.dateFormatIncludesTime("MMM Do, HH:mm")).toBe(true); + expect(CalendarUtils.dateFormatIncludesTime("lll")).toBe(true); + }); + + it("ignores literals and date-only formats", () => { + expect(CalendarUtils.dateFormatIncludesTime("MMM Do")).toBe(false); + expect(CalendarUtils.dateFormatIncludesTime("[at] MMM Do")).toBe(false); + }); + }); + + describe("formatTimedEventEnd", () => { + it("uses time only on the same day and dateEndFormat otherwise", () => { + const start = now.clone(); + expect(CalendarUtils.formatTimedEventEnd(start, start.clone().add(1, "hour"), "MMM Do")).toBe(start.clone().add(1, "hour").format("LT")); + expect(CalendarUtils.formatTimedEventEnd(start, start.clone().add(1, "day"), "MMM Do")).toBe("Jan 2nd"); + }); + }); + + describe("buildRelativeTimeText", () => { + it("shows 'Running' for ongoing events", () => { + const start = now.clone().subtract(1, "hour"); + const end = now.clone().add(1, "hour"); + expect(CalendarUtils.buildRelativeTimeText(makeEvent(start, end), start, end, now, baseConfig, translate)).toBe("Running an hour"); + }); + + it("shows a relative time within getRelative hours", () => { + const start = now.clone().add(2, "hours"); + const end = now.clone().add(3, "hours"); + expect(CalendarUtils.buildRelativeTimeText(makeEvent(start, end), start, end, now, baseConfig, translate)).toBe("In 2 hours"); + }); + + it("appends the end time for same-day timed events beyond getRelative", () => { + const start = now.clone().add(8, "hours"); + const end = now.clone().add(9, "hours"); + const text = CalendarUtils.buildRelativeTimeText(makeEvent(start, end), start, end, now, { ...baseConfig, showEnd: true }, translate); + expect(text).toContain(`-${end.format("LT")}`); + }); + + it("hideTime overrides showEnd for timed events", () => { + const start = now.clone().add(8, "hours"); + const end = now.clone().add(9, "hours"); + const text = CalendarUtils.buildRelativeTimeText(makeEvent(start, end), start, end, now, { ...baseConfig, showEnd: true, hideTime: true }, translate); + expect(text).not.toContain("-"); + }); + + it("shows 'Today' for full-day events today and the end date for multi-day ones", () => { + const start = now.clone().startOf("day"); + const end = start.clone().add(1, "day"); + const event = makeEvent(start, end, { fullDayEvent: true, today: true }); + expect(CalendarUtils.buildRelativeTimeText(event, start, end, now, baseConfig, translate)).toBe("Today"); + + // a multi-day event starting later today shows the range end + const laterStart = now.clone().add(1, "hour"); + const multiDayEnd = start.clone().add(3, "days"); + const multiDay = makeEvent(laterStart, multiDayEnd, { fullDayEvent: true, today: true }); + expect(CalendarUtils.buildRelativeTimeText(multiDay, laterStart, multiDayEnd, now, { ...baseConfig, showEnd: true }, translate)).toBe("Today-Jan 3rd"); + }); + + it("skips untranslated day-after-tomorrow label", () => { + const start = now.clone().add(2, "days").startOf("day"); + const end = start.clone().add(1, "day"); + const event = makeEvent(start, end, { fullDayEvent: true, dayAfterTomorrow: true }); + expect(CalendarUtils.buildRelativeTimeText(event, start, end, now, baseConfig, translate)).toBe("Thursday"); + }); + }); + + describe("buildAbsoluteTimeText", () => { + it("shows the formatted start date for events beyond urgency", () => { + const start = now.clone().add(30, "days"); + const end = start.clone().add(1, "hour"); + expect(CalendarUtils.buildAbsoluteTimeText(makeEvent(start, end), start, end, now, baseConfig, translate)).toBe("Jan 31st"); + }); + + it("adds start time and end time on the same day when the format has no time", () => { + const start = now.clone().add(30, "days"); + const end = start.clone().add(1, "hour"); + const text = CalendarUtils.buildAbsoluteTimeText(makeEvent(start, end), start, end, now, { ...baseConfig, showEnd: true }, translate); + expect(text).toBe(`Jan 31st, ${start.format("LT")}-${end.format("LT")}`); + }); + + it("omits the end when showEndsOnlyWithDuration is set and the event has no duration", () => { + const start = now.clone().add(30, "days"); + const text = CalendarUtils.buildAbsoluteTimeText(makeEvent(start, start), start, start, now, { ...baseConfig, showEnd: true, showEndsOnlyWithDuration: true }, translate); + expect(text).toBe("Jan 31st"); + }); + + it("shows 'Running' for ongoing timed events and relative text when urgent", () => { + const runningStart = now.clone().subtract(1, "hour"); + const runningEnd = now.clone().add(1, "hour"); + expect(CalendarUtils.buildAbsoluteTimeText(makeEvent(runningStart, runningEnd), runningStart, runningEnd, now, baseConfig, translate)).toBe("Running an hour"); + + const start = now.clone().add(2, "days"); + const end = start.clone().add(1, "hour"); + expect(CalendarUtils.buildAbsoluteTimeText(makeEvent(start, end), start, end, now, { ...baseConfig, getRelative: 0 }, translate)).toBe("In 2 days"); + }); + + it("shows the full-day range and relative labels", () => { + const start = now.clone().add(30, "days").startOf("day"); + const end = start.clone().add(2, "days"); + const event = makeEvent(start, end, { fullDayEvent: true }); + expect(CalendarUtils.buildAbsoluteTimeText(event, start, end, now, { ...baseConfig, showEnd: true }, translate)).toBe("Jan 31st-Feb 1st"); + + const today = now.clone().startOf("day"); + const todayEnd = today.clone().add(1, "day"); + const todayEvent = makeEvent(today, todayEnd, { fullDayEvent: true, today: true }); + expect(CalendarUtils.buildAbsoluteTimeText(todayEvent, today, todayEnd, now, { ...baseConfig, nextDaysRelative: true }, translate)).toBe("Today"); + }); + }); + }); + + describe("showEnd time text (America/Chicago, 24h)", () => { + const zone = "America/Chicago"; + const at = (value) => moment.tz(value, zone); + const translations = { TODAY: "Today", TOMORROW: "Tomorrow" }; + const translate = (key) => translations[key] ?? key; + const config = (overrides) => ({ + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + showEnd: true, + showEndsOnlyWithDuration: false, + hideTime: false, + nextDaysRelative: false, + getRelative: 0, + urgency: 0, + ...overrides + }); + const timedMultiDay = { start: at("2024-10-26T01:00:00Z"), end: at("2024-10-26T11:00:00Z") }; + const timedSameDay = { start: at("2024-10-25T20:00:00"), end: at("2024-10-25T22:00:00") }; + const timedNoDuration = { start: at("2024-10-25T20:00:00"), end: at("2024-10-25T20:00:00") }; + const fullDayMultiDay = { start: at("2024-10-25T00:00:00"), end: at("2024-10-31T00:00:00"), fullDayEvent: true }; + let originalEnglishTimeFormat; + const run = (build, { start: rawStart, end: rawEnd, fullDayEvent = false }, cfg, nowText = "2024-10-08T19:30:00Z", flags = {}) => { + vi.setSystemTime(new Date(nowText)); + // fixtures are created before updateLocale(), and moments keep the locale object they were created with + const start = rawStart.clone().locale("en"); + const end = rawEnd.clone().locale("en"); + const event = { startDate: start.format("x"), endDate: end.format("x"), fullDayEvent, ...flags }; + return CalendarUtils[build](event, start, end, moment(), cfg, translate); + }; + + beforeEach(() => { + globalThis.Log = { debug () {}, info () {} }; + originalEnglishTimeFormat = moment.localeData("en").longDateFormat("LT"); + moment.updateLocale("en", { longDateFormat: { LT: "HH:mm" } }); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + moment.updateLocale("en", { longDateFormat: { LT: originalEnglishTimeFormat } }); + Reflect.deleteProperty(globalThis, "Log"); + }); + + it.each([ + ["absolute timed multi-day: display end", "buildAbsoluteTimeText", timedMultiDay, { dateFormat: "Do.MMM, HH:mm" }, "25th.Oct, 20:00-26th.Oct, 06:00"], + ["relative timed multi-day: display end", "buildRelativeTimeText", timedMultiDay, { dateFormat: "Do.MMM, HH:mm" }, "25th.Oct, 20:00-26th.Oct, 06:00"], + // the event has a duration, so showEndsOnlyWithDuration still shows the end + ["absolute timed multi-day: showEndsOnlyWithDuration with real duration", "buildAbsoluteTimeText", timedMultiDay, { dateFormat: "Do.MMM, HH:mm", showEndsOnlyWithDuration: true }, "25th.Oct, 20:00-26th.Oct, 06:00"], + ["absolute timed: showEndsOnlyWithDuration without duration hides the end", "buildAbsoluteTimeText", timedNoDuration, { dateFormat: "Do.MMM, HH:mm", showEndsOnlyWithDuration: true }, "25th.Oct, 20:00"], + ["relative timed: showEndsOnlyWithDuration without duration hides the end", "buildRelativeTimeText", timedNoDuration, { dateFormat: "Do.MMM, HH:mm", showEndsOnlyWithDuration: true }, "25th.Oct, 20:00"], + ["absolute same-day: start and end without repeating date", "buildAbsoluteTimeText", timedSameDay, {}, "25th.Oct, 20:00-22:00"], + ["absolute same-day: time already in dateFormat", "buildAbsoluteTimeText", timedSameDay, { dateFormat: "Do.MMM, HH:mm" }, "25th.Oct, 20:00-22:00"], + ["relative same-day: start and end without repeating date", "buildRelativeTimeText", timedSameDay, {}, "25th.Oct, 20:00-22:00"], + ["relative same-day: hideTime shows no times", "buildRelativeTimeText", timedSameDay, { hideTime: true }, "25th.Oct"], + ["relative multi-day full-day: start and end date", "buildRelativeTimeText", fullDayMultiDay, {}, "25th.Oct-30th.Oct"] + ])("%s", (name, build, event, overrides, expected) => { + expect(run(build, event, config(overrides))).toBe(expected); + }); + + it("absolute same-day: dateFormat LLL does not duplicate the start time", () => { + const text = run("buildAbsoluteTimeText", timedSameDay, config({ dateFormat: "LLL" })); + expect(text.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi)).toHaveLength(2); + expect(text).toContain("-"); + }); + + it("absolute multi-day full-day with nextDaysRelative: relative label and end date", () => { + const text = run("buildAbsoluteTimeText", fullDayMultiDay, config({ nextDaysRelative: true }), "2024-10-24T19:30:00Z", { tomorrow: true }); + expect(text).toBe("Tomorrow-30th.Oct"); + }); + }); });