diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa1214690..39324fdb2 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -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. diff --git a/src/main.ts b/src/main.ts index adb711fdc..68a66326f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,7 +49,6 @@ import { ViewStateManager } from "./services/ViewStateManager"; import { DragDropManager } from "./utils/DragDropManager"; import { formatDateForStorage, - createUTCDateFromLocalCalendarDate, parseDateToLocal, getTodayLocal, } from "./utils/dateUtils"; @@ -1060,17 +1059,8 @@ export default class TaskNotesPlugin extends Plugin { */ async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise { 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); diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index e0adc342f..01af8cc3a 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -35,6 +35,9 @@ export class TaskCalendarSyncService { /** Track previous task state for detecting recurrence removal */ private previousTaskState: Map = new Map(); + /** Store the latest explicitly passed task object during debounce to avoid cache race conditions */ + private pendingTasks: Map = new Map(); + constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; this.googleCalendarService = googleCalendarService; @@ -49,6 +52,7 @@ export class TaskCalendarSyncService { } this.pendingSyncs.clear(); this.previousTaskState.clear(); + this.pendingTasks.clear(); } /** @@ -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 () => { @@ -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; diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index c34e6c717..2a3b39b7f 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -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"; @@ -1275,6 +1277,28 @@ export class TaskService { /** * Toggle completion status for recurring tasks on a specific date */ + async resolveRecurringTaskActionDate(task: TaskInfo, date?: Date): Promise { + 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 { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); if (!(file instanceof TFile)) { @@ -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 @@ -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 diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts new file mode 100644 index 000000000..21c0dd4be --- /dev/null +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -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); + }); +}); diff --git a/tests/unit/issues/issue-396-recurring-late-completion-wrong-date.test.ts b/tests/unit/issues/issue-396-recurring-late-completion-wrong-date.test.ts new file mode 100644 index 000000000..53ce2fc52 --- /dev/null +++ b/tests/unit/issues/issue-396-recurring-late-completion-wrong-date.test.ts @@ -0,0 +1,204 @@ +/** + * Issue #396: Recurring tasks completed/skipped after scheduled date do not + * process into next available recurrence. + * + * Root cause: toggleRecurringTaskComplete and toggleRecurringTaskSkipped + * default to getTodayLocal() when no explicit date is passed. For + * scheduled-anchor recurring tasks, this records today in + * complete_instances / skipped_instances instead of the scheduled + * occurrence date. + * + * Fix: default to task.scheduled (via getDatePart) for scheduled-anchor tasks. + */ + +import { TFile } from "obsidian"; +import { TaskService } from "../../../src/services/TaskService"; +import { TaskInfo } from "../../../src/types"; +import { TaskFactory } from "../../helpers/mock-factories"; +import { getTodayLocal } from "../../../src/utils/dateUtils"; + +// Mock dateUtils so we can control "today" +jest.mock("../../../src/utils/dateUtils", () => ({ + ...jest.requireActual("../../../src/utils/dateUtils"), + getTodayLocal: jest.fn(), +})); + +const mockGetTodayLocal = getTodayLocal as jest.MockedFunction; + +function buildMockPlugin(task: TaskInfo) { + const writtenFrontmatter: Record = {}; + const plugin = { + app: { + vault: { + getAbstractFileByPath: jest.fn().mockReturnValue(new TFile(task.path)), + modify: jest.fn(), + read: jest.fn().mockResolvedValue(""), + }, + workspace: { getActiveFile: jest.fn() }, + metadataCache: { getCache: jest.fn() }, + fileManager: { + processFrontMatter: jest.fn().mockImplementation((_file: any, fn: any) => { + const fm: Record = {}; + fn(fm); + Object.assign(writtenFrontmatter, fm); + return Promise.resolve(); + }), + }, + }, + settings: { + taskFolder: "tasks", + fieldMapping: {}, + defaultTaskStatus: "open", + taskTag: "#task", + storeTitleInFilename: false, + resetCheckboxesOnRecurrence: false, + maintainDueDateOffsetInRecurring: false, + }, + statusManager: { + isCompletedStatus: jest.fn((s: string) => s === "done"), + getCompletedStatuses: jest.fn(() => ["done"]), + }, + fieldMapper: { toUserField: jest.fn((f: string) => f) }, + cacheManager: { + getTaskInfo: jest.fn().mockResolvedValue(task), + updateTaskInfoInCache: jest.fn(), + waitForFreshTaskData: jest.fn().mockResolvedValue(undefined), + }, + emitter: { trigger: jest.fn() }, + } as any; + return { plugin, writtenFrontmatter }; +} + +describe("Issue #396 — recurring late completion records wrong date", () => { + it("scheduled-anchor task completed late records the scheduled date, not today", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Weekly task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + recurrence_anchor: "scheduled", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + }); + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + // Call WITHOUT explicit date — should default to scheduled, not today + await taskService.toggleRecurringTaskComplete(task); + + expect(writtenFrontmatter.completeInstances).toContain("2026-04-04"); + expect(writtenFrontmatter.completeInstances).not.toContain("2026-04-05"); + }); + + it("completion-anchor task completed late records today, not the scheduled date", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Completion-anchor task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + recurrence_anchor: "completion", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + }); + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + await taskService.toggleRecurringTaskComplete(task); + + expect(writtenFrontmatter.completeInstances).toContain("2026-04-05"); + expect(writtenFrontmatter.completeInstances).not.toContain("2026-04-04"); + }); + + it("undefined recurrence_anchor defaults to using the scheduled date", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Default anchor task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + }); + delete (task as any).recurrence_anchor; + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + await taskService.toggleRecurringTaskComplete(task); + + expect(writtenFrontmatter.completeInstances).toContain("2026-04-04"); + expect(writtenFrontmatter.completeInstances).not.toContain("2026-04-05"); + }); +}); + +describe("Issue #396 — recurring late skip records wrong date", () => { + it("scheduled-anchor task skipped late records the scheduled date, not today", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Weekly task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + recurrence_anchor: "scheduled", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + skipped_instances: [], + }); + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + await taskService.toggleRecurringTaskSkipped(task); + + expect(writtenFrontmatter.skippedInstances).toContain("2026-04-04"); + expect(writtenFrontmatter.skippedInstances).not.toContain("2026-04-05"); + }); + + it("completion-anchor task skipped late records today, not the scheduled date", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Completion-anchor task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + recurrence_anchor: "completion", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + skipped_instances: [], + }); + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + await taskService.toggleRecurringTaskSkipped(task); + + expect(writtenFrontmatter.skippedInstances).toContain("2026-04-05"); + expect(writtenFrontmatter.skippedInstances).not.toContain("2026-04-04"); + }); + + it("undefined recurrence_anchor defaults to using the scheduled date for skip", async () => { + mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday + + const task = TaskFactory.createTask({ + path: "tasks/test.md", + title: "Default anchor task", + recurrence: "FREQ=WEEKLY;BYDAY=SA", + scheduled: "2026-04-04", // Saturday + complete_instances: [], + skipped_instances: [], + }); + delete (task as any).recurrence_anchor; + + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); + + await taskService.toggleRecurringTaskSkipped(task); + + expect(writtenFrontmatter.skippedInstances).toContain("2026-04-04"); + expect(writtenFrontmatter.skippedInstances).not.toContain("2026-04-05"); + }); +});