Skip to content

Commit 0347be6

Browse files
authored
Merge pull request #1764 from martin-forge/fix/google-calendar-sync-reliability
Fix Google Calendar sync race condition and recurring completion date bug
2 parents 8ede1c4 + ad07201 commit 0347be6

6 files changed

Lines changed: 332 additions & 29 deletions

File tree

docs/releases/unreleased.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ Example:
2323
```
2424
2525
-->
26+
27+
## Fixed
28+
29+
- (#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.
30+
- Thanks to @martin-forge for the PR and to @jpmoo for reporting the recurring completion issues.

src/main.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ import { ViewStateManager } from "./services/ViewStateManager";
4949
import { DragDropManager } from "./utils/DragDropManager";
5050
import {
5151
formatDateForStorage,
52-
createUTCDateFromLocalCalendarDate,
5352
parseDateToLocal,
5453
getTodayLocal,
5554
} from "./utils/dateUtils";
@@ -1060,17 +1059,8 @@ export default class TaskNotesPlugin extends Plugin {
10601059
*/
10611060
async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
10621061
try {
1063-
// Let TaskService handle the date logic (defaults to local today, not selectedDate)
1064-
const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, date);
1065-
1066-
// For notification, determine the actual completion date from the task
1067-
// Use local today if no explicit date provided
1068-
const targetDate =
1069-
date ||
1070-
(() => {
1071-
const todayLocal = getTodayLocal();
1072-
return createUTCDateFromLocalCalendarDate(todayLocal);
1073-
})();
1062+
const targetDate = await this.taskService.resolveRecurringTaskActionDate(task, date);
1063+
const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, targetDate);
10741064

10751065
const dateStr = formatDateForStorage(targetDate);
10761066
const wasCompleted = updatedTask.complete_instances?.includes(dateStr);

src/services/TaskCalendarSyncService.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export class TaskCalendarSyncService {
3535
/** Track previous task state for detecting recurrence removal */
3636
private previousTaskState: Map<string, TaskInfo> = new Map();
3737

38+
/** Store the latest explicitly passed task object during debounce to avoid cache race conditions */
39+
private pendingTasks: Map<string, TaskInfo> = new Map();
40+
3841
constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) {
3942
this.plugin = plugin;
4043
this.googleCalendarService = googleCalendarService;
@@ -49,6 +52,7 @@ export class TaskCalendarSyncService {
4952
}
5053
this.pendingSyncs.clear();
5154
this.previousTaskState.clear();
55+
this.pendingTasks.clear();
5256
}
5357

5458
/**
@@ -765,6 +769,9 @@ export class TaskCalendarSyncService {
765769
clearTimeout(existingTimer);
766770
}
767771

772+
// Store the authoritative task state passed to us so we don't rely on the async metadata cache
773+
this.pendingTasks.set(taskPath, task);
774+
768775
// Return a promise that resolves when the debounced sync completes
769776
return new Promise((resolve, reject) => {
770777
const timer = setTimeout(async () => {
@@ -776,8 +783,13 @@ export class TaskCalendarSyncService {
776783
await inFlight.catch(() => {}); // Ignore errors from previous sync
777784
}
778785

779-
// Re-fetch the task to get the latest state after debounce
780-
const freshTask = await this.plugin.cacheManager.getTaskInfo(taskPath);
786+
// Use the latest task data that was passed to us explicitly
787+
const latestTask = this.pendingTasks.get(taskPath);
788+
this.pendingTasks.delete(taskPath);
789+
790+
// Fallback to cache only if the pending task is missing
791+
const freshTask = latestTask || await this.plugin.cacheManager.getTaskInfo(taskPath);
792+
781793
if (!freshTask) {
782794
resolve();
783795
return;

src/services/TaskService.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ import {
3939
import { getProjectDisplayName } from "../utils/linkUtils";
4040
import {
4141
formatDateForStorage,
42+
getDatePart,
4243
getCurrentDateString,
4344
getCurrentTimestamp,
4445
getTodayLocal,
4546
createUTCDateFromLocalCalendarDate,
47+
parseDateToUTC,
4648
} from "../utils/dateUtils";
4749
import { format } from "date-fns";
4850
import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplateProcessor";
@@ -1275,6 +1277,28 @@ export class TaskService {
12751277
/**
12761278
* Toggle completion status for recurring tasks on a specific date
12771279
*/
1280+
async resolveRecurringTaskActionDate(task: TaskInfo, date?: Date): Promise<Date> {
1281+
if (date) {
1282+
return date;
1283+
}
1284+
1285+
const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task;
1286+
return this.getRecurringTaskActionDate(freshTask);
1287+
}
1288+
1289+
private getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date {
1290+
if (date) {
1291+
return date;
1292+
}
1293+
1294+
if (task.recurrence_anchor !== "completion" && task.scheduled) {
1295+
return parseDateToUTC(getDatePart(task.scheduled));
1296+
}
1297+
1298+
const todayLocal = getTodayLocal();
1299+
return createUTCDateFromLocalCalendarDate(todayLocal);
1300+
}
1301+
12781302
async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
12791303
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
12801304
if (!(file instanceof TFile)) {
@@ -1288,14 +1312,7 @@ export class TaskService {
12881312
throw new Error("Task is not recurring");
12891313
}
12901314

1291-
// Default to local today instead of selectedDate for recurring task completion
1292-
// This ensures completion is recorded for user's actual calendar day unless explicitly overridden
1293-
const targetDate =
1294-
date ||
1295-
(() => {
1296-
const todayLocal = getTodayLocal();
1297-
return createUTCDateFromLocalCalendarDate(todayLocal);
1298-
})();
1315+
const targetDate = this.getRecurringTaskActionDate(freshTask, date);
12991316
const dateStr = formatDateForStorage(targetDate);
13001317

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

1517-
// Default to local today
1518-
const targetDate =
1519-
date ||
1520-
(() => {
1521-
const todayLocal = getTodayLocal();
1522-
return createUTCDateFromLocalCalendarDate(todayLocal);
1523-
})();
1534+
const targetDate = this.getRecurringTaskActionDate(freshTask, date);
15241535
const dateStr = formatDateForStorage(targetDate);
15251536

15261537
// Check current skip status for this date
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { TaskCalendarSyncService } from "../../src/services/TaskCalendarSyncService";
2+
import { TaskInfo } from "../../src/types";
3+
4+
describe("TaskCalendarSyncService", () => {
5+
let syncService: any;
6+
let mockPlugin: any;
7+
let mockGoogleCalendarService: any;
8+
9+
beforeEach(() => {
10+
jest.useFakeTimers();
11+
12+
mockPlugin = {
13+
settings: {
14+
googleCalendarExport: {
15+
syncOnTaskUpdate: true,
16+
targetCalendarId: "test-calendar",
17+
}
18+
},
19+
cacheManager: {
20+
getTaskInfo: jest.fn()
21+
},
22+
statusManager: {
23+
getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" })
24+
},
25+
priorityManager: {
26+
getPriorityConfig: jest.fn().mockReturnValue({ label: "High" })
27+
},
28+
i18n: {
29+
translate: jest.fn().mockReturnValue("Untitled Task")
30+
}
31+
};
32+
33+
mockGoogleCalendarService = {
34+
updateEvent: jest.fn().mockResolvedValue({}),
35+
createEvent: jest.fn().mockResolvedValue({ id: "test-id" })
36+
};
37+
38+
syncService = new TaskCalendarSyncService(mockPlugin, mockGoogleCalendarService);
39+
40+
// Mock internal methods to avoid testing downstream serialization logic which might be complex
41+
syncService.executeTaskUpdate = jest.fn().mockResolvedValue(undefined);
42+
});
43+
44+
afterEach(() => {
45+
jest.useRealTimers();
46+
});
47+
48+
it("should use the most recently passed task explicitly, avoiding stale cacheManager payloads during debounce", async () => {
49+
const taskPath = "test/path.md";
50+
51+
const firstPayload: TaskInfo = {
52+
path: taskPath,
53+
title: "Task Title",
54+
scheduled: "2026-04-04"
55+
};
56+
57+
const secondPayload: TaskInfo = {
58+
path: taskPath,
59+
title: "Task Title",
60+
scheduled: "2026-04-06" // Agent updated it to April 6
61+
};
62+
63+
// Pretend the metadataCache hasn't caught up and still returns the stale task
64+
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(firstPayload);
65+
66+
// Act: trigger sync twice rapidly to simulate MCP updates or user typing
67+
syncService.updateTaskInCalendar(firstPayload);
68+
syncService.updateTaskInCalendar(secondPayload);
69+
70+
// Fast-forward past the 500ms debounce
71+
jest.advanceTimersByTime(500);
72+
73+
// Flush the microtask queue so the async debounce handler completes
74+
await Promise.resolve();
75+
await Promise.resolve();
76+
77+
// Assert: It should execute only once, and pass the explicit secondPayload, not the stale cache!
78+
expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1);
79+
expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload);
80+
});
81+
});

0 commit comments

Comments
 (0)