Skip to content

Commit 24de7e6

Browse files
committed
Normalize zero-duration external calendar events
1 parent 2538f5b commit 24de7e6

3 files changed

Lines changed: 175 additions & 2 deletions

File tree

docs/releases/unreleased.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,10 @@ Example:
2323
```
2424
2525
-->
26+
27+
## Fixed
28+
29+
- (#1823) Fixed zero-duration timed external calendar events rendering on multiple days in list-style calendar views
30+
- Adds a minimal display duration before passing point-in-time external events to FullCalendar
31+
- Preserves the original provider event data for context menus and debugging
32+
- Thanks to @martin-forge for reporting and debugging

src/bases/calendar-core.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,11 +652,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
652652
subscriptionName = subscription.name;
653653
}
654654

655+
const { start, end } = normalizeExternalTimedEventRange(
656+
icsEvent.start,
657+
icsEvent.end,
658+
icsEvent.allDay
659+
);
660+
655661
return {
656662
id: icsEvent.id,
657663
title: icsEvent.title,
658-
start: icsEvent.start,
659-
end: icsEvent.end,
664+
start: start,
665+
end: end,
660666
allDay: icsEvent.allDay,
661667
backgroundColor: backgroundColor,
662668
borderColor: borderColor,
@@ -676,6 +682,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
676682
}
677683
}
678684

685+
/**
686+
* FullCalendar list views can render a timed external event under multiple day
687+
* headers when the provider supplies a true zero-duration range (end === start).
688+
* Clamp those point-in-time external events to a minimal positive duration
689+
* before handing them to FullCalendar, while preserving the raw provider event
690+
* unchanged in extendedProps for display and debugging.
691+
*/
692+
function normalizeExternalTimedEventRange(
693+
start: string,
694+
end: string | undefined,
695+
allDay: boolean
696+
): { start: string; end?: string } {
697+
if (allDay || !end) {
698+
return { start, end };
699+
}
700+
701+
const startDate = new Date(start);
702+
const endDate = new Date(end);
703+
704+
if (
705+
Number.isNaN(startDate.getTime()) ||
706+
Number.isNaN(endDate.getTime()) ||
707+
endDate.getTime() !== startDate.getTime()
708+
) {
709+
return { start, end };
710+
}
711+
712+
const normalizedEnd = new Date(endDate.getTime() + 1);
713+
return {
714+
start,
715+
end: formatExternalTimedEventEnd(normalizedEnd, end),
716+
};
717+
}
718+
719+
function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
720+
if (/Z$/i.test(originalEnd)) {
721+
return date.toISOString();
722+
}
723+
724+
const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
725+
if (offsetMatch) {
726+
const [, sign, hours, minutes] = offsetMatch;
727+
const offsetMinutes = Number(hours) * 60 + Number(minutes);
728+
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
729+
const shifted = new Date(date.getTime() + offsetMs);
730+
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
731+
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
732+
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
733+
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
734+
}
735+
736+
return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
737+
}
738+
679739
/**
680740
* Get recurring time from task recurrence rule
681741
*/
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, describe, expect, it } from "@jest/globals";
2+
3+
import { createICSEvent } from "../../../src/bases/calendar-core";
4+
import type TaskNotesPlugin from "../../../src/main";
5+
import type { ICSEvent } from "../../../src/types";
6+
7+
function createCalendarPlugin(): TaskNotesPlugin {
8+
return {} as TaskNotesPlugin;
9+
}
10+
11+
beforeEach(() => {
12+
(globalThis as typeof globalThis & {
13+
activeDocument?: {
14+
body: {
15+
classList: {
16+
contains: (className: string) => boolean;
17+
};
18+
};
19+
};
20+
}).activeDocument = {
21+
body: {
22+
classList: {
23+
contains: (_className: string) => false,
24+
},
25+
},
26+
};
27+
});
28+
29+
function createGoogleCalendarEvent(overrides: Partial<ICSEvent> = {}): ICSEvent {
30+
return {
31+
id: "google-primary-zero-duration-event",
32+
subscriptionId: "google-primary",
33+
title: "Ocado reserved cutoff",
34+
start: "2026-04-22T23:12:00",
35+
end: "2026-04-22T23:12:00",
36+
allDay: false,
37+
color: "#16a765",
38+
...overrides,
39+
};
40+
}
41+
42+
describe("Issue #1823: zero-duration Google Calendar list duplication", () => {
43+
it("adds a minimal duration to zero-duration timed Google Calendar events", () => {
44+
const icsEvent = createGoogleCalendarEvent();
45+
46+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
47+
48+
expect(calendarEvent).not.toBeNull();
49+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
50+
expect(calendarEvent?.end).not.toBe("2026-04-22T23:12:00");
51+
expect(calendarEvent?.allDay).toBe(false);
52+
expect(calendarEvent?.extendedProps.icsEvent?.end).toBe("2026-04-22T23:12:00");
53+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
54+
});
55+
56+
it("preserves an explicit offset when normalizing zero-duration timed events", () => {
57+
const icsEvent = createGoogleCalendarEvent({
58+
start: "2026-04-22T23:12:00+01:00",
59+
end: "2026-04-22T23:12:00+01:00",
60+
});
61+
62+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
63+
64+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00+01:00");
65+
expect(calendarEvent?.end).toBe("2026-04-22T23:12:00.001+01:00");
66+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
67+
});
68+
69+
it("preserves UTC formatting when normalizing zero-duration timed events", () => {
70+
const icsEvent = createGoogleCalendarEvent({
71+
start: "2026-04-22T22:12:00.000Z",
72+
end: "2026-04-22T22:12:00.000Z",
73+
});
74+
75+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
76+
77+
expect(calendarEvent?.start).toBe("2026-04-22T22:12:00.000Z");
78+
expect(calendarEvent?.end).toBe("2026-04-22T22:12:00.001Z");
79+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
80+
});
81+
82+
it("leaves non-zero timed Google Calendar events unchanged", () => {
83+
const icsEvent = createGoogleCalendarEvent({
84+
end: "2026-04-22T23:42:00",
85+
});
86+
87+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
88+
89+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
90+
expect(calendarEvent?.end).toBe("2026-04-22T23:42:00");
91+
});
92+
93+
it("leaves all-day Google Calendar events unchanged", () => {
94+
const icsEvent = createGoogleCalendarEvent({
95+
start: "2026-04-22",
96+
end: "2026-04-23",
97+
allDay: true,
98+
});
99+
100+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
101+
102+
expect(calendarEvent?.start).toBe("2026-04-22");
103+
expect(calendarEvent?.end).toBe("2026-04-23");
104+
expect(calendarEvent?.allDay).toBe(true);
105+
});
106+
});

0 commit comments

Comments
 (0)