From a73781c2cded83c4a9fcdc00a54770c95d87f720 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 15:38:34 +1200 Subject: [PATCH 01/10] feat(completion): add four-mode completion-date resolver U3: resolveCompletionDate(task, mode, ctx) maps today/asScheduled/onDue/onPicked to a concrete date, reporting unavailability (via i18n reason key) instead of substituting a fallback so the menu can disable an option. asScheduled prefers the clicked occurrence and never falls through to due; completion-anchored recurrences resolve asScheduled to scheduled (explicit re-anchor). --- src/ui/completionDateResolver.ts | 86 ++++++++++++ tests/unit/ui/completionDateResolver.test.ts | 140 +++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/ui/completionDateResolver.ts create mode 100644 tests/unit/ui/completionDateResolver.test.ts diff --git a/src/ui/completionDateResolver.ts b/src/ui/completionDateResolver.ts new file mode 100644 index 00000000..c2a92cbc --- /dev/null +++ b/src/ui/completionDateResolver.ts @@ -0,0 +1,86 @@ +import type { TaskInfo } from "../types"; +import { + createUTCDateFromLocalCalendarDate, + getDatePart, + getTodayLocal, + parseDateToUTC, +} from "../utils/dateUtils"; + +/** + * The four completion "modes" offered by the task context menu. Modes are a UI + * affordance only — each resolves to a concrete date here, then the caller + * dispatches the existing completion mechanism (recurring toggle or a + * non-recurring status change). No service method learns about "modes". + */ +export type CompletionMode = "today" | "asScheduled" | "onDue" | "onPicked"; + +export interface CompletionDateContext { + /** + * The clicked recurring occurrence, when the producing view addresses a + * concrete occurrence (Calendar). Takes precedence over the task's own + * `scheduled` for `asScheduled`. + */ + occurrenceDate?: Date; + /** The date/time chosen via the "Complete on…" picker (`onPicked` only). */ + pickedDate?: Date; +} + +/** + * Discriminated result. `available: false` reports *why* the mode cannot resolve + * (via an i18n key) rather than silently substituting a fallback date, so the + * menu can render the item disabled with a reason tooltip (R4). + */ +export type CompletionDateResolution = + | { available: true; date: Date } + | { available: false; reasonKey: string }; + +function toUtcDay(dateString: string | undefined): Date | null { + if (!dateString) { + return null; + } + const datePart = getDatePart(dateString); + if (!datePart) { + return null; + } + return parseDateToUTC(datePart); +} + +/** + * Resolve one completion mode to a concrete date (KTD1): + * - `today` -> today (UTC-anchored, matching the recurring action path) + * - `asScheduled` -> the clicked occurrence, else the task's scheduled date; + * never falls through to `due` (R4) + * - `onDue` -> the task's due date + * - `onPicked` -> the user-picked date/time + * + * `asScheduled`/`onDue` report unavailable when their source is absent so the + * caller disables the option instead of collapsing it onto another date. For a + * completion-anchored recurring task, `asScheduled` deliberately resolves to + * `scheduled` (an explicit re-anchor), distinct from the skip/default path. + */ +export function resolveCompletionDate( + task: TaskInfo, + mode: CompletionMode, + ctx: CompletionDateContext = {} +): CompletionDateResolution { + switch (mode) { + case "today": + return { available: true, date: createUTCDateFromLocalCalendarDate(getTodayLocal()) }; + case "asScheduled": { + const date = ctx.occurrenceDate ?? toUtcDay(task.scheduled); + return date + ? { available: true, date } + : { available: false, reasonKey: "contextMenus.task.completion.noScheduledDate" }; + } + case "onDue": { + const date = toUtcDay(task.due); + return date + ? { available: true, date } + : { available: false, reasonKey: "contextMenus.task.completion.noDueDate" }; + } + case "onPicked": + return ctx.pickedDate + ? { available: true, date: ctx.pickedDate } + : { available: false, reasonKey: "contextMenus.task.completion.noPickedDate" }; + } +} diff --git a/tests/unit/ui/completionDateResolver.test.ts b/tests/unit/ui/completionDateResolver.test.ts new file mode 100644 index 00000000..ae418ed5 --- /dev/null +++ b/tests/unit/ui/completionDateResolver.test.ts @@ -0,0 +1,140 @@ +import { resolveCompletionDate } from "../../../src/ui/completionDateResolver"; +import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils"; +import type { TaskInfo } from "../../../src/types"; + +/** + * U3 — the four-mode completion-date resolver (KTD1). Modes resolve to a concrete + * date; `asScheduled`/`onDue` report unavailable (never silently substitute) + * when their source is absent, so the menu can disable the option (R4). + */ + +function task(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/t.md", + path: "Tasks/t.md", + title: "T", + status: "open", + priority: "normal", + ...overrides, + } as TaskInfo; +} + +describe("U3: resolveCompletionDate", () => { + describe("today", () => { + it("resolves to today for recurring and non-recurring", () => { + const nonRecurring = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "today"); + const recurring = resolveCompletionDate( + task({ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", scheduled: "2026-06-02" }), + "today" + ); + expect(nonRecurring.available).toBe(true); + expect(recurring.available).toBe(true); + if (nonRecurring.available) { + expect(formatDateForStorage(nonRecurring.date)).toBe(getTodayString()); + } + }); + }); + + describe("asScheduled", () => { + it("resolves to the task scheduled date when present", () => { + const result = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "asScheduled"); + expect(result).toEqual({ available: true, date: expect.any(Date) }); + if (result.available) { + expect(formatDateForStorage(result.date)).toBe("2026-06-02"); + } + }); + + it("prefers the clicked occurrence over the task scheduled date", () => { + const occurrenceDate = new Date("2026-06-09T00:00:00Z"); + const result = resolveCompletionDate( + task({ recurrence: "x", scheduled: "2026-06-02" }), + "asScheduled", + { occurrenceDate } + ); + if (result.available) { + expect(formatDateForStorage(result.date)).toBe("2026-06-09"); + } else { + throw new Error("expected available"); + } + }); + + it("is unavailable when there is no scheduled date (including due-only recurring)", () => { + const nonRecurring = resolveCompletionDate(task({ due: "2026-06-02" }), "asScheduled"); + const dueOnlyRecurring = resolveCompletionDate( + task({ recurrence: "x", due: "2026-06-02" }), + "asScheduled" + ); + expect(nonRecurring).toEqual({ + available: false, + reasonKey: "contextMenus.task.completion.noScheduledDate", + }); + expect(dueOnlyRecurring.available).toBe(false); + }); + + it("never falls through to due", () => { + const result = resolveCompletionDate(task({ due: "2026-06-30" }), "asScheduled"); + expect(result.available).toBe(false); + }); + + it("resolves to scheduled for a completion-anchored recurrence (explicit re-anchor)", () => { + const result = resolveCompletionDate( + task({ + recurrence: "x", + recurrence_anchor: "completion", + scheduled: "2026-06-02", + }), + "asScheduled" + ); + if (result.available) { + expect(formatDateForStorage(result.date)).toBe("2026-06-02"); + } else { + throw new Error("expected available"); + } + }); + }); + + describe("onDue", () => { + it("resolves to the due date when present", () => { + const result = resolveCompletionDate(task({ due: "2026-06-30" }), "onDue"); + if (result.available) { + expect(formatDateForStorage(result.date)).toBe("2026-06-30"); + } else { + throw new Error("expected available"); + } + }); + + it("is unavailable when there is no due date", () => { + const result = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "onDue"); + expect(result).toEqual({ + available: false, + reasonKey: "contextMenus.task.completion.noDueDate", + }); + }); + }); + + it("asScheduled and onDue never resolve to the same date when both exist", () => { + const t = task({ scheduled: "2026-06-02", due: "2026-06-30" }); + const scheduled = resolveCompletionDate(t, "asScheduled"); + const due = resolveCompletionDate(t, "onDue"); + expect(scheduled.available && due.available).toBe(true); + if (scheduled.available && due.available) { + expect(formatDateForStorage(scheduled.date)).not.toBe(formatDateForStorage(due.date)); + } + }); + + describe("onPicked", () => { + it("returns the picked date, preserving time", () => { + const pickedDate = new Date("2026-05-20T14:30:00Z"); + const result = resolveCompletionDate(task(), "onPicked", { pickedDate }); + expect(result).toEqual({ available: true, date: pickedDate }); + }); + + it("is unavailable when no date was picked", () => { + const result = resolveCompletionDate(task(), "onPicked"); + expect(result).toEqual({ + available: false, + reasonKey: "contextMenus.task.completion.noPickedDate", + }); + }); + }); +}); From e46e7e31eaca29fdc8ecc4214976fb0c35c0815a Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 15:40:36 +1200 Subject: [PATCH 02/10] feat(completion): thread optional completion date through status pipeline U4: updateProperty/updateTaskProperty accept an optional completionDate that overrides the getCompletionDateForTask default (task.occurrence_date || today) for non-recurring status completions. Parameter is optional so all existing status writes and the status-icon path are unchanged; recurring tasks remain unaffected by the existing property===status && !recurring guard. External HTTP/CLI/MCP exposure deferred (Scope Boundaries). --- src/main.ts | 2 +- src/services/TaskService.ts | 14 +++++-- tests/unit/services/TaskService.test.ts | 49 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index d10796aa..f5639f77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1085,7 +1085,7 @@ export default class TaskNotesPlugin extends Plugin { task: TaskInfo, property: keyof TaskInfo, value: TaskInfo[keyof TaskInfo], - options: { silent?: boolean } = {} + options: { silent?: boolean; completionDate?: string } = {} ): Promise { try { const updatedTask = await this.taskService.updateProperty( diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 533b325c..272cc630 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -566,7 +566,7 @@ export class TaskService { task: TaskInfo, property: keyof TaskInfo, value: unknown, - options: { silent?: boolean } = {} + options: { silent?: boolean; completionDate?: string } = {} ): Promise { try { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); @@ -577,13 +577,21 @@ export class TaskService { // Get fresh task data to prevent overwrites const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task; + // A status completion may carry an explicit completion date (context-menu + // "Complete on…/as scheduled/on due"); otherwise fall back to the default + // (task.occurrence_date || today). Only meaningful for non-recurring + // status completions — the frontmatter write is guarded by + // `property === "status" && !recurring`. + const completionDateString = + options.completionDate ?? this.getCompletionDateForTask(freshTask); + // Step 1: Construct new state in memory using fresh data const updatePlan = buildTaskPropertyUpdatePlan({ freshTask, property, value, currentTimestamp: getCurrentTimestamp(), - currentDateString: this.getCompletionDateForTask(freshTask), + currentDateString: completionDateString, normalizeStatusValue: (candidate) => this.normalizeStatusValue(candidate), isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), }); @@ -621,7 +629,7 @@ export class TaskService { normalizeStatusValue: (candidate) => this.normalizeStatusValue(candidate), isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), - currentDateString: this.getCompletionDateForTask(freshTask), + currentDateString: completionDateString, }); this.writeOptionalFrontmatterField( diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index e50f126b..d46d4dc3 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -838,6 +838,55 @@ describe('TaskService', () => { expect(result.completedDate).toBeUndefined(); }); + // U4: an explicit completion date threads through the status pipeline. + it('should record an explicit completion date on a non-recurring status completion', async () => { + const nonRecurringTask = TaskFactory.createTask({ recurrence: undefined }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurringTask); + + const result = await taskService.updateProperty(nonRecurringTask, 'status', 'done', { + completionDate: '2025-05-20', + }); + + expect(result.status).toBe('done'); + expect(result.completedDate).toBe('2025-05-20'); + }); + + it('should record a chosen completion date for an undated non-recurring task', async () => { + const undatedTask = TaskFactory.createTask({ + recurrence: undefined, + scheduled: undefined, + due: undefined, + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(undatedTask); + + const result = await taskService.updateProperty(undatedTask, 'status', 'done', { + completionDate: '2025-04-15', + }); + + expect(result.status).toBe('done'); + expect(result.completedDate).toBe('2025-04-15'); + }); + + it('should default the completion date to today when none is provided', async () => { + const nonRecurringTask = TaskFactory.createTask({ recurrence: undefined }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurringTask); + + const result = await taskService.updateProperty(nonRecurringTask, 'status', 'done'); + + expect(result.completedDate).toBe('2025-01-01'); // mocked today, unchanged + }); + + it('should ignore an explicit completion date for recurring tasks', async () => { + const recurringTask = TaskFactory.createTask({ recurrence: 'FREQ=DAILY' }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty(recurringTask, 'status', 'done', { + completionDate: '2025-05-20', + }); + + expect(result.completedDate).toBeUndefined(); + }); + it('should remove empty due/scheduled dates', async () => { await taskService.updateProperty(task, 'due', undefined); From 8bc4f1326c2dbb00b88f7736fd2e4416a01f42cf Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 15:53:50 +1200 Subject: [PATCH 03/10] feat(completion): explicit completion menu actions with date control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U5: replace the single recurring 'Mark complete for this date' item with four explicit actions (Complete today / as scheduled / on due date / on…), and add them net-new for non-recurring tasks. Each resolves via the four-mode resolver and dispatches the native mechanism: recurring -> toggleRecurringTaskComplete; non-recurring -> a status change carrying the completion date. Unavailable modes render disabled with a reason tooltip (R4). Un-complete is per task type (R11): recurring toggles the resolved date out per action; a completed non-recurring task collapses to one 'Mark incomplete' that reverts to the default status. Recurring labels carry an (Instance) prefix. U6: 'Complete on…' opens the shared date picker and records the chosen date (date-only, matching completedDate/complete_instances storage); cancel is a no-op. Adds contextMenus.task.completion i18n keys (en); other locales fall back to English pending translation (tracked in i18n.state.json). --- i18n.manifest.json | 11 + i18n.state.json | 88 +++++++ src/components/TaskContextMenu.ts | 230 ++++++++++++++-- src/i18n/resources/en.ts | 13 + .../taskContextMenu.complete.test.ts | 245 ++++++++++++++++++ .../taskContextMenu.pickDate.test.ts | 145 +++++++++++ ...724-recurring-actions-date-section.test.ts | 13 +- 7 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 tests/unit/components/taskContextMenu.complete.test.ts create mode 100644 tests/unit/components/taskContextMenu.pickDate.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbe..68957567 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1841,6 +1841,17 @@ "contextMenus.task.markIncomplete": "8d0d3b84c6c7d795e205c345054f9335ae222c21", "contextMenus.task.skipInstance": "ebb18d68bb33bb3188ca558e22656fa8f4cb7e95", "contextMenus.task.unskipInstance": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", + "contextMenus.task.completion.completeToday": "cb7dca935d78097829e74f81bae75e5f00a2eb8c", + "contextMenus.task.completion.completeAsScheduled": "cda4bd3468ff894c546e88928e3218b2a4baab9d", + "contextMenus.task.completion.completeOnDue": "1c54e47df71ce00db1ec20508491fb3a96ac3676", + "contextMenus.task.completion.completeOnPicked": "03dec1227ec4bc22922c3b627555c953f9536fe4", + "contextMenus.task.completion.markIncomplete": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "contextMenus.task.completion.instancePrefix": "01163bdc578e28a373b67e18af3f35d5f1b84948", + "contextMenus.task.completion.noScheduledDate": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "contextMenus.task.completion.noDueDate": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "contextMenus.task.completion.noPickedDate": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "contextMenus.task.completion.noCompletedStatus": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "contextMenus.task.completion.pickDateTitle": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", "contextMenus.task.quickReminders.atTime": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "contextMenus.task.quickReminders.fiveMinutes": "c777f20872b577e3164c1b6d3f4f50d70faa8e93", "contextMenus.task.quickReminders.fifteenMinutes": "4bba5a72574fef683d773d45198acbf9a56d8961", diff --git a/i18n.state.json b/i18n.state.json index 01d90a50..79a1a033 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7368,6 +7368,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dc50f18bbaa3249131904a53adc71400e7ebcd31" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "13edcc9c4ebe474df17518c264115a00ddf30433" @@ -16234,6 +16245,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "968673d261b5844d3efa0738be3c13af2077c0d9" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "2838cecba7368421d90e0f07c0d0f1e28830ffa4" @@ -25100,6 +25122,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dddefd2443584c9cf6331b468a73c21e6fe8556e" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "c6fa1b8853d66e5ede8d309bb9f6d82dc4f10657" @@ -33966,6 +33999,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "7cd7d70058059bb1602bce7e2c25a6edaf8ef029" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7fdead3a6d8aaccda120699da7e45e369edc108c" @@ -42832,6 +42876,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "eff53181b011d57fdf7dfb4617f7807573a53d23" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "ee19ba1d573cbe4fb6cbbab4cb59e86260dd132a" @@ -51698,6 +51753,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5c886f6f62aeb8f203759a44e10a075079e1555e" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "a2936a5b99f3ba9aef0ef806a85097d30db91392" @@ -60564,6 +60630,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "4b21cae430620be61f61dc2db0e6dc1ed867b28d" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "0a461c2b22896eac59c259415fdbbe96b207b9bc" @@ -69430,6 +69507,17 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5d08dfcf44803b1604301a2e36d0eb317fa81ca2" }, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.instancePrefix": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7aaeb02feb12bd8334344d70a17e16b367174054" diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 6f49b2b9..e444ae9c 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1,8 +1,13 @@ -import { Menu, Notice, Platform, TFile, type MenuItem, type TAbstractFile } from "obsidian"; +import { Menu, Notice, Platform, TFile, setTooltip, type MenuItem, type TAbstractFile } from "obsidian"; import type { OccurrenceMaterializationMode, OccurrenceNextTrigger } from "@tasknotes/model"; import TaskNotesPlugin from "../main"; import { TaskDependency, TaskInfo } from "../types"; -import { formatDateForStorage } from "../utils/dateUtils"; +import { formatDateForStorage, getDatePart, parseDateToUTC } from "../utils/dateUtils"; +import { + resolveCompletionDate, + type CompletionDateContext, + type CompletionMode, +} from "../ui/completionDateResolver"; import { ReminderModal } from "../modals/ReminderModal"; import { addTaskToProject, @@ -280,6 +285,8 @@ export class TaskContextMenu { this.addCustomDateFieldMenuItems(task, plugin); + this.addCompletionMenuItems(task, plugin); + if (task.recurrence) { this.addRecurringInstanceMenuItems(task, plugin); } @@ -866,37 +873,208 @@ export class TaskContextMenu { }); } - private addRecurringInstanceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { - const dateStr = formatDateForStorage(this.options.targetDate); - const isCompletedForDate = task.complete_instances?.includes(dateStr) || false; + private instanceLabel(label: string, isRecurring: boolean): string { + return isRecurring + ? this.t("contextMenus.task.completion.instancePrefix", { label }) + : label; + } + + /** + * Render the four explicit completion actions (Complete today / as scheduled / + * on due date / on…) for every task, with the un-complete affordance indexed + * per task type (R11): recurring completeness is per-date (each action toggles + * its own resolved date); non-recurring completeness is a single status (all + * four collapse to one "Mark incomplete"). + */ + private addCompletionMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { + const isRecurring = !!task.recurrence; + + if (!isRecurring && plugin.statusManager.isCompletedStatus(task.status)) { + this.menu.addItem((item) => { + item.setTitle(this.t("contextMenus.task.completion.markIncomplete")); + item.setIcon("x"); + item.onClick(async () => { + await this.uncompleteNonRecurringTask(task, plugin); + }); + }); + return; + } + + this.addConcreteCompletionAction( + task, + plugin, + "today", + "contextMenus.task.completion.completeToday" + ); + this.addConcreteCompletionAction( + task, + plugin, + "asScheduled", + "contextMenus.task.completion.completeAsScheduled" + ); + this.addConcreteCompletionAction( + task, + plugin, + "onDue", + "contextMenus.task.completion.completeOnDue" + ); + this.addPickCompletionDateAction(task, plugin); + } + + private addConcreteCompletionAction( + task: TaskInfo, + plugin: TaskNotesPlugin, + mode: CompletionMode, + baseLabelKey: string + ): void { + const isRecurring = !!task.recurrence; + const ctx: CompletionDateContext = { occurrenceDate: this.options.occurrenceDate }; + const resolution = resolveCompletionDate(task, mode, ctx); this.menu.addItem((item) => { + if (!resolution.available) { + // Shown disabled (not hidden) with a reason tooltip (R4). + item.setTitle(this.instanceLabel(this.t(baseLabelKey), isRecurring)); + item.setIcon("check"); + item.setDisabled(true); + const el = getMenuItemElement(item); + if (el) { + setTooltip(el, this.t(resolution.reasonKey)); + } + return; + } + + const resolvedDate = resolution.date; + // Recurring: this action independently reflects whether ITS resolved + // date is already recorded, and toggles that date out. + const alreadyComplete = + isRecurring && + (task.complete_instances?.includes(formatDateForStorage(resolvedDate)) ?? false); item.setTitle( - isCompletedForDate - ? this.t("contextMenus.task.markIncomplete") - : this.t("contextMenus.task.markComplete") + alreadyComplete + ? this.instanceLabel(this.t("contextMenus.task.completion.markIncomplete"), true) + : this.instanceLabel(this.t(baseLabelKey), isRecurring) ); - item.setIcon(isCompletedForDate ? "x" : "check"); + item.setIcon(alreadyComplete ? "x" : "check"); item.onClick(async () => { - try { - await plugin.toggleRecurringTaskComplete(task, this.options.targetDate); - this.options.onUpdate?.(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - tasknotesLogger.error("Error toggling recurring task completion:", { - category: "persistence", - operation: "toggling-recurring-task-completion", - details: { taskPath: task.path }, - error: errorMessage, - }); - new Notice( - this.t("contextMenus.task.notices.toggleCompletionFailure", { - message: errorMessage, - }) - ); - } + await this.dispatchCompletion(task, plugin, resolvedDate); }); }); + } + + private addPickCompletionDateAction(task: TaskInfo, plugin: TaskNotesPlugin): void { + const isRecurring = !!task.recurrence; + this.menu.addItem((item) => { + item.setTitle( + this.instanceLabel( + this.t("contextMenus.task.completion.completeOnPicked"), + isRecurring + ) + ); + item.setIcon("calendar-plus"); + item.onClick(() => { + this.pickCompletionDate(task, plugin); + }); + }); + } + + /** + * U6: open the shared date picker and record completion against the chosen + * date. Completion is recorded date-only (both `completedDate` and + * `complete_instances` keys are date-only in storage), so the picker does not + * offer a time. Cancel is a no-op. + */ + private pickCompletionDate(task: TaskInfo, plugin: TaskNotesPlugin): void { + this.menu.hide(); + const modal = new DateTimePickerModal(plugin.app, { + currentDate: null, + title: this.t("contextMenus.task.completion.pickDateTitle"), + showTime: false, + plugin, + onSelect: (date) => { + if (!date) { + return; + } + const picked = parseDateToUTC(getDatePart(date)); + const resolution = resolveCompletionDate(task, "onPicked", { pickedDate: picked }); + if (!resolution.available) { + return; + } + void this.dispatchCompletion(task, plugin, resolution.date); + }, + }); + modal.open(); + } + + private async dispatchCompletion( + task: TaskInfo, + plugin: TaskNotesPlugin, + resolvedDate: Date + ): Promise { + try { + if (task.recurrence) { + // Recurring records into complete_instances via the date-accepting + // toggle; completion-anchored recurrences re-anchor DTSTART from the + // recorded date through the existing toggle behavior. + await plugin.toggleRecurringTaskComplete(task, resolvedDate); + } else { + const completedStatus = plugin.statusManager.getCompletedStatuses()[0]; + if (!completedStatus) { + new Notice(this.t("contextMenus.task.completion.noCompletedStatus")); + return; + } + // Non-recurring completion is a status transition carrying the date. + await plugin.updateTaskProperty(task, "status", completedStatus, { + completionDate: formatDateForStorage(resolvedDate), + }); + } + this.options.onUpdate?.(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + tasknotesLogger.error("Error completing task:", { + category: "persistence", + operation: "completing-task", + details: { taskPath: task.path }, + error: errorMessage, + }); + new Notice( + this.t("contextMenus.task.notices.toggleCompletionFailure", { + message: errorMessage, + }) + ); + } + } + + private async uncompleteNonRecurringTask( + task: TaskInfo, + plugin: TaskNotesPlugin + ): Promise { + try { + // Revert to the default open status; the status pipeline clears + // completedDate for a non-completed status (mirrors uncompleteTask). + const openStatus = plugin.settings.defaultTaskStatus ?? "open"; + await plugin.updateTaskProperty(task, "status", openStatus); + this.options.onUpdate?.(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + tasknotesLogger.error("Error marking task incomplete:", { + category: "persistence", + operation: "uncompleting-task", + details: { taskPath: task.path }, + error: errorMessage, + }); + new Notice( + this.t("contextMenus.task.notices.toggleCompletionFailure", { + message: errorMessage, + }) + ); + } + } + + private addRecurringInstanceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { + // The four explicit completion actions (which replaced the single + // "Mark complete for this date" item) are added for every task by + // addCompletionMenuItems. This method now only owns skip + occurrence note. // Skip records the occurrence, never the card's view-wide "today". When // the producing view supplies a concrete occurrence (Calendar) use it; diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b..55fd8e7f 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3038,6 +3038,19 @@ export const en: TranslationTree = { markIncomplete: "Mark incomplete for this date", skipInstance: "Skip instance", unskipInstance: "Unskip instance", + completion: { + completeToday: "Complete today", + completeAsScheduled: "Complete as scheduled", + completeOnDue: "Complete on due date", + completeOnPicked: "Complete on…", + markIncomplete: "Mark incomplete", + instancePrefix: "(Instance) {label}", + noScheduledDate: "No scheduled date on this task", + noDueDate: "No due date on this task", + noPickedDate: "No date selected", + noCompletedStatus: "No completed status configured", + pickDateTitle: "Complete on date", + }, quickReminders: { atTime: "At time of event", fiveMinutes: "5 minutes before", diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts new file mode 100644 index 00000000..d88dcdda --- /dev/null +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -0,0 +1,245 @@ +import { App, Menu } from "obsidian"; +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { createI18nService } from "../../../src/i18n"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +/** + * U5 — the four explicit completion actions (with un-complete) replace the single + * recurring "Mark complete for this date" item and are net-new for non-recurring + * tasks. Covers AE1, AE2, AE5, AE7. + */ + +type MockMenuItem = Record | { type: string }; +type MockMenu = { items: MockMenuItem[] }; + +const menuMock = Menu as unknown as jest.Mock; + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: createI18nService(), + settings: { + defaultTaskStatus: "open", + customStatuses: [ + { value: "open", label: "Open", order: 0 }, + { value: "done", label: "Done", order: 1, isCompleted: true }, + ], + 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" }]), + getCompletedStatuses: jest.fn(() => ["done"]), + 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 task(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/t.md", + path: "Tasks/t.md", + title: "T", + status: "open", + priority: "normal", + complete_instances: [], + skipped_instances: [], + ...overrides, + } as TaskInfo; +} + +function findItem(title: string): Record | undefined { + const menu = menuMock.mock.results[0].value as MockMenu; + return menu.items.find( + (item): item is Record => + !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title + ); +} + +function allTitles(): string[] { + const menu = menuMock.mock.results[0].value as MockMenu; + return menu.items + .filter((item): item is Record => !("type" in item)) + .map((item) => item.setTitle.mock.calls[0]?.[0]) + .filter((t): t is string => typeof t === "string"); +} + +describe("U5: completion menu items", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-06-15T12:00:00Z")); // not the scheduled/due dates below + menuMock.mockClear(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + menuMock.mockClear(); + }); + + describe("recurring", () => { + const recurring = (o: Partial = {}) => + task({ + recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", + recurrence_anchor: "scheduled", + scheduled: "2026-06-02", + ...o, + }); + + it("offers the four actions with an (Instance) prefix and removes the old single item", () => { + new TaskContextMenu({ + task: recurring({ due: "2026-06-30" }), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + expect(findItem("(Instance) Complete today")).toBeDefined(); + expect(findItem("(Instance) Complete as scheduled")).toBeDefined(); + expect(findItem("(Instance) Complete on due date")).toBeDefined(); + expect(findItem("(Instance) Complete on…")).toBeDefined(); + // old single item gone + expect(findItem("Mark complete for this date")).toBeUndefined(); + }); + + it("records the scheduled occurrence for 'Complete as scheduled' (AE1)", async () => { + const plugin = createPlugin(); + const t = recurring(); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("(Instance) Complete as scheduled")?.onClick.mock.calls[0]?.[0](); + + const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; + const { formatDateForStorage } = require("../../../src/utils/dateUtils"); + expect(formatDateForStorage(date)).toBe("2026-06-02"); + }); + + it("resolves 'Complete as scheduled' to scheduled for a completion-anchored recurrence (AE2 re-anchor)", async () => { + const plugin = createPlugin(); + const t = recurring({ recurrence_anchor: "completion", scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("(Instance) Complete as scheduled")?.onClick.mock.calls[0]?.[0](); + const { formatDateForStorage } = require("../../../src/utils/dateUtils"); + const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; + expect(formatDateForStorage(date)).toBe("2026-06-02"); + }); + + it("disables 'Complete on due date' with a reason when there is no due date (AE5)", () => { + new TaskContextMenu({ + task: recurring({ due: undefined }), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + const onDue = findItem("(Instance) Complete on due date"); + expect(onDue?.setDisabled).toHaveBeenCalledWith(true); + // enabled actions remain + expect(findItem("(Instance) Complete today")?.setDisabled).not.toHaveBeenCalledWith(true); + }); + + it("shows '(Instance) Mark incomplete' for an already-recorded instance and toggles it out (AE7)", async () => { + const plugin = createPlugin(); + const t = recurring({ complete_instances: ["2026-06-02"] }); + new TaskContextMenu({ + task: t, + plugin, + targetDate: new Date("2026-06-02T00:00:00Z"), + occurrenceDate: new Date("2026-06-02T00:00:00Z"), + }); + + // The asScheduled action (resolving 2026-06-02) collapses to Mark incomplete. + const incompleteItem = findItem("(Instance) Mark incomplete"); + expect(incompleteItem).toBeDefined(); + + await incompleteItem?.onClick.mock.calls[0]?.[0](); + expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalled(); + }); + }); + + describe("non-recurring", () => { + it("offers the four actions net-new WITHOUT an (Instance) prefix", () => { + new TaskContextMenu({ + task: task({ scheduled: "2026-06-02", due: "2026-06-30" }), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + expect(findItem("Complete today")).toBeDefined(); + expect(findItem("Complete as scheduled")).toBeDefined(); + expect(findItem("Complete on due date")).toBeDefined(); + expect(findItem("Complete on…")).toBeDefined(); + expect(allTitles().some((t) => t.startsWith("(Instance)"))).toBe(false); + }); + + it("dispatches a status change carrying the resolved completion date", async () => { + const plugin = createPlugin(); + const t = task({ scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Complete as scheduled")?.onClick.mock.calls[0]?.[0](); + + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { + completionDate: "2026-06-02", + }); + }); + + it("disables scheduled/due for an undated task but keeps today and pick enabled (AE5)", () => { + new TaskContextMenu({ + task: task(), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + expect(findItem("Complete as scheduled")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Complete on due date")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Complete today")?.setDisabled).not.toHaveBeenCalledWith(true); + expect(findItem("Complete on…")?.setDisabled).not.toHaveBeenCalledWith(true); + }); + + it("collapses to a single 'Mark incomplete' when already in a completed status and reverts to the default status (AE7)", async () => { + const plugin = createPlugin(); + const t = task({ status: "done", completedDate: "2026-06-02", scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + expect(findItem("Complete today")).toBeUndefined(); + expect(findItem("Complete as scheduled")).toBeUndefined(); + expect(findItem("Complete on…")).toBeUndefined(); + + const incomplete = findItem("Mark incomplete"); + expect(incomplete).toBeDefined(); + await incomplete?.onClick.mock.calls[0]?.[0](); + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "open"); + }); + }); +}); diff --git a/tests/unit/components/taskContextMenu.pickDate.test.ts b/tests/unit/components/taskContextMenu.pickDate.test.ts new file mode 100644 index 00000000..dffe00e4 --- /dev/null +++ b/tests/unit/components/taskContextMenu.pickDate.test.ts @@ -0,0 +1,145 @@ +import { App, Menu } from "obsidian"; + +// Capture the DateTimePickerModal options so the test can drive onSelect. +jest.mock("../../../src/modals/DateTimePickerModal", () => ({ + DateTimePickerModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })), +})); + +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal"; +import { createI18nService } from "../../../src/i18n"; +import { formatDateForStorage } from "../../../src/utils/dateUtils"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +/** + * U6 — "Complete on… (pick a date)" opens the shared picker and records the + * chosen date (recurring -> complete_instances; non-recurring -> status + + * completedDate). Cancel is a no-op. Covers AE3. + */ + +type MockMenuItem = Record | { type: string }; +type MockMenu = { items: MockMenuItem[] }; + +const menuMock = Menu as unknown as jest.Mock; +const modalMock = DateTimePickerModal as unknown as jest.Mock; + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: createI18nService(), + settings: { + defaultTaskStatus: "open", + customStatuses: [ + { value: "open", label: "Open", order: 0 }, + { value: "done", label: "Done", order: 1, isCompleted: true }, + ], + 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" }]), + getCompletedStatuses: jest.fn(() => ["done"]), + 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 task(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/t.md", + path: "Tasks/t.md", + title: "T", + status: "open", + priority: "normal", + complete_instances: [], + skipped_instances: [], + ...overrides, + } as TaskInfo; +} + +function findItem(title: string): Record | undefined { + const menu = menuMock.mock.results[0].value as MockMenu; + return menu.items.find( + (item): item is Record => + !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title + ); +} + +function getPickerOnSelect(): (date: string | null) => void { + return modalMock.mock.calls[modalMock.mock.calls.length - 1][1].onSelect; +} + +describe("U6: Complete on… date picker", () => { + beforeEach(() => { + menuMock.mockClear(); + modalMock.mockClear(); + }); + + it("records the picked date into complete_instances for a recurring task", async () => { + const plugin = createPlugin(); + const t = task({ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("(Instance) Complete on…")?.onClick.mock.calls[0]?.[0](); + expect(modalMock).toHaveBeenCalledTimes(1); + + await getPickerOnSelect()("2026-05-20"); + + expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalledTimes(1); + const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; + expect(formatDateForStorage(date)).toBe("2026-05-20"); + }); + + it("records the picked date as completedDate via a status change for a non-recurring task (AE3)", async () => { + const plugin = createPlugin(); + const t = task(); // undated + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Complete on…")?.onClick.mock.calls[0]?.[0](); + await getPickerOnSelect()("2026-05-20"); + + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { + completionDate: "2026-05-20", + }); + }); + + it("records nothing when the picker is cancelled", async () => { + const plugin = createPlugin(); + const t = task({ scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Complete on…")?.onClick.mock.calls[0]?.[0](); + await getPickerOnSelect()(null); + + expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); + expect(plugin.toggleRecurringTaskComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts index 7b25f848..394860d2 100644 --- a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts +++ b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts @@ -145,21 +145,26 @@ describe("Issue #1724: recurring task actions belong with date menu items", () = "Priority", "Due date", "Scheduled date", - "Mark complete for this date", + "(Instance) Complete today", + "(Instance) Complete on…", "Skip instance", "Open or create occurrence note", "Reminders", ]) ); + // The single "Mark complete for this date" item was replaced by the four + // explicit completion actions. + expect(titles).not.toContain("Mark complete for this date"); + expect(titles.indexOf("Status")).toBeLessThan(titles.indexOf("Priority")); expect(titles.indexOf("Priority")).toBeLessThan( - titles.indexOf("Mark complete for this date") + titles.indexOf("(Instance) Complete today") ); expect(titles.indexOf("Scheduled date")).toBeLessThan( - titles.indexOf("Mark complete for this date") + titles.indexOf("(Instance) Complete today") ); - expect(titles.indexOf("Mark complete for this date")).toBeLessThan( + expect(titles.indexOf("(Instance) Complete on…")).toBeLessThan( titles.indexOf("Skip instance") ); expect(titles.indexOf("Skip instance")).toBeLessThan( From 1cf16b837e860af97bf68af651b6672b2fc9bbd5 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 16:00:49 +1200 Subject: [PATCH 04/10] fix(review): neutral completion failure notice + close coverage gaps Non-recurring completion/un-completion now surface a neutral failure notice (the old key wrongly said 'recurring task'). Adds tests for the no-completed- status guard, recurring 'Complete on due date' dispatch, and non-recurring 'Complete today' dispatch. --- i18n.manifest.json | 1 + i18n.state.json | 8 +++++ src/components/TaskContextMenu.ts | 4 +-- src/i18n/resources/en.ts | 1 + .../taskContextMenu.complete.test.ts | 36 +++++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 68957567..e755ae0f 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1852,6 +1852,7 @@ "contextMenus.task.completion.noPickedDate": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", "contextMenus.task.completion.noCompletedStatus": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", "contextMenus.task.completion.pickDateTitle": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "contextMenus.task.completion.completeFailure": "2bd697dbae7139872c337782e64b31b0f78b4a25", "contextMenus.task.quickReminders.atTime": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "contextMenus.task.quickReminders.fiveMinutes": "c777f20872b577e3164c1b6d3f4f50d70faa8e93", "contextMenus.task.quickReminders.fifteenMinutes": "4bba5a72574fef683d773d45198acbf9a56d8961", diff --git a/i18n.state.json b/i18n.state.json index 79a1a033..df8321c0 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7379,6 +7379,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "13edcc9c4ebe474df17518c264115a00ddf30433" @@ -16256,6 +16257,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "2838cecba7368421d90e0f07c0d0f1e28830ffa4" @@ -25133,6 +25135,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "c6fa1b8853d66e5ede8d309bb9f6d82dc4f10657" @@ -34010,6 +34013,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7fdead3a6d8aaccda120699da7e45e369edc108c" @@ -42887,6 +42891,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "ee19ba1d573cbe4fb6cbbab4cb59e86260dd132a" @@ -51764,6 +51769,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "a2936a5b99f3ba9aef0ef806a85097d30db91392" @@ -60641,6 +60647,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "0a461c2b22896eac59c259415fdbbe96b207b9bc" @@ -69518,6 +69525,7 @@ "contextMenus.task.completion.noPickedDate": null, "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7aaeb02feb12bd8334344d70a17e16b367174054" diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index e444ae9c..dc86dc02 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1038,7 +1038,7 @@ export class TaskContextMenu { error: errorMessage, }); new Notice( - this.t("contextMenus.task.notices.toggleCompletionFailure", { + this.t("contextMenus.task.completion.completeFailure", { message: errorMessage, }) ); @@ -1064,7 +1064,7 @@ export class TaskContextMenu { error: errorMessage, }); new Notice( - this.t("contextMenus.task.notices.toggleCompletionFailure", { + this.t("contextMenus.task.completion.completeFailure", { message: errorMessage, }) ); diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 55fd8e7f..3c26d3c2 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3050,6 +3050,7 @@ export const en: TranslationTree = { noPickedDate: "No date selected", noCompletedStatus: "No completed status configured", pickDateTitle: "Complete on date", + completeFailure: "Failed to update task completion: {message}", }, quickReminders: { atTime: "At time of event", diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts index d88dcdda..804af3f9 100644 --- a/tests/unit/components/taskContextMenu.complete.test.ts +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -185,6 +185,18 @@ describe("U5: completion menu items", () => { await incompleteItem?.onClick.mock.calls[0]?.[0](); expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalled(); }); + + it("records the due date for 'Complete on due date'", async () => { + const plugin = createPlugin(); + const t = recurring({ due: "2026-06-30" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("(Instance) Complete on due date")?.onClick.mock.calls[0]?.[0](); + + const { formatDateForStorage } = require("../../../src/utils/dateUtils"); + const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; + expect(formatDateForStorage(date)).toBe("2026-06-30"); + }); }); describe("non-recurring", () => { @@ -214,6 +226,30 @@ describe("U5: completion menu items", () => { }); }); + it("records today for 'Complete today'", async () => { + const plugin = createPlugin(); + const t = task({ scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Complete today")?.onClick.mock.calls[0]?.[0](); + + const { getTodayString } = require("../../../src/utils/dateUtils"); + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { + completionDate: getTodayString(), + }); + }); + + it("shows a notice and does not dispatch when no completed status is configured", async () => { + const plugin = createPlugin(); + (plugin.statusManager.getCompletedStatuses as jest.Mock).mockReturnValue([]); + const t = task({ scheduled: "2026-06-02" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Complete today")?.onClick.mock.calls[0]?.[0](); + + expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); + }); + it("disables scheduled/due for an undated task but keeps today and pick enabled (AE5)", () => { new TaskContextMenu({ task: task(), From 031ebcc1b286e9bcc55531099324626fb58a66f0 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 16:10:49 +1200 Subject: [PATCH 05/10] test: pin Jest timezone to UTC to match CI Local runs in non-UTC timezones (e.g. Pacific/Auckland, UTC+13) fail date/ day-boundary tests that pass on CI (GitHub Actions runs in UTC). Set process.env.TZ='UTC' before the config is exported so Jest's worker processes inherit it and read the zone at startup, before any Date is constructed. Overridable via 'TZ=... jest' for timezone-specific testing. --- jest.config.js | 7 +++++++ jest.integration.config.js | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/jest.config.js b/jest.config.js index f7d44592..1f5a6c6a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,10 @@ +// Pin the test timezone to UTC so local runs match CI (GitHub Actions runs in +// UTC). Set here — before Jest spawns its worker processes — so each worker +// inherits TZ and reads it at startup, before any Date is constructed (V8 caches +// the zone on first use). Overridable with an explicit `TZ=... jest` for +// timezone-specific testing. +process.env.TZ = process.env.TZ || 'UTC'; + module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', diff --git a/jest.integration.config.js b/jest.integration.config.js index b14fbcf5..c23a9cc2 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -1,3 +1,8 @@ +// Pin the test timezone to UTC so local runs match CI (GitHub Actions runs in +// UTC). See jest.config.js for the rationale (set before workers spawn so each +// inherits TZ before any Date is constructed). Overridable via `TZ=... jest`. +process.env.TZ = process.env.TZ || 'UTC'; + module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', From 4a838a87925a27b0860ac3ee3ac31c7fc9c5f1b9 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 18:38:41 +1200 Subject: [PATCH 06/10] feat(completion): nest actions in a submenu, uniform labels, reschedule reactivates occurrence Post-review UX refinements: - Nest the four completion actions (and, for recurring, skip) under a single 'Complete or skip' submenu ('Complete' for non-recurring) to keep the top-level menu compact; the occurrence note stays top-level. - Uniform sentence-case labels for both task types (no per-type qualifier): Completed today / Completed on schedule / Completed on due date / Completed on (pick date); Skip instance; Mark incomplete. - Rescheduling a recurring task onto a date removes that date from complete_instances/skipped_instances, so moving the scheduled date back reactivates the occurrence without manually editing those lists. --- i18n.manifest.json | 11 +- i18n.state.json | 24 ++- src/components/TaskContextMenu.ts | 95 +++++----- src/i18n/resources/en.ts | 11 +- src/services/TaskService.ts | 41 +++++ .../taskContextMenu.complete.test.ts | 167 +++++++++++------- .../taskContextMenu.occurrenceContext.test.ts | 27 ++- .../taskContextMenu.pickDate.test.ts | 33 +++- .../components/taskContextMenu.skip.test.ts | 27 ++- ...724-recurring-actions-date-section.test.ts | 35 ++-- tests/unit/services/TaskService.test.ts | 74 ++++++++ 11 files changed, 384 insertions(+), 161 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index e755ae0f..22cd7544 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1841,12 +1841,13 @@ "contextMenus.task.markIncomplete": "8d0d3b84c6c7d795e205c345054f9335ae222c21", "contextMenus.task.skipInstance": "ebb18d68bb33bb3188ca558e22656fa8f4cb7e95", "contextMenus.task.unskipInstance": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", - "contextMenus.task.completion.completeToday": "cb7dca935d78097829e74f81bae75e5f00a2eb8c", - "contextMenus.task.completion.completeAsScheduled": "cda4bd3468ff894c546e88928e3218b2a4baab9d", - "contextMenus.task.completion.completeOnDue": "1c54e47df71ce00db1ec20508491fb3a96ac3676", - "contextMenus.task.completion.completeOnPicked": "03dec1227ec4bc22922c3b627555c953f9536fe4", + "contextMenus.task.completion.submenu": "267f880e67256da7fe01d7a30b2245b83f804b32", + "contextMenus.task.completion.submenuCompleteOnly": "1f5a1abf2f08ec18beb2664f458f86e399c233a4", + "contextMenus.task.completion.completeToday": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "contextMenus.task.completion.completeAsScheduled": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "contextMenus.task.completion.completeOnDue": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "contextMenus.task.completion.completeOnPicked": "9c2baea91e78091a5c390492c920218cc2a66146", "contextMenus.task.completion.markIncomplete": "3ae756e7c4063158572bde2513eeebd18a7ffb62", - "contextMenus.task.completion.instancePrefix": "01163bdc578e28a373b67e18af3f35d5f1b84948", "contextMenus.task.completion.noScheduledDate": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", "contextMenus.task.completion.noDueDate": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", "contextMenus.task.completion.noPickedDate": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", diff --git a/i18n.state.json b/i18n.state.json index df8321c0..c3558acf 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7368,12 +7368,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dc50f18bbaa3249131904a53adc71400e7ebcd31" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -16246,12 +16247,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "968673d261b5844d3efa0738be3c13af2077c0d9" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -25124,12 +25126,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dddefd2443584c9cf6331b468a73c21e6fe8556e" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -34002,12 +34005,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "7cd7d70058059bb1602bce7e2c25a6edaf8ef029" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -42880,12 +42884,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "eff53181b011d57fdf7dfb4617f7807573a53d23" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -51758,12 +51763,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5c886f6f62aeb8f203759a44e10a075079e1555e" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -60636,12 +60642,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "4b21cae430620be61f61dc2db0e6dc1ed867b28d" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, @@ -69514,12 +69521,13 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5d08dfcf44803b1604301a2e36d0eb317fa81ca2" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, "contextMenus.task.completion.completeToday": null, "contextMenus.task.completion.completeAsScheduled": null, "contextMenus.task.completion.completeOnDue": null, "contextMenus.task.completion.completeOnPicked": null, "contextMenus.task.completion.markIncomplete": null, - "contextMenus.task.completion.instancePrefix": null, "contextMenus.task.completion.noScheduledDate": null, "contextMenus.task.completion.noDueDate": null, "contextMenus.task.completion.noPickedDate": null, diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index dc86dc02..96c81021 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -285,10 +285,11 @@ export class TaskContextMenu { this.addCustomDateFieldMenuItems(task, plugin); - this.addCompletionMenuItems(task, plugin); + this.addCompleteOrSkipSubmenu(task, plugin); - if (task.recurrence) { - this.addRecurringInstanceMenuItems(task, plugin); + // Occurrence note stays at the top level (it is not a complete/skip action). + if (task.recurrence && !this.options.promoteOccurrenceControls) { + this.addOccurrenceNoteMenuItem(task, plugin); } if (!hasPromotedOccurrenceControls && task.recurrence_parent && task.occurrence_date) { @@ -873,24 +874,42 @@ export class TaskContextMenu { }); } - private instanceLabel(label: string, isRecurring: boolean): string { - return isRecurring - ? this.t("contextMenus.task.completion.instancePrefix", { label }) - : label; + /** + * Nest all completion (and, for recurring tasks, skip) actions under a single + * "Complete or Skip" submenu to keep the top-level menu compact. Non-recurring + * tasks have no skip action, so their submenu is labelled just "Complete". + */ + private addCompleteOrSkipSubmenu(task: TaskInfo, plugin: TaskNotesPlugin): void { + const isRecurring = !!task.recurrence; + this.menu.addItem((item) => { + item.setTitle( + this.t( + isRecurring + ? "contextMenus.task.completion.submenu" + : "contextMenus.task.completion.submenuCompleteOnly" + ) + ); + item.setIcon("check-check"); + const submenu = getSubmenu(item); + this.addCompletionMenuItems(task, plugin, submenu); + if (isRecurring) { + this.addSkipMenuItem(task, plugin, submenu); + } + }); } /** * Render the four explicit completion actions (Complete today / as scheduled / - * on due date / on…) for every task, with the un-complete affordance indexed - * per task type (R11): recurring completeness is per-date (each action toggles - * its own resolved date); non-recurring completeness is a single status (all - * four collapse to one "Mark incomplete"). + * on due date / on…) into the given menu, with the un-complete affordance + * indexed per task type (R11): recurring completeness is per-date (each action + * toggles its own resolved date); non-recurring completeness is a single + * status (all four collapse to one "Mark incomplete"). */ - private addCompletionMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { + private addCompletionMenuItems(task: TaskInfo, plugin: TaskNotesPlugin, menu: Menu): void { const isRecurring = !!task.recurrence; if (!isRecurring && plugin.statusManager.isCompletedStatus(task.status)) { - this.menu.addItem((item) => { + menu.addItem((item) => { item.setTitle(this.t("contextMenus.task.completion.markIncomplete")); item.setIcon("x"); item.onClick(async () => { @@ -904,37 +923,41 @@ export class TaskContextMenu { task, plugin, "today", - "contextMenus.task.completion.completeToday" + "contextMenus.task.completion.completeToday", + menu ); this.addConcreteCompletionAction( task, plugin, "asScheduled", - "contextMenus.task.completion.completeAsScheduled" + "contextMenus.task.completion.completeAsScheduled", + menu ); this.addConcreteCompletionAction( task, plugin, "onDue", - "contextMenus.task.completion.completeOnDue" + "contextMenus.task.completion.completeOnDue", + menu ); - this.addPickCompletionDateAction(task, plugin); + this.addPickCompletionDateAction(task, plugin, menu); } private addConcreteCompletionAction( task: TaskInfo, plugin: TaskNotesPlugin, mode: CompletionMode, - baseLabelKey: string + baseLabelKey: string, + menu: Menu ): void { const isRecurring = !!task.recurrence; const ctx: CompletionDateContext = { occurrenceDate: this.options.occurrenceDate }; const resolution = resolveCompletionDate(task, mode, ctx); - this.menu.addItem((item) => { + menu.addItem((item) => { if (!resolution.available) { // Shown disabled (not hidden) with a reason tooltip (R4). - item.setTitle(this.instanceLabel(this.t(baseLabelKey), isRecurring)); + item.setTitle(this.t(baseLabelKey)); item.setIcon("check"); item.setDisabled(true); const el = getMenuItemElement(item); @@ -952,8 +975,8 @@ export class TaskContextMenu { (task.complete_instances?.includes(formatDateForStorage(resolvedDate)) ?? false); item.setTitle( alreadyComplete - ? this.instanceLabel(this.t("contextMenus.task.completion.markIncomplete"), true) - : this.instanceLabel(this.t(baseLabelKey), isRecurring) + ? this.t("contextMenus.task.completion.markIncomplete") + : this.t(baseLabelKey) ); item.setIcon(alreadyComplete ? "x" : "check"); item.onClick(async () => { @@ -962,15 +985,13 @@ export class TaskContextMenu { }); } - private addPickCompletionDateAction(task: TaskInfo, plugin: TaskNotesPlugin): void { - const isRecurring = !!task.recurrence; - this.menu.addItem((item) => { - item.setTitle( - this.instanceLabel( - this.t("contextMenus.task.completion.completeOnPicked"), - isRecurring - ) - ); + private addPickCompletionDateAction( + task: TaskInfo, + plugin: TaskNotesPlugin, + menu: Menu + ): void { + menu.addItem((item) => { + item.setTitle(this.t("contextMenus.task.completion.completeOnPicked")); item.setIcon("calendar-plus"); item.onClick(() => { this.pickCompletionDate(task, plugin); @@ -1071,11 +1092,7 @@ export class TaskContextMenu { } } - private addRecurringInstanceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { - // The four explicit completion actions (which replaced the single - // "Mark complete for this date" item) are added for every task by - // addCompletionMenuItems. This method now only owns skip + occurrence note. - + private addSkipMenuItem(task: TaskInfo, plugin: TaskNotesPlugin, menu: Menu): void { // 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 @@ -1088,7 +1105,7 @@ export class TaskContextMenu { const skipDateStr = formatDateForStorage(skipLabelDate); const isSkippedForDate = task.skipped_instances?.includes(skipDateStr) || false; - this.menu.addItem((item) => { + menu.addItem((item) => { item.setTitle( isSkippedForDate ? this.t("contextMenus.task.unskipInstance") @@ -1115,10 +1132,6 @@ export class TaskContextMenu { } }); }); - - if (!this.options.promoteOccurrenceControls) { - this.addOccurrenceNoteMenuItem(task, plugin); - } } private addPromotedOccurrenceControls(task: TaskInfo, plugin: TaskNotesPlugin): boolean { diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 3c26d3c2..9b916cd4 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3039,12 +3039,13 @@ export const en: TranslationTree = { skipInstance: "Skip instance", unskipInstance: "Unskip instance", completion: { - completeToday: "Complete today", - completeAsScheduled: "Complete as scheduled", - completeOnDue: "Complete on due date", - completeOnPicked: "Complete on…", + submenu: "Complete or skip", + submenuCompleteOnly: "Complete", + completeToday: "Completed today", + completeAsScheduled: "Completed on schedule", + completeOnDue: "Completed on due date", + completeOnPicked: "Completed on (pick date)", markIncomplete: "Mark incomplete", - instancePrefix: "(Instance) {label}", noScheduledDate: "No scheduled date on this task", noDueDate: "No due date on this task", noPickedDate: "No date selected", diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 272cc630..722a0d07 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -31,6 +31,7 @@ import { formatDateForStorage, getCurrentDateString, getCurrentTimestamp, + getDatePart, } from "../utils/dateUtils"; import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplateProcessor"; @@ -607,6 +608,33 @@ export class TaskService { applyGoogleCalendarRecurringExceptionCleanup(updatePlan.updatedTask); } + // Rescheduling a recurring task onto a specific date reactivates that + // occurrence: drop the date from complete_instances/skipped_instances so + // the user doesn't have to manually un-complete/un-skip after moving the + // scheduled date (e.g. undoing an accidental completion that advanced the + // schedule). No-op when there is no matching recorded entry. + let rescheduleClearedOccurrence = false; + if ( + property === "scheduled" && + freshTask.recurrence && + typeof updatePlan.normalizedValue === "string" && + updatePlan.normalizedValue.length > 0 + ) { + const scheduledDateStr = getDatePart(updatePlan.normalizedValue); + const completeInstances = freshTask.complete_instances ?? []; + const skippedInstances = freshTask.skipped_instances ?? []; + const cleanedComplete = completeInstances.filter((d) => d !== scheduledDateStr); + const cleanedSkipped = skippedInstances.filter((d) => d !== scheduledDateStr); + if ( + cleanedComplete.length !== completeInstances.length || + cleanedSkipped.length !== skippedInstances.length + ) { + rescheduleClearedOccurrence = true; + updatePlan.updatedTask.complete_instances = cleanedComplete; + updatePlan.updatedTask.skipped_instances = cleanedSkipped; + } + } + // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { // Use field mapper to get the correct frontmatter property name @@ -642,6 +670,19 @@ export class TaskService { this.plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates"), updatePlan.updatedTask.googleCalendarMovedOriginalDates ); + + if (rescheduleClearedOccurrence) { + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField("completeInstances"), + updatePlan.updatedTask.complete_instances + ); + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField("skippedInstances"), + updatePlan.updatedTask.skipped_instances + ); + } }); // Step 3: Run post-write side effects (cache, events, webhooks, calendar, auto-archive) diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts index 804af3f9..8d5bceaa 100644 --- a/tests/unit/components/taskContextMenu.complete.test.ts +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -1,13 +1,14 @@ import { App, Menu } from "obsidian"; import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; import { createI18nService } from "../../../src/i18n"; +import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils"; import type TaskNotesPlugin from "../../../src/main"; import type { TaskInfo } from "../../../src/types"; /** - * U5 — the four explicit completion actions (with un-complete) replace the single - * recurring "Mark complete for this date" item and are net-new for non-recurring - * tasks. Covers AE1, AE2, AE5, AE7. + * U5 — the completion actions live under a "Complete or skip" submenu (just + * "Complete" for non-recurring). Labels are uniform across task types (no + * per-type qualifier). Covers AE1, AE2, AE5, AE7. */ type MockMenuItem = Record | { type: string }; @@ -79,20 +80,45 @@ function task(overrides: Partial = {}): TaskInfo { } as TaskInfo; } -function findItem(title: string): Record | undefined { - const menu = menuMock.mock.results[0].value as MockMenu; - return menu.items.find( - (item): item is Record => - !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title - ); +function titleOf(item: MockMenuItem): string | undefined { + return "type" in item ? undefined : item.setTitle?.mock.calls[0]?.[0]; +} + +function submenuOf(item: MockMenuItem): MockMenu | undefined { + if ("type" in item) return undefined; + const results = item.setSubmenu?.mock.results; + return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined; } -function allTitles(): string[] { - const menu = menuMock.mock.results[0].value as MockMenu; - return menu.items - .filter((item): item is Record => !("type" in item)) - .map((item) => item.setTitle.mock.calls[0]?.[0]) - .filter((t): t is string => typeof t === "string"); +/** Find a menu item by title anywhere in the tree (top level or any submenu). */ +function findItem( + title: string, + menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu, + seen = new Set() +): Record | undefined { + if (!menu || seen.has(menu)) return undefined; + seen.add(menu); + for (const item of menu.items) { + if ("type" in item) continue; + if (titleOf(item) === title) return item as Record; + const found = findItem(title, submenuOf(item), seen); + if (found) return found; + } + return undefined; +} + +/** The items directly inside the top-level "Complete or skip"/"Complete" submenu. */ +function completionSubmenuTitles(): string[] { + const top = menuMock.mock.results[0].value as MockMenu; + const parent = top.items.find( + (it) => titleOf(it) === "Complete or skip" || titleOf(it) === "Complete" + ); + const sub = parent ? submenuOf(parent) : undefined; + return sub + ? sub.items + .map(titleOf) + .filter((t): t is string => typeof t === "string") + : []; } describe("U5: completion menu items", () => { @@ -117,58 +143,74 @@ describe("U5: completion menu items", () => { ...o, }); - it("offers the four actions with an (Instance) prefix and removes the old single item", () => { + it("nests the four actions + skip under a 'Complete or Skip' submenu and drops the old single item", () => { new TaskContextMenu({ task: recurring({ due: "2026-06-30" }), plugin: createPlugin(), targetDate: new Date("2026-06-15T12:00:00"), }); - expect(findItem("(Instance) Complete today")).toBeDefined(); - expect(findItem("(Instance) Complete as scheduled")).toBeDefined(); - expect(findItem("(Instance) Complete on due date")).toBeDefined(); - expect(findItem("(Instance) Complete on…")).toBeDefined(); + // The submenu exists at the top level... + const top = menuMock.mock.results[0].value as MockMenu; + expect(top.items.some((it) => titleOf(it) === "Complete or skip")).toBe(true); + // ...and holds the four completion actions plus skip. + expect(completionSubmenuTitles()).toEqual( + expect.arrayContaining([ + "Completed today", + "Completed on schedule", + "Completed on due date", + "Completed on (pick date)", + "Skip instance", + ]) + ); // old single item gone expect(findItem("Mark complete for this date")).toBeUndefined(); }); - it("records the scheduled occurrence for 'Complete as scheduled' (AE1)", async () => { + it("records the scheduled occurrence for 'Completed on Schedule' (AE1)", async () => { const plugin = createPlugin(); const t = recurring(); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("(Instance) Complete as scheduled")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0](); const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; - const { formatDateForStorage } = require("../../../src/utils/dateUtils"); expect(formatDateForStorage(date)).toBe("2026-06-02"); }); - it("resolves 'Complete as scheduled' to scheduled for a completion-anchored recurrence (AE2 re-anchor)", async () => { + it("resolves 'Completed on Schedule' to scheduled for a completion-anchored recurrence (AE2 re-anchor)", async () => { const plugin = createPlugin(); const t = recurring({ recurrence_anchor: "completion", scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("(Instance) Complete as scheduled")?.onClick.mock.calls[0]?.[0](); - const { formatDateForStorage } = require("../../../src/utils/dateUtils"); + await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0](); const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; expect(formatDateForStorage(date)).toBe("2026-06-02"); }); - it("disables 'Complete on due date' with a reason when there is no due date (AE5)", () => { + it("records the due date for 'Completed on Due Date'", async () => { + const plugin = createPlugin(); + const t = recurring({ due: "2026-06-30" }); + new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); + + await findItem("Completed on due date")?.onClick.mock.calls[0]?.[0](); + + const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; + expect(formatDateForStorage(date)).toBe("2026-06-30"); + }); + + it("disables 'Completed on Due Date' with a reason when there is no due date (AE5)", () => { new TaskContextMenu({ task: recurring({ due: undefined }), plugin: createPlugin(), targetDate: new Date("2026-06-15T12:00:00"), }); - const onDue = findItem("(Instance) Complete on due date"); - expect(onDue?.setDisabled).toHaveBeenCalledWith(true); - // enabled actions remain - expect(findItem("(Instance) Complete today")?.setDisabled).not.toHaveBeenCalledWith(true); + expect(findItem("Completed on due date")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Completed today")?.setDisabled).not.toHaveBeenCalledWith(true); }); - it("shows '(Instance) Mark incomplete' for an already-recorded instance and toggles it out (AE7)", async () => { + it("shows 'Mark Incomplete' for an already-recorded instance and toggles it out (AE7)", async () => { const plugin = createPlugin(); const t = recurring({ complete_instances: ["2026-06-02"] }); new TaskContextMenu({ @@ -178,40 +220,36 @@ describe("U5: completion menu items", () => { occurrenceDate: new Date("2026-06-02T00:00:00Z"), }); - // The asScheduled action (resolving 2026-06-02) collapses to Mark incomplete. - const incompleteItem = findItem("(Instance) Mark incomplete"); + // The asScheduled action (resolving 2026-06-02) collapses to Mark Incomplete. + const incompleteItem = findItem("Mark incomplete"); expect(incompleteItem).toBeDefined(); await incompleteItem?.onClick.mock.calls[0]?.[0](); expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalled(); }); - - it("records the due date for 'Complete on due date'", async () => { - const plugin = createPlugin(); - const t = recurring({ due: "2026-06-30" }); - new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - - await findItem("(Instance) Complete on due date")?.onClick.mock.calls[0]?.[0](); - - const { formatDateForStorage } = require("../../../src/utils/dateUtils"); - const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0]; - expect(formatDateForStorage(date)).toBe("2026-06-30"); - }); }); describe("non-recurring", () => { - it("offers the four actions net-new WITHOUT an (Instance) prefix", () => { + it("nests the four actions under a 'Complete' submenu (no skip, uniform labels)", () => { new TaskContextMenu({ task: task({ scheduled: "2026-06-02", due: "2026-06-30" }), plugin: createPlugin(), targetDate: new Date("2026-06-15T12:00:00"), }); - expect(findItem("Complete today")).toBeDefined(); - expect(findItem("Complete as scheduled")).toBeDefined(); - expect(findItem("Complete on due date")).toBeDefined(); - expect(findItem("Complete on…")).toBeDefined(); - expect(allTitles().some((t) => t.startsWith("(Instance)"))).toBe(false); + const top = menuMock.mock.results[0].value as MockMenu; + expect(top.items.some((it) => titleOf(it) === "Complete")).toBe(true); + const titles = completionSubmenuTitles(); + expect(titles).toEqual( + expect.arrayContaining([ + "Completed today", + "Completed on schedule", + "Completed on due date", + "Completed on (pick date)", + ]) + ); + // Non-recurring has no skip action. + expect(titles).not.toContain("Skip instance"); }); it("dispatches a status change carrying the resolved completion date", async () => { @@ -219,21 +257,20 @@ describe("U5: completion menu items", () => { const t = task({ scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("Complete as scheduled")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0](); expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { completionDate: "2026-06-02", }); }); - it("records today for 'Complete today'", async () => { + it("records today for 'Completed Today'", async () => { const plugin = createPlugin(); const t = task({ scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("Complete today")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed today")?.onClick.mock.calls[0]?.[0](); - const { getTodayString } = require("../../../src/utils/dateUtils"); expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { completionDate: getTodayString(), }); @@ -245,7 +282,7 @@ describe("U5: completion menu items", () => { const t = task({ scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("Complete today")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed today")?.onClick.mock.calls[0]?.[0](); expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); }); @@ -257,20 +294,20 @@ describe("U5: completion menu items", () => { targetDate: new Date("2026-06-15T12:00:00"), }); - expect(findItem("Complete as scheduled")?.setDisabled).toHaveBeenCalledWith(true); - expect(findItem("Complete on due date")?.setDisabled).toHaveBeenCalledWith(true); - expect(findItem("Complete today")?.setDisabled).not.toHaveBeenCalledWith(true); - expect(findItem("Complete on…")?.setDisabled).not.toHaveBeenCalledWith(true); + expect(findItem("Completed on schedule")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Completed on due date")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Completed today")?.setDisabled).not.toHaveBeenCalledWith(true); + expect(findItem("Completed on (pick date)")?.setDisabled).not.toHaveBeenCalledWith(true); }); - it("collapses to a single 'Mark incomplete' when already in a completed status and reverts to the default status (AE7)", async () => { + it("collapses to a single 'Mark Incomplete' when already completed and reverts to the default status (AE7)", async () => { const plugin = createPlugin(); const t = task({ status: "done", completedDate: "2026-06-02", scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - expect(findItem("Complete today")).toBeUndefined(); - expect(findItem("Complete as scheduled")).toBeUndefined(); - expect(findItem("Complete on…")).toBeUndefined(); + expect(findItem("Completed today")).toBeUndefined(); + expect(findItem("Completed on schedule")).toBeUndefined(); + expect(findItem("Completed on (pick date)")).toBeUndefined(); const incomplete = findItem("Mark incomplete"); expect(incomplete).toBeDefined(); diff --git a/tests/unit/components/taskContextMenu.occurrenceContext.test.ts b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts index 95796eb8..15e602b9 100644 --- a/tests/unit/components/taskContextMenu.occurrenceContext.test.ts +++ b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts @@ -84,12 +84,27 @@ function createPlugin(): TaskNotesPlugin { } 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 - ); +function submenuOf(item: MockMenuItem): MockMenu | undefined { + if ("type" in item) return undefined; + const results = item.setSubmenu?.mock.results; + return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined; +} + +// Skip now lives inside the "Complete or skip" submenu, so search deep. +function findTopLevelMenuItem( + title: string, + menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu, + seen = new Set() +): Record | undefined { + if (!menu || seen.has(menu)) return undefined; + seen.add(menu); + for (const item of menu.items) { + if ("type" in item) continue; + if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record; + const found = findTopLevelMenuItem(title, submenuOf(item), seen); + if (found) return found; + } + return undefined; } describe("U1: occurrenceDate context field", () => { diff --git a/tests/unit/components/taskContextMenu.pickDate.test.ts b/tests/unit/components/taskContextMenu.pickDate.test.ts index dffe00e4..bb80e4f2 100644 --- a/tests/unit/components/taskContextMenu.pickDate.test.ts +++ b/tests/unit/components/taskContextMenu.pickDate.test.ts @@ -85,12 +85,27 @@ function task(overrides: Partial = {}): TaskInfo { } as TaskInfo; } -function findItem(title: string): Record | undefined { - const menu = menuMock.mock.results[0].value as MockMenu; - return menu.items.find( - (item): item is Record => - !("type" in item) && item.setTitle.mock.calls[0]?.[0] === title - ); +function submenuOf(item: MockMenuItem): MockMenu | undefined { + if ("type" in item) return undefined; + const results = item.setSubmenu?.mock.results; + return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined; +} + +// Completion actions live inside the "Complete or Skip" submenu, so search deep. +function findItem( + title: string, + menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu, + seen = new Set() +): Record | undefined { + if (!menu || seen.has(menu)) return undefined; + seen.add(menu); + for (const item of menu.items) { + if ("type" in item) continue; + if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record; + const found = findItem(title, submenuOf(item), seen); + if (found) return found; + } + return undefined; } function getPickerOnSelect(): (date: string | null) => void { @@ -108,7 +123,7 @@ describe("U6: Complete on… date picker", () => { const t = task({ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("(Instance) Complete on…")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0](); expect(modalMock).toHaveBeenCalledTimes(1); await getPickerOnSelect()("2026-05-20"); @@ -123,7 +138,7 @@ describe("U6: Complete on… date picker", () => { const t = task(); // undated new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("Complete on…")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0](); await getPickerOnSelect()("2026-05-20"); expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { @@ -136,7 +151,7 @@ describe("U6: Complete on… date picker", () => { const t = task({ scheduled: "2026-06-02" }); new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") }); - await findItem("Complete on…")?.onClick.mock.calls[0]?.[0](); + await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0](); await getPickerOnSelect()(null); expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); diff --git a/tests/unit/components/taskContextMenu.skip.test.ts b/tests/unit/components/taskContextMenu.skip.test.ts index 02f9d58b..535c2726 100644 --- a/tests/unit/components/taskContextMenu.skip.test.ts +++ b/tests/unit/components/taskContextMenu.skip.test.ts @@ -86,12 +86,27 @@ function createPlugin(): TaskNotesPlugin { } 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 - ); +function submenuOf(item: MockMenuItem): MockMenu | undefined { + if ("type" in item) return undefined; + const results = item.setSubmenu?.mock.results; + return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined; +} + +// Skip now lives inside the "Complete or skip" submenu, so search deep. +function findTopLevelMenuItem( + title: string, + menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu, + seen = new Set() +): Record | undefined { + if (!menu || seen.has(menu)) return undefined; + seen.add(menu); + for (const item of menu.items) { + if ("type" in item) continue; + if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record; + const found = findTopLevelMenuItem(title, submenuOf(item), seen); + if (found) return found; + } + return undefined; } describe("U2: Skip this instance records the occurrence date", () => { diff --git a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts index 394860d2..5535db69 100644 --- a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts +++ b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts @@ -130,7 +130,7 @@ describe("Issue #1724: recurring task actions belong with date menu items", () = menuMock.mockClear(); }); - it("places recurring complete and skip actions after scheduled date, not between status and priority", () => { + it("places the 'Complete or Skip' submenu after scheduled date, before the occurrence note", () => { new TaskContextMenu({ task: createRecurringTask(), plugin: createPlugin(), @@ -145,34 +145,37 @@ describe("Issue #1724: recurring task actions belong with date menu items", () = "Priority", "Due date", "Scheduled date", - "(Instance) Complete today", - "(Instance) Complete on…", - "Skip instance", + "Complete or skip", "Open or create occurrence note", "Reminders", ]) ); - // The single "Mark complete for this date" item was replaced by the four - // explicit completion actions. + // The single "Mark complete for this date" item was replaced by the + // "Complete or skip" submenu; the completion actions live inside it. expect(titles).not.toContain("Mark complete for this date"); expect(titles.indexOf("Status")).toBeLessThan(titles.indexOf("Priority")); - expect(titles.indexOf("Priority")).toBeLessThan( - titles.indexOf("(Instance) Complete today") - ); - expect(titles.indexOf("Scheduled date")).toBeLessThan( - titles.indexOf("(Instance) Complete today") - ); - expect(titles.indexOf("(Instance) Complete on…")).toBeLessThan( - titles.indexOf("Skip instance") - ); - expect(titles.indexOf("Skip instance")).toBeLessThan( + expect(titles.indexOf("Priority")).toBeLessThan(titles.indexOf("Complete or skip")); + expect(titles.indexOf("Scheduled date")).toBeLessThan(titles.indexOf("Complete or skip")); + expect(titles.indexOf("Complete or skip")).toBeLessThan( titles.indexOf("Open or create occurrence note") ); expect(titles.indexOf("Open or create occurrence note")).toBeLessThan( titles.indexOf("Reminders") ); + + // The submenu holds the completion actions + skip. + const submenuTitles = getAllMenuTitles(); + expect(submenuTitles).toEqual( + expect.arrayContaining([ + "Completed today", + "Completed on schedule", + "Completed on due date", + "Completed on (pick date)", + "Skip instance", + ]) + ); }); it("categorizes the quick-action recurring completion action with date actions", () => { diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index d46d4dc3..1eff910d 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -887,6 +887,80 @@ describe('TaskService', () => { expect(result.completedDate).toBeUndefined(); }); + // #3: rescheduling a recurring task onto a recorded date reactivates that occurrence. + it('removes the rescheduled date from complete_instances for a recurring task', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-09', + complete_instances: ['2026-06-02', '2026-05-26'], + skipped_instances: [], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-02'); + + expect(result.complete_instances).toEqual(['2026-05-26']); + }); + + it('removes the rescheduled date from skipped_instances for a recurring task', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-09', + complete_instances: [], + skipped_instances: ['2026-06-02'], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-02'); + + expect(result.skipped_instances).toEqual([]); + }); + + it('ignores the time component when matching the rescheduled date', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-09', + complete_instances: ['2026-06-02'], + skipped_instances: [], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty( + recurringTask, + 'scheduled', + '2026-06-02T09:30' + ); + + expect(result.complete_instances).toEqual([]); + }); + + it('leaves complete/skipped instances untouched when the rescheduled date is not recorded', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-09', + complete_instances: ['2026-06-02'], + skipped_instances: ['2026-05-26'], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-16'); + + expect(result.complete_instances).toEqual(['2026-06-02']); + expect(result.skipped_instances).toEqual(['2026-05-26']); + }); + + it('does not touch instances for a non-recurring reschedule', async () => { + const nonRecurring = TaskFactory.createTask({ + recurrence: undefined, + scheduled: '2026-06-09', + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurring); + + await expect( + taskService.updateProperty(nonRecurring, 'scheduled', '2026-06-02') + ).resolves.toBeDefined(); + }); + it('should remove empty due/scheduled dates', async () => { await taskService.updateProperty(task, 'due', undefined); From 8c3864e9fb881824f97919415c9ad5f9c9aae7aa Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 19:00:46 +1200 Subject: [PATCH 07/10] refine(completion): rename submenu to 'Mark complete or skip'; reschedule clears instances on or after the new date - Rename the submenu 'Complete or skip' -> 'Mark complete or skip' ('Complete' -> 'Mark complete' for the non-recurring variant). - Rescheduling a recurring task now drops every complete_instances/ skipped_instances entry on or after the new scheduled date (was: exact-date match only), so an accidental off-schedule completion recorded on a later date is also cleared when moving the schedule back. Entries strictly before the new date are preserved as genuine history. --- i18n.manifest.json | 4 ++-- src/i18n/resources/en.ts | 4 ++-- src/services/TaskService.ts | 17 +++++++++------- .../taskContextMenu.complete.test.ts | 10 +++++----- ...724-recurring-actions-date-section.test.ts | 10 +++++----- tests/unit/services/TaskService.test.ts | 20 ++++++++++++++++++- 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 22cd7544..377ef614 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1841,8 +1841,8 @@ "contextMenus.task.markIncomplete": "8d0d3b84c6c7d795e205c345054f9335ae222c21", "contextMenus.task.skipInstance": "ebb18d68bb33bb3188ca558e22656fa8f4cb7e95", "contextMenus.task.unskipInstance": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", - "contextMenus.task.completion.submenu": "267f880e67256da7fe01d7a30b2245b83f804b32", - "contextMenus.task.completion.submenuCompleteOnly": "1f5a1abf2f08ec18beb2664f458f86e399c233a4", + "contextMenus.task.completion.submenu": "54a51295b3ea49320049e2c7a225326711e0a571", + "contextMenus.task.completion.submenuCompleteOnly": "0c018491b96aadfff5ebeb45434a2945bdd12741", "contextMenus.task.completion.completeToday": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", "contextMenus.task.completion.completeAsScheduled": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", "contextMenus.task.completion.completeOnDue": "d915b497e5849ebf0a27d1ed54173a355fe96232", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 9b916cd4..361e8e52 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3039,8 +3039,8 @@ export const en: TranslationTree = { skipInstance: "Skip instance", unskipInstance: "Unskip instance", completion: { - submenu: "Complete or skip", - submenuCompleteOnly: "Complete", + submenu: "Mark complete or skip", + submenuCompleteOnly: "Mark complete", completeToday: "Completed today", completeAsScheduled: "Completed on schedule", completeOnDue: "Completed on due date", diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 722a0d07..69bcc1ca 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -608,11 +608,14 @@ export class TaskService { applyGoogleCalendarRecurringExceptionCleanup(updatePlan.updatedTask); } - // Rescheduling a recurring task onto a specific date reactivates that - // occurrence: drop the date from complete_instances/skipped_instances so - // the user doesn't have to manually un-complete/un-skip after moving the - // scheduled date (e.g. undoing an accidental completion that advanced the - // schedule). No-op when there is no matching recorded entry. + // Rescheduling a recurring task onto a date restarts its timeline from + // that date: drop every complete_instances/skipped_instances entry on or + // after the new scheduled date so the user doesn't have to manually + // un-complete/un-skip after moving the schedule back (e.g. undoing an + // accidental completion — including an off-schedule one recorded on a + // later date than the reschedule target). Entries strictly before the new + // scheduled date are genuine history and are preserved. Instances are + // stored as YYYY-MM-DD, so a lexicographic compare is chronological. let rescheduleClearedOccurrence = false; if ( property === "scheduled" && @@ -623,8 +626,8 @@ export class TaskService { const scheduledDateStr = getDatePart(updatePlan.normalizedValue); const completeInstances = freshTask.complete_instances ?? []; const skippedInstances = freshTask.skipped_instances ?? []; - const cleanedComplete = completeInstances.filter((d) => d !== scheduledDateStr); - const cleanedSkipped = skippedInstances.filter((d) => d !== scheduledDateStr); + const cleanedComplete = completeInstances.filter((d) => d < scheduledDateStr); + const cleanedSkipped = skippedInstances.filter((d) => d < scheduledDateStr); if ( cleanedComplete.length !== completeInstances.length || cleanedSkipped.length !== skippedInstances.length diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts index 8d5bceaa..3d92331a 100644 --- a/tests/unit/components/taskContextMenu.complete.test.ts +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -6,7 +6,7 @@ import type TaskNotesPlugin from "../../../src/main"; import type { TaskInfo } from "../../../src/types"; /** - * U5 — the completion actions live under a "Complete or skip" submenu (just + * U5 — the completion actions live under a "Mark complete or skip" submenu (just * "Complete" for non-recurring). Labels are uniform across task types (no * per-type qualifier). Covers AE1, AE2, AE5, AE7. */ @@ -107,11 +107,11 @@ function findItem( return undefined; } -/** The items directly inside the top-level "Complete or skip"/"Complete" submenu. */ +/** The items directly inside the top-level "Mark complete or skip"/"Complete" submenu. */ function completionSubmenuTitles(): string[] { const top = menuMock.mock.results[0].value as MockMenu; const parent = top.items.find( - (it) => titleOf(it) === "Complete or skip" || titleOf(it) === "Complete" + (it) => titleOf(it) === "Mark complete or skip" || titleOf(it) === "Mark complete" ); const sub = parent ? submenuOf(parent) : undefined; return sub @@ -152,7 +152,7 @@ describe("U5: completion menu items", () => { // The submenu exists at the top level... const top = menuMock.mock.results[0].value as MockMenu; - expect(top.items.some((it) => titleOf(it) === "Complete or skip")).toBe(true); + expect(top.items.some((it) => titleOf(it) === "Mark complete or skip")).toBe(true); // ...and holds the four completion actions plus skip. expect(completionSubmenuTitles()).toEqual( expect.arrayContaining([ @@ -238,7 +238,7 @@ describe("U5: completion menu items", () => { }); const top = menuMock.mock.results[0].value as MockMenu; - expect(top.items.some((it) => titleOf(it) === "Complete")).toBe(true); + expect(top.items.some((it) => titleOf(it) === "Mark complete")).toBe(true); const titles = completionSubmenuTitles(); expect(titles).toEqual( expect.arrayContaining([ diff --git a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts index 5535db69..ca46bbe6 100644 --- a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts +++ b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts @@ -145,20 +145,20 @@ describe("Issue #1724: recurring task actions belong with date menu items", () = "Priority", "Due date", "Scheduled date", - "Complete or skip", + "Mark complete or skip", "Open or create occurrence note", "Reminders", ]) ); // The single "Mark complete for this date" item was replaced by the - // "Complete or skip" submenu; the completion actions live inside it. + // "Mark complete or skip" submenu; the completion actions live inside it. expect(titles).not.toContain("Mark complete for this date"); expect(titles.indexOf("Status")).toBeLessThan(titles.indexOf("Priority")); - expect(titles.indexOf("Priority")).toBeLessThan(titles.indexOf("Complete or skip")); - expect(titles.indexOf("Scheduled date")).toBeLessThan(titles.indexOf("Complete or skip")); - expect(titles.indexOf("Complete or skip")).toBeLessThan( + expect(titles.indexOf("Priority")).toBeLessThan(titles.indexOf("Mark complete or skip")); + expect(titles.indexOf("Scheduled date")).toBeLessThan(titles.indexOf("Mark complete or skip")); + expect(titles.indexOf("Mark complete or skip")).toBeLessThan( titles.indexOf("Open or create occurrence note") ); expect(titles.indexOf("Open or create occurrence note")).toBeLessThan( diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 1eff910d..49926015 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -934,7 +934,25 @@ describe('TaskService', () => { expect(result.complete_instances).toEqual([]); }); - it('leaves complete/skipped instances untouched when the rescheduled date is not recorded', async () => { + it('clears an off-schedule completion/skip recorded on or after the rescheduled date, keeping older history', async () => { + // Completed off-schedule on 2026-06-08 and a later skip on 2026-06-19; the + // user reschedules back to 2026-06-05. Exact-match would miss both; the + // ">= new date" rule clears them while preserving the genuine older 2026-05-29. + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-12', + complete_instances: ['2026-05-29', '2026-06-08'], + skipped_instances: ['2026-06-19'], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05'); + + expect(result.complete_instances).toEqual(['2026-05-29']); + expect(result.skipped_instances).toEqual([]); + }); + + it('leaves complete/skipped instances untouched when nothing is on or after the rescheduled date', async () => { const recurringTask = TaskFactory.createTask({ recurrence: 'FREQ=WEEKLY', scheduled: '2026-06-09', From ee44ffed4856ebac178dfe94251d9f07c08269aa Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 19:13:19 +1200 Subject: [PATCH 08/10] feat(completion): confirm before a reschedule clears recorded instances Rescheduling a recurring task that would clear completed/skipped instances (on or after the new date) now prompts for confirmation, listing the affected dates; declining aborts the reschedule entirely (nothing is written). Threaded via an optional confirmClearInstances callback on updateProperty: the context-menu and scheduled-date-modal paths supply a ConfirmationModal; the headless API (and any silent update) omit it and keep the prior behavior. Calendar drag still clears silently (revert-on-cancel deferred). --- i18n.manifest.json | 3 ++ i18n.state.json | 24 +++++++++ src/i18n/resources/en.ts | 4 ++ src/main.ts | 67 +++++++++++++++++++++---- src/services/TaskService.ts | 37 ++++++++++---- tests/unit/services/TaskService.test.ts | 55 ++++++++++++++++++++ 6 files changed, 171 insertions(+), 19 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 377ef614..471e884a 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1854,6 +1854,9 @@ "contextMenus.task.completion.noCompletedStatus": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", "contextMenus.task.completion.pickDateTitle": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", "contextMenus.task.completion.completeFailure": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "contextMenus.task.completion.clearInstancesConfirmTitle": "a819ff5087a10bfd7d0300249ce038f90e818702", + "contextMenus.task.completion.clearInstancesConfirmMessage": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "contextMenus.task.completion.clearInstancesConfirmButton": "3bdcd7189cfa14996da1fdf37761df828a182d7e", "contextMenus.task.quickReminders.atTime": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "contextMenus.task.quickReminders.fiveMinutes": "c777f20872b577e3164c1b6d3f4f50d70faa8e93", "contextMenus.task.quickReminders.fifteenMinutes": "4bba5a72574fef683d773d45198acbf9a56d8961", diff --git a/i18n.state.json b/i18n.state.json index c3558acf..c61714b4 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7381,6 +7381,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "13edcc9c4ebe474df17518c264115a00ddf30433" @@ -16260,6 +16263,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "2838cecba7368421d90e0f07c0d0f1e28830ffa4" @@ -25139,6 +25145,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "c6fa1b8853d66e5ede8d309bb9f6d82dc4f10657" @@ -34018,6 +34027,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7fdead3a6d8aaccda120699da7e45e369edc108c" @@ -42897,6 +42909,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "ee19ba1d573cbe4fb6cbbab4cb59e86260dd132a" @@ -51776,6 +51791,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "a2936a5b99f3ba9aef0ef806a85097d30db91392" @@ -60655,6 +60673,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "0a461c2b22896eac59c259415fdbbe96b207b9bc" @@ -69534,6 +69555,9 @@ "contextMenus.task.completion.noCompletedStatus": null, "contextMenus.task.completion.pickDateTitle": null, "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7aaeb02feb12bd8334344d70a17e16b367174054" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 361e8e52..c74ee704 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3052,6 +3052,10 @@ export const en: TranslationTree = { noCompletedStatus: "No completed status configured", pickDateTitle: "Complete on date", completeFailure: "Failed to update task completion: {message}", + clearInstancesConfirmTitle: "Clear recorded instances?", + clearInstancesConfirmMessage: + "Rescheduling will clear these recorded completed/skipped instances on or after the new date: {dates}. They will no longer be marked complete or skipped. Continue?", + clearInstancesConfirmButton: "Reschedule and clear", }, quickReminders: { atTime: "At time of event", diff --git a/src/main.ts b/src/main.ts index f5639f77..936a2686 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1085,18 +1085,40 @@ export default class TaskNotesPlugin extends Plugin { task: TaskInfo, property: keyof TaskInfo, value: TaskInfo[keyof TaskInfo], - options: { silent?: boolean; completionDate?: string } = {} + options: { + silent?: boolean; + completionDate?: string; + confirmClearInstances?: (cleared: { + complete: string[]; + skipped: string[]; + }) => Promise; + } = {} ): Promise { try { - const updatedTask = await this.taskService.updateProperty( - task, - property, - value, - options - ); + // When rescheduling a recurring task would clear recorded completions/ + // skips, prompt for confirmation (unless silent or the caller supplied its + // own handler). A declined prompt aborts the reschedule, so suppress the + // success notice in that case. + let cancelledByUser = false; + const confirmClearInstances = + options.confirmClearInstances ?? + (options.silent + ? undefined + : async (cleared: { complete: string[]; skipped: string[] }) => { + const proceed = await this.confirmClearRescheduledInstances(cleared); + if (!proceed) { + cancelledByUser = true; + } + return proceed; + }); - // Provide user feedback unless silent - if (!options.silent) { + const updatedTask = await this.taskService.updateProperty(task, property, value, { + ...options, + confirmClearInstances, + }); + + // Provide user feedback unless silent or the user cancelled the reschedule + if (!options.silent && !cancelledByUser) { if (property === "status") { const statusValue = typeof value === "string" ? value : String(value); const statusConfig = this.statusManager.getStatusConfig(statusValue); @@ -1118,6 +1140,28 @@ export default class TaskNotesPlugin extends Plugin { } } + /** + * Ask the user to confirm clearing recorded completed/skipped instances that a + * reschedule would remove (those on or after the new scheduled date). + */ + private async confirmClearRescheduledInstances(cleared: { + complete: string[]; + skipped: string[]; + }): Promise { + const { showConfirmationModal } = await import("./modals/ConfirmationModal"); + const dates = Array.from(new Set([...cleared.complete, ...cleared.skipped])).sort(); + return showConfirmationModal(this.app, { + title: this.i18n.translate("contextMenus.task.completion.clearInstancesConfirmTitle"), + message: this.i18n.translate( + "contextMenus.task.completion.clearInstancesConfirmMessage", + { dates: dates.join(", ") } + ), + confirmText: this.i18n.translate( + "contextMenus.task.completion.clearInstancesConfirmButton" + ), + }); + } + /** * Toggles a recurring task's completion status for the selected date */ @@ -1366,7 +1410,10 @@ export default class TaskNotesPlugin extends Plugin { void (async () => { const value = date && time ? combineDateAndTime(date, time) : date || undefined; - await this.taskService.updateProperty(task, field, value); + await this.taskService.updateProperty(task, field, value, { + confirmClearInstances: (cleared) => + this.confirmClearRescheduledInstances(cleared), + }); })(); }, }); diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 69bcc1ca..3bbd73b6 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -567,7 +567,14 @@ export class TaskService { task: TaskInfo, property: keyof TaskInfo, value: unknown, - options: { silent?: boolean; completionDate?: string } = {} + options: { + silent?: boolean; + completionDate?: string; + confirmClearInstances?: (cleared: { + complete: string[]; + skipped: string[]; + }) => Promise; + } = {} ): Promise { try { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); @@ -626,15 +633,27 @@ export class TaskService { const scheduledDateStr = getDatePart(updatePlan.normalizedValue); const completeInstances = freshTask.complete_instances ?? []; const skippedInstances = freshTask.skipped_instances ?? []; - const cleanedComplete = completeInstances.filter((d) => d < scheduledDateStr); - const cleanedSkipped = skippedInstances.filter((d) => d < scheduledDateStr); - if ( - cleanedComplete.length !== completeInstances.length || - cleanedSkipped.length !== skippedInstances.length - ) { + const removedComplete = completeInstances.filter((d) => d >= scheduledDateStr); + const removedSkipped = skippedInstances.filter((d) => d >= scheduledDateStr); + if (removedComplete.length > 0 || removedSkipped.length > 0) { + // Give the caller a chance to confirm the destructive clear before + // anything is written; a false result aborts the whole reschedule. + if (options.confirmClearInstances) { + const proceed = await options.confirmClearInstances({ + complete: removedComplete, + skipped: removedSkipped, + }); + if (!proceed) { + return freshTask; + } + } rescheduleClearedOccurrence = true; - updatePlan.updatedTask.complete_instances = cleanedComplete; - updatePlan.updatedTask.skipped_instances = cleanedSkipped; + updatePlan.updatedTask.complete_instances = completeInstances.filter( + (d) => d < scheduledDateStr + ); + updatePlan.updatedTask.skipped_instances = skippedInstances.filter( + (d) => d < scheduledDateStr + ); } } diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 49926015..f705238a 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -979,6 +979,61 @@ describe('TaskService', () => { ).resolves.toBeDefined(); }); + it('aborts the reschedule (no write, instances intact) when confirmClearInstances returns false', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-12', + complete_instances: ['2026-06-08'], + skipped_instances: ['2026-06-19'], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + const confirm = jest.fn(async () => false); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', { + confirmClearInstances: confirm, + }); + + expect(confirm).toHaveBeenCalledWith({ complete: ['2026-06-08'], skipped: ['2026-06-19'] }); + expect(result.complete_instances).toEqual(['2026-06-08']); + expect(result.scheduled).toBe('2026-06-12'); // unchanged + expect(mockPlugin.app.fileManager.processFrontMatter).not.toHaveBeenCalled(); + }); + + it('proceeds and clears when confirmClearInstances returns true', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-12', + complete_instances: ['2026-06-08'], + skipped_instances: [], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + const confirm = jest.fn(async () => true); + + const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', { + confirmClearInstances: confirm, + }); + + expect(confirm).toHaveBeenCalled(); + expect(result.complete_instances).toEqual([]); + }); + + it('does not call confirmClearInstances when nothing would be cleared', async () => { + const recurringTask = TaskFactory.createTask({ + recurrence: 'FREQ=WEEKLY', + scheduled: '2026-06-12', + complete_instances: ['2026-05-01'], + skipped_instances: [], + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + const confirm = jest.fn(async () => true); + + await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', { + confirmClearInstances: confirm, + }); + + expect(confirm).not.toHaveBeenCalled(); + }); + it('should remove empty due/scheduled dates', async () => { await taskService.updateProperty(task, 'due', undefined); From 7365a86a1f9e7259990fa264c3b117cbfb41b39d Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 19:32:25 +1200 Subject: [PATCH 09/10] feat(settings): toggle complete/skip actions between submenu and flat menu Adds an appearance setting 'Group complete and skip actions in a submenu' (default on = current submenu behavior). When off, the completion actions (and skip for recurring tasks) render directly in the task context menu instead of nested under 'Mark complete or skip'. Read via settings.completionMenuAsSubmenu, defaulting to the submenu when unset (backward compatible). --- i18n.manifest.json | 2 + i18n.state.json | 16 ++++++ src/components/TaskContextMenu.ts | 21 ++++++-- src/i18n/resources/en.ts | 5 ++ src/settings/defaults.ts | 2 + src/settings/tabs/appearanceTab.ts | 12 +++++ src/types/settings.ts | 2 + .../taskContextMenu.complete.test.ts | 49 +++++++++++++++++++ 8 files changed, 104 insertions(+), 5 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 471e884a..cf10a56d 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -811,6 +811,8 @@ "settings.appearance.taskCards.description": "4554f6f0816fa0c30fb2d11d11b7a4e0e9a610de", "settings.appearance.taskCards.defaultVisibleProperties.name": "0aa5efd8c0508868b5a726a4f3890fc3efb71d26", "settings.appearance.taskCards.defaultVisibleProperties.description": "1673317e8fda5fc944e0458b5e51563c048e2dcd", + "settings.appearance.taskCards.completionSubmenu.name": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "settings.appearance.taskCards.completionSubmenu.description": "edc8f49c252f25483a01c4675be3e296ba2623c9", "settings.appearance.taskCards.propertyGroups.coreProperties": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "settings.appearance.taskCards.propertyGroups.organization": "9da72239ddeee345a6b025c468d9817c49cae014", "settings.appearance.taskCards.propertyGroups.customProperties": "07daf13a314add9a92c0f8e5564d81ce72e4937c", diff --git a/i18n.state.json b/i18n.state.json index c61714b4..da9e2a93 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -3248,6 +3248,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "6dcf0eab268427f03a4e65a6caa6da86bf7b388b" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8b854e53ad33addbdd700ee4851887dbc68b254c" @@ -12130,6 +12132,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "7544b9bce2f306bf0f132f20496495986a8c0232" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "a3fde73e81c95a92d5645faa11a31f43498671bf" @@ -21012,6 +21016,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4e2b33fa3b0ae5c5bb3ad726108d1eb4b4da505f" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "45c3c60132d0d7392905160e46d902e841ae8053" @@ -29894,6 +29900,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "de48cc0c5751bfd7eb1c2a3f6043c0dcd7382581" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "5547ff8f91ea1468c73df95fe41ba7ae215a21a1" @@ -38776,6 +38784,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "8c6ff390fde8a9506470b23ec2db414c267a6909" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "cc315ebd9ef6c843c3663a2a97dba8eda9edb868" @@ -47658,6 +47668,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4a1cd9917a4b43ae471d4c2a299469db22cd0210" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8a083e47d10d9cb24fbe3039f3247ffb08f7f97c" @@ -56540,6 +56552,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "046d0996624d117cbbafac2278b6c7a1bf2d99c3" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "273a4585152f46d25617f5b68785c0aaa5ad0743" @@ -65422,6 +65436,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "b0935ca50aeb4136e0ed0229514b3e25e05bb98f" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "6503db48028c919fb82aa2f1b6beb676980bc93e" diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 96c81021..7044efe7 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -285,7 +285,7 @@ export class TaskContextMenu { this.addCustomDateFieldMenuItems(task, plugin); - this.addCompleteOrSkipSubmenu(task, plugin); + this.addCompleteOrSkipSection(task, plugin); // Occurrence note stays at the top level (it is not a complete/skip action). if (task.recurrence && !this.options.promoteOccurrenceControls) { @@ -875,12 +875,23 @@ export class TaskContextMenu { } /** - * Nest all completion (and, for recurring tasks, skip) actions under a single - * "Complete or Skip" submenu to keep the top-level menu compact. Non-recurring - * tasks have no skip action, so their submenu is labelled just "Complete". + * Render the completion (and, for recurring tasks, skip) actions. By default + * they are nested under a single "Mark complete or skip" submenu ("Mark + * complete" for non-recurring tasks) to keep the top-level menu compact; when + * `completionMenuAsSubmenu` is disabled they are added directly to the menu. */ - private addCompleteOrSkipSubmenu(task: TaskInfo, plugin: TaskNotesPlugin): void { + private addCompleteOrSkipSection(task: TaskInfo, plugin: TaskNotesPlugin): void { const isRecurring = !!task.recurrence; + + // Default to the submenu when the setting is unset (backward compatible). + if (plugin.settings.completionMenuAsSubmenu === false) { + this.addCompletionMenuItems(task, plugin, this.menu); + if (isRecurring) { + this.addSkipMenuItem(task, plugin, this.menu); + } + return; + } + this.menu.addItem((item) => { item.setTitle( this.t( diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index c74ee704..db487e7d 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1379,6 +1379,11 @@ export const en: TranslationTree = { name: "Default visible properties", description: "Choose which properties appear on task cards by default.", }, + completionSubmenu: { + name: "Group complete and skip actions in a submenu", + description: + "Nest the complete and skip actions under a submenu in the task context menu. Turn off to show them directly in the menu.", + }, propertyGroups: { coreProperties: "Core properties", organization: "ORGANIZATION", diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5..0596a599 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -333,6 +333,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Task card in note defaults showTaskCardInNote: true, showCompletedTaskStrikethrough: true, + // Nest the complete/skip context-menu actions under a submenu by default + completionMenuAsSubmenu: true, // Task card expandable subtasks defaults showExpandableSubtasks: true, expandSubtasksByDefault: false, diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 10c9a6c5..a6f7eb8f 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -107,6 +107,18 @@ export function renderAppearanceTab( setting.setDesc(`Currently showing: ${currentLabels.join(", ")}`); setting.settingEl.addClass("settings-view__group-description"); }); + + group.addSetting((setting) => + void configureToggleSetting(setting, { + name: translate("settings.appearance.taskCards.completionSubmenu.name"), + desc: translate("settings.appearance.taskCards.completionSubmenu.description"), + getValue: () => plugin.settings.completionMenuAsSubmenu, + setValue: async (value: boolean) => { + plugin.settings.completionMenuAsSubmenu = value; + save(); + }, + }) + ); } ); diff --git a/src/types/settings.ts b/src/types/settings.ts index 960391ce..fb654c8b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -186,6 +186,8 @@ export interface TaskNotesSettings { expandSubtasksByDefault: boolean; // Subtask chevron position in task cards subtaskChevronPosition: "left" | "right"; + // Nest the complete/skip context-menu actions under a submenu (vs. flat menu items) + completionMenuAsSubmenu: boolean; // Filter toolbar layout viewsButtonAlignment: "left" | "right"; // Overdue behavior settings diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts index 3d92331a..59d0a0ea 100644 --- a/tests/unit/components/taskContextMenu.complete.test.ts +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -315,4 +315,53 @@ describe("U5: completion menu items", () => { expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "open"); }); }); + + describe("menu style setting", () => { + it("renders completion + skip as top-level items when completionMenuAsSubmenu is false", () => { + const plugin = createPlugin(); + (plugin.settings as { completionMenuAsSubmenu?: boolean }).completionMenuAsSubmenu = false; + new TaskContextMenu({ + task: task({ + recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", + scheduled: "2026-06-02", + }), + plugin, + targetDate: new Date("2026-06-15T12:00:00"), + }); + + const top = menuMock.mock.results[0].value as MockMenu; + const topTitles = top.items + .map(titleOf) + .filter((t): t is string => typeof t === "string"); + expect(topTitles).toEqual( + expect.arrayContaining([ + "Completed today", + "Completed on schedule", + "Completed on due date", + "Completed on (pick date)", + "Skip instance", + ]) + ); + // No submenu wrapper in flat mode. + expect(topTitles).not.toContain("Mark complete or skip"); + }); + + it("nests under a submenu by default (setting unset)", () => { + new TaskContextMenu({ + task: task({ + recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", + scheduled: "2026-06-02", + }), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + const top = menuMock.mock.results[0].value as MockMenu; + const topTitles = top.items + .map(titleOf) + .filter((t): t is string => typeof t === "string"); + expect(topTitles).toContain("Mark complete or skip"); + expect(topTitles).not.toContain("Completed today"); // it's inside the submenu + }); + }); }); From e925c9d420050bbf8df5849af6ab074fd392c1df Mon Sep 17 00:00:00 2001 From: renatomen Date: Sat, 4 Jul 2026 20:08:37 +1200 Subject: [PATCH 10/10] feat(completion): add dividers around the flat complete/skip block In flat menu mode (submenu setting off), wrap the completion/skip actions with a separator before and after so the block is visually delimited from the rest of the task context menu. --- src/components/TaskContextMenu.ts | 3 +++ tests/unit/components/taskContextMenu.complete.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7044efe7..2f2573c5 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -885,10 +885,13 @@ export class TaskContextMenu { // Default to the submenu when the setting is unset (backward compatible). if (plugin.settings.completionMenuAsSubmenu === false) { + // Delimit the flat completion/skip block from the surrounding menu. + this.menu.addSeparator(); this.addCompletionMenuItems(task, plugin, this.menu); if (isRecurring) { this.addSkipMenuItem(task, plugin, this.menu); } + this.menu.addSeparator(); return; } diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts index 59d0a0ea..90d896c3 100644 --- a/tests/unit/components/taskContextMenu.complete.test.ts +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -344,6 +344,14 @@ describe("U5: completion menu items", () => { ); // No submenu wrapper in flat mode. expect(topTitles).not.toContain("Mark complete or skip"); + + // A divider precedes the block and another follows it. + const items = top.items; + const firstIdx = items.findIndex((it) => titleOf(it) === "Completed today"); + const skipIdx = items.findIndex((it) => titleOf(it) === "Skip instance"); + expect(firstIdx).toBeGreaterThan(0); + expect("type" in items[firstIdx - 1]).toBe(true); // separator before "Completed today" + expect("type" in items[skipIdx + 1]).toBe(true); // separator after "Skip instance" }); it("nests under a submenu by default (setting unset)", () => {