Skip to content

Commit 46fa91b

Browse files
committed
Fix Google Calendar event rendering
1 parent 2538f5b commit 46fa91b

5 files changed

Lines changed: 251 additions & 9 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+
- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links.

src/bases/calendar-core.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import { TimeblockCreationModal } from "../modals/TimeblockCreationModal";
3535
import { openTaskSelector } from "../modals/TaskSelectorWithCreateModal";
3636
import { TimeblockInfoModal } from "../modals/TimeblockInfoModal";
3737

38+
const MIN_EXTERNAL_TIMED_EVENT_DURATION_MS = 1;
39+
3840
export interface CalendarEvent {
3941
id: string;
4042
title: string;
@@ -652,11 +654,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
652654
subscriptionName = subscription.name;
653655
}
654656

657+
const { start, end } = normalizeExternalTimedEventRange(
658+
icsEvent.start,
659+
icsEvent.end,
660+
icsEvent.allDay
661+
);
662+
655663
return {
656664
id: icsEvent.id,
657665
title: icsEvent.title,
658-
start: icsEvent.start,
659-
end: icsEvent.end,
666+
start,
667+
end,
660668
allDay: icsEvent.allDay,
661669
backgroundColor: backgroundColor,
662670
borderColor: borderColor,
@@ -676,6 +684,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
676684
}
677685
}
678686

687+
/**
688+
* FullCalendar list views can render a timed external event under multiple day
689+
* headers when the provider supplies a true zero-duration range (end === start).
690+
* Clamp those point-in-time external events to a minimal positive duration
691+
* before handing them to FullCalendar, while preserving the raw provider event
692+
* unchanged in extendedProps for display and debugging.
693+
*/
694+
function normalizeExternalTimedEventRange(
695+
start: string,
696+
end: string | undefined,
697+
allDay: boolean
698+
): { start: string; end?: string } {
699+
if (allDay || !end) {
700+
return { start, end };
701+
}
702+
703+
const startDate = new Date(start);
704+
const endDate = new Date(end);
705+
706+
if (
707+
Number.isNaN(startDate.getTime()) ||
708+
Number.isNaN(endDate.getTime()) ||
709+
endDate.getTime() !== startDate.getTime()
710+
) {
711+
return { start, end };
712+
}
713+
714+
const normalizedEnd = new Date(endDate.getTime() + MIN_EXTERNAL_TIMED_EVENT_DURATION_MS);
715+
return {
716+
start,
717+
end: formatExternalTimedEventEnd(normalizedEnd, end),
718+
};
719+
}
720+
721+
function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
722+
if (/Z$/i.test(originalEnd)) {
723+
return date.toISOString();
724+
}
725+
726+
const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
727+
if (offsetMatch) {
728+
const [, sign, hours, minutes] = offsetMatch;
729+
const offsetMinutes = Number(hours) * 60 + Number(minutes);
730+
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
731+
const shifted = new Date(date.getTime() + offsetMs);
732+
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
733+
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
734+
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
735+
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
736+
}
737+
738+
return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
739+
}
740+
679741
/**
680742
* Get recurring time from task recurrence rule
681743
*/

