Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2191 (2023-08-03 10:17:43)

feat: Add a button, with context menu, to "postpone" tasks
<!--- Provide a general summary of your changes in the Title above -->

- Example of the full mode:
<img width="1360" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/82860775-5968-45e6-bcda-09c8476d9692">

- Example of the short mode:
<img width="1337" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/bc84eb09-5eae-40fa-ba3b-322ff1dd2807">


# Description

This button allows you to quickly "snooze" (postpone) a task until tomorrow. 
If you click on the '⏩' button, the due date of the task is moved to tomorrow.

## Motivation and Context

Because
- This is my frequent use case
- This is the fastest way to delay unimportant tasks
- I've seen at minimum one issue about this feature
	- https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1446
	- https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2039

## How has this been tested?

- Tested manually on my vault with two view modes (short and full). I have the latest Obsidian with many plugins.
- MacOS Ventura, Node 20:
	<img width="416" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/641a3b13-2ca6-4ca4-aec9-f0a66f81c83e">
 
## Screenshots (if appropriate)

<img width="307" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/6d34824e-bae6-4d3b-a9a0-c7d71c8d2df1">

## Types of changes

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:

- [ ] **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

- [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 @m0rtyn,

Thanks very much indeed for implementing this. I'm sure I'm not alone in getting irritated at how long it takes to defer tasks I don't feel like working on today!

I pulled the code in to my own fork of Tasks and used it on my own personal vault for a couple of hours, and then tried various changes to it, to see how much improvement they were, if any.

Some notes follow, but I think the **tl,dr** is:

- I think it's really, really promising
- and also that it needs more work before it's ready for release...
- how much time and energy do you have for more work on this?
- I see from your GH profile you have a lot of coding experience that I don't have - would you be interested in working together with me on this to get it to the stage where it's ready for release?

---

Some initial thoughts...

For example, these are the things I wondered about whilst experimenting with it...

- I'm not sure that users will be able to figure out what 'Snooze' actually means
- It shows the snooze button even when there is no due date which is a bit non-obvious
- It's got a fast forward icon but if the date is ahead of tomorrow it goes backwards to tomorrow
- I think that if the date is already in the future then it should just add one day
- I think that if the date was in the past it should probably first forward to the current date
- If a task has a scheduled date only, currently the snooze button adds a due date for tomorrow. I think in this situation it should probably edit the schedule date instead.
- I think people who use multiple dates on their tasks, such as a start date and a due date, may well want to be able to advance individual dates separately. For example, I might want to leave the due date unchanged, but advance the start date.
- It seems to not do anything if the scheduled date value was generated by inferring it from the file name.

In my fork, I experimented with adding an UnSnooze button, as well, and tried out updating any of Due, Scheduled and Starts that were already set - and changing the behaviour depending on the current date. The code is below...

I still don't think I've got the logic quite right (for example, advancing an overdue task moves it to today, which, in short mode, gives no visual feedback if the query is `due before tomorrow`, as the task is still shown, so it feels like the click was missed...

Also, I often found myself wanting to move a date multiple days forward, like a week or two.... And doing it one click at a time was rather annoying...

Anyway, here was my experiment (after a bit of refactoring):

```ts
    private addSnoozeButton(listItem: HTMLElement, task: Task, shortMode: boolean) {
        function snooze(oldDate: Moment | null) {
            // If no date, do not add one
            if (!oldDate) {
                return null;
            }

            // If overdue, fast-forward to today
            if (oldDate.isBefore(window.moment(), 'day')) {
                return window.moment().startOf('day');
            }

            // Otherwise, fast-forward to next day
            return oldDate.clone().add(1, 'days');
        }

        const updatedTask = new Task({
            ...task,
            dueDate: snooze(task.dueDate),
            scheduledDate: snooze(task.scheduledDate),
            startDate: snooze(task.startDate),
        });
        this.addButton(listItem, 'tasks-snooze-button', shortMode, '⏩', 'Snooze', task, updatedTask);
    }

    // Move any dates a day earlier
    private addUnSnoozeButton(listItem: HTMLElement, task: Task, shortMode: boolean) {
        function unSnooze(oldDate: Moment | null) {
            // If no date, do not add one
            if (!oldDate) {
                return null;
            }

            // Otherwise, rewind to previous day
            return oldDate.clone().subtract(1, 'days');
        }

        const updatedTask = new Task({
            ...task,
            dueDate: unSnooze(task.dueDate),
            scheduledDate: unSnooze(task.scheduledDate),
            startDate: unSnooze(task.startDate),
        });
        this.addButton(listItem, 'tasks-unsnooze-button', shortMode, '⏪', 'UnSnooze', task, updatedTask);
    }

    private addButton(
        listItem: HTMLElement,
        cls: string,
        shortMode: boolean,
        icon: string,
        buttonDisplayText: string,
        task: Task,
        updatedTask: Task,
    ) {
        // TODO Add a Tooltip describing what the change will be
        const snoozeButton = listItem.createSpan({ cls: cls });

        const button = snoozeButton.createEl('button');

        button.addClass('internal-button');
        if (shortMode) {
            button.addClass('internal-button-short-mode');
        }

        let buttonText = ' ' + icon;
        if (!shortMode) {
            buttonText += ' ' + buttonDisplayText;
        }

        button.setText(buttonText);

        button.addEventListener('click', async () => {
            replaceTaskWithTasks({
                originalTask: task,
                newTasks: updatedTask,
            });
        });

        button.addEventListener('mousedown', async (ev: MouseEvent) => {
            // Open in a new tab on middle-click.
            // This distinction is not available in the 'click' event, so we handle the 'mousedown' event
            // solely for this.
            // (for regular left-click we prefer the 'click' event, and not to just do everything here, because
            // the 'click' event is more generic for touch devices etc.)
            if (ev.button === 1) {
                replaceTaskWithTasks({
                    originalTask: task,
                    newTasks: updatedTask,
                });
            }
        });
    }
```



claremacrae: > I see from your GH profile you have a lot of coding experience that I don't have

... by this, I mean that it seems you have UI experience, whereas in TypeScript, I am much more a back-end, algorithms kind of person...
m0rtyn: @claremacrae Hi. I really like your reply and want to improve this PR. Thank you a lot for the feedback and ideas to improve it.

> how much time and energy do you have for more work on this?
Currently, I have enough time to complete this PR with your suggestions within a couple of weeks (approximately).

> I'm not sure that users will be able to figure out what 'Snooze' actually means
Yes, it can be "to Next day / Tomorrow / Later" or something similar

> It shows the snooze button even when there is no due date which is a bit non-obvious
> It's got a fast forward icon but if the date is ahead of tomorrow it goes backwards to tomorrow
Yes, these are bugs. I will fix them.


> I think that if the date is already in the future then it should just add one day
> I think that if the date was in the past it should probably first forward to the current date
> If a task has a scheduled date only, currently the snooze button adds a due date for tomorrow. I think in this situation it should probably edit the schedule date instead.

Good ideas, and I want to implement or fix them as you suggested.

> I think people who use multiple dates on their tasks, such as a start date and a due date, may well want to be able to advance individual dates separately. For example, I might want to leave the due date unchanged, but advance the start date.
> It seems to not do anything if the scheduled date value was generated by inferring it from the file name.

These points confuse me because they involve unfamiliar use cases. I need to think about them. Could you provide more context or examples to clarify the issue further? I prefer to exclude these improvements or reduce their complexity.  

claremacrae: Thanks for the reply @m0rtyn. I’ve done several of these things on my vault as an experiment and wanted to show them to you, to get your feedback. 

My message was meant for us to start a conversation, rather than have you go off on your for a couple of weeks. 

This is such a complicated topic that by far the easiest way ahead would be for us to have a chat about it, rather than typing back and forward here.

Please would you send me an email - address is in my profile here - to try to set up a call? Many thanks. 
chrabia: This is the most important case I was thinking of when I wrote this issue https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2039
It'd be extremely useful in my opinion.

Is there any way I can help with pushing this forward?
claremacrae: > Is there any way I can help with pushing this forward?

What skills do you have?
claremacrae: That said, this is still being worked, and I find that trying to push forward volunteer contributors is rarely effective. So please be patient @chrabia, thanks. 
m0rtyn: @chrabia, hi. Thank you for your question.

I am mindful of this PR and intend to finalize it.

I think at best I will finalize it today/tomorrow, and at worst I can work with it in a fortnight. No problems at all, but the schedule of my new job is affecting.

Sorry for the silence here.
claremacrae: Thanks very much @m0rtyn.

@chrabia Once the PR is submitted, you could help with the testing, and ideally with writing or reviewing the user docs as well. 
chrabia: Oh, I didn't want to rush you, just wanted to help :).

> What skills do you have?

Oh, I can do all kind of stuff I guess, but I'm more of a backend person, so I prefer/I am better with typescript than html/css etc.

> @chrabia Once the PR is submitted, you could help with the testing, and ideally with writing or reviewing the user docs as well.

Yeah, sure I can help with testing (and docs probably too). Actually, I already tested it a bit. Mostly I found some things that were already mentioned (past date should go to today and future dates should get +1 day).
Besides that, I'm not really sure what "mousedown" part is for.
Also, the button looks a bit strange on my side. I turned off the css snipets and changed the theme to default, also turned off other plugins, so they are not at fault here.
<img width="356" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/6050673/2de4ecbd-4d4d-490d-a86c-dd556bc2839a">




claremacrae: Hi @chrabia, thanks for a super helpful message. Can you be more specific about what was strange with the button please?
chrabia: Yeah, sure.
So, in the screenshot from m0rtyn the button is only a emoji, while on my pc it is a complete button, with borders etc (and the emoji inside the button). It looks a lot better as only emoji. Maybe (even though I believe I checked all the setting etc) it's something on my side. I think you can check how it looks on your side @claremacrae .
claremacrae: Perfect, thanks, that's really clear.
claremacrae: Hi @m0rtyn, I see you pushed some changes recently - many thanks! ❤️ 

Don't worry about the merge conflicts... I can sort those out when you're ready...

How are you getting on with this? Is there anything I can do to help?
m0rtyn: @claremacrae, hi. It's still in progress. It works, but I tested it and found a couple of bugs. 

BTW, I added your suggestion with the context menu (right click) to postpone the task for a week/month/quarter. I think I'll work with PR over the weekend and make final corrections.  

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/674f7826-b1b2-4d83-a20e-0d244ece3ce6)
![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/a7b84625-ff7a-4ca0-b59a-ae967574e81f)

> Is there anything I can do to help?

Yes, I don't know how to rerender the task list without reloading, to be able to click the postpone button more than once. I currently have an error message on subsequent clicks: "Tasks: could not find the correct task line to update...". I have some hypotheses how to solve it.

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/f425835f-4780-40a7-baf1-812750968fa7)

