Skip to content

Commit 09ede7f

Browse files
fix(calendar): correct multi-day slice day counts (#4232)
While reviewing #4208, I had a bit of a headache figuring out the `sliceMultiDay` logic. I cleaned that part up to make it easier to follow and structurally clearer. In doing so, I noticed the previous calculation was wrong in two edge cases: - events crossing a DST change were not split by the actual calendar days they touched - events ending exactly at 00:00 were incorrectly counted as an additional day These cases are easy to miss because they are rare and only show up in specific time windows or timezone transitions. The logic now splits multi-day events by the calendar days they actually cover and handles the boundary cases correctly.
1 parent 9474e79 commit 09ede7f

4 files changed

Lines changed: 102 additions & 32 deletions

File tree

defaultmodules/calendar/calendar.js

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,20 @@ Module.register("calendar", {
465465
return moment(timestamp, "x").tz(moment.tz.guess());
466466
},
467467

468+
/**
469+
* Sets the relative day flags (today, yesterday, ...) on an event based on its day.
470+
* @param {object} event The event to flag.
471+
* @param {moment.Moment} dayMoment The day the event belongs to.
472+
* @param {moment.Moment} now The current moment.
473+
*/
474+
setRelativeDayFlags (event, dayMoment, now) {
475+
event.today = dayMoment.isSame(now, "d");
476+
event.dayBeforeYesterday = dayMoment.isSame(now.clone().subtract(2, "days"), "d");
477+
event.yesterday = dayMoment.isSame(now.clone().subtract(1, "days"), "d");
478+
event.tomorrow = dayMoment.isSame(now.clone().add(1, "days"), "d");
479+
event.dayAfterTomorrow = dayMoment.isSame(now.clone().add(2, "days"), "d");
480+
},
481+
468482
/**
469483
* Creates the sorted list of all events.
470484
* @param {boolean} limitNumberOfEntries Whether to filter returned events for display.
@@ -503,43 +517,36 @@ Module.register("calendar", {
503517
}
504518

505519
event.url = calendarUrl;
506-
event.today = eventStartDateMoment.isSame(now, "d");
507-
event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d");
508-
event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d");
509-
event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d");
510-
event.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, "days"), "d");
520+
this.setRelativeDayFlags(event, eventStartDateMoment, now);
511521

512522
/*
513-
* if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days,
514-
* otherwise, esp. in dateheaders mode it is not clear how long these events are.
523+
* If sliceMultiDayEvents is enabled, an event spanning several calendar days is split into one entry per day.
524+
* Otherwise, esp. in dateheaders mode, it is not clear how long these events are.
525+
* dayCount is the number of calendar days the event touches (an end exactly at midnight does not add a day).
515526
*/
516-
const maxCount = eventEndDateMoment.diff(eventStartDateMoment, "days");
517-
if (this.config.sliceMultiDayEvents && maxCount > 1) {
527+
const eventStartDay = eventStartDateMoment.clone().startOf("day");
528+
const eventEndDay = eventEndDateMoment.clone().startOf("day");
529+
const endsAtMidnight = !eventEndDateMoment.isAfter(eventEndDay);
530+
const dayCount = eventEndDay.diff(eventStartDay, "days") + (endsAtMidnight ? 0 : 1);
531+
if (this.config.sliceMultiDayEvents && dayCount > 1) {
518532
const splitEvents = [];
519-
let midnight
520-
= eventStartDateMoment
521-
.clone()
522-
.startOf("day")
523-
.add(1, "day")
524-
.endOf("day");
525-
let count = 1;
526-
while (eventEndDateMoment.isAfter(midnight)) {
527-
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
528-
thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d");
529-
thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d");
530-
thisEvent.endDate = midnight.clone().subtract(1, "day").format("x");
531-
thisEvent.title += ` (${count}/${maxCount})`;
532-
splitEvents.push(thisEvent);
533-
534-
event.startDate = midnight.clone().startOf("day").format("x"); // start next slice at 00:00, not 23:59
535-
count += 1;
536-
midnight = midnight.clone().add(1, "day").endOf("day"); // next day
533+
// Each slice covers one day: it starts at the event start (first slice) or midnight,
534+
// and ends at the event end (last slice) or one millisecond before the next midnight.
535+
let sliceStart = eventStartDateMoment.clone();
536+
537+
for (let dayNumber = 1; dayNumber <= dayCount; dayNumber++) {
538+
const isLastSlice = dayNumber === dayCount;
539+
const nextMidnight = sliceStart.clone().startOf("day").add(1, "day");
540+
541+
const slice = JSON.parse(JSON.stringify(event)); // clone object
542+
slice.startDate = sliceStart.format("x");
543+
slice.endDate = isLastSlice ? event.endDate : nextMidnight.clone().subtract(1, "millisecond").format("x");
544+
slice.title = `${event.title} (${dayNumber}/${dayCount})`;
545+
this.setRelativeDayFlags(slice, sliceStart, now);
546+
splitEvents.push(slice);
547+
548+
sliceStart = nextMidnight;
537549
}
538-
// Last day
539-
event.title += ` (${count}/${maxCount})`;
540-
event.today += this.timestampToMoment(event.startDate).isSame(now, "d");
541-
event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d");
542-
splitEvents.push(event);
543550

544551
for (const splitEvent of splitEvents) {
545552
if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const config = {
2+
address: "0.0.0.0",
3+
ipWhitelist: [],
4+
timeFormat: 24,
5+
modules: [
6+
{
7+
module: "calendar",
8+
position: "bottom_bar",
9+
config: {
10+
fade: false,
11+
urgency: 0,
12+
dateFormat: "Do.MMM, HH:mm",
13+
fullDayEventDateFormat: "Do.MMM",
14+
timeFormat: "absolute",
15+
getRelative: 0,
16+
maximumEntries: 100,
17+
showEnd: true,
18+
sliceMultiDayEvents: true,
19+
calendars: [
20+
{
21+
maximumEntries: 100,
22+
url: "http://localhost:8080/tests/mocks/calendar_test_slice_multiday_ends_midnight.ics"
23+
}
24+
]
25+
}
26+
}
27+
]
28+
};
29+
30+
/*************** DO NOT EDIT THE LINE BELOW ***************/
31+
if (typeof module !== "undefined") {
32+
module.exports = config;
33+
}

tests/electron/modules/calendar_spec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ describe("Calendar module", () => {
179179
await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", [], "Europe/Berlin");
180180
await expect(doTestCount()).resolves.toBe(6);
181181
});
182+
183+
it("counts all touched dates across DST, not just elapsed 24h blocks", async () => {
184+
// Event runs from 2024-10-25 to 2024-10-28 in Europe/Berlin, crossing the DST change.
185+
// It touches 4 calendar dates: Fri, Sat, Sun, Mon.
186+
await startCalendarShowEndScenario("slice_multiday_timed_start_midnight", "25 Oct 2024 06:00:00 GMT", "Europe/Berlin");
187+
await expect(doTestCount()).resolves.toBe(4);
188+
});
189+
190+
it("does not create an extra slice when an event ends exactly at 00:00", async () => {
191+
// Event runs Fri 12:00 -> Mon 00:00. It should cover Fri, Sat, Sun only; Monday is not touched.
192+
await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEventsEndsMidnight.js", "25 Oct 2024 06:00:00 GMT", [], "GMT");
193+
await expect(doTestCount()).resolves.toBe(3);
194+
await expect(doTestTableContent(".calendar .event", ".title", "(3/3)", last)).resolves.toBe(true);
195+
});
182196
});
183197

184198
describe("sliceMultiDayEvents slice start time", () => {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//MagicMirror//slice regression//EN
4+
CALSCALE:GREGORIAN
5+
METHOD:PUBLISH
6+
BEGIN:VEVENT
7+
DTSTART:20241025T120000Z
8+
DTEND:20241028T000000Z
9+
DTSTAMP:20241024T153358Z
10+
UID:slice-midnight-end-regression@magicmirror.test
11+
SEQUENCE:0
12+
STATUS:CONFIRMED
13+
SUMMARY:Slice
14+
TRANSP:OPAQUE
15+
END:VEVENT
16+
END:VCALENDAR

0 commit comments

Comments
 (0)