Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2476 (2023-12-04 22:39:40)

feat: add "change task status" context menu
# Description

- added context menu appearing after right-click on the checkbox of the task, with all user-specified task statuses possible to choose
- little refactor of the Task.toggle method (by adding handleNewStatus method) so the same code could be used to handle tasks recurrence if the user chooses DONE status

Actually, I'm not sure about this refactor I mentioned - I guess there is some code in "Create or edit Task" modal after clicking "Apply" button that would have this logic of changing the status, maybe that would be better to use here. But I had some problems finding that piece of code.
I didn't wrote unit tests - I don't really see room for unit tests here, as the change is so very small? But I can of course add them if there will be suggestions what really could be unit tested here.
I didn't add anything to documentation, but I guess that it should be added somewhere that there is a possibility of using this feature. I will add documentation at the end, when there won't be any more issues with this task.

## Motivation and Context

This is yet another improvement for modifying tasks directly from the reading view of tasks list. It adds possibility to change task status to any other status from the context menu appearing after right-click on the checkbox.

For example this issue talks about these kind of improvements https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2039 (more about buttons like the new postpone button, but oh well, the idea stays kind of the same)

The change was inspired by the context menu (for postpoing) added in this pull request:
https://github.com/obsidian-tasks-group/obsidian-tasks/pull/2191

I think it is very helpful, as users with any kind of more complex status system (more complicated than [ ] -> [x], so the "cycle" of task statuses is longer) can use this feature to easily bypass some task statuses if they want (for example go from TODO to DONE, skipping IN_PROGRESS). 

This kind of funcionality is prevalent in task management software (e.g. JIRA)

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## How has this been tested?

I tested it locally, by changing statuses of my tasks, in particular also recurring tasks to check that they will recurr correctly.

## Screenshots (if appropriate)

<img width="335" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/6050673/b89d3e08-a39b-433a-aeb5-3eea35a8e6d6">

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [ ] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


claremacrae: HI @chrabia, thanks very much indeed for doing this.

I just downloaded the build of the demo vault from this PR, and it works really well.

I wondered about whether the context menu item for the current status might be greyed out, for clarity and usability?

In any case, I agree - this will be a very helpful facility to have.

We are currently having problems with the code that you mention this change was inspired by - #2471 - and so my coding time is virtually entirely taken up with trying to figure out how to fix that...

Once the Postpone facility is released, I should have some time to review this properly and think about automated tests for it too.

> I guess there is some code in "Create or edit Task" modal after clicking "Apply" button that would have this logic of changing the status

Sadly not:
- #1590


Patch:
diff --git a/docs/Getting Started/Dates.md b/docs/Getting Started/Dates.md
index 84187749..36041b1e 100644
--- a/docs/Getting Started/Dates.md
+++ b/docs/Getting Started/Dates.md
@@ -18,7 +18,7 @@ When you use the command, you can also set dates like "Monday", "tomorrow", or "
 You can find out more in [[Create or edit Task|‘Create or edit Task’ Modal]].
 
 > [!info]
-> If you prefer to type, it is now very easy to add emojis and other information for you tasks using [[Auto-Suggest|Intelligent Auto-Suggest]].
+> If you prefer to type, it is now very easy to add emojis and other information for your tasks using [[Auto-Suggest|Intelligent Auto-Suggest]].
 
 <!-- NEW_TASK_FIELD_EDIT_REQUIRED -->
 
diff --git a/docs/Getting Started/Getting Started.md b/docs/Getting Started/Getting Started.md
index 05d67f40..49e6c256 100644
--- a/docs/Getting Started/Getting Started.md
+++ b/docs/Getting Started/Getting Started.md
@@ -185,7 +185,7 @@ Obsidian supports two styles of **comments**:
 - `%% I am text in a comment %%`
 
 > [!warning]