claremacrae: Hi @m0rtyn,

> @claremacrae, hi. It's still in progress. It works, but I tested it and found a couple of bugs.
> 
> BTW, I added your suggestion with the context menu (right click) to postpone the task for a week/month/quarter. I think I'll work with PR over the weekend and make final corrections.

Sounds good - thanks.

> > Is there anything I can do to help?
> 
> Yes, I don't know how to rerender the task list without reloading, to be able to click the postpone button more than once. I currently have an error message on subsequent clicks: "Tasks: could not find the correct task line to update...". I have some hypotheses how to solve it.

I don't exactly know the answer to that, and won't be able to look at it for several days.

But in case it helps, I don't get that problem in my modified copy of your original PR cod...

I was so keen to have the feature that I forked your work.

Here are my diffs that are specific to your work - that is, diffs from my other customisations of Tasks.

https://github.com/claremacrae/obsidian-tasks/compare/bleeding-edge...claremacrae:obsidian-tasks:feat--snooze-button-clare-experiments

The main changes I made to your version are:

- Change from 1 button to 3 (1 day earlier, 1 day later, 3 days later)
- Move the date calculation logic out to a separate file, and add tests for it
- I've been merging main in, so it also has all fixes in the code on main since your branch... I don't know if any of them could explain the difference.