src/services/TaskCalendarSyncService.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,12 @@ export class TaskCalendarSyncService {
289289

290290
// Add contexts
291291
if (task.contexts && task.contexts.length > 0) {
292-
parts.push(t("contexts", { value: task.contexts.map((c) => `@${c}`).join(", ") }));
292+
parts.push(t("contexts", { value: task.contexts.map((c) => `@${this.toCalendarDescriptionLabel(c)}`).join(", ") }));
293293
}
294294

295295
// Add projects
296296
if (task.projects && task.projects.length > 0) {
297-
parts.push(t("projects", { value: task.projects.join(", ") }));
297+
parts.push(t("projects", { value: task.projects.map((p) => this.toCalendarDescriptionLabel(p)).join(", ") }));
298298
}
299299

300300
// Add separator before link
@@ -308,14 +308,28 @@ export class TaskCalendarSyncService {
308308
const vaultName = this.plugin.app.vault.getName();
309309
const encodedPath = encodeURIComponent(task.path);
310310
const obsidianUri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedPath}`;
311-
// Google Calendar renders HTML in descriptions, so use an anchor tag
312311
const linkText = t("openInObsidian");
313-
parts.push(`<a href="${obsidianUri}">${linkText}</a>`);
312+
parts.push(`${linkText}: ${obsidianUri}`);
314313
}
315314

316315
return parts.join("\n");
317316
}
318317

318+
private toCalendarDescriptionLabel(value: string): string {
319+
return value
320+
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
321+
.replace(/\[\[([^\]]+)\]\]/g, (_match, target: string) => this.basenameForDisplay(target))
322+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
323+
.trim();
324+
}
325+
326+
private basenameForDisplay(target: string): string {
327+
const withoutHeading = target.split("#")[0];
328+
const withoutExtension = withoutHeading.replace(/\.md$/i, "");
329+
const basename = withoutExtension.split("/").pop();
330+
return basename || withoutExtension || target;
331+
}
332+
319333
/**
320334
* Get the date to use for the calendar event based on settings
321335
*/

tests/services/TaskCalendarSyncService.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,38 @@ describe("TaskCalendarSyncService", () => {
1414
googleCalendarExport: {
1515
syncOnTaskUpdate: true,
1616
targetCalendarId: "test-calendar",
17+
includeObsidianLink: true,
1718
}
1819
},
20+
app: {
21+
vault: {
22+
getName: jest.fn().mockReturnValue("Example Vault"),
23+
},
24+
},
1925
cacheManager: {
2026
getTaskInfo: jest.fn()
2127
},
2228
statusManager: {
23-
getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" })
29+
getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" }))
2430
},
2531
priorityManager: {
26-
getPriorityConfig: jest.fn().mockReturnValue({ label: "High" })
32+
getPriorityConfig: jest.fn((priority: string) => ({ label: priority === "2-high" ? "High" : "Medium" }))
2733
},
2834
i18n: {
29-
translate: jest.fn().mockReturnValue("Untitled Task")
35+
translate: jest.fn((key: string, params?: Record<string, string | number>) => {
36+
const translations: Record<string, string> = {
37+
"settings.integrations.googleCalendarExport.eventDescription.untitledTask": "Untitled Task",
38+
"settings.integrations.googleCalendarExport.eventDescription.priority": "Priority: {value}",
39+
"settings.integrations.googleCalendarExport.eventDescription.status": "Status: {value}",
40+
"settings.integrations.googleCalendarExport.eventDescription.scheduled": "Scheduled: {value}",
41+
"settings.integrations.googleCalendarExport.eventDescription.timeEstimate": "Time Estimate: {value}",
42+
"settings.integrations.googleCalendarExport.eventDescription.contexts": "Contexts: {value}",
43+
"settings.integrations.googleCalendarExport.eventDescription.projects": "Projects: {value}",
44+
"settings.integrations.googleCalendarExport.eventDescription.openInObsidian": "Open in Obsidian",
45+
};
46+
const translation = translations[key] || key;
47+
return translation.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ""));
48+
})
3049
}
3150
};
3251

@@ -78,4 +97,38 @@ describe("TaskCalendarSyncService", () => {
7897
expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1);
7998
expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload);
8099
});
100+
101+
it("should build plain-text calendar descriptions for external calendar clients", () => {
102+
const description = syncService.buildEventDescription({
103+
path: "Tasks/Prepare quarterly planning notes.md",
104+
title: "Prepare quarterly planning notes",
105+
status: "ready",
106+
priority: "2-high",
107+
scheduled: "2026-04-29",
108+
timeEstimate: 180,
109+
projects: [
110+
"[[Projects/Quarterly Planning|Quarterly Planning]]",
111+
"[[Projects/Nested Project.md]]",
112+
"[Markdown Project](Projects/Markdown%20Project.md)",
113+
],
114+
contexts: ["[[People/Alex Example|Alex Example]]", "admin"],
115+
} as TaskInfo);
116+
117+
expect(description).toContain("Priority: High");
118+
expect(description).toContain("Status: Ready");
119+
expect(description).toContain("Scheduled: 2026-04-29");
120+
expect(description).toContain("Time Estimate: 3h 0m");
121+
expect(description).toContain("Contexts: @Alex Example, @admin");
122+
expect(description).toContain(
123+
"Projects: Quarterly Planning, Nested Project, Markdown Project"
124+
);
125+
expect(description).toContain(
126+
"Open in Obsidian: obsidian://open?vault=Example%20Vault&file=Tasks%2FPrepare%20quarterly%20planning%20notes.md"
127+
);
128+
expect(description).not.toContain("[[");
129+
expect(description).not.toContain("]]");
130+
expect(description).not.toContain("<a ");
131+
expect(description).not.toContain("</a>");
132+
expect(description).not.toContain("](");
133+
});
81134
});
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: "Reserved pickup 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)