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
6 changes: 6 additions & 0 deletions src/bases/CalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 22 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,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;
}
Expand Down Expand Up @@ -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(
Expand All @@ -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);
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
163 changes: 163 additions & 0 deletions tests/unit/components/taskContextMenu.occurrenceContext.test.ts
Original file line number Diff line number Diff line change
@@ -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<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("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();
});
});
Loading