diff --git a/src/main.ts b/src/main.ts index ca7dd7cf..1764bd11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1060,20 +1060,21 @@ 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); - - // Use the same implicit-date resolution as TaskService (#396) + // Resolve the implicit date from cacheManager — the same authoritative + // source the service uses — before the service mutates state (#396) + const freshTask = (await this.cacheManager.getTaskInfo(task.path)) || task; const targetDate = date || (() => { - if (task.recurrence_anchor !== "completion" && task.scheduled) { - return parseDateToUTC(getDatePart(task.scheduled)); + if (freshTask.recurrence_anchor !== "completion" && freshTask.scheduled) { + return parseDateToUTC(getDatePart(freshTask.scheduled)); } const todayLocal = getTodayLocal(); return createUTCDateFromLocalCalendarDate(todayLocal); })(); + const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, date); + const dateStr = formatDateForStorage(targetDate); const wasCompleted = updatedTask.complete_instances?.includes(dateStr); const action = wasCompleted ? "completed" : "marked incomplete"; 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 index 33d665f2..53ce2fc5 100644 --- 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 @@ -1,10 +1,12 @@ /** - * Issue #396: Recurring tasks completed after scheduled date do not process - * into next available recurrence. + * Issue #396: Recurring tasks completed/skipped after scheduled date do not + * process into next available recurrence. * - * Root cause: toggleRecurringTaskComplete defaults to getTodayLocal() when no - * explicit date is passed. For scheduled-anchor recurring tasks, this records - * today in complete_instances instead of the scheduled occurrence date. + * 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. */ @@ -23,59 +25,55 @@ jest.mock("../../../src/utils/dateUtils", () => ({ 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", () => { - let taskService: TaskService; - let writtenFrontmatter: Record; - - function buildMockPlugin(task: TaskInfo) { - writtenFrontmatter = {}; - return { - 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) => { - // Start with empty frontmatter like Obsidian does - const fm: Record = {}; - fn(fm); - Object.assign(writtenFrontmatter, fm); - return Promise.resolve(); - }), - }, - }, - settings: { - taskFolder: "tasks", - fieldMapping: {}, - defaultTaskStatus: "open", - taskTag: "#task", - storeTitleInFilename: false, - resetCheckboxesOnRecurrence: false, - }, - statusManager: { - isCompletedStatus: jest.fn((s: string) => s === "done"), - getCompletedStatuses: jest.fn(() => ["done"]), - }, - // Return field names as-is — the test checks the mapped field name - 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; - } - it("scheduled-anchor task completed late records the scheduled date, not today", async () => { - // Saturday task, completed on Sunday mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday - const saturdayTask = TaskFactory.createTask({ + const task = TaskFactory.createTask({ path: "tasks/test.md", title: "Weekly task", recurrence: "FREQ=WEEKLY;BYDAY=SA", @@ -84,13 +82,12 @@ describe("Issue #396 — recurring late completion records wrong date", () => { complete_instances: [], }); - const plugin = buildMockPlugin(saturdayTask); - taskService = new TaskService(plugin); + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); // Call WITHOUT explicit date — should default to scheduled, not today - await taskService.toggleRecurringTaskComplete(saturdayTask); + await taskService.toggleRecurringTaskComplete(task); - // completeInstances is the mapped field name (toUserField returns as-is) expect(writtenFrontmatter.completeInstances).toContain("2026-04-04"); expect(writtenFrontmatter.completeInstances).not.toContain("2026-04-05"); }); @@ -98,7 +95,7 @@ describe("Issue #396 — recurring late completion records wrong date", () => { it("completion-anchor task completed late records today, not the scheduled date", async () => { mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday - const completionTask = TaskFactory.createTask({ + const task = TaskFactory.createTask({ path: "tasks/test.md", title: "Completion-anchor task", recurrence: "FREQ=WEEKLY;BYDAY=SA", @@ -107,12 +104,11 @@ describe("Issue #396 — recurring late completion records wrong date", () => { complete_instances: [], }); - const plugin = buildMockPlugin(completionTask); - taskService = new TaskService(plugin); + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); - await taskService.toggleRecurringTaskComplete(completionTask); + await taskService.toggleRecurringTaskComplete(task); - // completion-anchor should use today (Sunday), not scheduled (Saturday) expect(writtenFrontmatter.completeInstances).toContain("2026-04-05"); expect(writtenFrontmatter.completeInstances).not.toContain("2026-04-04"); }); @@ -120,22 +116,89 @@ describe("Issue #396 — recurring late completion records wrong date", () => { it("undefined recurrence_anchor defaults to using the scheduled date", async () => { mockGetTodayLocal.mockReturnValue(new Date("2026-04-05T12:00:00")); // Sunday - const defaultTask = TaskFactory.createTask({ + const task = TaskFactory.createTask({ path: "tasks/test.md", title: "Default anchor task", recurrence: "FREQ=WEEKLY;BYDAY=SA", scheduled: "2026-04-04", // Saturday complete_instances: [], }); - // Ensure recurrence_anchor is undefined (default = scheduled) - delete (defaultTask as any).recurrence_anchor; + delete (task as any).recurrence_anchor; - const plugin = buildMockPlugin(defaultTask); - taskService = new TaskService(plugin); + const { plugin, writtenFrontmatter } = buildMockPlugin(task); + const taskService = new TaskService(plugin); - await taskService.toggleRecurringTaskComplete(defaultTask); + 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"); + }); +});