Skip to content
5 changes: 5 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ Example:
```

-->

## Fixed

- (#1764) Fixed Google Calendar sync using stale task metadata after rapid task updates, and fixed late recurring completions/skips recording the completion day instead of the scheduled occurrence date.
- Thanks to @martin-forge for the PR and to @jpmoo for reporting the recurring completion issues.
14 changes: 2 additions & 12 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import { ViewStateManager } from "./services/ViewStateManager";
import { DragDropManager } from "./utils/DragDropManager";
import {
formatDateForStorage,
createUTCDateFromLocalCalendarDate,
parseDateToLocal,
getTodayLocal,
} from "./utils/dateUtils";
Expand Down Expand Up @@ -1060,17 +1059,8 @@ export default class TaskNotesPlugin extends Plugin {
*/
async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
try {
// Let TaskService handle the date logic (defaults to local today, not selectedDate)
const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, date);

// For notification, determine the actual completion date from the task
// Use local today if no explicit date provided
const targetDate =
date ||
(() => {
const todayLocal = getTodayLocal();
return createUTCDateFromLocalCalendarDate(todayLocal);
})();
const targetDate = await this.taskService.resolveRecurringTaskActionDate(task, date);
const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, targetDate);

const dateStr = formatDateForStorage(targetDate);
const wasCompleted = updatedTask.complete_instances?.includes(dateStr);
Expand Down
16 changes: 14 additions & 2 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export class TaskCalendarSyncService {
/** Track previous task state for detecting recurrence removal */
private previousTaskState: Map<string, TaskInfo> = new Map();

/** Store the latest explicitly passed task object during debounce to avoid cache race conditions */
private pendingTasks: Map<string, TaskInfo> = new Map();

constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) {
this.plugin = plugin;
this.googleCalendarService = googleCalendarService;
Expand All @@ -49,6 +52,7 @@ export class TaskCalendarSyncService {
}
this.pendingSyncs.clear();
this.previousTaskState.clear();
this.pendingTasks.clear();
}

/**
Expand Down Expand Up @@ -765,6 +769,9 @@ export class TaskCalendarSyncService {
clearTimeout(existingTimer);
}

// Store the authoritative task state passed to us so we don't rely on the async metadata cache
this.pendingTasks.set(taskPath, task);

// Return a promise that resolves when the debounced sync completes
return new Promise((resolve, reject) => {
const timer = setTimeout(async () => {
Expand All @@ -776,8 +783,13 @@ export class TaskCalendarSyncService {
await inFlight.catch(() => {}); // Ignore errors from previous sync
}

// Re-fetch the task to get the latest state after debounce
const freshTask = await this.plugin.cacheManager.getTaskInfo(taskPath);
// Use the latest task data that was passed to us explicitly
const latestTask = this.pendingTasks.get(taskPath);
this.pendingTasks.delete(taskPath);

// Fallback to cache only if the pending task is missing
const freshTask = latestTask || await this.plugin.cacheManager.getTaskInfo(taskPath);

if (!freshTask) {
resolve();
return;
Expand Down
41 changes: 26 additions & 15 deletions src/services/TaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ import {
import { getProjectDisplayName } from "../utils/linkUtils";
import {
formatDateForStorage,
getDatePart,
getCurrentDateString,
getCurrentTimestamp,
getTodayLocal,
createUTCDateFromLocalCalendarDate,
parseDateToUTC,
} from "../utils/dateUtils";
import { format } from "date-fns";
import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplateProcessor";
Expand Down Expand Up @@ -1275,6 +1277,28 @@ export class TaskService {
/**
* Toggle completion status for recurring tasks on a specific date
*/
async resolveRecurringTaskActionDate(task: TaskInfo, date?: Date): Promise<Date> {
if (date) {
return date;
}

const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task;
return this.getRecurringTaskActionDate(freshTask);
}

private getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date {
if (date) {
return date;
}

if (task.recurrence_anchor !== "completion" && task.scheduled) {
return parseDateToUTC(getDatePart(task.scheduled));
}

const todayLocal = getTodayLocal();
return createUTCDateFromLocalCalendarDate(todayLocal);
}

async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
if (!(file instanceof TFile)) {
Expand All @@ -1288,14 +1312,7 @@ export class TaskService {
throw new Error("Task is not recurring");
}

// Default to local today instead of selectedDate for recurring task completion
// This ensures completion is recorded for user's actual calendar day unless explicitly overridden
const targetDate =
date ||
(() => {
const todayLocal = getTodayLocal();
return createUTCDateFromLocalCalendarDate(todayLocal);
})();
const targetDate = this.getRecurringTaskActionDate(freshTask, date);
const dateStr = formatDateForStorage(targetDate);

// Check current completion status for this date using fresh data
Expand Down Expand Up @@ -1514,13 +1531,7 @@ export class TaskService {
throw new Error("Task is not recurring");
}

// Default to local today
const targetDate =
date ||
(() => {
const todayLocal = getTodayLocal();
return createUTCDateFromLocalCalendarDate(todayLocal);
})();
const targetDate = this.getRecurringTaskActionDate(freshTask, date);
const dateStr = formatDateForStorage(targetDate);

// Check current skip status for this date
Expand Down
81 changes: 81 additions & 0 deletions tests/services/TaskCalendarSyncService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { TaskCalendarSyncService } from "../../src/services/TaskCalendarSyncService";
import { TaskInfo } from "../../src/types";

describe("TaskCalendarSyncService", () => {
let syncService: any;
let mockPlugin: any;
let mockGoogleCalendarService: any;

beforeEach(() => {
jest.useFakeTimers();

mockPlugin = {
settings: {
googleCalendarExport: {
syncOnTaskUpdate: true,
targetCalendarId: "test-calendar",
}
},
cacheManager: {
getTaskInfo: jest.fn()
},
statusManager: {
getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" })
},
priorityManager: {
getPriorityConfig: jest.fn().mockReturnValue({ label: "High" })
},
i18n: {
translate: jest.fn().mockReturnValue("Untitled Task")
}
};

mockGoogleCalendarService = {
updateEvent: jest.fn().mockResolvedValue({}),
createEvent: jest.fn().mockResolvedValue({ id: "test-id" })
};

syncService = new TaskCalendarSyncService(mockPlugin, mockGoogleCalendarService);

// Mock internal methods to avoid testing downstream serialization logic which might be complex
syncService.executeTaskUpdate = jest.fn().mockResolvedValue(undefined);
});

afterEach(() => {
jest.useRealTimers();
});

it("should use the most recently passed task explicitly, avoiding stale cacheManager payloads during debounce", async () => {
const taskPath = "test/path.md";

const firstPayload: TaskInfo = {
path: taskPath,
title: "Task Title",
scheduled: "2026-04-04"
};

const secondPayload: TaskInfo = {
path: taskPath,
title: "Task Title",
scheduled: "2026-04-06" // Agent updated it to April 6
};

// Pretend the metadataCache hasn't caught up and still returns the stale task
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(firstPayload);

// Act: trigger sync twice rapidly to simulate MCP updates or user typing
syncService.updateTaskInCalendar(firstPayload);
syncService.updateTaskInCalendar(secondPayload);

// Fast-forward past the 500ms debounce
jest.advanceTimersByTime(500);

// Flush the microtask queue so the async debounce handler completes
await Promise.resolve();
await Promise.resolve();

// Assert: It should execute only once, and pass the explicit secondPayload, not the stale cache!
expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1);
expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload);
});
});
Loading
Loading