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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/features/task-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions i18n.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions i18n.state.json
Original file line number Diff line number Diff line change
Expand Up @@ -5552,6 +5552,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "701ef7cbc5e9410a49a411169cc61ecb29860976"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "a1ea8fbfef22bd7dee871bcc04f042f4fd78c047"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "58afd3ef24b97707f57b09bb5f2374c8ec29ba67"
Expand Down Expand Up @@ -14442,6 +14446,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "5a39bc387e6eea8bb5a4daadf6821b3b459e40d0"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "ba0e902580a0a462631797761fa047c2e0ebe3b8"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "8188693563eac7261e01fcaba68226bfafae8c49"
Expand Down Expand Up @@ -23332,6 +23340,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "f3655e1a9e3bea33eb3fc614ffc8f9ef66e92fce"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "f1ee690411b80cc528f2914c809a7178299917d3"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "984ba6464d80fa3ddd90ab976313eee77983b0bf"
Expand Down Expand Up @@ -32222,6 +32234,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "c822eb9692a7f8dd6eab45edddc517f6720c5af2"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "b3d9cd9f506ade3a7940d9544a64c2e4a97f3ba1"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "fe5eddf56b7d0554db3fc47e303f4b0e6d07ac3d"
Expand Down Expand Up @@ -41112,6 +41128,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "4c834a48b9ada6c8697fd5ecc08a6b86ca0228d1"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "cc9bc8a91df886aee6338d39cc00211a6e0f3f8d"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "aa9c56274d2710c95529ed88b2244bc75066e559"
Expand Down Expand Up @@ -50002,6 +50022,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "ff9264bd736905ee499a2f2c573ece7c4f765f20"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "ae22993299f77df5554e731a07e4b9bd4ba42365"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "b58c8b4710846b06850c42e8e6dea039f24e1c1e"
Expand Down Expand Up @@ -58892,6 +58916,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "b7e9b24542615253bbd005d57c91afe6168be29c"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "5763d6683d7d5b651ee89fd682986af80c0a29f2"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "726c4d162c2dd284d830dfef55ca22bb76e1eb2c"
Expand Down Expand Up @@ -67782,6 +67810,10 @@
"source": "61c5c35adec710369dbcd6da430c381a70156e5d",
"translation": "cd39cf908e9bf16b756d7dc2565bdb5e3f95d737"
},
"commands.repeatCurrentTask": {
"source": "b1a568d46de22dc7529d7234dea3d56b17043ad4",
"translation": "a7bc1d6dc3d4eef1c6530765525f0078136de9cd"
},
"commands.cycleCurrentTaskPriority": {
"source": "943d8224c722f6f234c78dbe137391b70e468627",
"translation": "5f87219a73e32dfb6b74224db5bc9da18557cf6d"
Expand Down
7 changes: 7 additions & 0 deletions src/commands/taskNotesCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,7 @@ export const ja: TranslationTree = {
quickActionsTaskUnderCursor: "カーソル下のタスクのクイックアクション",
editCurrentTask: "現在のタスクを編集",
cycleCurrentTaskStatus: "現在のタスクのステータスを切り替え",
repeatCurrentTask: "現在のタスクを繰り返す",
cycleCurrentTaskPriority: "現在のタスクの優先度を切り替え",
addProjectToCurrentTask: "現在のタスクにプロジェクトを追加",
addSubtaskToCurrentNote: "現在のノートにサブタスクを追加"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2257,6 +2257,7 @@ export const ko: TranslationTree = {
quickActionsTaskUnderCursor: "커서 아래 작업의 빠른 작업",
editCurrentTask: "현재 작업 편집",
cycleCurrentTaskStatus: "현재 작업 상태 순환",
repeatCurrentTask: "현재 작업 반복",
cycleCurrentTaskPriority: "현재 작업 우선순위 순환",
addProjectToCurrentTask: "현재 작업에 프로젝트 추가",
addSubtaskToCurrentNote: "현재 노트에 하위 작업 추가"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,7 @@ export const ru: TranslationTree = {
quickActionsTaskUnderCursor: "Быстрые действия для задачи под курсором",
editCurrentTask: "Редактировать текущую задачу",
cycleCurrentTaskStatus: "Переключить статус текущей задачи",
repeatCurrentTask: "Повторить текущую задачу",
cycleCurrentTaskPriority: "Переключить приоритет текущей задачи",
addProjectToCurrentTask: "Добавить проект к текущей задаче",
addSubtaskToCurrentNote: "Добавить подзадачу к текущей заметке"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/resources/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,7 @@ export const zh: TranslationTree = {
quickActionsTaskUnderCursor: "光标下任务的快速操作",
editCurrentTask: "编辑当前任务",
cycleCurrentTaskStatus: "循环当前任务状态",
repeatCurrentTask: "重复当前任务",
cycleCurrentTaskPriority: "循环当前任务优先级",
addProjectToCurrentTask: "向当前任务添加项目",
addSubtaskToCurrentNote: "向当前笔记添加子任务"
Expand Down
35 changes: 35 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1711,6 +1712,40 @@ export default class TaskNotesPlugin extends Plugin {
}
}

async repeatCurrentTask(): Promise<void> {
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<void> {
try {
const taskInfo = await this.getCurrentTaskForCommand();
Expand Down
82 changes: 69 additions & 13 deletions src/services/TaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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<TaskInfo> {
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<TaskInfo> = { 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<TaskInfo> {
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
if (!(file instanceof TFile)) {
Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading
Loading