From 832008000407e737c988995bebdb8c4b66867e03 Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Thu, 14 May 2026 10:14:58 +0100 Subject: [PATCH] Fix Google Calendar event rendering --- docs/releases/unreleased.md | 4 + src/bases/calendar-core.ts | 66 ++++++++++- src/services/TaskCalendarSyncService.ts | 22 +++- .../services/TaskCalendarSyncService.test.ts | 59 +++++++++- ...n-google-calendar-list-duplication.test.ts | 106 ++++++++++++++++++ 5 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 67d0144c..394b4f53 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -28,3 +28,7 @@ Example: - Made the markdown editor areas in task modals easier to click and focus. - Reduced false-positive plugin review warnings by making background auto-export and auto-archive schedulers non-overlapping and tightening type/string conversion paths. +- (#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 +- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links. diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index a3b3cea8..b8fd3b03 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -35,6 +35,8 @@ import { TimeblockCreationModal } from "../modals/TimeblockCreationModal"; import { openTaskSelector } from "../modals/TaskSelectorWithCreateModal"; import { TimeblockInfoModal } from "../modals/TimeblockInfoModal"; +const MIN_EXTERNAL_TIMED_EVENT_DURATION_MS = 1; + export interface CalendarEvent { id: string; title: string; @@ -652,11 +654,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, + end, allDay: icsEvent.allDay, backgroundColor: backgroundColor, borderColor: borderColor, @@ -676,6 +684,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() + MIN_EXTERNAL_TIMED_EVENT_DURATION_MS); + 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/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 71b162ab..6f955389 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -289,12 +289,12 @@ export class TaskCalendarSyncService { // Add contexts if (task.contexts && task.contexts.length > 0) { - parts.push(t("contexts", { value: task.contexts.map((c) => `@${c}`).join(", ") })); + parts.push(t("contexts", { value: task.contexts.map((c) => `@${this.toCalendarDescriptionLabel(c)}`).join(", ") })); } // Add projects if (task.projects && task.projects.length > 0) { - parts.push(t("projects", { value: task.projects.join(", ") })); + parts.push(t("projects", { value: task.projects.map((p) => this.toCalendarDescriptionLabel(p)).join(", ") })); } // Add separator before link @@ -308,14 +308,28 @@ export class TaskCalendarSyncService { const vaultName = this.plugin.app.vault.getName(); const encodedPath = encodeURIComponent(task.path); const obsidianUri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedPath}`; - // Google Calendar renders HTML in descriptions, so use an anchor tag const linkText = t("openInObsidian"); - parts.push(`${linkText}`); + parts.push(`${linkText}: ${obsidianUri}`); } return parts.join("\n"); } + private toCalendarDescriptionLabel(value: string): string { + return value + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]]+)\]\]/g, (_match, target: string) => this.basenameForDisplay(target)) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .trim(); + } + + private basenameForDisplay(target: string): string { + const withoutHeading = target.split("#")[0]; + const withoutExtension = withoutHeading.replace(/\.md$/i, ""); + const basename = withoutExtension.split("/").pop(); + return basename || withoutExtension || target; + } + /** * Get the date to use for the calendar event based on settings */ diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts index 21c0dd4b..9c82c249 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -14,19 +14,38 @@ describe("TaskCalendarSyncService", () => { googleCalendarExport: { syncOnTaskUpdate: true, targetCalendarId: "test-calendar", + includeObsidianLink: true, } }, + app: { + vault: { + getName: jest.fn().mockReturnValue("Example Vault"), + }, + }, cacheManager: { getTaskInfo: jest.fn() }, statusManager: { - getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" }) + getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" })) }, priorityManager: { - getPriorityConfig: jest.fn().mockReturnValue({ label: "High" }) + getPriorityConfig: jest.fn((priority: string) => ({ label: priority === "2-high" ? "High" : "Medium" })) }, i18n: { - translate: jest.fn().mockReturnValue("Untitled Task") + translate: jest.fn((key: string, params?: Record) => { + const translations: Record = { + "settings.integrations.googleCalendarExport.eventDescription.untitledTask": "Untitled Task", + "settings.integrations.googleCalendarExport.eventDescription.priority": "Priority: {value}", + "settings.integrations.googleCalendarExport.eventDescription.status": "Status: {value}", + "settings.integrations.googleCalendarExport.eventDescription.scheduled": "Scheduled: {value}", + "settings.integrations.googleCalendarExport.eventDescription.timeEstimate": "Time Estimate: {value}", + "settings.integrations.googleCalendarExport.eventDescription.contexts": "Contexts: {value}", + "settings.integrations.googleCalendarExport.eventDescription.projects": "Projects: {value}", + "settings.integrations.googleCalendarExport.eventDescription.openInObsidian": "Open in Obsidian", + }; + const translation = translations[key] || key; + return translation.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? "")); + }) } }; @@ -78,4 +97,38 @@ describe("TaskCalendarSyncService", () => { expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1); expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload); }); + + it("should build plain-text calendar descriptions for external calendar clients", () => { + const description = syncService.buildEventDescription({ + path: "Tasks/Prepare quarterly planning notes.md", + title: "Prepare quarterly planning notes", + status: "ready", + priority: "2-high", + scheduled: "2026-04-29", + timeEstimate: 180, + projects: [ + "[[Projects/Quarterly Planning|Quarterly Planning]]", + "[[Projects/Nested Project.md]]", + "[Markdown Project](Projects/Markdown%20Project.md)", + ], + contexts: ["[[People/Alex Example|Alex Example]]", "admin"], + } as TaskInfo); + + expect(description).toContain("Priority: High"); + expect(description).toContain("Status: Ready"); + expect(description).toContain("Scheduled: 2026-04-29"); + expect(description).toContain("Time Estimate: 3h 0m"); + expect(description).toContain("Contexts: @Alex Example, @admin"); + expect(description).toContain( + "Projects: Quarterly Planning, Nested Project, Markdown Project" + ); + expect(description).toContain( + "Open in Obsidian: obsidian://open?vault=Example%20Vault&file=Tasks%2FPrepare%20quarterly%20planning%20notes.md" + ); + expect(description).not.toContain("[["); + expect(description).not.toContain("]]"); + expect(description).not.toContain(""); + expect(description).not.toContain("]("); + }); }); 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..9cbca0b2 --- /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: "Reserved pickup 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); + }); +});