Use authoritative cache state for completion notice, add skip path tests (#396)

The main.ts wrapper was resolving the notice date from the caller's
potentially stale task object. Now reads from cacheManager before the
service call, matching the authoritative source the service uses.

Added behavioural regression tests for toggleRecurringTaskSkipped to
match the existing completion path coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Ball 2026-04-05 03:16:30 +01:00
parent 7506c59b69
commit f7dfd7b1ae
2 changed files with 139 additions and 75 deletions

View file

@ -1060,20 +1060,21 @@ 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);
// 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";

View file

@ -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<typeof getTodayLocal>;
function buildMockPlugin(task: TaskInfo) {
const writtenFrontmatter: Record<string, any> = {};
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<string, any> = {};
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<string, any>;
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<string, any> = {};
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");
});
});