Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
66 changes: 64 additions & 2 deletions src/bases/calendar-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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
*/
Expand Down
22 changes: 18 additions & 4 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(`<a href="${obsidianUri}">${linkText}</a>`);
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
*/
Expand Down
59 changes: 56 additions & 3 deletions tests/services/TaskCalendarSyncService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>) => {
const translations: Record<string, string> = {
"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] ?? ""));
})
}
};

Expand Down Expand Up @@ -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("<a ");
expect(description).not.toContain("</a>");
expect(description).not.toContain("](");
});
});
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: "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);
});
});
Loading