It's working well for me, so there must be something significantly different between this and yours... Maybe you can spot the difference...

If this error is the only problem you have, I suggest you ignore it and create a PR anyway, as I can probably solve it quite quickly - I've looked at that code a lot.

chrabia: I'm using all the time @m0rtyn version and I also have his bug. 

Another bug is that "I think that if the date is already in the future then it should just add one day" is not fixed yet.

The right click context menu is a quite cool feature, but I'm wondering if it would be possible for user to configure it for themselves, the way it is right now I didn't use it even once unfortunately. For example, from my experience it's much more often that I'm postponing something for 2/3 days or postponing to the monday next week. I can't imagine a situation when I'd like to postpone something for a quarter actually.

And again thanks for this contribution! Having this feature already benefitted me greatly.
m0rtyn: <img width="1440" alt="Screenshot 2023-11-04 at 12 08 14" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/20739202/7be1a8ca-64b4-4807-9266-8b385cf8365d">

I've fixed the bug with past dates and also change the option list in the context menu. Postponing for the future dates working well.
m0rtyn: @claremacrae I'm going to finish my work with this PR. Could you improve a codestyle and complete a refactoring?
claremacrae: > @claremacrae I'm going to finish my work with this PR. Could you improve a codestyle and complete a refactoring?

Hi @m0rtyn, thanks very much indeed - and yes, with pleasure I'll get it ready to release...

