Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bases/CalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2753,6 +2753,7 @@ export class CalendarView extends BasesViewBase {
task: taskInfo,
plugin: this.plugin,
targetDate: targetDate,
occurrenceDate: taskInfo.recurrence ? targetDate : undefined,
promoteOccurrenceControls: Boolean(
taskInfo.recurrence ||
(taskInfo.recurrence_parent && taskInfo.occurrence_date)
Expand Down
14 changes: 9 additions & 5 deletions src/components/TaskContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -142,6 +143,8 @@ export interface TaskContextMenuOptions {
task: TaskInfo;
plugin: TaskNotesPlugin;
targetDate: Date;
/** The clicked occurrence (Calendar); omit to let the service's anchor-aware default resolve the date. */
occurrenceDate?: Date;
onUpdate?: () => void;
promoteOccurrenceControls?: boolean;
}
Expand Down Expand Up @@ -888,7 +891,11 @@ export class TaskContextMenu {
});
});

const isSkippedForDate = task.skipped_instances?.includes(dateStr) || false;
// Not routed through the four-mode resolver: that would drop the completion-anchor guard.
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(
Expand All @@ -899,10 +906,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);
Expand Down
3 changes: 2 additions & 1 deletion src/ui/taskCardContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export async function showTaskContextMenu(
taskPath: string,
plugin: TaskNotesPlugin,
targetDate: Date,
options: { promoteOccurrenceControls?: boolean } = {}
options: { promoteOccurrenceControls?: boolean; occurrenceDate?: Date } = {}
): Promise<void> {
const file = plugin.app.vault.getAbstractFileByPath(taskPath);
const showFileMenuFallback = () => {
Expand All @@ -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");
Expand Down
152 changes: 152 additions & 0 deletions tests/unit/components/taskContextMenu.occurrenceContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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";

type MockMenuItem = Record<string, jest.Mock> | { type: string };
type MockMenu = { items: MockMenuItem[] };

const menuMock = Menu as unknown as jest.Mock;

function createRecurringTask(overrides: Partial<TaskInfo> = {}): 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<string, jest.Mock> | undefined {
const topLevelMenu = menuMock.mock.results[0].value as MockMenu;
return topLevelMenu.items.find(
(item): item is Record<string, jest.Mock> =>
!("type" in item) && item.setTitle.mock.calls[0]?.[0] === title
);
}

describe("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 () => {
// Skipped date matches occurrenceDate but not targetDate, so "Unskip" proves forwarding.
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();
});
});
Loading
Loading