diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index fc306e4e..96e19e53 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -2753,6 +2753,12 @@ export class CalendarView extends BasesViewBase { task: taskInfo, plugin: this.plugin, targetDate: targetDate, + // Calendar addresses a concrete occurrence: carry the clicked + // date as the occurrence signal for recurring tasks so skip and + // completion resolution record against it rather than falling + // back to the anchor-aware default. Omitted for non-recurring + // tasks, which have no occurrence concept. + occurrenceDate: taskInfo.recurrence ? targetDate : undefined, promoteOccurrenceControls: Boolean( taskInfo.recurrence || (taskInfo.recurrence_parent && taskInfo.occurrence_date) diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b..6f49b2b9 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -10,6 +10,7 @@ import { buildSubtaskCreationPrePopulatedValues, } from "../services/taskRelationshipActions"; import { renameVaultFile } from "../services/VaultMutationService"; +import { getRecurringTaskActionDate } from "../services/task-service/taskRecurringPlanning"; import { showConfirmationModal } from "../modals/ConfirmationModal"; import { DateContextMenu } from "./DateContextMenu"; import { DateTimePickerModal } from "../modals/DateTimePickerModal"; @@ -142,6 +143,15 @@ export interface TaskContextMenuOptions { task: TaskInfo; plugin: TaskNotesPlugin; targetDate: Date; + /** + * The specific recurring occurrence this menu was opened against, when the + * producing view addresses a concrete occurrence (Calendar). Distinct from + * the overloaded `targetDate`: occurrence-aware logic (skip, completion + * resolution) reads this field and never infers occurrence-ness from + * `targetDate`. Omitted by list/board producers, which lets the service's + * anchor-aware default resolve the date instead. + */ + occurrenceDate?: Date; onUpdate?: () => void; promoteOccurrenceControls?: boolean; } @@ -888,7 +898,17 @@ export class TaskContextMenu { }); }); - const isSkippedForDate = task.skipped_instances?.includes(dateStr) || false; + // Skip records the occurrence, never the card's view-wide "today". When + // the producing view supplies a concrete occurrence (Calendar) use it; + // otherwise resolve the anchor-aware default (scheduled for + // scheduled-anchored recurrences, today for completion-anchored) for the + // label, and pass no explicit date to the service so it re-resolves on + // the fresh task. Deliberately NOT routed through the four-mode resolver, + // which would drop the completion-anchor guard (KTD3). + const skipOccurrenceDate = this.options.occurrenceDate; + const skipLabelDate = skipOccurrenceDate ?? getRecurringTaskActionDate(task); + const skipDateStr = formatDateForStorage(skipLabelDate); + const isSkippedForDate = task.skipped_instances?.includes(skipDateStr) || false; this.menu.addItem((item) => { item.setTitle( @@ -899,10 +919,7 @@ export class TaskContextMenu { item.setIcon(isSkippedForDate ? "undo" : "x-circle"); item.onClick(async () => { try { - await plugin.taskService.toggleRecurringTaskSkipped( - task, - this.options.targetDate - ); + await plugin.taskService.toggleRecurringTaskSkipped(task, skipOccurrenceDate); this.options.onUpdate?.(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/ui/taskCardContextMenu.ts b/src/ui/taskCardContextMenu.ts index 7c014b32..e66cc27e 100644 --- a/src/ui/taskCardContextMenu.ts +++ b/src/ui/taskCardContextMenu.ts @@ -68,7 +68,7 @@ export async function showTaskContextMenu( taskPath: string, plugin: TaskNotesPlugin, targetDate: Date, - options: { promoteOccurrenceControls?: boolean } = {} + options: { promoteOccurrenceControls?: boolean; occurrenceDate?: Date } = {} ): Promise { const file = plugin.app.vault.getAbstractFileByPath(taskPath); const showFileMenuFallback = () => { @@ -88,6 +88,7 @@ export async function showTaskContextMenu( task, plugin, targetDate, + occurrenceDate: options.occurrenceDate, promoteOccurrenceControls: options.promoteOccurrenceControls, onUpdate: () => { plugin.app.workspace.trigger("tasknotes:refresh-views"); diff --git a/tests/unit/components/taskContextMenu.occurrenceContext.test.ts b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts new file mode 100644 index 00000000..95796eb8 --- /dev/null +++ b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts @@ -0,0 +1,163 @@ +import { App, Menu } from "obsidian"; +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { showTaskContextMenu } from "../../../src/ui/taskCardContextMenu"; +import { createI18nService } from "../../../src/i18n"; +import { formatDateForStorage } from "../../../src/utils/dateUtils"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +/** + * U1 — a distinct optional `occurrenceDate` carries the clicked-occurrence + * signal, separate from the overloaded `targetDate`. Occurrence-aware menu + * logic must read `occurrenceDate` and never infer occurrence-ness from + * `targetDate` (KTD5). When absent, behavior falls through to the anchor-aware + * service default. + */ + +type MockMenuItem = Record | { type: string }; +type MockMenu = { items: MockMenuItem[] }; + +const menuMock = Menu as unknown as jest.Mock; + +function createRecurringTask(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/recurring.md", + path: "Tasks/recurring.md", + title: "Recurring task", + status: "open", + priority: "normal", + recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", + recurrence_anchor: "scheduled", + scheduled: "2026-06-02", + complete_instances: [], + skipped_instances: [], + ...overrides, + } as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: createI18nService(), + settings: { + customStatuses: [ + { value: "open", label: "Open", order: 0 }, + { value: "done", label: "Done", order: 1 }, + ], + customPriorities: [{ value: "normal", label: "Normal", weight: 0 }], + calendarViewSettings: { enableTimeblocking: false }, + useFrontmatterMarkdownLinks: true, + }, + statusManager: { + getAllStatuses: jest.fn(() => [ + { value: "open", label: "Open" }, + { value: "done", label: "Done" }, + ]), + getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]), + isCompletedStatus: jest.fn((status: string) => status === "done"), + }, + priorityManager: { + getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]), + getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]), + }, + taskService: { + toggleRecurringTaskSkipped: jest.fn(), + updateBlockingRelationships: jest.fn(), + }, + cacheManager: { + getAllContexts: jest.fn(() => []), + getAllTasks: jest.fn(() => []), + getTaskInfo: jest.fn(), + }, + updateTaskProperty: jest.fn(), + toggleRecurringTaskComplete: jest.fn(), + getActiveTimeSession: jest.fn(() => null), + stopTimeTracking: jest.fn(), + startTimeTracking: jest.fn(), + openDueDateModal: jest.fn(), + openScheduledDateModal: jest.fn(), + openTimeEntryEditor: jest.fn(), + toggleTaskArchive: jest.fn(), + openTaskEditModal: jest.fn(), + openTaskCreationModal: jest.fn(), + } as unknown as TaskNotesPlugin; +} + +function findTopLevelMenuItem(title: string): Record | undefined { + const topLevelMenu = menuMock.mock.results[0].value as MockMenu; + return topLevelMenu.items.find( + (item): item is Record => + !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title + ); +} + +describe("U1: occurrenceDate context field", () => { + beforeEach(() => { + jest.useFakeTimers(); + menuMock.mockClear(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + menuMock.mockClear(); + }); + + it("is optional — existing callers that omit it still build a menu", () => { + expect( + () => + new TaskContextMenu({ + task: createRecurringTask(), + plugin: createPlugin(), + targetDate: new Date("2026-06-06T12:00:00"), + }) + ).not.toThrow(); + + expect(findTopLevelMenuItem("Skip instance")).toBeDefined(); + }); + + it("reads occurrenceDate (not targetDate) to decide the skip/unskip label", () => { + // The already-skipped date matches occurrenceDate but NOT targetDate. + const occurrence = new Date("2026-06-09T00:00:00Z"); + const task = createRecurringTask({ + skipped_instances: [formatDateForStorage(occurrence)], + }); + + new TaskContextMenu({ + task, + plugin: createPlugin(), + // A different, unrelated targetDate — if the label inferred from + // targetDate it would (wrongly) show "Skip instance". + targetDate: new Date("2026-06-06T12:00:00"), + occurrenceDate: occurrence, + }); + + expect(findTopLevelMenuItem("Unskip instance")).toBeDefined(); + expect(findTopLevelMenuItem("Skip instance")).toBeUndefined(); + }); + + it("showTaskContextMenu forwards occurrenceDate into the built menu", async () => { + // Proves the U1 plumbing: any occurrence-addressed producer routing through + // showTaskContextMenu has its occurrenceDate honored. The already-skipped + // date matches the forwarded occurrenceDate but not the targetDate, so a + // forwarded field is the only way the menu shows "Unskip instance". + const occurrence = new Date("2026-06-09T00:00:00Z"); + const task = createRecurringTask({ + skipped_instances: [formatDateForStorage(occurrence)], + }); + const plugin = createPlugin(); + plugin.cacheManager.getTaskInfo = jest.fn(async () => task); + + await showTaskContextMenu( + new MouseEvent("contextmenu"), + task.path, + plugin, + new Date("2026-06-06T12:00:00"), // unrelated targetDate + { occurrenceDate: occurrence } + ); + + expect(findTopLevelMenuItem("Unskip instance")).toBeDefined(); + expect(findTopLevelMenuItem("Skip instance")).toBeUndefined(); + }); +}); diff --git a/tests/unit/components/taskContextMenu.skip.test.ts b/tests/unit/components/taskContextMenu.skip.test.ts new file mode 100644 index 00000000..02f9d58b --- /dev/null +++ b/tests/unit/components/taskContextMenu.skip.test.ts @@ -0,0 +1,246 @@ +import { App, Menu } from "obsidian"; +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { getRecurringTaskActionDate } from "../../../src/services/task-service/taskRecurringPlanning"; +import { createI18nService } from "../../../src/i18n"; +import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +/** + * U2 — "Skip this instance" records the occurrence date, never the card's + * view-wide "today". Covers AE4: + * - List/Kanban skip (no occurrenceDate) → the service resolves the + * anchor-aware date (scheduled for scheduled-anchored recurrences). + * - Calendar skip (occurrenceDate present) → the clicked occurrence. + * - Completion-anchored skip → still today (unchanged behavior). + * - Unskip toggles the same recorded date back out. + */ + +type MockMenuItem = Record | { type: string }; +type MockMenu = { items: MockMenuItem[] }; + +const menuMock = Menu as unknown as jest.Mock; + +function createRecurringTask(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/recurring.md", + path: "Tasks/recurring.md", + title: "Recurring task", + status: "open", + priority: "normal", + recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", + recurrence_anchor: "scheduled", + scheduled: "2026-06-02", + complete_instances: [], + skipped_instances: [], + ...overrides, + } as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: createI18nService(), + settings: { + customStatuses: [ + { value: "open", label: "Open", order: 0 }, + { value: "done", label: "Done", order: 1 }, + ], + customPriorities: [{ value: "normal", label: "Normal", weight: 0 }], + calendarViewSettings: { enableTimeblocking: false }, + useFrontmatterMarkdownLinks: true, + }, + statusManager: { + getAllStatuses: jest.fn(() => [ + { value: "open", label: "Open" }, + { value: "done", label: "Done" }, + ]), + getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]), + isCompletedStatus: jest.fn((status: string) => status === "done"), + }, + priorityManager: { + getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]), + getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]), + }, + taskService: { + toggleRecurringTaskSkipped: jest.fn(), + updateBlockingRelationships: jest.fn(), + }, + cacheManager: { + getAllContexts: jest.fn(() => []), + getAllTasks: jest.fn(() => []), + getTaskInfo: jest.fn(), + }, + updateTaskProperty: jest.fn(), + toggleRecurringTaskComplete: jest.fn(), + getActiveTimeSession: jest.fn(() => null), + stopTimeTracking: jest.fn(), + startTimeTracking: jest.fn(), + openDueDateModal: jest.fn(), + openScheduledDateModal: jest.fn(), + openTimeEntryEditor: jest.fn(), + toggleTaskArchive: jest.fn(), + openTaskEditModal: jest.fn(), + openTaskCreationModal: jest.fn(), + } as unknown as TaskNotesPlugin; +} + +function findTopLevelMenuItem(title: string): Record | undefined { + const topLevelMenu = menuMock.mock.results[0].value as MockMenu; + return topLevelMenu.items.find( + (item): item is Record => + !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title + ); +} + +describe("U2: Skip this instance records the occurrence date", () => { + beforeEach(() => { + jest.useFakeTimers(); + // Freeze at midday UTC so "today" resolves deterministically regardless of + // the runner's timezone (getTodayLocal vs. new Date() agree within +/-12h). + jest.setSystemTime(new Date("2026-06-06T12:00:00Z")); + menuMock.mockClear(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + menuMock.mockClear(); + }); + + it("List/Kanban skip passes no explicit date so the service resolves the scheduled occurrence, not today", async () => { + const task = createRecurringTask(); + const plugin = createPlugin(); + + // List/Kanban open the menu with a view-wide "today" targetDate and NO + // occurrenceDate. The old behavior forwarded that today to the service. + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-06T12:00:00"), // a Saturday review, not the Tuesday occurrence + }); + + const skipItem = findTopLevelMenuItem("Skip instance"); + await skipItem?.onClick.mock.calls[0]?.[0](); + + // The fix: pass undefined so getRecurringTaskActionDate resolves anchor-aware. + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined); + expect(plugin.taskService.toggleRecurringTaskSkipped).not.toHaveBeenCalledWith( + task, + expect.any(Date) + ); + + // And that anchor-aware resolution records the scheduled Tuesday, not today. + const resolved = getRecurringTaskActionDate(task, undefined); + expect(formatDateForStorage(resolved)).toBe("2026-06-02"); + }); + + it("Calendar skip records the clicked occurrence via occurrenceDate", async () => { + const task = createRecurringTask(); + const plugin = createPlugin(); + const clickedOccurrence = new Date("2026-06-09T00:00:00Z"); // next Tuesday + + new TaskContextMenu({ + task, + plugin, + targetDate: clickedOccurrence, + occurrenceDate: clickedOccurrence, + }); + + const skipItem = findTopLevelMenuItem("Skip instance"); + await skipItem?.onClick.mock.calls[0]?.[0](); + + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith( + task, + clickedOccurrence + ); + }); + + it("completion-anchored skip still records today (unchanged)", async () => { + const task = createRecurringTask({ + recurrence_anchor: "completion", + scheduled: "2026-06-02", + }); + const plugin = createPlugin(); + + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-06T12:00:00"), + }); + + const skipItem = findTopLevelMenuItem("Skip instance"); + await skipItem?.onClick.mock.calls[0]?.[0](); + + // No explicit date passed; the service's anchor-aware default returns today + // for completion-anchored recurrences. + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined); + const resolved = getRecurringTaskActionDate(task, undefined); + expect(formatDateForStorage(resolved)).toBe(getTodayString()); // today, not scheduled + }); + + it("List/Kanban shows Unskip once the resolved scheduled date is already skipped, and toggles it out", async () => { + // The label corollary of the fix: skip records the scheduled date, so the + // card must offer "Unskip" for that same date even with NO occurrenceDate. + const task = createRecurringTask({ skipped_instances: ["2026-06-02"] }); + const plugin = createPlugin(); + + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-06T12:00:00"), // view-wide today, not the occurrence + }); + + expect(findTopLevelMenuItem("Unskip instance")).toBeDefined(); + expect(findTopLevelMenuItem("Skip instance")).toBeUndefined(); + + const unskipItem = findTopLevelMenuItem("Unskip instance"); + await unskipItem?.onClick.mock.calls[0]?.[0](); + // Still no explicit date: the service re-resolves the same scheduled date on the fresh task. + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined); + }); + + it("due-only recurring skip resolves to today (no scheduled anchor)", async () => { + // recurrence + due, no scheduled: getRecurringTaskActionDate falls through to today. + const task = createRecurringTask({ due: "2026-06-02" }); + delete (task as Partial).scheduled; + const plugin = createPlugin(); + + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-06T12:00:00"), + }); + + const skipItem = findTopLevelMenuItem("Skip instance"); + await skipItem?.onClick.mock.calls[0]?.[0](); + + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined); + expect(formatDateForStorage(getRecurringTaskActionDate(task, undefined))).toBe(getTodayString()); + }); + + it("shows Unskip for a Calendar occurrence that is already skipped and toggles the same date out", async () => { + const clickedOccurrence = new Date("2026-06-09T00:00:00Z"); + const task = createRecurringTask({ + skipped_instances: [formatDateForStorage(clickedOccurrence)], + }); + const plugin = createPlugin(); + + new TaskContextMenu({ + task, + plugin, + targetDate: clickedOccurrence, + occurrenceDate: clickedOccurrence, + }); + + const unskipItem = findTopLevelMenuItem("Unskip instance"); + expect(unskipItem).toBeDefined(); + + await unskipItem?.onClick.mock.calls[0]?.[0](); + expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith( + task, + clickedOccurrence + ); + }); +});