Huge thanks!
claremacrae: Just a quick update...
I'm currently working on finishing up Tasks 5.1.0 as I've ended up with lots of stuff in-flight...
There are a few things like this that are a bit bigger than my time available for 5.1.0.

I expect to be able to pick up work on Tasks again in 2 to 3 weeks, and this will be the first big thing that I pick up, as I know it's especially valuable to users...
claremacrae: I've renamed the PR to reflect that the button is now called "postpone".
claremacrae: @m0rtyn I'm starting to look at this now.

Please ignore the comments that I am about to add in code review - they are placeholders to remind me to deal with things before merging, and not requests for you to change anything...
claremacrae: Just noting what I've found in exploratory testing before the merge...

Something has changed since the original PR about how the new dates are applied...

In this code, which is a slightly modified version of the original code in this PR, I can make many repeated deferrals to a task with no problems at all:
- https://github.com/claremacrae/obsidian-tasks/commit/646c44bed549467a94ca0004fd697e2f33db0b25

With the latest code in this PR, after a few deferrals I get error messages after a few postponements:

<img width="202" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/1029c570-9026-4301-88e6-f1439373c964">

I now need to try and work out how the file-editing/saving has changed before merging this.

claremacrae: I see now that this bug was mentioned by @m0rtyn in https://github.com/obsidian-tasks-group/obsidian-tasks/pull/2191#issuecomment-1750115807 - and also by @chrabia ... So it's not specific to my build.

And that I said:

> If this error is the only problem you have, I suggest you ignore it and create a PR anyway, as I can probably solve it quite quickly - I've looked at that code a lot.

🤣 

Patch:
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 9d519476..e4ff014e 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -31,7 +31,7 @@ export class Query implements IQuery {
     private _ignoreGlobalQuery: boolean = false;
 
     private readonly hideOptionsRegexp =
-        /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency|tags)/i;
+        /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|postpone button|urgency|tags)/i;
     private readonly shortModeRegexp = /^short/i;
     private readonly explainQueryRegexp = /^explain/i;
     private readonly ignoreGlobalQueryRegexp = /^ignore global query/i;
