Skip to content

Commit e1c44a8

Browse files
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" />
1 parent d072345 commit e1c44a8

File tree

7 files changed

+735
-193
lines changed

7 files changed

+735
-193
lines changed

defaultmodules/calendar/calendar.js

Lines changed: 224 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -384,139 +384,19 @@ Module.register("calendar", {
384384
}
385385

386386
if (this.config.timeFormat === "dateheaders") {
387-
if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
388-
389-
if (event.fullDayEvent) {
390-
titleWrapper.colSpan = "2";
391-
titleWrapper.classList.add("align-left");
392-
} else {
393-
const timeWrapper = document.createElement("td");
394-
timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`;
395-
timeWrapper.style.paddingLeft = "2px";
396-
timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
397-
timeWrapper.innerHTML = eventStartDateMoment.format("LT");
398-
399-
// Add endDate to dataheaders if showEnd is enabled
400-
if (this.config.showEnd) {
401-
if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) {
402-
// no duration here, don't display end
403-
} else {
404-
timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`;
405-
}
406-
}
407-
408-
eventWrapper.appendChild(timeWrapper);
409-
410-
if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
411-
}
412-
if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
387+
this.renderDateHeadersEventTime(eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment);
413388
} else {
414389
const timeWrapper = document.createElement("td");
415390

416391
eventWrapper.appendChild(titleWrapper);
417392
const now = moment();
418393

419394
if (this.config.timeFormat === "absolute") {
420-
// Use dateFormat
421-
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
422-
// Add end time if showEnd
423-
if (this.config.showEnd) {
424-
// and has a duration
425-
if (event.startDate !== event.endDate) {
426-
timeWrapper.innerHTML += "-";
427-
timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat));
428-
}
429-
}
430-
431-
// For full day events we use the fullDayEventDateFormat
432-
if (event.fullDayEvent) {
433-
//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
434-
eventEndDateMoment.subtract(1, "second");
435-
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
436-
// only show end if requested and allowed and the dates are different
437-
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) {
438-
timeWrapper.innerHTML += "-";
439-
timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat));
440-
} else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) {
441-
timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
442-
}
443-
} else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
444-
// Ongoing and getRelative is set
445-
timeWrapper.innerHTML = CalendarUtils.capFirst(
446-
this.translate("RUNNING", {
447-
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
448-
timeUntilEnd: eventEndDateMoment.fromNow(true)
449-
})
450-
);
451-
} else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) {
452-
// Within urgency days
453-
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow());
454-
}
455-
if (event.fullDayEvent && this.config.nextDaysRelative) {
456-
// Full days events within the next two days
457-
if (event.today) {
458-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
459-
} else if (event.yesterday) {
460-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
461-
} else if (event.tomorrow) {
462-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
463-
} else if (event.dayAfterTomorrow) {
464-
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
465-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
466-
}
467-
}
468-
}
395+
timeWrapper.innerHTML = this.buildAbsoluteTimeText(event, eventStartDateMoment, eventEndDateMoment, now);
469396
} else {
470-
// Show relative times
471-
if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
472-
// Use relative time
473-
if (!this.config.hideTime && !event.fullDayEvent) {
474-
Log.debug("[calendar] event not hidden and not fullday");
475-
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
476-
} else {
477-
Log.debug("[calendar] event full day or hidden");
478-
timeWrapper.innerHTML = `${CalendarUtils.capFirst(
479-
eventStartDateMoment.calendar(null, {
480-
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
481-
nextDay: `[${this.translate("TOMORROW")}]`,
482-
nextWeek: "dddd",
483-
sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
484-
})
485-
)}`;
486-
}
487-
if (event.fullDayEvent) {
488-
// Full days events within the next two days
489-
if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
490-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
491-
} else if (event.dayBeforeYesterday) {
492-
if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
493-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
494-
}
495-
} else if (event.yesterday) {
496-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
497-
} else if (event.tomorrow) {
498-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
499-
} else if (event.dayAfterTomorrow) {
500-
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
501-
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
502-
}
503-
}
504-
Log.info("[calendar] event fullday");
505-
} else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
506-
Log.info("[calendar] not full day but within getRelative size");
507-
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()
508-
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;
509-
}
510-
} else {
511-
// Ongoing event
512-
timeWrapper.innerHTML = CalendarUtils.capFirst(
513-
this.translate("RUNNING", {
514-
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
515-
timeUntilEnd: eventEndDateMoment.fromNow(true)
516-
})
517-
);
518-
}
397+
timeWrapper.innerHTML = this.buildRelativeTimeText(event, eventStartDateMoment, eventEndDateMoment, now);
519398
}
399+
520400
timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`;
521401
eventWrapper.appendChild(timeWrapper);
522402
}
@@ -793,6 +673,226 @@ Module.register("calendar", {
793673
);
794674
},
795675

676+
createDateHeadersTimeWrapper (url) {
677+
const timeWrapper = document.createElement("td");
678+
timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(url)}`;
679+
timeWrapper.style.paddingLeft = "2px";
680+
timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
681+
return timeWrapper;
682+
},
683+
684+
hasEventDuration (event) {
685+
return event.startDate !== event.endDate;
686+
},
687+
688+
shouldShowDateHeadersTimedEnd (event) {
689+
return this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event));
690+
},
691+
692+
shouldShowRelativeTimedEnd (event) {
693+
return !this.config.hideTime && this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event));
694+
},
695+
696+
getAdjustedFullDayEndMoment (endMoment) {
697+
return endMoment.clone().subtract(1, "second");
698+
},
699+
700+
renderDateHeadersEventTime (eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment) {
701+
if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
702+
703+
if (event.fullDayEvent) {
704+
const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
705+
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
706+
const timeWrapper = this.createDateHeadersTimeWrapper(event.url);
707+
timeWrapper.innerHTML = `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
708+
eventWrapper.appendChild(timeWrapper);
709+
if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
710+
} else {
711+
titleWrapper.colSpan = "2";
712+
titleWrapper.classList.add("align-left");
713+
}
714+
} else {
715+
const timeWrapper = this.createDateHeadersTimeWrapper(event.url);
716+
timeWrapper.innerHTML = eventStartDateMoment.format("LT");
717+
718+
// In dateheaders mode, keep the end as time-only to avoid redundant date info under a date header.
719+
if (this.shouldShowDateHeadersTimedEnd(event)) {
720+
timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`;
721+
}
722+
723+
eventWrapper.appendChild(timeWrapper);
724+
if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
725+
}
726+
727+
if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
728+
},
729+
730+
buildAbsoluteTimeText (event, eventStartDateMoment, eventEndDateMoment, now) {
731+
let timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
732+
733+
if (this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event))) {
734+
const sameDay = this.isSameDay(eventStartDateMoment, eventEndDateMoment);
735+
if (sameDay && !this.dateFormatIncludesTime()) {
736+
timeText += `, ${eventStartDateMoment.format("LT")}`;
737+
}
738+
timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`;
739+
}
740+
741+
if (event.fullDayEvent) {
742+
const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
743+
timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
744+
745+
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
746+
timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
747+
} else if (!eventStartDateMoment.isSame(adjustedEndMoment, "d") && eventStartDateMoment.isBefore(now)) {
748+
timeText = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
749+
}
750+
751+
if (this.config.nextDaysRelative) {
752+
let relativeLabel = false;
753+
if (event.today) {
754+
timeText = CalendarUtils.capFirst(this.translate("TODAY"));
755+
relativeLabel = true;
756+
} else if (event.yesterday) {
757+
timeText = CalendarUtils.capFirst(this.translate("YESTERDAY"));
758+
relativeLabel = true;
759+
} else if (event.tomorrow) {
760+
timeText = CalendarUtils.capFirst(this.translate("TOMORROW"));
761+
relativeLabel = true;
762+
} else if (event.dayAfterTomorrow && this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
763+
timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
764+
relativeLabel = true;
765+
}
766+
767+
if (relativeLabel && this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
768+
timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
769+
}
770+
}
771+
772+
return timeText;
773+
}
774+
775+
if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
776+
return CalendarUtils.capFirst(
777+
this.translate("RUNNING", {
778+
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
779+
timeUntilEnd: eventEndDateMoment.fromNow(true)
780+
})
781+
);
782+
}
783+
784+
if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) {
785+
return CalendarUtils.capFirst(eventStartDateMoment.fromNow());
786+
}
787+
788+
return timeText;
789+
},
790+
791+
buildRelativeTimeText (event, eventStartDateMoment, eventEndDateMoment, now) {
792+
if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
793+
let timeText;
794+
795+
if (!this.config.hideTime && !event.fullDayEvent) {
796+
Log.debug("[calendar] event not hidden and not fullday");
797+
timeText = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
798+
} else {
799+
Log.debug("[calendar] event full day or hidden");
800+
timeText = `${CalendarUtils.capFirst(
801+
eventStartDateMoment.calendar(null, {
802+
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
803+
nextDay: `[${this.translate("TOMORROW")}]`,
804+
nextWeek: "dddd",
805+
sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
806+
})
807+
)}`;
808+
}
809+
810+
if (event.fullDayEvent) {
811+
if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
812+
timeText = CalendarUtils.capFirst(this.translate("TODAY"));
813+
} else if (event.dayBeforeYesterday) {
814+
if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
815+
timeText = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
816+
}
817+
} else if (event.yesterday) {
818+
timeText = CalendarUtils.capFirst(this.translate("YESTERDAY"));
819+
} else if (event.tomorrow) {
820+
timeText = CalendarUtils.capFirst(this.translate("TOMORROW"));
821+
} else if (event.dayAfterTomorrow) {
822+
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
823+
timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
824+
}
825+
}
826+
827+
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration) {
828+
const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment);
829+
if (!eventStartDateMoment.isSame(adjustedEndMoment, "d")) {
830+
timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`;
831+
}
832+
}
833+
834+
Log.info("[calendar] event fullday");
835+
} else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
836+
Log.info("[calendar] not full day but within getRelative size");
837+
timeText = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;
838+
} else if (this.shouldShowRelativeTimedEnd(event)) {
839+
if (this.isSameDay(eventStartDateMoment, eventEndDateMoment)) {
840+
const sameElseFormat = this.dateFormatIncludesTime() ? this.config.dateFormat : `${this.config.dateFormat}, LT`;
841+
timeText = CalendarUtils.capFirst(
842+
eventStartDateMoment.calendar(null, { sameElse: sameElseFormat })
843+
);
844+
}
845+
timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`;
846+
}
847+
848+
return timeText;
849+
}
850+
851+
return CalendarUtils.capFirst(
852+
this.translate("RUNNING", {
853+
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
854+
timeUntilEnd: eventEndDateMoment.fromNow(true)
855+
})
856+
);
857+
},
858+
859+
/**
860+
* Determines whether two moments are on the same day.
861+
* @param {moment.Moment} startMoment The start moment.
862+
* @param {moment.Moment} endMoment The end moment.
863+
* @returns {boolean} True when both moments share the same calendar day.
864+
*/
865+
isSameDay (startMoment, endMoment) {
866+
return startMoment.isSame(endMoment, "d");
867+
},
868+
869+
/**
870+
* Checks whether the configured dateFormat already contains time components.
871+
* @returns {boolean} True when dateFormat includes time tokens.
872+
*/
873+
dateFormatIncludesTime () {
874+
const dateFormatWithoutLiterals = this.config.dateFormat.replace(/\[[^\]]*\]/g, "");
875+
const localeDateFormat = moment.localeData();
876+
const expandedDateFormat = dateFormatWithoutLiterals.replace(
877+
/LTS|LT|LLLL|LLL|LL|L|llll|lll|ll|l/g,
878+
(token) => localeDateFormat.longDateFormat(token) || token
879+
);
880+
const expandedDateFormatWithoutLiterals = expandedDateFormat.replace(/\[[^\]]*\]/g, "");
881+
return (/(H{1,2}|h{1,2}|k{1,2}|m{1,2}|s{1,2}|a|A)/).test(expandedDateFormatWithoutLiterals);
882+
},
883+
884+
/**
885+
* Formats a timed event end value.
886+
* Uses time-only for same-day events and dateEndFormat for multi-day events.
887+
* @param {moment.Moment} startMoment The event start moment.
888+
* @param {moment.Moment} endMoment The event end moment.
889+
* @returns {string} The formatted end value.
890+
*/
891+
formatTimedEventEnd (startMoment, endMoment) {
892+
const endFormat = this.isSameDay(startMoment, endMoment) ? "LT" : this.config.dateEndFormat;
893+
return CalendarUtils.capFirst(endMoment.format(endFormat));
894+
},
895+
796896
/**
797897
* Retrieves the symbolClass for a specific calendar url.
798898
* @param {string} url The calendar url

0 commit comments

Comments
 (0)