Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
Add Postpone-like context menu to the Date Emojis (Start, Scheduled, Due and likely others too) to allow easy changing of individual fields
### ⚠️ Please check that this feature request hasn't been suggested before.

- [X] I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas-any-new-feature-requests-go-in-issues-please?discussions_q=category%3A%22Ideas%3A+Any+New+Feature+Requests+go+in+Issues+please%22+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests.
- [X] I searched previous [Issues](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22+sort%3Acomments-desc) didn't find any similar feature requests.
- [X] I am only requesting a single feature. Multiple changes should be split into individual requests, with links between them.
- [X] I believe my requested feature will be generally applicable to other users, not just me: it is not uniquely tied to my personal workflow.

### 🔖 Feature description

There has been a lot of back-and-forth about alternative behaviours for the Postpone button and its context menu.

This is to capture an idea first written down in #2452:

**Add right-click menu on each of the date emojis (including created and done) - to edit specific fields**

Initially this will be enabled in the Tasks query results view.

It may end up appearing in Reading mode too, for free.

### ✔️ Solution

Use the patterns of code in these two directories to generalise the code for the Postpone context menu, and apply it for each of the date emojis.

- [src/ui/EditInstructions/](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/ui/EditInstructions)
- [src/ui/Menus/](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/ui/Menus)


### ❓ Alternatives

_No response_

### 📝 Additional Context

I have actually tried to do this several times already, trying to refactor the Postpone code for re-use, and not succeeded, because of the complexity of the call-backs in that code.

Pull request description:
feat: Add date context menu in Reading mode and Tasks Query results
# Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
  - Issue/discussion: #2648

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)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Description

Add right-click context menus on all date values in Read Mode and Tasks Query results.

This offers the same date choices as the Postpone menu, except that:

- It acts on the particular date value that the user right-clicks on
- It also works on Done, Created and Cancelled, but offers a selection of recent past dates rather than future dates, as more appropriate for those fields.
Note: documentation is needed, and I will do this before release. For now, I want to test it out further in my own main vault.

## Motivation and Context

Fixes #2648.


## How has this been tested?

- Via new unit tests
    - All the new code was written with TDD, so the coverage is good.
- Exploratory testing in the sample vault.
- Plus I have been using an earlier version of this code in my own vault for some months.

## Screenshots (if appropriate)