@@ -295,6 +295,9 @@ Problem line: "${line}"`;
                 case 'backlink':
                     this._layoutOptions.hideBacklinks = hide;
                     break;
+                case 'postpone button':
+                    this._layoutOptions.hidePostponeButton = hide;
+                    break;
                 case 'priority':
                     this._layoutOptions.hidePriority = hide;
                     break;
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index ed2bec8d..afc33495 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -1,5 +1,6 @@
 import type { EventRef, MarkdownPostProcessorContext } from 'obsidian';
-import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Plugin, TFile } from 'obsidian';
+import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Menu, MenuItem, Notice, Plugin, TFile } from 'obsidian';
+import type { unitOfTime } from 'moment';
 import { State } from './Cache';
 import { GlobalFilter } from './Config/GlobalFilter';
 import { GlobalQuery } from './Config/GlobalQuery';
@@ -11,11 +12,12 @@ import { explainResults, getQueryForQueryRenderer } from './lib/QueryRendererHel
 import type { GroupDisplayHeading } from './Query/GroupDisplayHeading';
 import type { QueryResult } from './Query/QueryResult';
 import type { TaskGroups } from './Query/TaskGroups';
-import type { Task } from './Task';
+import { Task } from './Task';
 import { TaskLayout } from './TaskLayout';
 import { TaskLineRenderer } from './TaskLineRenderer';
 import { TaskModal } from './TaskModal';
 import type { TasksEvents } from './TasksEvents';
+import { TasksDate } from './Scripting/TasksDate';
 
 export class QueryRenderer {
     private readonly app: App;
@@ -232,8 +234,14 @@ class QueryRenderChild extends MarkdownRenderChild {
             const footnotes = listItem.querySelectorAll('[data-footnote-id]');
             footnotes.forEach((footnote) => footnote.remove());
 
+            const shortMode = this.query.layoutOptions.shortMode;
+
             const extrasSpan = listItem.createSpan('task-extras');
 
+            if (!this.query.layoutOptions.hidePostponeButton) {
+                this.addPostponeButton(extrasSpan, task, shortMode);
+            }
+
             if (!this.query.layoutOptions.hideUrgency) {
                 this.addUrgency(extrasSpan, task);
             }
@@ -247,6 +255,13 @@ class QueryRenderChild extends MarkdownRenderChild {
                 this.addEditButton(extrasSpan, task);
             }
 
+            // NEW
+            // if (!this.query.layoutOptions.hideSnoozeButton) {
+            //     this.addUnSnoozeButton(extrasSpan, task, shortMode);
+            //     this.addSnoozeButton1Day(extrasSpan, task, shortMode);
+            //     this.addSnoozeButton3Days(extrasSpan, task, shortMode);
+            // }
+
             taskList.appendChild(listItem);
         }
 
@@ -383,6 +398,45 @@ class QueryRenderChild extends MarkdownRenderChild {
         }
     }
 
+    private addPostponeButton(listItem: HTMLElement, task: Task, shortMode: boolean) {
+        const button = listItem.createEl('button', {
+            attr: {
+                id: 'postpone-button',
+                title: 'ℹ️ Postpone the task (right-click for more options)',
+            },
+        });
+
+        const classNames = shortMode ? ['internal-button', 'internal-button-short-mode'] : ['internal-button'];
+        button.addClasses(classNames);
+        const buttonText = shortMode ? ' ⏩' : ' ⏩ Postpone';
+        button.setText(buttonText);
+
+        button.addEventListener('click', () => this.getOnClickCallback(task, button, 'days'));
+
+        /** Open a context menu on right-click.
+         * Give a choice of postponing for a week, month, or quarter.
+         */
+        button.addEventListener('contextmenu', async (ev: MouseEvent) => {
+            const menu = new Menu();
+            const commonTitle = 'Postpone for';
+
+            const getMenuItemCallback = (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),
+                );
+            };
+
+            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.showAtPosition({ x: ev.clientX, y: ev.clientY });
+        });
+    }
+
     private addTaskCount(content: HTMLDivElement, queryResult: QueryResult) {
         if (!this.query.layoutOptions.hideTaskCount) {
             content.createDiv({
@@ -417,4 +471,41 @@ class QueryRenderChild extends MarkdownRenderChild {
         }
         return groupingRules.join(',');
     }
+
+    private async getOnClickCallback(
+        task: Task,
+        button: HTMLButtonElement,
+        timeUnit: unitOfTime.DurationConstructor = 'days',
+        amount = 1,
+    ) {
+        const errorMessage = '⚠️ Postponement requires a date: due or scheduled.';
+        if (!task.dueDate && !task.scheduledDate) return new Notice(errorMessage, 10000);
+        const scheduledDateOrNull = task.scheduledDate ? 'scheduledDate' : null;
+        const dateTypeToUpdate = task.dueDate ? 'dueDate' : scheduledDateOrNull;
+        if (dateTypeToUpdate === null) return;
+
+        const dateToUpdate = task[dateTypeToUpdate];
+        const postponedDate = new TasksDate(dateToUpdate).postpone(timeUnit, amount);
+        const newTasks = new Task({ ...task, [dateTypeToUpdate]: postponedDate });
+
+        await replaceTaskWithTasks({
+            originalTask: task,
+            newTasks,
+        });
+
+        const postponedDateString = postponedDate?.format('DD MMM YYYY');
+        this.onPostponeSuccessCallback(button, dateTypeToUpdate, postponedDateString);
+    }
+
+    private onPostponeSuccessCallback(
+        button: HTMLButtonElement,
+        updatedDateType: 'dueDate' | 'scheduledDate',
+        postponedDateString: string,
+    ) {
+        // 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.');
+        new Notice(`Task's ${updatedDateType} postponed untill ${postponedDateString}`, 5000);
+        this.events.triggerRequestCacheUpdate(this.render.bind(this));
+    }
 }
diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts
index a5df6316..fbb7ecc6 100644
--- a/src/Scripting/TasksDate.ts
+++ b/src/Scripting/TasksDate.ts
@@ -1,4 +1,5 @@
-import type { DurationInputArg2, Moment } from 'moment';
+import type { DurationInputArg2, Moment, unitOfTime } from 'moment';
+import { Notice } from 'obsidian';
 import { TaskRegularExpressions } from '../Task';
 import { PropertyCategory } from '../lib/PropertyCategory';
 
@@ -109,4 +110,18 @@ export class TasksDate {
         const unit = words[1] as DurationInputArg2; // day, days, weeks, month, year
         return earlier ? now.subtract(multiplier, unit) : now.add(multiplier, unit);
     }
+
+    public postpone(unitOfTime: unitOfTime.DurationConstructor = 'days', amount: number = 1) {
+        if (!this._date) throw new Notice('Cannot postpone a null date');
+
+        const today = window.moment().startOf('day');
+        // According to the moment.js docs, isBefore is not stable so we use !isSameOrAfter: https://momentjs.com/docs/#/query/is-before/
+        const isDateBeforeToday = !this._date.isSameOrAfter(today, 'day');
+
+        if (isDateBeforeToday) {
+            return today.add(amount, unitOfTime);
+        }
+
+        return this._date.add(amount, unitOfTime);
+    }
 }
diff --git a/src/TaskLayout.ts b/src/TaskLayout.ts
index 322ce396..b840ff8f 100644
--- a/src/TaskLayout.ts
+++ b/src/TaskLayout.ts
@@ -3,6 +3,7 @@
  * See applyOptions below when adding options here.
  */
 export class LayoutOptions {
+    hidePostponeButton: boolean = false;
     hideTaskCount: boolean = false;
     hideBacklinks: boolean = false;
     hidePriority: boolean = false;
@@ -94,6 +95,7 @@ export class TaskLayout {
             [this.options.hideUrgency, 'urgency'],
             [this.options.hideBacklinks, 'backlinks'],
             [this.options.hideEditButton, 'edit-button'],
+            [this.options.hidePostponeButton, 'postpone-button'],
         ];
         for (const [hide, component] of componentsToGenerateClassesOnly) {
             this.generateHiddenClassForTaskList(hide, component);
diff --git a/styles.css b/styles.css
index e7a1301b..bcd19b59 100644
--- a/styles.css
+++ b/styles.css
@@ -72,6 +72,15 @@
     text-decoration: none;
 }
 
+.internal-button {
+    background-color: transparent;
+    padding: 0;
+    font-size: var(--font-text-size);
+    background-color: transparent;
+    display: contents;
+    cursor: pointer;
+}
+
 .tasks-list-text {
     position: relative;
 }


Test patch:
diff --git a/tests/TaskLayout.test.ts b/tests/TaskLayout.test.ts
index 2bab99f3..0cef973e 100644
--- a/tests/TaskLayout.test.ts
+++ b/tests/TaskLayout.test.ts
@@ -57,6 +57,7 @@ describe('TaskLayout tests', () => {
             tasks-layout-hide-tags
             tasks-layout-hide-backlinks
             tasks-layout-hide-edit-button
+            tasks-layout-hide-postpone-button
             tasks-layout-short-mode"
         `);
     });