From 99ddb35630959cfdf817e8ac3bdd73db78715909 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Sat, 1 Aug 2026 20:02:11 -0700 Subject: [PATCH] add repeat current task command Reuse a completed, non-recurring task for a future date: log its completion date in complete_instances, reopen it at the default status, unarchive it when it had been archived, then open the edit modal to pick the new scheduled and due dates. Recurring tasks and materialized occurrence notes are rejected, since they already record each occurrence when completed. Body checkboxes are reset when resetCheckboxesOnRecurrence is enabled, sharing a helper extracted from toggleRecurringTaskComplete. --- docs/features/task-management.md | 19 ++++ docs/releases/unreleased.md | 10 ++ i18n.manifest.json | 1 + i18n.state.json | 32 ++++++ src/commands/taskNotesCommands.ts | 7 ++ src/i18n/resources/de.ts | 1 + src/i18n/resources/en.ts | 1 + src/i18n/resources/es.ts | 1 + src/i18n/resources/fr.ts | 1 + src/i18n/resources/ja.ts | 1 + src/i18n/resources/ko.ts | 1 + src/i18n/resources/pt.ts | 1 + src/i18n/resources/ru.ts | 1 + src/i18n/resources/zh.ts | 1 + src/main.ts | 35 +++++++ src/services/TaskService.ts | 82 ++++++++++++--- tests/unit/services/TaskService.test.ts | 131 +++++++++++++++++++++++- 17 files changed, 312 insertions(+), 14 deletions(-) diff --git a/docs/features/task-management.md b/docs/features/task-management.md index 7ce977d09..260ae7054 100644 --- a/docs/features/task-management.md +++ b/docs/features/task-management.md @@ -178,6 +178,25 @@ These settings let you align task files with existing vault conventions (for exa For configuration details, see [Task Defaults](../settings/task-defaults.md). For template variables, see [Template Variables Reference](template-variables.md). +## Repeating a Completed Task + +Some tasks come back without following a schedule you can express as a rule: replacing a filter, reviewing a document, or watering a plant when it needs it. For these, **TaskNotes: Repeat current task** reuses the same note for the next round instead of creating a new one. + +Run the command from the command palette with the completed task's note open. TaskNotes will: + +1. Append the task's completion date to its `complete_instances` list, building up a record of every time you have finished it. +2. Return the task to your default status, clearing `completedDate`. +3. Unarchive the task if it had been archived, moving the file back out of the archive folder when **Move archived tasks to folder** is enabled. +4. Open the task edit modal so you can set the new scheduled and due dates. + +The old scheduled and due dates are left as they were until you change them in the modal, so closing the modal without saving leaves the task open with its previous dates. + +The command applies to completed tasks that are not recurring. Recurring tasks and materialized occurrence notes already record each occurrence in `complete_instances` when you complete them, so the command declines to act on them. A task completed without a `completedDate` value is still reopened, but there is no date to add to the history. + +If **Reset checkboxes on recurrence** is enabled in **Settings → Features → Recurring tasks**, the checkboxes in the task body are also unchecked, the same as when a recurring task completes. + +Note that the `complete_instances` history recorded this way is stored in frontmatter for your own reference and for Bases views; the completions calendar in the task edit modal is only shown for tasks with a recurrence rule. + ## Recurring Tasks TaskNotes recurring tasks use RFC 5545 RRule syntax with `DTSTART`, separate pattern definition from next occurrence scheduling, and support independent instance completion. When an individual recurrence needs its own checklist, time entries, or notes, you can create a materialized occurrence note from the task or calendar context menu. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..75949304a 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,13 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + + +## Added + +- Added a **Repeat current task** command for reusing a completed task on a new + date. It records the completion date in `complete_instances`, reopens the + task, unarchives it if needed, and opens the edit modal so you can set the + next scheduled and due dates. Useful for tasks that come back irregularly and + do not fit a recurrence rule. See + [Task Management](https://tasknotes.dev/features/task-management/#repeating-a-completed-task). diff --git a/i18n.manifest.json b/i18n.manifest.json index e28e799f5..e26a3ad7d 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1387,6 +1387,7 @@ "commands.quickActionsTaskUnderCursor": "3cd5d4f1806626306cd61e494c92503f16a73cb7", "commands.editCurrentTask": "21af1c6600d8208725cd6b57c07f10c7c66421ad", "commands.cycleCurrentTaskStatus": "61c5c35adec710369dbcd6da430c381a70156e5d", + "commands.repeatCurrentTask": "b1a568d46de22dc7529d7234dea3d56b17043ad4", "commands.cycleCurrentTaskPriority": "943d8224c722f6f234c78dbe137391b70e468627", "commands.addProjectToCurrentTask": "398692671e3609ec5b8b0fec36d7490e231a1ede", "commands.addSubtaskToCurrentNote": "6a632e4256dc14f31c838090636eea96590fea1a", diff --git a/i18n.state.json b/i18n.state.json index 7ebb09e0d..d32be973b 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -5552,6 +5552,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "701ef7cbc5e9410a49a411169cc61ecb29860976" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "a1ea8fbfef22bd7dee871bcc04f042f4fd78c047" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "58afd3ef24b97707f57b09bb5f2374c8ec29ba67" @@ -14442,6 +14446,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "5a39bc387e6eea8bb5a4daadf6821b3b459e40d0" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "ba0e902580a0a462631797761fa047c2e0ebe3b8" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "8188693563eac7261e01fcaba68226bfafae8c49" @@ -23332,6 +23340,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "f3655e1a9e3bea33eb3fc614ffc8f9ef66e92fce" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "f1ee690411b80cc528f2914c809a7178299917d3" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "984ba6464d80fa3ddd90ab976313eee77983b0bf" @@ -32222,6 +32234,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "c822eb9692a7f8dd6eab45edddc517f6720c5af2" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "b3d9cd9f506ade3a7940d9544a64c2e4a97f3ba1" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "fe5eddf56b7d0554db3fc47e303f4b0e6d07ac3d" @@ -41112,6 +41128,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "4c834a48b9ada6c8697fd5ecc08a6b86ca0228d1" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "cc9bc8a91df886aee6338d39cc00211a6e0f3f8d" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "aa9c56274d2710c95529ed88b2244bc75066e559" @@ -50002,6 +50022,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "ff9264bd736905ee499a2f2c573ece7c4f765f20" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "ae22993299f77df5554e731a07e4b9bd4ba42365" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "b58c8b4710846b06850c42e8e6dea039f24e1c1e" @@ -58892,6 +58916,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "b7e9b24542615253bbd005d57c91afe6168be29c" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "5763d6683d7d5b651ee89fd682986af80c0a29f2" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "726c4d162c2dd284d830dfef55ca22bb76e1eb2c" @@ -67782,6 +67810,10 @@ "source": "61c5c35adec710369dbcd6da430c381a70156e5d", "translation": "cd39cf908e9bf16b756d7dc2565bdb5e3f95d737" }, + "commands.repeatCurrentTask": { + "source": "b1a568d46de22dc7529d7234dea3d56b17043ad4", + "translation": "a7bc1d6dc3d4eef1c6530765525f0078136de9cd" + }, "commands.cycleCurrentTaskPriority": { "source": "943d8224c722f6f234c78dbe137391b70e468627", "translation": "5f87219a73e32dfb6b74224db5bc9da18557cf6d" diff --git a/src/commands/taskNotesCommands.ts b/src/commands/taskNotesCommands.ts index 6a20b9c39..56ef4b63c 100644 --- a/src/commands/taskNotesCommands.ts +++ b/src/commands/taskNotesCommands.ts @@ -160,6 +160,13 @@ export function createTaskNotesCommandDefinitions( await ctx.cycleCurrentTaskStatus(); }, }, + { + id: "repeat-current-task", + nameKey: "commands.repeatCurrentTask", + callback: async (ctx) => { + await ctx.repeatCurrentTask(); + }, + }, { id: "cycle-current-task-priority", nameKey: "commands.cycleCurrentTaskPriority", diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 531055a6a..a71d60ec9 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -2273,6 +2273,7 @@ export const de: TranslationTree = { quickActionsTaskUnderCursor: "Schnellaktionen für Aufgabe unter dem Cursor", editCurrentTask: "Aktuelle Aufgabe bearbeiten", cycleCurrentTaskStatus: "Status der aktuellen Aufgabe wechseln", + repeatCurrentTask: "Aktuelle Aufgabe wiederholen", cycleCurrentTaskPriority: "Priorität der aktuellen Aufgabe wechseln", addProjectToCurrentTask: "Projekt zur aktuellen Aufgabe hinzufügen", addSubtaskToCurrentNote: "Unteraufgabe zur aktuellen Notiz hinzufügen" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2a427eb2f..3e6114900 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2414,6 +2414,7 @@ export const en: TranslationTree = { quickActionsTaskUnderCursor: "Quick actions for task under cursor", editCurrentTask: "Edit current task", cycleCurrentTaskStatus: "Cycle current task status", + repeatCurrentTask: "Repeat current task", cycleCurrentTaskPriority: "Cycle current task priority", addProjectToCurrentTask: "Add project to current task", addSubtaskToCurrentNote: "Add subtask to current note", diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 62722a5c2..5ba02164a 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -2273,6 +2273,7 @@ export const es: TranslationTree = { quickActionsTaskUnderCursor: "Acciones rápidas para la tarea bajo el cursor", editCurrentTask: "Editar tarea actual", cycleCurrentTaskStatus: "Cambiar estado de la tarea actual", + repeatCurrentTask: "Repetir la tarea actual", cycleCurrentTaskPriority: "Cambiar prioridad de la tarea actual", addProjectToCurrentTask: "Añadir proyecto a la tarea actual", addSubtaskToCurrentNote: "Añadir subtarea a la nota actual" diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 6b63ac044..920335c81 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -2273,6 +2273,7 @@ export const fr: TranslationTree = { quickActionsTaskUnderCursor: "Actions rapides pour la tâche sous le curseur", editCurrentTask: "Modifier la tâche actuelle", cycleCurrentTaskStatus: "Faire défiler le statut de la tâche actuelle", + repeatCurrentTask: "Répéter la tâche en cours", cycleCurrentTaskPriority: "Faire défiler la priorité de la tâche actuelle", addProjectToCurrentTask: "Ajouter un projet à la tâche actuelle", addSubtaskToCurrentNote: "Ajouter une sous-tâche à la note actuelle" diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index bbc1b08e9..3f98f84d2 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -2273,6 +2273,7 @@ export const ja: TranslationTree = { quickActionsTaskUnderCursor: "カーソル下のタスクのクイックアクション", editCurrentTask: "現在のタスクを編集", cycleCurrentTaskStatus: "現在のタスクのステータスを切り替え", + repeatCurrentTask: "現在のタスクを繰り返す", cycleCurrentTaskPriority: "現在のタスクの優先度を切り替え", addProjectToCurrentTask: "現在のタスクにプロジェクトを追加", addSubtaskToCurrentNote: "現在のノートにサブタスクを追加" diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index 938227e07..046db6935 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -2257,6 +2257,7 @@ export const ko: TranslationTree = { quickActionsTaskUnderCursor: "커서 아래 작업의 빠른 작업", editCurrentTask: "현재 작업 편집", cycleCurrentTaskStatus: "현재 작업 상태 순환", + repeatCurrentTask: "현재 작업 반복", cycleCurrentTaskPriority: "현재 작업 우선순위 순환", addProjectToCurrentTask: "현재 작업에 프로젝트 추가", addSubtaskToCurrentNote: "현재 노트에 하위 작업 추가" diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 188a44419..0913cf6f4 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -2275,6 +2275,7 @@ export const pt: TranslationTree = { quickActionsTaskUnderCursor: "Ações rápidas para a tarefa sob o cursor", editCurrentTask: "Editar tarefa atual", cycleCurrentTaskStatus: "Alternar status da tarefa atual", + repeatCurrentTask: "Repetir a tarefa atual", cycleCurrentTaskPriority: "Alternar prioridade da tarefa atual", addProjectToCurrentTask: "Adicionar projeto à tarefa atual", addSubtaskToCurrentNote: "Adicionar subtarefa à nota atual" diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 19c45e007..47043875f 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -2273,6 +2273,7 @@ export const ru: TranslationTree = { quickActionsTaskUnderCursor: "Быстрые действия для задачи под курсором", editCurrentTask: "Редактировать текущую задачу", cycleCurrentTaskStatus: "Переключить статус текущей задачи", + repeatCurrentTask: "Повторить текущую задачу", cycleCurrentTaskPriority: "Переключить приоритет текущей задачи", addProjectToCurrentTask: "Добавить проект к текущей задаче", addSubtaskToCurrentNote: "Добавить подзадачу к текущей заметке" diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 0c6b0d1b6..a3ced7ef3 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -2273,6 +2273,7 @@ export const zh: TranslationTree = { quickActionsTaskUnderCursor: "光标下任务的快速操作", editCurrentTask: "编辑当前任务", cycleCurrentTaskStatus: "循环当前任务状态", + repeatCurrentTask: "重复当前任务", cycleCurrentTaskPriority: "循环当前任务优先级", addProjectToCurrentTask: "向当前任务添加项目", addSubtaskToCurrentNote: "向当前笔记添加子任务" diff --git a/src/main.ts b/src/main.ts index 643880fd4..027ca0852 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,7 @@ import { FieldMapper } from "./services/FieldMapper"; import { StatusManager } from "./services/StatusManager"; import { PriorityManager } from "./services/PriorityManager"; import { TaskService } from "./services/TaskService"; +import { isMaterializedOccurrenceTask } from "@tasknotes/model/operations"; import { FilterService } from "./services/FilterService"; import { TaskStatsService } from "./services/TaskStatsService"; import type { ViewPerformanceService } from "./services/ViewPerformanceService"; @@ -1711,6 +1712,40 @@ export default class TaskNotesPlugin extends Plugin { } } + async repeatCurrentTask(): Promise { + try { + const taskInfo = await this.getCurrentTaskForCommand(); + if (!taskInfo) { + return; + } + + if (taskInfo.recurrence) { + new Notice("Recurring tasks already track each occurrence"); + return; + } + + if (isMaterializedOccurrenceTask(taskInfo)) { + new Notice("This task is an occurrence of a recurring task"); + return; + } + + if (!this.statusManager.isCompletedStatus(taskInfo.status)) { + new Notice("Current task is not completed"); + return; + } + + const repeatedTask = await this.taskService.repeatTask(taskInfo); + await this.openTaskEditModal(repeatedTask); + } catch (error) { + tasknotesLogger.error("Failed to repeat current task:", { + category: "persistence", + operation: "repeat-current-task", + error: error, + }); + new Notice("Failed to repeat task"); + } + } + async cycleCurrentTaskPriority(): Promise { try { const taskInfo = await this.getCurrentTaskForCommand(); diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 899390967..59902968c 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -1754,6 +1754,72 @@ export class TaskService { return getRecurringTaskActionDate(task, date); } + /** + * Reset markdown checkboxes in a task body. + * Returns the rewritten details, or null when the body had nothing to reset. + */ + private async resetTaskBodyCheckboxes(file: TFile): Promise { + const currentContent = await this.plugin.app.vault.read(file); + const { frontmatter: frontmatterText, body } = splitFrontmatterAndBody(currentContent); + const { content: resetBody, changed } = resetMarkdownCheckboxes(body); + + if (!changed) { + return null; + } + + const frontmatterBlock = frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : ""; + const finalBody = resetBody.trimEnd(); + const newContent = + finalBody.length > 0 ? `${frontmatterBlock}${finalBody}\n` : frontmatterBlock; + await this.plugin.app.vault.modify(file, newContent); + + return resetBody.replace(/\r\n/g, "\n").trimEnd(); + } + + /** + * Reuse a completed, non-recurring task for a future occurrence: log its + * completion date in complete_instances, then reopen it. Callers are expected + * to prompt for the new scheduled/due dates afterwards. + */ + async repeatTask(task: TaskInfo): Promise { + const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task; + + if (freshTask.recurrence || isMaterializedOccurrenceTask(freshTask)) { + throw new Error("Task is recurring"); + } + + if (!this.plugin.statusManager.isCompletedStatus(freshTask.status)) { + throw new Error("Task is not completed"); + } + + // Unarchiving owns the archive tag and can move the file, so it has to + // happen before the frontmatter write that reopens the task. + const activeTask = freshTask.archived ? await this.toggleArchive(freshTask) : freshTask; + + const updates: Partial = { status: this.plugin.settings.defaultTaskStatus }; + const completedDate = this.getSafeDatePart(activeTask.completedDate); + const completeInstances = Array.isArray(activeTask.complete_instances) + ? activeTask.complete_instances + : []; + if (completedDate && !completeInstances.includes(completedDate)) { + updates.complete_instances = [...completeInstances, completedDate]; + } + + const updatedTask = await this.updateTask(activeTask, updates); + + if (this.plugin.settings.resetCheckboxesOnRecurrence) { + const file = this.plugin.app.vault.getAbstractFileByPath(updatedTask.path); + if (file instanceof TFile) { + const resetDetails = await this.resetTaskBodyCheckboxes(file); + if (resetDetails !== null) { + updatedTask.details = resetDetails; + } + } + } + + return updatedTask; + } + async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); if (!(file instanceof TFile)) { @@ -1805,20 +1871,10 @@ export class TaskService { // Step 2b: Reset checkboxes in task body when completing (if setting enabled) if (newComplete && this.plugin.settings.resetCheckboxesOnRecurrence) { - const currentContent = await this.plugin.app.vault.read(file); - const { frontmatter: frontmatterText, body } = splitFrontmatterAndBody(currentContent); - const { content: resetBody, changed } = resetMarkdownCheckboxes(body); - - if (changed) { - const frontmatterBlock = - frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : ""; - const finalBody = resetBody.trimEnd(); - const newContent = - finalBody.length > 0 ? `${frontmatterBlock}${finalBody}\n` : frontmatterBlock; - await this.plugin.app.vault.modify(file, newContent); - + const resetDetails = await this.resetTaskBodyCheckboxes(file); + if (resetDetails !== null) { // Update the details field in the returned task - updatedTask.details = resetBody.replace(/\r\n/g, "\n").trimEnd(); + updatedTask.details = resetDetails; } } diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 28762757b..3da1554ec 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -53,7 +53,8 @@ jest.mock('../../../src/utils/helpers', () => ({ addDTSTARTToRecurrenceRule: jest.fn((task: { recurrence?: string }) => task.recurrence ? `DTSTART:20250110T120000Z;${task.recurrence}` : null), updateDTSTARTInRecurrenceRule: jest.fn((rule: string) => rule), updateToNextScheduledOccurrence: jest.fn(), - splitFrontmatterAndBody: jest.fn(() => ({ frontmatter: {}, body: '' })) + splitFrontmatterAndBody: jest.fn(() => ({ frontmatter: {}, body: '' })), + resetMarkdownCheckboxes: jest.fn(() => ({ content: '', changed: false })) })); jest.mock('../../../src/utils/templateProcessor', () => ({ @@ -1282,6 +1283,134 @@ describe('TaskService', () => { }); }); + describe('repeatTask', () => { + let completedTask: TaskInfo; + let capturedFrontmatter: any; + + beforeEach(() => { + completedTask = TaskFactory.createTask({ + status: 'done', + completedDate: '2025-01-01', + scheduled: '2024-12-28' + }); + mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(new TFile(completedTask.path)); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(completedTask); + + capturedFrontmatter = { completedDate: '2025-01-01', status: 'done' }; + mockPlugin.app.fileManager.processFrontMatter.mockImplementation(async (_file: any, fn: any) => { + fn(capturedFrontmatter); + }); + }); + + it('should log the completion date and reopen the task', async () => { + const result = await taskService.repeatTask(completedTask); + + expect(result.status).toBe('open'); + expect(result.complete_instances).toEqual(['2025-01-01']); + expect(result.completedDate).toBeUndefined(); + expect(capturedFrontmatter.completedDate).toBeUndefined(); + }); + + it('should append to an existing completion history', async () => { + completedTask = TaskFactory.createTask({ + status: 'done', + completedDate: '2025-01-01', + complete_instances: ['2024-11-05'] + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(completedTask); + + const result = await taskService.repeatTask(completedTask); + + expect(result.complete_instances).toEqual(['2024-11-05', '2025-01-01']); + }); + + it('should not record the same completion date twice', async () => { + completedTask = TaskFactory.createTask({ + status: 'done', + completedDate: '2025-01-01', + complete_instances: ['2025-01-01'] + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(completedTask); + + const result = await taskService.repeatTask(completedTask); + + expect(result.complete_instances).toEqual(['2025-01-01']); + expect(result.status).toBe('open'); + }); + + it('should store only the date part of a completion timestamp', async () => { + completedTask = TaskFactory.createTask({ + status: 'done', + completedDate: '2025-01-01T14:30:00' + }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(completedTask); + + const result = await taskService.repeatTask(completedTask); + + expect(result.complete_instances).toEqual(['2025-01-01']); + }); + + it('should still reopen a task that has no completion date', async () => { + completedTask = TaskFactory.createTask({ status: 'done', completedDate: undefined }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(completedTask); + + const result = await taskService.repeatTask(completedTask); + + expect(result.status).toBe('open'); + expect(result.complete_instances).toBeUndefined(); + }); + + it('should reject a recurring task', async () => { + const recurringTask = TaskFactory.createRecurringTask('FREQ=DAILY', { status: 'done' }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask); + + await expect(taskService.repeatTask(recurringTask)).rejects.toThrow('Task is recurring'); + }); + + it('should reject a task that is not completed', async () => { + const openTask = TaskFactory.createTask({ status: 'open' }); + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(openTask); + + await expect(taskService.repeatTask(openTask)).rejects.toThrow('Task is not completed'); + }); + + it('should unarchive before reopening an archived task', async () => { + const archivedTask = TaskFactory.createTask({ + status: 'done', + completedDate: '2025-01-01', + archived: true + }); + const unarchivedTask = { ...archivedTask, archived: false, path: 'tasks/unarchived.md' }; + mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(archivedTask); + const toggleArchive = jest.spyOn(taskService, 'toggleArchive').mockResolvedValue(unarchivedTask); + const updateTask = jest.spyOn(taskService, 'updateTask'); + + const result = await taskService.repeatTask(archivedTask); + + expect(toggleArchive).toHaveBeenCalledWith(archivedTask); + expect(updateTask).toHaveBeenCalledWith(unarchivedTask, expect.objectContaining({ status: 'open' })); + expect(result.archived).toBe(false); + }); + + it('should reset body checkboxes only when the recurrence setting is enabled', async () => { + const helpers = jest.requireMock('../../../src/utils/helpers'); + helpers.splitFrontmatterAndBody.mockReturnValue({ frontmatter: 'title: Test', body: '- [x] Step one' }); + helpers.resetMarkdownCheckboxes.mockReturnValue({ content: '- [ ] Step one', changed: true }); + + await taskService.repeatTask(completedTask); + expect(mockPlugin.app.vault.modify).not.toHaveBeenCalled(); + + mockPlugin.settings.resetCheckboxesOnRecurrence = true; + const result = await taskService.repeatTask(completedTask); + + expect(mockPlugin.app.vault.modify).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('- [ ] Step one') + ); + expect(result.details).toBe('- [ ] Step one'); + }); + }); + describe('deleteTask', () => { let task: TaskInfo; let mockFile: TFile;