-> By design, Tasks does read any tasks that are inside these comments, because Obsidian does not read them.
+> By design, Tasks does **not** read any tasks that are inside these comments, because Obsidian does not read them.
 
 ### Tasks with Footnotes
 
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index c5fd1769..2b84791d 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -415,7 +415,7 @@ class QueryRenderChild extends MarkdownRenderChild {
         button.addClasses(classNames);
         button.setText(' ⏩');
 
-        button.addEventListener('click', () => this.getOnClickCallback(task, button, 'days'));
+        button.addEventListener('click', () => this.postponeOnClickCallback(button, task, 1, 'days'));
 
         /** Open a context menu on right-click.
          * Give a choice of postponing for a week, month, or quarter.
@@ -424,18 +424,18 @@ class QueryRenderChild extends MarkdownRenderChild {
             const menu = new Menu();
             const commonTitle = 'Postpone for';
 
-            const getMenuItemCallback = (item: MenuItem, timeUnit: unitOfTime.DurationConstructor, amount = 1) => {
+            const postponeMenuItemCallback = (item: MenuItem, timeUnit: unitOfTime.DurationConstructor, amount = 1) => {
                 const amountOrArticle = amount > 1 ? amount : 'a';
                 item.setTitle(`${commonTitle} ${amountOrArticle} ${timeUnit}`).onClick(() =>
-                    this.getOnClickCallback(task, button, timeUnit, amount),
+                    this.postponeOnClickCallback(button, task, amount, timeUnit),
                 );
             };
 
-            menu.addItem((item) => getMenuItemCallback(item, 'days', 2));
-            menu.addItem((item) => getMenuItemCallback(item, 'days', 3));
-            menu.addItem((item) => getMenuItemCallback(item, 'week'));
-            menu.addItem((item) => getMenuItemCallback(item, 'weeks', 2));
-            menu.addItem((item) => getMenuItemCallback(item, 'month'));
+            menu.addItem((item) => postponeMenuItemCallback(item, 'days', 2));
+            menu.addItem((item) => postponeMenuItemCallback(item, 'days', 3));
+            menu.addItem((item) => postponeMenuItemCallback(item, 'week'));
+            menu.addItem((item) => postponeMenuItemCallback(item, 'weeks', 2));
+            menu.addItem((item) => postponeMenuItemCallback(item, 'month'));
 
             menu.showAtPosition({ x: ev.clientX, y: ev.clientY });
         });
@@ -476,11 +476,11 @@ class QueryRenderChild extends MarkdownRenderChild {
         return groupingRules.join(',');
     }
 
-    private async getOnClickCallback(
-        task: Task,
+    private async postponeOnClickCallback(
         button: HTMLButtonElement,
-        timeUnit: unitOfTime.DurationConstructor = 'days',
-        amount = 1,
+        task: Task,
+        amount: number,
+        timeUnit: moment.unitOfTime.DurationConstructor,
     ) {
         const dateTypeToUpdate = getDateFieldToPostpone(task);
         if (dateTypeToUpdate === null) {
@@ -490,24 +490,18 @@ class QueryRenderChild extends MarkdownRenderChild {
 
         const { postponedDate, newTasks } = createPostponedTask(task, dateTypeToUpdate, timeUnit, amount);
 
-        this.query.debug('[postpone]: getOnClickCallback() - before call to replaceTaskWithTasks()');
         await replaceTaskWithTasks({
             originalTask: task,
             newTasks,
         });
-        this.query.debug('[postpone]: getOnClickCallback() - after  call to replaceTaskWithTasks()');
-        this.onPostponeSuccessCallback(button, dateTypeToUpdate, postponedDate);
+        this.postponeSuccessCallback(button, dateTypeToUpdate, postponedDate);
     }
 
-    private onPostponeSuccessCallback(button: HTMLButtonElement, updatedDateType: HappensDate, postponedDate: Moment) {
-        this.query.debug('[postpone]: onPostponeSuccessCallback() entered');
+    private postponeSuccessCallback(button: HTMLButtonElement, updatedDateType: HappensDate, postponedDate: Moment) {
         // Disable the button to prevent update error due to the task not being reloaded yet.
         button.disabled = true;
-        button.setAttr('title', 'You can perform this action again after reloading the file.');
 
         const successMessage = postponementSuccessMessage(postponedDate, updatedDateType);
-        new Notice(successMessage, 5000);
-        this.events.triggerRequestCacheUpdate(this.render.bind(this));
-        this.query.debug('[postpone]: onPostponeSuccessCallback() exiting');
+        new Notice(successMessage, 1000);
     }
 }
diff --git a/src/Scripting/Postponer.ts b/src/Scripting/Postponer.ts
index cf44adb4..a0c95417 100644
--- a/src/Scripting/Postponer.ts
+++ b/src/Scripting/Postponer.ts
@@ -3,7 +3,11 @@ import { Task } from '../Task';
 import { TasksDate } from './TasksDate';
 
 export function shouldShowPostponeButton(task: Task) {
-    return !task.isDone;
+    const hasAValidHappensDate = task.happensDates.some((date) => {
+        return !!date?.isValid();
+    });
+
+    return !task.isDone && hasAValidHappensDate;
 }
 
 export type HappensDate = keyof Pick<Task, 'startDate' | 'scheduledDate' | 'dueDate'>;
diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts
index fbb7ecc6..a9384c3f 100644
--- a/src/Scripting/TasksDate.ts
+++ b/src/Scripting/TasksDate.ts
@@ -122,6 +122,6 @@ export class TasksDate {
             return today.add(amount, unitOfTime);
         }
 
-        return this._date.add(amount, unitOfTime);
+        return this._date.clone().add(amount, unitOfTime);
     }
 }
diff --git a/src/Task.ts b/src/Task.ts
index 4620d596..0528b8e2 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -378,6 +378,12 @@ export class Task {
 
         const newStatus = StatusRegistry.getInstance().getNextStatusOrCreate(this.status);
 
+        const newTasks = this.handleNewStatus(newStatus);
+        logEndOfTaskEdit(logger, codeLocation, newTasks);
+        return newTasks;
+    }
+
+    private handleNewStatus(newStatus: Status): Task[] {
         let newDoneDate = null;
 
         let nextOccurrence: {
@@ -430,7 +436,6 @@ export class Task {
         // Write next occurrence before previous occurrence.
         newTasks.push(toggledTask);
 
-        logEndOfTaskEdit(logger, codeLocation, newTasks);
         return newTasks;
     }
 
@@ -458,6 +463,17 @@ export class Task {
         return recurrenceOnNextLine ? newTasks.reverse() : newTasks;
     }
 
+    public handleStatusChangeFromContextMenuWithRecurrenceInUsersOrder(newStatus: Status): Task[] {
+        const logger = logging.getLogger('tasks.Task');
+        logger.trace(
+            `changed task ${this.taskLocation.path} ${this.taskLocation.lineNumber} ${this.originalMarkdown} status to ${newStatus}`,
+        );
+
+        const newTasks = this.handleNewStatus(newStatus);
+        const { recurrenceOnNextLine: recurrenceOnNextLine } = getSettings();
+        return recurrenceOnNextLine ? newTasks.reverse() : newTasks;
+    }
+
     /**
      * Return whether the task is considered done.
      * @returns true if the status type is {@link StatusType.DONE}, {@link StatusType.CANCELLED} or {@link StatusType.NON_TASK}, and false otherwise.
diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts
index 7b379e45..5654ccc6 100644
--- a/src/TaskLineRenderer.ts
+++ b/src/TaskLineRenderer.ts
@@ -1,8 +1,9 @@
 import type { Moment } from 'moment';
-import { Component, MarkdownRenderer } from 'obsidian';
+import { Component, MarkdownRenderer, Menu, MenuItem } from 'obsidian';
 import { GlobalFilter } from './Config/GlobalFilter';
 import { TASK_FORMATS, getSettings } from './Config/Settings';
 import { replaceTaskWithTasks } from './File';
+import { StatusRegistry } from './StatusRegistry';
 import type { Task } from './Task';
 import * as taskModule from './Task';
 import { TaskFieldRenderer } from './TaskFieldRenderer';
@@ -123,6 +124,32 @@ export class TaskLineRenderer {
             });
         });
 
+        checkbox.addEventListener('contextmenu', async (ev: MouseEvent) => {
+            const menu = new Menu();
+            const commonTitle = 'Change status to: ';
+
+            const getMenuItemCallback = (item: MenuItem, statusName: string, newStatusSymbol: string) => {
+                item.setTitle(`${commonTitle}  ${statusName}`).onClick(() => {
+                    const status = StatusRegistry.getInstance().bySymbol(newStatusSymbol);
+                    const newTask = task.handleStatusChangeFromContextMenuWithRecurrenceInUsersOrder(status);
+                    replaceTaskWithTasks({
+                        originalTask: task,
+                        newTasks: newTask,
+                    });
+                });
+            };
+
+            const { statusSettings } = getSettings();
+            for (const status of statusSettings.coreStatuses) {
+                menu.addItem((item) => getMenuItemCallback(item, status.name, status.symbol));
+            }
+            for (const status of statusSettings.customStatuses) {
+                menu.addItem((item) => getMenuItemCallback(item, status.name, status.symbol));
+            }
+
+            menu.showAtPosition({ x: ev.clientX, y: ev.clientY });
+        });
+
         li.prepend(checkbox);
 
         // Set these to be compatible with stock obsidian lists:
@@ -152,24 +179,23 @@ export class TaskLineRenderer {
                 // Create the text span that will hold the rendered component
                 const span = document.createElement('span');
                 parentElement.appendChild(span);
-                if (span) {
-                    // Inside that text span, we are creating another internal span, that will hold the text itself.
-                    // This may seem redundant, and by default it indeed does nothing, but we do it to allow the CSS
-                    // to differentiate between the container of the text and the text itself, so it will be possible
-                    // to do things like surrounding only the text (rather than its whole placeholder) with a highlight
-                    const internalSpan = document.createElement('span');
-                    span.appendChild(internalSpan);
-                    await this.renderComponentText(internalSpan, componentString, component, task);
-                    this.addInternalClasses(component, internalSpan);
-
-                    // Add the component's CSS class describing what this component is (priority, due date etc.)
-                    const componentClass = fieldRenderer.className(component);
-                    span.classList.add(...[componentClass]);
-
-                    // Add the component's attribute ('priority-medium', 'due-past-1d' etc.)
-                    fieldRenderer.addDataAttribute(span, task, component);
-                    fieldRenderer.addDataAttribute(li, task, component);
-                }
+
+                // Inside that text span, we are creating another internal span, that will hold the text itself.
+                // This may seem redundant, and by default it indeed does nothing, but we do it to allow the CSS
+                // to differentiate between the container of the text and the text itself, so it will be possible
+                // to do things like surrounding only the text (rather than its whole placeholder) with a highlight
+                const internalSpan = document.createElement('span');
+                span.appendChild(internalSpan);
+                await this.renderComponentText(internalSpan, componentString, component, task);
+                this.addInternalClasses(component, internalSpan);
+
+                // Add the component's CSS class describing what this component is (priority, due date etc.)
+                const componentClass = fieldRenderer.className(component);
+                span.classList.add(...[componentClass]);
+
+                // Add the component's attribute ('priority-medium', 'due-past-1d' etc.)
+                fieldRenderer.addDataAttribute(span, task, component);
+                fieldRenderer.addDataAttribute(li, task, component);
             }
         }
 


Test patch:
diff --git a/tests/Scripting/Postponer.test.ts b/tests/Scripting/Postponer.test.ts
index ee223444..3735604e 100644
--- a/tests/Scripting/Postponer.test.ts
+++ b/tests/Scripting/Postponer.test.ts
@@ -2,15 +2,17 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import type { Task } from 'Task';
 import {
     type HappensDate,
+    createPostponedTask,
     getDateFieldToPostpone,
     postponementSuccessMessage,
     shouldShowPostponeButton,
 } from '../../src/Scripting/Postponer';
-import { TaskBuilder } from '../TestingTools/TaskBuilder';
-import { StatusConfiguration, StatusType } from '../../src/StatusConfiguration';
 import { Status } from '../../src/Status';
+import { StatusConfiguration, StatusType } from '../../src/StatusConfiguration';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
 
 window.moment = moment;
 
@@ -67,13 +69,15 @@ describe('postpone - date field choice', () => {
         const taskBuilder = new TaskBuilder().scheduledDate(date).startDate(date);
         checkPostponeField(taskBuilder, 'scheduledDate');
     });
+
+    // TODO Check it refuses to postpone an invalid date (failing test)
 });
 
 describe('postpone - whether to show button', () => {
     it('should account for status type', () => {
         function checkPostponeButtonVisibility(statusType: StatusType, expected: boolean) {
             const status = new Status(new StatusConfiguration('p', 'Test', 'q', true, statusType));
-            const task = new TaskBuilder().status(status).build();
+            const task = new TaskBuilder().dueDate('2023-10-30').status(status).build();
             expect(shouldShowPostponeButton(task)).toEqual(expected);
         }
 
@@ -86,6 +90,105 @@ describe('postpone - whether to show button', () => {
         checkPostponeButtonVisibility(StatusType.CANCELLED, false);
         checkPostponeButtonVisibility(StatusType.DONE, false);
     });
+
+    it('should not show button for a task with no dates', () => {
+        const task = new TaskBuilder().build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+
+    it('should not show button for a task with a created date only', () => {
+        const task = new TaskBuilder().createdDate('2023-11-29').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+
+    it('should not show button for a task with a done date only', () => {
+        const task = new TaskBuilder().doneDate('2023-11-30').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+
+    it('should show button for a task with a start date only', () => {
+        const task = new TaskBuilder().startDate('2023-12-01').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(true);
+    });
+
+    it('should not show button for a task with an invalid start date', () => {
+        const task = new TaskBuilder().startDate('2023-13-01').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+
+    it('should show button for a task with a scheduled date only', () => {
+        const task = new TaskBuilder().scheduledDate('2023-12-02').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(true);
+    });
+
+    it('should not show button for a task with an invalid scheduled date', () => {
+        const task = new TaskBuilder().scheduledDate('2023-12-36').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+
+    it('should show button for a task with a due date only', () => {
+        const task = new TaskBuilder().dueDate('2023-12-03').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(true);
+    });
+
+    it('should not show button for a task with an invalid due date', () => {
+        const task = new TaskBuilder().dueDate('20233-12-03').build();
+
+        expect(shouldShowPostponeButton(task)).toEqual(false);
+    });
+});
+
+describe('postpone - new task creation', () => {
+    beforeEach(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2023-12-03'));
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+    });
+
+    function testPostponedTaskAndDate(task: Task, expectedDateField: HappensDate, expectedPostponedDate: string) {
+        const { postponedDate, newTasks } = createPostponedTask(task, expectedDateField, 'day', 1);
+        expect(postponedDate.format('YYYY-MM-DD')).toEqual(expectedPostponedDate);
+        expect(newTasks[expectedDateField]?.format('YYYY-MM-DD')).toEqual(expectedPostponedDate);
+
+        // If the scheduled date was inferred from the filename, and it is the scheduledDate that was postponed,
+        // we must ensure that the 'inferred' flag has been reset to false.
+        // Otherwise, the new scheduled date will be ignored in some locations, like rendering of dates.
+        if (task.scheduledDateIsInferred && expectedDateField === 'scheduledDate') {
+            expect(newTasks.scheduledDateIsInferred).toEqual(false);
+        }
+    }
+
+    it('should postpone an overdue task to today', () => {
+        const task = new TaskBuilder().dueDate('2023-11-01').build();
+        const expectedPostponedDate = '2023-12-04';
+        testPostponedTaskAndDate(task, 'dueDate', expectedPostponedDate);
+    });
+
+    it('should postpone a task scheduled today to tomorrow', () => {
+        const task = new TaskBuilder().scheduledDate('2023-12-03').build();
+        testPostponedTaskAndDate(task, 'scheduledDate', '2023-12-04');
+    });
+
+    it.failing('should postpone a task scheduled today to tomorrow, when the scheduled date is inferred', () => {
+        const task = new TaskBuilder().scheduledDate('2023-12-03').scheduledDateIsInferred(true).build();
+        testPostponedTaskAndDate(task, 'scheduledDate', '2023-12-04');
+    });
+
+    it('should postpone a task that starts in the future to the next day', () => {
+        const task = new TaskBuilder().startDate('2024-03-05').build();
+        testPostponedTaskAndDate(task, 'startDate', '2024-03-06');
+    });
 });
 
 describe('postpone - postponement success message', () => {
diff --git a/tests/Scripting/TasksDate.test.ts b/tests/Scripting/TasksDate.test.ts
index 0758d22f..f4f1936c 100644
--- a/tests/Scripting/TasksDate.test.ts
+++ b/tests/Scripting/TasksDate.test.ts
@@ -4,6 +4,7 @@
 
 import moment from 'moment';
 
+import type { unitOfTime } from 'moment/moment';
 import { TasksDate } from '../../src/Scripting/TasksDate';
 
 window.moment = moment;
@@ -105,3 +106,156 @@ describe('TasksDate', () => {
         expect(new TasksDate(moment('2023-02-31')).fromNow.groupText).toEqual('%%320230611%% Invalid date');
     });
 });
+
+describe('TasksDate - postpone', () => {
+    beforeEach(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date('2023-11-28'));
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+    });
+
+    function checkDatePostponesTo(
+        initialDate: string,
+        amount: number,
+        unitOfTime: unitOfTime.DurationConstructor,
+        expectedDate: string,
+    ) {
+        const tasksDate = new TasksDate(moment(initialDate));
+        const postponedDate = new TasksDate(tasksDate.postpone(unitOfTime, amount));
+        expect(postponedDate.formatAsDate()).toEqual(expectedDate);
+    }
+
+    it('should not modify the original date when postponing', () => {
+        function checkPostponingDoesNotModifyOriginalDate(
+            initialDate: string,
+            amount: number,
+            unitOfTime: moment.unitOfTime.DurationConstructor,
+        ) {
+            // Arrange
+            const initialTasksDate = new TasksDate(moment(initialDate));
+
+            // Act
+            const postponed = new TasksDate(initialTasksDate.postpone(unitOfTime, amount));
+
+            // Assert
+            // We don't care what the new date is; we just need to know that the date has been modified
+            // or the Assert below would pass spuriously.
+            expect(postponed.formatAsDate()).not.toEqual(initialDate);
+
+            expect(initialTasksDate.formatAsDate()).toEqual(initialDate);
+        }
+
+        checkPostponingDoesNotModifyOriginalDate('2023-11-27', 1, 'day'); // before today
+        checkPostponingDoesNotModifyOriginalDate('2023-11-28', 1, 'day'); // today
+        checkPostponingDoesNotModifyOriginalDate('2023-11-30', 1, 'day'); // a future date
+    });
+
+    it('should postpone an older date (before yesterday) to tomorrow', () => {
+        checkDatePostponesTo('2023-11-20', 1, 'day', '2023-11-29');
+    });
+
+    it('should postpone yesterday date to tomorrow', () => {
+        checkDatePostponesTo('2023-11-27', 1, 'day', '2023-11-29');
+
+        // TODO: Review this behaviour.
+        //       It is not clear that this is the best result - it is just the current result, since #2473.
+        checkDatePostponesTo('2023-11-27', 1, 'week', '2023-12-05');
+        checkDatePostponesTo('2023-11-27', 1, 'month', '2023-12-28');
+    });
+
+    it('should postpone today date to tomorrow', () => {
+        checkDatePostponesTo('2023-11-28', 1, 'day', '2023-11-29');
+    });
+
+    it('should postpone tomorrow date to after tomorrow', () => {
+        checkDatePostponesTo('2023-11-29', 1, 'day', '2023-11-30');
+    });
+
+    it('should postpone a future date (after tomorrow)', () => {
+        checkDatePostponesTo('2024-03-20', 1, 'day', '2024-03-21');
+    });
+
+    it('should postpone by a month, to a shorter month (in a leap year)', () => {
+        checkDatePostponesTo('2024-01-31', 1, 'month', '2024-02-29');
+    });
+
+    // TODO Add more tests for increments other than 1 day
+
+    describe('visualise postpone behaviour', () => {
+        function postponeMultipleDatesBy(amount: number, unitOfTime: unitOfTime.DurationConstructor) {
+            // Set a date that is easy to decrement and increment
+            const today = '2023-11-10';
+            jest.setSystemTime(new Date(today));
+
+            const dates = [
+                '2023-11-01',
+                '2023-11-02',
+                '2023-11-03',
+                '2023-11-04',
+                '2023-11-05',
+                '2023-11-06',
+                '2023-11-07',
+                '2023-11-08',
+                '2023-11-09',
+                '2023-11-10',
+                '2023-11-11',
+                '2023-11-12',
+                '2023-11-13',
+            ];
+            let output = `[initial]     => [postponed on '${today}' by '${amount} ${unitOfTime}']
+`;
+            dates.forEach((date) => {
+                const tasksDate = new TasksDate(moment(date));
+                const format = 'YYYY-MM-DD ddd';
+                const postponedDate = new TasksDate(tasksDate.postpone(unitOfTime, amount));
+                output += `${tasksDate.format(format)} => ${postponedDate.format(format)}\n`;
+            });
+            return output;
+        }
+
+        it('by 1 day', () => {
+            const postponedDatesDescription = postponeMultipleDatesBy(1, 'day');
+            expect(postponedDatesDescription).toMatchInlineSnapshot(`
+                "[initial]     => [postponed on '2023-11-10' by '1 day']
+                2023-11-01 Wed => 2023-11-11 Sat
+                2023-11-02 Thu => 2023-11-11 Sat
+                2023-11-03 Fri => 2023-11-11 Sat
+                2023-11-04 Sat => 2023-11-11 Sat
+                2023-11-05 Sun => 2023-11-11 Sat
+                2023-11-06 Mon => 2023-11-11 Sat
+                2023-11-07 Tue => 2023-11-11 Sat
+                2023-11-08 Wed => 2023-11-11 Sat
+                2023-11-09 Thu => 2023-11-11 Sat
+                2023-11-10 Fri => 2023-11-11 Sat
+                2023-11-11 Sat => 2023-11-12 Sun
+                2023-11-12 Sun => 2023-11-13 Mon
+                2023-11-13 Mon => 2023-11-14 Tue
+                "
+            `);
+        });
+
+        it('by 1 week', () => {
+            const postponedDatesDescription = postponeMultipleDatesBy(1, 'week');
+            expect(postponedDatesDescription).toMatchInlineSnapshot(`
+                "[initial]     => [postponed on '2023-11-10' by '1 week']
+                2023-11-01 Wed => 2023-11-17 Fri
+                2023-11-02 Thu => 2023-11-17 Fri
+                2023-11-03 Fri => 2023-11-17 Fri
+                2023-11-04 Sat => 2023-11-17 Fri
+                2023-11-05 Sun => 2023-11-17 Fri
+                2023-11-06 Mon => 2023-11-17 Fri
+                2023-11-07 Tue => 2023-11-17 Fri
+                2023-11-08 Wed => 2023-11-17 Fri
+                2023-11-09 Thu => 2023-11-17 Fri
+                2023-11-10 Fri => 2023-11-17 Fri
+                2023-11-11 Sat => 2023-11-18 Sat
+                2023-11-12 Sun => 2023-11-19 Sun
+                2023-11-13 Mon => 2023-11-20 Mon
+                "
+            `);
+        });
+    });
+});