![image](https://github.com/user-attachments/assets/26a8d276-122c-4359-b73f-ed7204a1781d)

![image](https://github.com/user-attachments/assets/712a137c-396f-4b2a-8fdd-a9d307fb5a38)

## 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.
    - _Docs will be added later_
- [x] 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 follows 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)


Patch:
diff --git a/src/DateTime/DateFieldTypes.ts b/src/DateTime/DateFieldTypes.ts
index 6d8ecf52..a22dfdd9 100644
--- a/src/DateTime/DateFieldTypes.ts
+++ b/src/DateTime/DateFieldTypes.ts
@@ -11,3 +11,7 @@ export type AllTaskDateFields = keyof Pick<
     Task,
     'cancelledDate' | 'createdDate' | 'doneDate' | 'dueDate' | 'scheduledDate' | 'startDate' // alphabetical order, please.
 >;
+
+export function isAHappensDate(field: AllTaskDateFields) {
+    return ['startDate', 'scheduledDate', 'dueDate'].includes(field);
+}
diff --git a/src/DateTime/Postponer.ts b/src/DateTime/Postponer.ts
index 3040b5b6..0fe12b9e 100644
--- a/src/DateTime/Postponer.ts
+++ b/src/DateTime/Postponer.ts
@@ -178,6 +178,10 @@ export function fixedDateMenuItemTitle(task: Task, amount: number, timeUnit: uni
  */
 export function removeDateMenuItemTitle(task: Task, _amount: number, _timeUnit: unitOfTime.DurationConstructor) {
     const updatedDateType = getDateFieldToPostpone(task)!;
+    return removeDateMenuItemTitleForField(updatedDateType, task);
+}
+
+export function removeDateMenuItemTitleForField(updatedDateType: AllTaskDateFields, task: Task) {
     if (updatedDateType === 'scheduledDate' && task.scheduledDateIsInferred) {
         return 'Cannot remove inferred scheduled date';
     } else {
@@ -185,7 +189,7 @@ export function removeDateMenuItemTitle(task: Task, _amount: number, _timeUnit:
     }
 }
 
-function prettyPrintDateFieldName(updatedDateType: HappensDate) {
+function prettyPrintDateFieldName(updatedDateType: AllTaskDateFields) {
     return capitalizeFirstLetter(updatedDateType.replace('Date', ''));
 }
 
@@ -193,8 +197,8 @@ export function splitDateText(updatedDateType: AllTaskDateFields) {
     return updatedDateType.replace('Date', ' date');
 }
 
-function postponeMenuItemTitleFromDate(
-    updatedDateType: HappensDate,
+export function postponeMenuItemTitleFromDate(
+    updatedDateType: AllTaskDateFields,
     dateToUpdate: moment.Moment,
     amount: number,
     timeUnit: unitOfTime.DurationConstructor,
@@ -205,11 +209,19 @@ function postponeMenuItemTitleFromDate(
     const amountOrArticle = amount != 1 ? amount : 'a';
     if (dateToUpdate.isSameOrBefore(window.moment(), 'day')) {
         const updatedDateDisplayText = prettyPrintDateFieldName(updatedDateType);
-        return `${updatedDateDisplayText} in ${amountOrArticle} ${timeUnit}, on ${formattedNewDate}`
+        const title =
+            amount >= 0
+                ? `${updatedDateDisplayText} in ${amountOrArticle} ${timeUnit}, on ${formattedNewDate}`
+                : `${updatedDateDisplayText} ${-amountOrArticle} ${timeUnit} ago, on ${formattedNewDate}`;
+        return title
+            .replace(' 1 day ago', ' yesterday')
             .replace(' in 0 days', ' today')
             .replace('in a day', 'tomorrow');
-    } else {
-        const updatedDateDisplayText = splitDateText(updatedDateType);
+    }
+    const updatedDateDisplayText = splitDateText(updatedDateType);
+    if (amount >= 0) {
         return `Postpone ${updatedDateDisplayText} by ${amountOrArticle} ${timeUnit}, to ${formattedNewDate}`;
+    } else {
+        return `Backdate ${updatedDateDisplayText} by ${-amountOrArticle} ${timeUnit}, to ${formattedNewDate}`;
     }
 }
diff --git a/src/Renderer/TaskLineRenderer.ts b/src/Renderer/TaskLineRenderer.ts
index ddf2b05b..702e5812 100644
--- a/src/Renderer/TaskLineRenderer.ts
+++ b/src/Renderer/TaskLineRenderer.ts
@@ -13,6 +13,7 @@ import type { AllTaskDateFields } from '../DateTime/DateFieldTypes';
 import { defaultTaskSaver } from '../ui/Menus/TaskEditingMenu';
 import { promptForDate } from '../ui/Menus/DatePicker';
 import { splitDateText } from '../DateTime/Postponer';
+import { DateMenu } from '../ui/Menus/DateMenu';
 import { TaskFieldRenderer } from './TaskFieldRenderer';
 
 /**
@@ -221,7 +222,16 @@ export class TaskLineRenderer {
                         promptForDate(li, task, componentDateField, defaultTaskSaver);
                     });
 
-                    span.setAttribute('title', `Click to edit ${splitDateText(componentDateField)}`);
+                    span.addEventListener('contextmenu', (ev: MouseEvent) => {
+                        ev.preventDefault(); // suppress the default context menu
+                        ev.stopPropagation(); // suppress further event propagation
+                        const menu = new DateMenu(componentDateField, task, defaultTaskSaver);
+                        menu.showAtPosition({ x: ev.clientX, y: ev.clientY });
+                    });
+                    span.setAttribute(
+                        'title',
+                        `Click to edit ${splitDateText(componentDateField)}, Right-click for more options`,
+                    );
                 }
             }
         }
diff --git a/src/ui/EditInstructions/DateInstructions.ts b/src/ui/EditInstructions/DateInstructions.ts
index bd339f64..f2f438ad 100644
--- a/src/ui/EditInstructions/DateInstructions.ts
+++ b/src/ui/EditInstructions/DateInstructions.ts
@@ -1,14 +1,25 @@
+import type { unitOfTime } from 'moment';
 import type { AllTaskDateFields } from '../../DateTime/DateFieldTypes';
 import { Task } from '../../Task/Task';
+import { postponeMenuItemTitleFromDate, removeDateMenuItemTitleForField } from '../../DateTime/Postponer';
+import { TasksDate } from '../../DateTime/TasksDate';
 import type { TaskEditingInstruction } from './TaskEditingInstruction';
+import { MenuDividerInstruction } from './MenuDividerInstruction';
 
+/**
+ * An instruction to set a date field to an absolute date.
+ *
+ * See also {@link SetRelativeTaskDate} and {@link RemoveTaskDate}.
+ */
 export class SetTaskDate implements TaskEditingInstruction {
     private readonly newDate: Date;
     private readonly dateFieldToEdit;
+    private readonly displayName: string;
 
-    constructor(dateFieldToEdit: AllTaskDateFields, date: Date) {
+    constructor(dateFieldToEdit: AllTaskDateFields, date: Date, displayName?: string) {
         this.newDate = date;
         this.dateFieldToEdit = dateFieldToEdit;
+        this.displayName = displayName ?? `Set Date: ${this.newDate.toDateString()}`;
     }
 
     public apply(task: Task): Task[] {
@@ -25,10 +36,133 @@ export class SetTaskDate implements TaskEditingInstruction {
     }
 
     public instructionDisplayName(): string {
-        return `Set Date: ${this.newDate.toDateString()}`;
+        return this.displayName;
     }
 
     public isCheckedForTask(task: Task): boolean {
         return task[this.dateFieldToEdit]?.isSame(window.moment(this.newDate)) || false;
     }
 }
+
+/**
+ * An instruction to set a date field to a date relative to the current value, or
+ * relative to today, if there is no current value.
+ *
+ * See also {@link SetTaskDate} and {@link RemoveTaskDate}.
+ */
+export class SetRelativeTaskDate extends SetTaskDate {
+    constructor(
+        dateFieldToEdit: AllTaskDateFields,
+        taskDueToday: Task,
+        amount: number,
+        timeUnit: unitOfTime.DurationConstructor,
+    ) {
+        const currentDate = taskDueToday[dateFieldToEdit] ?? window.moment();
+        const title = postponeMenuItemTitleFromDate(dateFieldToEdit, currentDate, amount, timeUnit);
+
+        const newDate = new TasksDate(window.moment(currentDate)).postpone(timeUnit, amount).toDate();
+        super(dateFieldToEdit, newDate, title);
+    }
+}
+
+/**
+ * An instruction to remove any value from a date field, if there is a current value.
+ *
+ * See also {@link SetTaskDate} and {@link SetRelativeTaskDate}.
+ */
+export class RemoveTaskDate implements TaskEditingInstruction {
+    private readonly dateFieldToEdit: AllTaskDateFields;
+    private readonly displayName: string;
+
+    constructor(dateFieldToEdit: AllTaskDateFields, task: Task) {
+        this.dateFieldToEdit = dateFieldToEdit;
+        this.displayName = removeDateMenuItemTitleForField(dateFieldToEdit, task);
+    }
+
+    apply(task: Task): Task[] {
+        // There's no point trying to remove an inferred scheduled date, as the next time
+        // Tasks starts up, it will infer the scheduled date again from the file name,
+        // which will be very confusing for users.
+        const fieldIsInferred = this.dateFieldToEdit === 'scheduledDate' && task.scheduledDateIsInferred;
+        const fieldIsAlreadyNull = task[this.dateFieldToEdit] === null;
+        if (fieldIsAlreadyNull || fieldIsInferred) {
+            return [task];
+        }
+        return [
+            new Task({
+                ...task,
+                [this.dateFieldToEdit]: null,
+            }),
+        ];
+    }
+
+    instructionDisplayName(): string {
+        return this.displayName;
+    }
+
+    isCheckedForTask(_task: Task): boolean {
+        return false;
+    }
+}
+
+/**
+ * For Starts, Scheduled, Due.
+ * @param field
+ * @param task
+ * @see allLifeCycleDateInstructions
+ */
+export function allHappensDateInstructions(field: AllTaskDateFields, task: Task) {
+    return allDateInstructions(task, field, 1);
+}
+
+/**
+ * For Done, Cancelled, Created.
+ * @param field
+ * @param task
+ * @see allHappensDateInstructions
+ */
+export function allLifeCycleDateInstructions(field: AllTaskDateFields, task: Task) {
+    return allDateInstructions(task, field, -1);
+}
+
+/**
+ * Add instructions to move a date either forwards or backwards
+ * @param task
+ * @param field
+ * @param factor - +1 means today or future dates; -1 = today or earlier dates.
+ */
+function allDateInstructions(task: Task, field: AllTaskDateFields, factor: number) {
+    const today = window.moment().startOf('day');
+    const todayAsDate = today.toDate();
+    const todayAsTasksDate = new TasksDate(today.clone());
+
+    return [
+        new SetTaskDate(field, todayAsDate, postponeMenuItemTitleFromDate(field, today, 0, 'days')),
+
+        // A confusing mixture of Date, Moment and TasksDate!!!
+        new SetTaskDate(
+            field,
+            todayAsTasksDate.postpone('day', factor).toDate(),
+            postponeMenuItemTitleFromDate(field, today, factor, 'day'),
+        ),
+
+        new MenuDividerInstruction(),
+
+        new SetRelativeTaskDate(field, task, factor * 2, 'days'),
+        new SetRelativeTaskDate(field, task, factor * 3, 'days'),
+        new SetRelativeTaskDate(field, task, factor * 4, 'days'),
+        new SetRelativeTaskDate(field, task, factor * 5, 'days'),
+        new SetRelativeTaskDate(field, task, factor * 6, 'days'),
+
+        new MenuDividerInstruction(),
+
+        new SetRelativeTaskDate(field, task, factor, 'week'),
+        new SetRelativeTaskDate(field, task, factor * 2, 'weeks'),
+        new SetRelativeTaskDate(field, task, factor * 3, 'weeks'),
+        new SetRelativeTaskDate(field, task, factor, 'month'),
+
+        new MenuDividerInstruction(),
+
+        new RemoveTaskDate(field, task),
+    ];
+}
diff --git a/src/ui/EditInstructions/MenuDividerInstruction.ts b/src/ui/EditInstructions/MenuDividerInstruction.ts
new file mode 100644
index 00000000..c5c8782e
--- /dev/null
+++ b/src/ui/EditInstructions/MenuDividerInstruction.ts
@@ -0,0 +1,21 @@
+import type { Task } from 'Task/Task';
+import type { TaskEditingInstruction } from './TaskEditingInstruction';
+
+/**
+ * A placeholder to indicate that an instruction is meant to be a menu separator.
+ */
+export const SEPARATOR_INSTRUCTION_DISPLAY_NAME = '---';
+
+export class MenuDividerInstruction implements TaskEditingInstruction {
+    apply(_task: Task): Task[] {
+        throw new Error('MenuDividerInstruction.apply(): Method not implemented.');
+    }
+
+    instructionDisplayName(): string {
+        return SEPARATOR_INSTRUCTION_DISPLAY_NAME;
+    }
+
+    isCheckedForTask(_task: Task): boolean {
+        return false;
+    }
+}
diff --git a/src/ui/Menus/DateMenu.ts b/src/ui/Menus/DateMenu.ts
new file mode 100644
index 00000000..8f74bc2a
--- /dev/null
+++ b/src/ui/Menus/DateMenu.ts
@@ -0,0 +1,21 @@
+import type { Task } from '../../Task/Task';
+import { type AllTaskDateFields, isAHappensDate } from '../../DateTime/DateFieldTypes';
+import { allHappensDateInstructions, allLifeCycleDateInstructions } from '../EditInstructions/DateInstructions';
+import { TaskEditingMenu, type TaskSaver, defaultTaskSaver } from './TaskEditingMenu';
+
+export class DateMenu extends TaskEditingMenu {
+    /**
+     * Constructor, which sets up the menu items.
+     * @param field - the Date field to edit
+     * @param task - the Task to be edited.
+     * @param taskSaver - an optional {@link TaskSaver} function. For details, see {@link TaskEditingMenu}.
+     */
+    constructor(field: AllTaskDateFields, task: Task, taskSaver: TaskSaver = defaultTaskSaver) {
+        super(taskSaver);
+
+        const instructions = isAHappensDate(field)
+            ? allHappensDateInstructions(field, task)
+            : allLifeCycleDateInstructions(field, task);
+        this.addItemsForInstructions(instructions, task);
+    }
+}
diff --git a/src/ui/Menus/TaskEditingMenu.ts b/src/ui/Menus/TaskEditingMenu.ts
index e50666de..018d1822 100644
--- a/src/ui/Menus/TaskEditingMenu.ts
+++ b/src/ui/Menus/TaskEditingMenu.ts
@@ -2,6 +2,7 @@ import { Menu, type MenuItem } from 'obsidian';
 import type { Task } from '../../Task/Task';
 import { replaceTaskWithTasks } from '../../Obsidian/File';
 import type { TaskEditingInstruction } from '../EditInstructions/TaskEditingInstruction';
+import { SEPARATOR_INSTRUCTION_DISPLAY_NAME } from '../EditInstructions/MenuDividerInstruction';
 
 /**
  * A function for replacing one task with zero or more new tasks.
@@ -48,7 +49,11 @@ export class TaskEditingMenu extends Menu {
     }
 
     private addItemForInstruction(task: Task, instruction: TaskEditingInstruction) {
-        this.addItem((item) => this.getMenuItemCallback(task, item, instruction));
+        if (instruction.instructionDisplayName() === SEPARATOR_INSTRUCTION_DISPLAY_NAME) {
+            this.addSeparator();
+        } else {
+            this.addItem((item) => this.getMenuItemCallback(task, item, instruction));
+        }
     }
 
     private getMenuItemCallback(task: Task, item: MenuItem, instruction: TaskEditingInstruction) {


Test patch:
diff --git a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html
index 4e18bb4c..d9ad41d8 100644
--- a/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html
+++ b/tests/Renderer/QueryResultsRenderer.test.QueryResultsRenderer_tests_fully_populated_task.approved.html
@@ -21,20 +21,34 @@
         <span class="task-priority" data-task-priority="medium"><span>🔼</span></span>
         <span class="task-recurring"><span>🔁 every day when done</span></span>
         <span class="task-onCompletion"><span>🏁 delete</span></span>
-        <span class="task-created" data-task-created="past-far" title="Click to edit created date">
+        <span
+          class="task-created"
+          data-task-created="past-far"
+          title="Click to edit created date, Right-click for more options">
           <span>➕ 2023-07-01</span>
         </span>
-        <span class="task-start" data-task-start="past-far" title="Click to edit start date">
+        <span
+          class="task-start"
+          data-task-start="past-far"
+          title="Click to edit start date, Right-click for more options">
           <span>🛫 2023-07-02</span>
         </span>
-        <span class="task-scheduled" data-task-scheduled="past-far" title="Click to edit scheduled date">
+        <span
+          class="task-scheduled"
+          data-task-scheduled="past-far"
+          title="Click to edit scheduled date, Right-click for more options">
           <span>⏳ 2023-07-03</span>
         </span>
-        <span class="task-due" data-task-due="past-far" title="Click to edit due date"><span>📅 2023-07-04</span></span>
-        <span class="task-cancelled" data-task-cancelled="past-far" title="Click to edit cancelled date">
+        <span class="task-due" data-task-due="past-far" title="Click to edit due date, Right-click for more options">
+          <span>📅 2023-07-04</span>
+        </span>
+        <span
+          class="task-cancelled"
+          data-task-cancelled="past-far"
+          title="Click to edit cancelled date, Right-click for more options">
           <span>❌ 2023-07-06</span>
         </span>
-        <span class="task-done" data-task-done="past-far" title="Click to edit done date">
+        <span class="task-done" data-task-done="past-far" title="Click to edit done date, Right-click for more options">
           <span>✅ 2023-07-05</span>
         </span>
         <span class="task-block-link"><span>^dcf64c</span></span>
diff --git a/tests/Renderer/QueryResultsRenderer.test.ts b/tests/Renderer/QueryResultsRenderer.test.ts
index 6e024c02..9865b7db 100644
--- a/tests/Renderer/QueryResultsRenderer.test.ts
+++ b/tests/Renderer/QueryResultsRenderer.test.ts
@@ -11,6 +11,15 @@ import { TaskBuilder } from '../TestingTools/TaskBuilder';
 
 window.moment = moment;
 
+beforeEach(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date('2024-08-19'));
+});
+
+afterEach(() => {
+    jest.useRealTimers();
+});
+
 describe('QueryResultsRenderer tests', () => {
     it('fully populated task', async () => {
         const renderer = new QueryResultsRenderer(
diff --git a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html
index 1ab0e59b..5360594f 100644
--- a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html
+++ b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_full_mode.approved.html
@@ -23,20 +23,33 @@
     <span class="task-priority" data-task-priority="medium"><span>🔼</span></span>
     <span class="task-recurring"><span>🔁 every day when done</span></span>
     <span class="task-onCompletion"><span>🏁 delete</span></span>
-    <span class="task-created" data-task-created="past-4d" title="Click to edit created date">
+    <span
+      class="task-created"
+      data-task-created="past-4d"
+      title="Click to edit created date, Right-click for more options">
       <span>➕ 2023-07-01</span>
     </span>
-    <span class="task-start" data-task-start="past-3d" title="Click to edit start date">
+    <span class="task-start" data-task-start="past-3d" title="Click to edit start date, Right-click for more options">
       <span>🛫 2023-07-02</span>
     </span>
-    <span class="task-scheduled" data-task-scheduled="past-2d" title="Click to edit scheduled date">
+    <span
+      class="task-scheduled"
+      data-task-scheduled="past-2d"
+      title="Click to edit scheduled date, Right-click for more options">
       <span>⏳ 2023-07-03</span>
     </span>
-    <span class="task-due" data-task-due="past-1d" title="Click to edit due date"><span>📅 2023-07-04</span></span>
-    <span class="task-cancelled" data-task-cancelled="future-1d" title="Click to edit cancelled date">
+    <span class="task-due" data-task-due="past-1d" title="Click to edit due date, Right-click for more options">
+      <span>📅 2023-07-04</span>
+    </span>
+    <span
+      class="task-cancelled"
+      data-task-cancelled="future-1d"
+      title="Click to edit cancelled date, Right-click for more options">
       <span>❌ 2023-07-06</span>
     </span>
-    <span class="task-done" data-task-done="today" title="Click to edit done date"><span>✅ 2023-07-05</span></span>
+    <span class="task-done" data-task-done="today" title="Click to edit done date, Right-click for more options">
+      <span>✅ 2023-07-05</span>
+    </span>
     <span class="task-block-link"><span>^dcf64c</span></span>
   </span>
 </li>
diff --git a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html
index 0fadf12f..378b57b8 100644
--- a/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html
+++ b/tests/Renderer/TaskLineRenderer.test.Visualise_HTML_Full_task_-_short_mode.approved.html
@@ -23,16 +23,33 @@
     <span class="task-priority" data-task-priority="medium"><span>🔼</span></span>
     <span class="task-recurring"><span>🔁</span></span>
     <span class="task-onCompletion"><span>🏁</span></span>
-    <span class="task-created" data-task-created="past-4d" title="Click to edit created date"><span>➕</span></span>
-    <span class="task-start" data-task-start="past-3d" title="Click to edit start date"><span>🛫</span></span>
-    <span class="task-scheduled" data-task-scheduled="past-2d" title="Click to edit scheduled date">
+    <span
+      class="task-created"
+      data-task-created="past-4d"
+      title="Click to edit created date, Right-click for more options">
+      <span>➕</span>
+    </span>
+    <span class="task-start" data-task-start="past-3d" title="Click to edit start date, Right-click for more options">
+      <span>🛫</span>
+    </span>
+    <span
+      class="task-scheduled"
+      data-task-scheduled="past-2d"
+      title="Click to edit scheduled date, Right-click for more options">
       <span>⏳</span>
     </span>
-    <span class="task-due" data-task-due="past-1d" title="Click to edit due date"><span>📅</span></span>
-    <span class="task-cancelled" data-task-cancelled="future-1d" title="Click to edit cancelled date">
+    <span class="task-due" data-task-due="past-1d" title="Click to edit due date, Right-click for more options">
+      <span>📅</span>
+    </span>
+    <span
+      class="task-cancelled"
+      data-task-cancelled="future-1d"
+      title="Click to edit cancelled date, Right-click for more options">
       <span>❌</span>
     </span>
-    <span class="task-done" data-task-done="today" title="Click to edit done date"><span>✅</span></span>
+    <span class="task-done" data-task-done="today" title="Click to edit done date, Right-click for more options">
+      <span>✅</span>
+    </span>
     <span class="task-block-link"><span>^dcf64c</span></span>
   </span>
 </li>
diff --git a/tests/ui/EditInstructions/DateInstructions.test.ts b/tests/ui/EditInstructions/DateInstructions.test.ts
index 45b7f9ba..7ef4a345 100644
--- a/tests/ui/EditInstructions/DateInstructions.test.ts
+++ b/tests/ui/EditInstructions/DateInstructions.test.ts
@@ -3,13 +3,27 @@
  */
 import moment from 'moment';
 
+import type { unitOfTime } from 'moment/moment';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
-import { SetTaskDate } from '../../../src/ui/EditInstructions/DateInstructions';
+import {
+    RemoveTaskDate,
+    SetRelativeTaskDate,
+    SetTaskDate,
+    allHappensDateInstructions,
+    allLifeCycleDateInstructions,
+} from '../../../src/ui/EditInstructions/DateInstructions';
+import type { AllTaskDateFields } from '../../../src/DateTime/DateFieldTypes';
+import { Task } from '../../../src/Task/Task';
+import { TaskLayoutComponent } from '../../../src/Layout/TaskLayoutOptions';
+import { TasksDate } from '../../../src/DateTime/TasksDate';
+import { SEPARATOR_INSTRUCTION_DISPLAY_NAME } from '../../../src/ui/EditInstructions/MenuDividerInstruction';
+import type { TaskEditingInstruction } from '../../../src/ui/EditInstructions/TaskEditingInstruction';
 
 window.moment = moment;
 
+const yesterday = '2024-09-30';
 const today = '2024-10-01';
-const tomorrow = '2023-12-04';
+const tomorrow = '2024-10-02';
 
 beforeEach(() => {
     jest.useFakeTimers();
@@ -20,11 +34,11 @@ afterEach(() => {
     jest.useRealTimers();
 });
 
-describe('SetTaskDate', () => {
-    const taskWithNoDates = new TaskBuilder().build();
-    const taskDueToday = new TaskBuilder().dueDate(today).build();
-    const taskDueTomorrow = new TaskBuilder().dueDate(tomorrow).build();
+const taskWithNoDates = new TaskBuilder().build();
+const taskDueToday = new TaskBuilder().dueDate(today).build();
+const taskDueTomorrow = new TaskBuilder().dueDate(tomorrow).build();
 
+describe('SetTaskDate', () => {
     it('should provide information to set up a menu item for due date', () => {
         // Arrange
         const instruction = new SetTaskDate('dueDate', new Date(today));
@@ -35,11 +49,19 @@ describe('SetTaskDate', () => {
         expect(instruction.isCheckedForTask(taskDueToday)).toEqual(true);
     });
 
+    it('should create a menu item with a custom display name', () => {
+        // Arrange
+        const instruction = new SetTaskDate('dueDate', new Date(today), 'Apple Sauce');
+
+        // Assert
+        expect(instruction.instructionDisplayName()).toEqual('Apple Sauce');
+    });
+
     it('should edit the date', () => {
         // Arrange
         const instruction = new SetTaskDate('dueDate', new Date(tomorrow));
 
-        // Assert
+        // Apply
         const newTasks = instruction.apply(taskWithNoDates);
 
         // Assert
@@ -51,7 +73,7 @@ describe('SetTaskDate', () => {
         // Arrange
         const instruction = new SetTaskDate('dueDate', new Date(tomorrow));
 
-        // Assert
+        // Apply
         const newTasks = instruction.apply(taskDueTomorrow);
 
         // Assert
@@ -60,3 +82,296 @@ describe('SetTaskDate', () => {
         expect(Object.is(newTasks[0], taskDueTomorrow)).toBe(true);
     });
 });
+
+describe('SetRelativeTaskDate', () => {
+    function testSetRelativeTaskDate(
+        task: Task,
+        dateFieldToEdit: AllTaskDateFields,
+        amount: number,
+        timeUnit: unitOfTime.DurationConstructor,
+        expectedTitle: string,
+        expectedNewDate: moment.Moment,
+    ) {
+        // Arrange
+        const instruction = new SetRelativeTaskDate(dateFieldToEdit, task, amount, timeUnit);
+
+        // Apply
+        const newTasks = instruction.apply(task);
+
+        // Assert
+        expect(instruction.instructionDisplayName()).toEqual(expectedTitle);
+        expect(newTasks.length).toEqual(1);
+        expect(newTasks[0][dateFieldToEdit]).toEqualMoment(expectedNewDate);
+    }
+
+    it('should postpone a task with a due date', () => {
+        const expectedTitle = 'Due tomorrow, on Wed 2nd Oct';
+        testSetRelativeTaskDate(taskDueToday, 'dueDate', 1, 'day', expectedTitle, moment(tomorrow));
+    });
+
+    it("should postpone a task without a due date, based on today's date", () => {
+        const expectedTitle = 'Start in 2 weeks, on Tue 15th Oct';
+        testSetRelativeTaskDate(taskWithNoDates, 'startDate', 2, 'weeks', expectedTitle, moment('2024-10-15'));
+    });
+
+    it('should set a done date', () => {
+        const expectedTitle = 'Done today, on Tue 1st Oct';
+        testSetRelativeTaskDate(taskWithNoDates, 'doneDate', 0, 'days', expectedTitle, moment(today));
+    });
+});
+
+describe('RemoveTaskDate', () => {
+    it('should provide information to set up a menu item for removing start date', () => {
+        const instruction = new RemoveTaskDate('startDate', taskWithNoDates);
+
+        expect(instruction.instructionDisplayName()).toEqual('Remove start date');
+        expect(instruction.isCheckedForTask(taskWithNoDates)).toEqual(false);
+    });
+
+    it('should refuse to remove scheduled date from task with inferred scheduled date', () => {
+        const task = new TaskBuilder().scheduledDate(today).scheduledDateIsInferred(true).build();
+        const instruction = new RemoveTaskDate('scheduledDate', task);
+
+        expect(instruction.instructionDisplayName()).toEqual('Cannot remove inferred scheduled date');
+
+        const newTasks = instruction.apply(task);
+        expect(newTasks.length).toEqual(1);
+        const newTask = newTasks[0];
+
+        // Check that the initial task has not been modified:
+        expect(newTask).toBe(task);
+    });
+
+    it('should not edit task if already has no value in field being removed', () => {
+        // Arrange
+        const task = new TaskBuilder()
+            .createdDate(today)
+            .doneDate(today)
+            .dueDate(today)
+            .scheduledDate(today)
+            .startDate(today)
+            .build();
+        const instruction = new RemoveTaskDate('cancelledDate', task);
+
+        // Apply
+        const newTasks = instruction.apply(task);
+
+        // Assert
+        expect(newTasks.length).toEqual(1);
+        // Expect it is the same object
+        expect(newTasks[0]).toBe(task);
+    });
+
+    it('should create a new task with Due date removed', () => {
+        const instruction = new RemoveTaskDate('dueDate', taskDueToday);
+
+        const newTasks = instruction.apply(taskDueToday);
+        expect(newTasks.length).toEqual(1);
+        const newTask = newTasks[0];
+
+        expect(newTask['dueDate']).toBeNull();
+        expect(newTask.description).toEqual(taskDueToday.description);
+    });
+});
+
+describe('DateInstruction lists', () => {
+    function applyAllInstructions(
+        field:
+            | TaskLayoutComponent.DueDate
+            | TaskLayoutComponent.ScheduledDate
+            | TaskLayoutComponent.StartDate
+            | TaskLayoutComponent.CreatedDate
+            | TaskLayoutComponent.DoneDate
+            | TaskLayoutComponent.CancelledDate,
+        currentFieldValue:
+            | { dueDate: any }
+            | { scheduledDate: any }
+            | { startDate: any }
+            | { createdDate: any }
+            | { doneDate: any }
+            | { cancelledDate: any },
+        datesFunction: (field: AllTaskDateFields, task: Task) => TaskEditingInstruction[],
+    ) {
+        const task = new Task({ ...new TaskBuilder().build(), ...currentFieldValue });
+        const instructions = datesFunction(field, task);
+        return (
+            '\n' +
+            instructions
+                .map((instruction) => {
+                    const checkMark = instruction.isCheckedForTask(taskWithNoDates) ? 'x' : ' ';
+
+                    const label = instruction.instructionDisplayName();
+                    if (label === SEPARATOR_INSTRUCTION_DISPLAY_NAME) {
+                        return `${checkMark} ${label}`;
+                    }
+
+                    const newTasks = instruction.apply(task);
+                    expect(newTasks.length).toEqual(1);
+                    const newDate = newTasks[0][field];
+
+                    return `${checkMark} ${label} => ${new TasksDate(newDate).formatAsDate('No Date')}`;
+                })
+                .join('\n')
+        );
+    }
+
+    describe('happens dates', () => {
+        it('should offer future dates for task due yesterday', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.DueDate,
+                { dueDate: window.moment(yesterday) },
+                allHappensDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Due today, on Tue 1st Oct => 2024-10-01
+                  Due tomorrow, on Wed 2nd Oct => 2024-10-02
+                  ---
+                  Due in 2 days, on Thu 3rd Oct => 2024-10-03
+                  Due in 3 days, on Fri 4th Oct => 2024-10-04
+                  Due in 4 days, on Sat 5th Oct => 2024-10-05
+                  Due in 5 days, on Sun 6th Oct => 2024-10-06
+                  Due in 6 days, on Mon 7th Oct => 2024-10-07
+                  ---
+                  Due in a week, on Tue 8th Oct => 2024-10-08
+                  Due in 2 weeks, on Tue 15th Oct => 2024-10-15
+                  Due in 3 weeks, on Tue 22nd Oct => 2024-10-22
+                  Due in a month, on Fri 1st Nov => 2024-11-01
+                  ---
+                  Remove due date => No Date"
+            `);
+        });
+
+        it('should offer future dates for task due today', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.ScheduledDate,
+                { scheduledDate: window.moment(today) },
+                allHappensDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Scheduled today, on Tue 1st Oct => 2024-10-01
+                  Scheduled tomorrow, on Wed 2nd Oct => 2024-10-02
+                  ---
+                  Scheduled in 2 days, on Thu 3rd Oct => 2024-10-03
+                  Scheduled in 3 days, on Fri 4th Oct => 2024-10-04
+                  Scheduled in 4 days, on Sat 5th Oct => 2024-10-05
+                  Scheduled in 5 days, on Sun 6th Oct => 2024-10-06
+                  Scheduled in 6 days, on Mon 7th Oct => 2024-10-07
+                  ---
+                  Scheduled in a week, on Tue 8th Oct => 2024-10-08
+                  Scheduled in 2 weeks, on Tue 15th Oct => 2024-10-15
+                  Scheduled in 3 weeks, on Tue 22nd Oct => 2024-10-22
+                  Scheduled in a month, on Fri 1st Nov => 2024-11-01
+                  ---
+                  Remove scheduled date => No Date"
+            `);
+        });
+
+        it('should offer future dates for task due tomorrow', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.StartDate,
+                { startDate: window.moment(tomorrow) },
+                allHappensDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Start today, on Tue 1st Oct => 2024-10-01
+                  Start tomorrow, on Wed 2nd Oct => 2024-10-02
+                  ---
+                  Postpone start date by 2 days, to Fri 4th Oct => 2024-10-04
+                  Postpone start date by 3 days, to Sat 5th Oct => 2024-10-05
+                  Postpone start date by 4 days, to Sun 6th Oct => 2024-10-06
+                  Postpone start date by 5 days, to Mon 7th Oct => 2024-10-07
+                  Postpone start date by 6 days, to Tue 8th Oct => 2024-10-08
+                  ---
+                  Postpone start date by a week, to Wed 9th Oct => 2024-10-09
+                  Postpone start date by 2 weeks, to Wed 16th Oct => 2024-10-16
+                  Postpone start date by 3 weeks, to Wed 23rd Oct => 2024-10-23
+                  Postpone start date by a month, to Sat 2nd Nov => 2024-11-02
+                  ---
+                  Remove start date => No Date"
+            `);
+        });
+    });
+
+    describe('lifecycle dates', () => {
+        it('should offer past dates for task due yesterday', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.CreatedDate,
+                { createdDate: window.moment(yesterday) },
+                allLifeCycleDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Created today, on Tue 1st Oct => 2024-10-01
+                  Created yesterday, on Mon 30th Sep => 2024-09-30
+                  ---
+                  Created 2 days ago, on Sun 29th Sep => 2024-09-29
+                  Created 3 days ago, on Sat 28th Sep => 2024-09-28
+                  Created 4 days ago, on Fri 27th Sep => 2024-09-27
+                  Created 5 days ago, on Thu 26th Sep => 2024-09-26
+                  Created 6 days ago, on Wed 25th Sep => 2024-09-25
+                  ---
+                  Created 1 week ago, on Tue 24th Sep => 2024-09-24
+                  Created 2 weeks ago, on Tue 17th Sep => 2024-09-17
+                  Created 3 weeks ago, on Tue 10th Sep => 2024-09-10
+                  Created 1 month ago, on Sun 1st Sep => 2024-09-01
+                  ---
+                  Remove created date => No Date"
+            `);
+        });
+
+        it('should offer past dates for task due today', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.DoneDate,
+                { doneDate: window.moment(today) },
+                allLifeCycleDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Done today, on Tue 1st Oct => 2024-10-01
+                  Done yesterday, on Mon 30th Sep => 2024-09-30
+                  ---
+                  Done 2 days ago, on Sun 29th Sep => 2024-09-29
+                  Done 3 days ago, on Sat 28th Sep => 2024-09-28
+                  Done 4 days ago, on Fri 27th Sep => 2024-09-27
+                  Done 5 days ago, on Thu 26th Sep => 2024-09-26
+                  Done 6 days ago, on Wed 25th Sep => 2024-09-25
+                  ---
+                  Done 1 week ago, on Tue 24th Sep => 2024-09-24
+                  Done 2 weeks ago, on Tue 17th Sep => 2024-09-17
+                  Done 3 weeks ago, on Tue 10th Sep => 2024-09-10
+                  Done 1 month ago, on Sun 1st Sep => 2024-09-01
+                  ---
+                  Remove done date => No Date"
+            `);
+        });
+
+        it('should offer past dates for task due tomorrow', () => {
+            const allAppliedToTask = applyAllInstructions(
+                TaskLayoutComponent.CancelledDate,
+                { cancelledDate: window.moment(tomorrow) },
+                allLifeCycleDateInstructions,
+            );
+            expect(allAppliedToTask).toMatchInlineSnapshot(`
+                "
+                  Cancelled today, on Tue 1st Oct => 2024-10-01
+                  Cancelled yesterday, on Mon 30th Sep => 2024-09-30
+                  ---
+                  Backdate cancelled date by 2 days, to Mon 30th Sep => 2024-09-30
+                  Backdate cancelled date by 3 days, to Sun 29th Sep => 2024-09-29
+                  Backdate cancelled date by 4 days, to Sat 28th Sep => 2024-09-28
+                  Backdate cancelled date by 5 days, to Fri 27th Sep => 2024-09-27
+                  Backdate cancelled date by 6 days, to Thu 26th Sep => 2024-09-26
+                  ---
+                  Backdate cancelled date by 1 week, to Wed 25th Sep => 2024-09-25
+                  Backdate cancelled date by 2 weeks, to Wed 18th Sep => 2024-09-18
+                  Backdate cancelled date by 3 weeks, to Wed 11th Sep => 2024-09-11
+                  Backdate cancelled date by 1 month, to Mon 2nd Sep => 2024-09-02
+                  ---
+                  Remove cancelled date => No Date"
+            `);
+        });
+    });
+});
diff --git a/tests/ui/EditInstructions/MenuDividerInstruction.test.ts b/tests/ui/EditInstructions/MenuDividerInstruction.test.ts
new file mode 100644
index 00000000..b371aaa9
--- /dev/null
+++ b/tests/ui/EditInstructions/MenuDividerInstruction.test.ts
@@ -0,0 +1,18 @@
+import { MenuDividerInstruction } from '../../../src/ui/EditInstructions/MenuDividerInstruction';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+
+describe('MenuDividerInstruction', () => {
+    it('should create an instruction to add a divider to a menu', () => {
+        const instruction = new MenuDividerInstruction();
+        const task = new TaskBuilder().build();
+
+        expect(instruction.instructionDisplayName()).toBe('---');
+        expect(instruction.isCheckedForTask(task)).toEqual(false);
+
+        const t = () => {
+            instruction.apply(task);
+        };
+        expect(t).toThrow(Error);
+        expect(t).toThrowError('MenuDividerInstruction.apply(): Method not implemented.');
+    });
+});
diff --git a/tests/ui/Menus/DateMenu.test.ts b/tests/ui/Menus/DateMenu.test.ts
new file mode 100644
index 00000000..a032d225
--- /dev/null
+++ b/tests/ui/Menus/DateMenu.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import moment from 'moment/moment';
+
+import { DateMenu } from '../../../src/ui/Menus/DateMenu';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { TaskLayoutComponent } from '../../../src/Layout/TaskLayoutOptions';
+import { TestableTaskSaver, menuToString } from './MenuTestingHelpers';
+
+window.moment = moment;
+
+const farPast = '2022-01-17';
+const today = '2023-12-03';
+
+beforeEach(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date(today));
+    TestableTaskSaver.reset();
+});
+
+afterEach(() => {
+    jest.useRealTimers();
+});
+
+describe('DateMenu', () => {
+    beforeEach(() => {
+        TestableTaskSaver.reset();
+    });
+
+    it('should populate a menu for a Due date', () => {
+        // Arrange
+        const task = new TaskBuilder().dueDate(farPast).build();
+
+        // Act
+        const field = TaskLayoutComponent.ScheduledDate;
+        const menu = new DateMenu(field, task);
+
+        // Assert
+        const itemsAsText = menuToString(menu);
+        expect(itemsAsText).toMatchInlineSnapshot(`
+            "
+              Scheduled today, on Sun 3rd Dec
+              Scheduled tomorrow, on Mon 4th Dec
+              ---
+              Scheduled in 2 days, on Tue 5th Dec
+              Scheduled in 3 days, on Wed 6th Dec
+              Scheduled in 4 days, on Thu 7th Dec
+              Scheduled in 5 days, on Fri 8th Dec
+              Scheduled in 6 days, on Sat 9th Dec
+              ---
+              Scheduled in a week, on Sun 10th Dec
+              Scheduled in 2 weeks, on Sun 17th Dec
+              Scheduled in 3 weeks, on Sun 24th Dec
+              Scheduled in a month, on Wed 3rd Jan
+              ---
+              Remove scheduled date"
+        `);
+    });
+
+    it('should populate a menu for a Cancelled date', () => {
+        // Arrange
+        const task = new TaskBuilder().cancelledDate(today).build();
+
+        // Act
+        const field = TaskLayoutComponent.CancelledDate;
+        const menu = new DateMenu(field, task);
+
+        // Assert
+        const itemsAsText = menuToString(menu);
+        expect(itemsAsText).toMatchInlineSnapshot(`
+            "
+            x Cancelled today, on Sun 3rd Dec
+              Cancelled yesterday, on Sat 2nd Dec
+              ---
+              Cancelled 2 days ago, on Fri 1st Dec
+              Cancelled 3 days ago, on Thu 30th Nov
+              Cancelled 4 days ago, on Wed 29th Nov
+              Cancelled 5 days ago, on Tue 28th Nov
+              Cancelled 6 days ago, on Mon 27th Nov
+              ---
+              Cancelled 1 week ago, on Sun 26th Nov
+              Cancelled 2 weeks ago, on Sun 19th Nov
+              Cancelled 3 weeks ago, on Sun 12th Nov
+              Cancelled 1 month ago, on Fri 3rd Nov
+              ---
+              Remove cancelled date"
+        `);
+    });
+});