Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ Example:
```

-->

## Fixed

- (#1823) Fixed zero-duration timed external calendar events rendering on multiple days in list-style calendar views
- Adds a minimal display duration before passing point-in-time external events to FullCalendar
- Preserves the original provider event data for context menus and debugging
- Thanks to @martin-forge for reporting and debugging
64 changes: 62 additions & 2 deletions src/bases/calendar-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
subscriptionName = subscription.name;
}

const { start, end } = normalizeExternalTimedEventRange(
icsEvent.start,
icsEvent.end,
icsEvent.allDay
);

return {
id: icsEvent.id,
title: icsEvent.title,
start: icsEvent.start,
end: icsEvent.end,
start: start,
end: end,
allDay: icsEvent.allDay,
backgroundColor: backgroundColor,
borderColor: borderColor,
Expand All @@ -602,6 +608,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
}
}

/**
* FullCalendar list views can render a timed external event under multiple day
* headers when the provider supplies a true zero-duration range (end === start).
* Clamp those point-in-time external events to a minimal positive duration
* before handing them to FullCalendar, while preserving the raw provider event
* unchanged in extendedProps for display and debugging.
*/
function normalizeExternalTimedEventRange(
start: string,
end: string | undefined,
allDay: boolean
): { start: string; end?: string } {
if (allDay || !end) {
return { start, end };
}

const startDate = new Date(start);
const endDate = new Date(end);

if (
Number.isNaN(startDate.getTime()) ||
Number.isNaN(endDate.getTime()) ||
endDate.getTime() !== startDate.getTime()
) {
return { start, end };
}

const normalizedEnd = new Date(endDate.getTime() + 1);
return {
start,
end: formatExternalTimedEventEnd(normalizedEnd, end),
};
}

function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
if (/Z$/i.test(originalEnd)) {
return date.toISOString();
}

const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
if (offsetMatch) {
const [, sign, hours, minutes] = offsetMatch;
const offsetMinutes = Number(hours) * 60 + Number(minutes);
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
const shifted = new Date(date.getTime() + offsetMs);
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
}

return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
}

/**
* Get recurring time from task recurrence rule
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it } from "@jest/globals";

import { createICSEvent } from "../../../src/bases/calendar-core";
import type TaskNotesPlugin from "../../../src/main";
import type { ICSEvent } from "../../../src/types";

function createCalendarPlugin(): TaskNotesPlugin {
return {} as TaskNotesPlugin;
}

beforeEach(() => {
(globalThis as typeof globalThis & {
activeDocument?: {
body: {
classList: {
contains: (className: string) => boolean;
};
};
};
}).activeDocument = {
body: {
classList: {
contains: (_className: string) => false,
},
},
};
});

function createGoogleCalendarEvent(overrides: Partial<ICSEvent> = {}): ICSEvent {
return {
id: "google-primary-zero-duration-event",
subscriptionId: "google-primary",
title: "Ocado reserved cutoff",
start: "2026-04-22T23:12:00",
end: "2026-04-22T23:12:00",
allDay: false,
color: "#16a765",
...overrides,
};
}

describe("Issue #1823: zero-duration Google Calendar list duplication", () => {
it("adds a minimal duration to zero-duration timed Google Calendar events", () => {
const icsEvent = createGoogleCalendarEvent();

const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());

expect(calendarEvent).not.toBeNull();
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
expect(calendarEvent?.end).not.toBe("2026-04-22T23:12:00");
expect(calendarEvent?.allDay).toBe(false);
expect(calendarEvent?.extendedProps.icsEvent?.end).toBe("2026-04-22T23:12:00");
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
});

it("preserves an explicit offset when normalizing zero-duration timed events", () => {
const icsEvent = createGoogleCalendarEvent({
start: "2026-04-22T23:12:00+01:00",
end: "2026-04-22T23:12:00+01:00",
});

const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());

expect(calendarEvent?.start).toBe("2026-04-22T23:12:00+01:00");
expect(calendarEvent?.end).toBe("2026-04-22T23:12:00.001+01:00");
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
});

it("preserves UTC formatting when normalizing zero-duration timed events", () => {
const icsEvent = createGoogleCalendarEvent({
start: "2026-04-22T22:12:00.000Z",
end: "2026-04-22T22:12:00.000Z",
});

const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());

expect(calendarEvent?.start).toBe("2026-04-22T22:12:00.000Z");
expect(calendarEvent?.end).toBe("2026-04-22T22:12:00.001Z");
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
});

it("leaves non-zero timed Google Calendar events unchanged", () => {
const icsEvent = createGoogleCalendarEvent({
end: "2026-04-22T23:42:00",
});

const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());

expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
expect(calendarEvent?.end).toBe("2026-04-22T23:42:00");
});

it("leaves all-day Google Calendar events unchanged", () => {
const icsEvent = createGoogleCalendarEvent({
start: "2026-04-22",
end: "2026-04-23",
allDay: true,
});

const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());

expect(calendarEvent?.start).toBe("2026-04-22");
expect(calendarEvent?.end).toBe("2026-04-23");
expect(calendarEvent?.allDay).toBe(true);
});
});
Loading