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);
+ });
+});