diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa121469..eea2cc70 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -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 diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 2ddf46d1..6a52ec9a 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -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, @@ -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 */ diff --git a/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts b/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts new file mode 100644 index 00000000..d42505a8 --- /dev/null +++ b/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts @@ -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 { + 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); + }); +});