diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbe..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", @@ -1841,6 +1843,22 @@ "contextMenus.task.markIncomplete": "8d0d3b84c6c7d795e205c345054f9335ae222c21", "contextMenus.task.skipInstance": "ebb18d68bb33bb3188ca558e22656fa8f4cb7e95", "contextMenus.task.unskipInstance": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", + "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", + "contextMenus.task.completion.completeOnPicked": "9c2baea91e78091a5c390492c920218cc2a66146", + "contextMenus.task.completion.markIncomplete": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "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.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 01d90a50..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" @@ -7368,6 +7370,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -12114,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" @@ -16234,6 +16254,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -20980,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" @@ -25100,6 +25138,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -29846,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" @@ -33966,6 +34022,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -38712,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" @@ -42832,6 +42906,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -47578,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" @@ -51698,6 +51790,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -56444,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" @@ -60564,6 +60674,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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" @@ -65310,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" @@ -69430,6 +69558,22 @@ "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.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "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/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', diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 6f49b2b9..2f2573c5 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,8 +285,11 @@ export class TaskContextMenu { this.addCustomDateFieldMenuItems(task, plugin); - if (task.recurrence) { - this.addRecurringInstanceMenuItems(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) { + this.addOccurrenceNoteMenuItem(task, plugin); } if (!hasPromotedOccurrenceControls && task.recurrence_parent && task.occurrence_date) { @@ -866,38 +874,239 @@ export class TaskContextMenu { }); } - private addRecurringInstanceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { - const dateStr = formatDateForStorage(this.options.targetDate); - const isCompletedForDate = task.complete_instances?.includes(dateStr) || false; + /** + * 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 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) { + // 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; + } this.menu.addItem((item) => { item.setTitle( - isCompletedForDate - ? this.t("contextMenus.task.markIncomplete") - : this.t("contextMenus.task.markComplete") + this.t( + isRecurring + ? "contextMenus.task.completion.submenu" + : "contextMenus.task.completion.submenuCompleteOnly" + ) ); - item.setIcon(isCompletedForDate ? "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, - }) - ); + 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…) 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, menu: Menu): void { + const isRecurring = !!task.recurrence; + + if (!isRecurring && plugin.statusManager.isCompletedStatus(task.status)) { + 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", + menu + ); + this.addConcreteCompletionAction( + task, + plugin, + "asScheduled", + "contextMenus.task.completion.completeAsScheduled", + menu + ); + this.addConcreteCompletionAction( + task, + plugin, + "onDue", + "contextMenus.task.completion.completeOnDue", + menu + ); + this.addPickCompletionDateAction(task, plugin, menu); + } + + private addConcreteCompletionAction( + task: TaskInfo, + plugin: TaskNotesPlugin, + mode: CompletionMode, + baseLabelKey: string, + menu: Menu + ): void { + const isRecurring = !!task.recurrence; + const ctx: CompletionDateContext = { occurrenceDate: this.options.occurrenceDate }; + const resolution = resolveCompletionDate(task, mode, ctx); + + menu.addItem((item) => { + if (!resolution.available) { + // Shown disabled (not hidden) with a reason tooltip (R4). + item.setTitle(this.t(baseLabelKey)); + 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( + alreadyComplete + ? this.t("contextMenus.task.completion.markIncomplete") + : this.t(baseLabelKey) + ); + item.setIcon(alreadyComplete ? "x" : "check"); + item.onClick(async () => { + await this.dispatchCompletion(task, plugin, resolvedDate); }); }); + } + 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); + }); + }); + } + + /** + * 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.completion.completeFailure", { + 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.completion.completeFailure", { + message: errorMessage, + }) + ); + } + } + + 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 @@ -910,7 +1119,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") @@ -937,10 +1146,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 0926250b..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", @@ -3038,6 +3043,25 @@ export const en: TranslationTree = { markIncomplete: "Mark incomplete for this date", skipInstance: "Skip instance", unskipInstance: "Unskip instance", + completion: { + submenu: "Mark complete or skip", + submenuCompleteOnly: "Mark complete", + completeToday: "Completed today", + completeAsScheduled: "Completed on schedule", + completeOnDue: "Completed on due date", + completeOnPicked: "Completed on (pick date)", + markIncomplete: "Mark incomplete", + 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", + 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", fiveMinutes: "5 minutes before", diff --git a/src/main.ts b/src/main.ts index d10796aa..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 } = {} + 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 533b325c..3bbd73b6 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"; @@ -566,7 +567,14 @@ export class TaskService { task: TaskInfo, property: keyof TaskInfo, value: unknown, - options: { silent?: boolean } = {} + options: { + silent?: boolean; + completionDate?: string; + confirmClearInstances?: (cleared: { + complete: string[]; + skipped: string[]; + }) => Promise; + } = {} ): Promise { try { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); @@ -577,13 +585,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), }); @@ -599,6 +615,48 @@ export class TaskService { applyGoogleCalendarRecurringExceptionCleanup(updatePlan.updatedTask); } + // 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" && + 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 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 = completeInstances.filter( + (d) => d < scheduledDateStr + ); + updatePlan.updatedTask.skipped_instances = skippedInstances.filter( + (d) => d < scheduledDateStr + ); + } + } + // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { // Use field mapper to get the correct frontmatter property name @@ -621,7 +679,7 @@ export class TaskService { normalizeStatusValue: (candidate) => this.normalizeStatusValue(candidate), isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), - currentDateString: this.getCompletionDateForTask(freshTask), + currentDateString: completionDateString, }); this.writeOptionalFrontmatterField( @@ -634,6 +692,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/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/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/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts new file mode 100644 index 00000000..90d896c3 --- /dev/null +++ b/tests/unit/components/taskContextMenu.complete.test.ts @@ -0,0 +1,375 @@ +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 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. + */ + +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 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; +} + +/** 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 "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) === "Mark complete or skip" || titleOf(it) === "Mark 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", () => { + 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("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"), + }); + + // The submenu exists at the top level... + const top = menuMock.mock.results[0].value as MockMenu; + 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([ + "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 '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("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("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("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("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"), + }); + + expect(findItem("Completed on due date")?.setDisabled).toHaveBeenCalledWith(true); + expect(findItem("Completed today")?.setDisabled).not.toHaveBeenCalledWith(true); + }); + + 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({ + 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("Mark incomplete"); + expect(incompleteItem).toBeDefined(); + + await incompleteItem?.onClick.mock.calls[0]?.[0](); + expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalled(); + }); + }); + + describe("non-recurring", () => { + 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"), + }); + + const top = menuMock.mock.results[0].value as MockMenu; + expect(top.items.some((it) => titleOf(it) === "Mark 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 () => { + 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("Completed on schedule")?.onClick.mock.calls[0]?.[0](); + + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", { + completionDate: "2026-06-02", + }); + }); + + 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("Completed today")?.onClick.mock.calls[0]?.[0](); + + 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("Completed 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(), + plugin: createPlugin(), + targetDate: new Date("2026-06-15T12:00:00"), + }); + + 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 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("Completed today")).toBeUndefined(); + expect(findItem("Completed on schedule")).toBeUndefined(); + expect(findItem("Completed on (pick date)")).toBeUndefined(); + + const incomplete = findItem("Mark incomplete"); + expect(incomplete).toBeDefined(); + await incomplete?.onClick.mock.calls[0]?.[0](); + 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"); + + // 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)", () => { + 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 + }); + }); +}); 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 new file mode 100644 index 00000000..bb80e4f2 --- /dev/null +++ b/tests/unit/components/taskContextMenu.pickDate.test.ts @@ -0,0 +1,160 @@ +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 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 { + 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("Completed on (pick date)")?.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("Completed on (pick date)")?.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("Completed on (pick date)")?.onClick.mock.calls[0]?.[0](); + await getPickerOnSelect()(null); + + expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); + expect(plugin.toggleRecurringTaskComplete).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 7b25f848..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 @@ -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,29 +145,37 @@ describe("Issue #1724: recurring task actions belong with date menu items", () = "Priority", "Due date", "Scheduled date", - "Mark complete for this date", - "Skip instance", + "Mark complete or skip", "Open or create occurrence note", "Reminders", ]) ); + // The single "Mark complete for this date" item was replaced by the + // "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("Mark complete for this date") - ); - expect(titles.indexOf("Scheduled date")).toBeLessThan( - titles.indexOf("Mark complete for this date") - ); - expect(titles.indexOf("Mark complete for this date")).toBeLessThan( - titles.indexOf("Skip instance") - ); - expect(titles.indexOf("Skip instance")).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( 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 e50f126b..f705238a 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -838,6 +838,202 @@ 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(); + }); + + // #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('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', + 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('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); 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", + }); + }); + }); +});