I reused `org-test-at-time'. Specifically, `org-test-at-time' is now an
alias of `org-at-time'. This "new" macro is what `org-todo-at' uses in
order to set the current time to the time that you provide it.
From 86f7300410ffbb885c3c60d2380411985ae7631f Mon Sep 17 00:00:00 2001
From: ApollonDeParnasse <[email protected]>
Date: Thu, 2 Jul 2026 16:43:08 -0500
Subject: [PATCH] lisp/org.el: New function for calling `org-todo' at a given
time.
* lisp/org.el (org-todo-at): Call `org-todo' at a given
time.
* lisp/org-macs.el (org-at-time): Perform an org action
at a given time.
* testing/org-test.el (org-test-at-time): Call
`org-at-time'.
* testing/lisp/test-org.el
(test-org/auto-repeat-maybe/org-todo-at): New tests
for `org-todo-at'.
(test-org/org-todo-at-interactive): New tests for
`org-todo-at'.
* lisp/org-keys.el (org-mode-map): New keybinding
for `org-todo-at'.
---
doc/org-manual.org | 22 ++++++
etc/ORG-NEWS | 4 +
lisp/org-keys.el | 2 +
lisp/org-macs.el | 66 +++++++++++++++++
lisp/org.el | 30 ++++++++
testing/lisp/test-org.el | 156 +++++++++++++++++++++++++++++++++++++++
testing/org-test.el | 67 +----------------
7 files changed, 281 insertions(+), 66 deletions(-)
diff --git a/doc/org-manual.org b/doc/org-manual.org
index 9201a341a..0eb3159b3 100644
--- a/doc/org-manual.org
+++ b/doc/org-manual.org
@@ -3977,6 +3977,14 @@ The most important commands to work with TODO entries are:
buffer with the {{{kbd(t)}}} command key (see [[*Commands in the
Agenda Buffer]]).
+- {{{kbd(C-c M-t)}}} (~org-todo-at~) ::
+
+ #+kindex: C-c M-t
+ #+cindex: cycling, of TODO states
+ Rotate the TODO state of the current item,
+ just like ~org-todo~. Will ask you for a
+ time for each state change.
+
- {{{kbd(S-RIGHT)}}} {{{kbd(S-LEFT)}}} ::
#+kindex: S-RIGHT
@@ -4369,6 +4377,13 @@ command ~org-todo~ with a prefix argument.
The note is inserted as a list item below the headline, but can also
be placed into a drawer, see [[*Tracking TODO state changes]].
+- {{{kbd(C-u C-c M-t)}}} (~org-todo-at~) ::
+
+ #+kindex: C-u C-M C-t
+ Prompt for a time for the TODO state change and a note.
+ The note is inserted as a list item below the headline,
+ but can also be placed into a drawer, just like ~org-todo~.
+
If you want to be more systematic, Org mode can automatically record a
timestamp and optionally a note when you mark a TODO item as DONE, or
even each time you change the state of a TODO item. This system is
@@ -7002,6 +7017,13 @@ about what to do with it.
Changing the TODO state of an item to DONE automatically stops the
clock if it is running in this same item.
+- {{{kbd(C-c M-t)}}} (~org-todo-at~) ::
+
+ #+kindex: C-c M-t
+ #+findex: org-todo-at
+ Changing the TODO state of an item to DONE automatically stops the
+ clock at the time that you provide if it is running in this same item.
+
- {{{kbd(C-c C-x C-q)}}} (~org-clock-cancel~) ::
#+kindex: C-c C-x C-q
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 261d5d3be..6e2ffd24b 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -227,6 +227,10 @@ needs. The recommended alternative value is ~itemize~.
Given the completed and total number of tasks, format the percent
cookie =[N%]=.
+*** New function ~org-todo-at~
+
+Like `org-todo', but the first argument is the time that you
+would like change the TODO state of an item.
** Removed or renamed functions and variables
diff --git a/lisp/org-keys.el b/lisp/org-keys.el
index 84eb3bf13..6853e9f3d 100644
--- a/lisp/org-keys.el
+++ b/lisp/org-keys.el
@@ -215,6 +215,7 @@
(declare-function org-timer-start "org-timer" (&optional offset))
(declare-function org-timer-stop "org-timer" ())
(declare-function org-todo "org" (&optional arg1))
+(declare-function org-todo-at "org" (time &optional arg))
(declare-function org-toggle-archive-tag "org-archive" (&optional find-done))
(declare-function org-toggle-checkbox "org-list" (&optional toggle-presence))
(declare-function org-toggle-radio-button "org-list" (&optional arg))
@@ -576,6 +577,7 @@ COMMANDS is a list of alternating OLDDEF NEWDEF command names."
(org-defkey org-mode-map (kbd "C-c C-x q") #'org-toggle-tags-groups)
(org-defkey org-mode-map (kbd "C-c C-j") #'org-goto)
(org-defkey org-mode-map (kbd "C-c C-t") #'org-todo)
+(org-defkey org-mode-map (kbd "C-c M-t") #'org-todo-at)
(org-defkey org-mode-map (kbd "C-c C-q") #'org-set-tags-command)
(org-defkey org-mode-map (kbd "C-c C-s") #'org-schedule)
(org-defkey org-mode-map (kbd "C-c C-d") #'org-deadline)
diff --git a/lisp/org-macs.el b/lisp/org-macs.el
index 2b867818b..f73fe50e8 100644
--- a/lisp/org-macs.el
+++ b/lisp/org-macs.el
@@ -1680,6 +1680,72 @@ Return 0. if S is not recognized as a valid value."
((string-match org-ts-regexp0 s) (org-2ft s))
(t 0.)))))
+(defmacro org-at-time (time &rest body)
+ "Run BODY while pretending that the current time is TIME.
+TIME can be a non-nil Lisp time value, or a string specifying a date and time."
+ (declare (indent 1))
+ (org-with-gensyms (tm at)
+ `(let* ((,tm ,time)
+ (,at (if (stringp ,tm)
+ (org-time-string-to-time ,tm)
+ ,tm)))
+ (cl-flet
+ ((org-at-current-time (_fun &rest _args)
+ ,at)
+ (org-at-current-time-string (fun &optional time &rest args)
+ (apply fun
+ (or time ,at) args))
+ (org-at-current-time-zone (fun &optional time &rest args)
+ (apply fun (or time ,at) args))
+ (org-at-decode-time (fun &optional time zone form)
+ (condition-case nil
+ (funcall fun (or time ,at) zone form)
+ (wrong-number-of-arguments
+ (funcall fun (or time ,at)))))
+ (org-at-encode-time (fun time &rest args)
+ (apply fun (or time ,at) args))
+ (org-at-float-time (fun &optional time)
+ (funcall fun (or time ,at)))
+ (org-at-format-time-string (fun format &optional time &rest args)
+ (apply fun format (or time ,at) args))
+ (org-at-set-file-times (fun file &optional time)
+ (funcall fun file (or time ,at)))
+ (org-at-time-add (fun a b)
+ (funcall fun (or a ,at) (or b ,at)))
+ (org-at-time-equal-p (fun a b)
+ (funcall fun (or a ,at) (or b ,at)))
+ (org-at-time-less-p (fun a b)
+ (funcall fun (or a ,at) (or b ,at)))
+ (org-at-time-subtract (fun a b)
+ (funcall fun (or a ,at) (or b ,at))))
+ (add-function :around (symbol-function 'current-time) #'org-at-current-time)
+ (add-function :around (symbol-function 'current-time-string) #'org-at-current-time-string)
+ (add-function :around (symbol-function 'current-time-zone) #'org-at-current-time-zone)
+ (add-function :around (symbol-function 'decode-time) #'org-at-decode-time)
+ (add-function :around (symbol-function 'encode-time) #'org-at-encode-time)
+ (add-function :around (symbol-function 'float-time) #'org-at-float-time)
+ (add-function :around (symbol-function 'format-time-string) #'org-at-format-time-string)
+ (add-function :around (symbol-function 'set-file-times) #'org-at-set-file-times)
+ (add-function :around (symbol-function 'time-add) #'org-at-time-add)
+ (add-function :around (symbol-function 'time-equal-p) #'org-at-time-equal-p)
+ (add-function :around (symbol-function 'time-less-p) #'org-at-time-less-p)
+ (add-function :around (symbol-function 'time-subtract) #'org-at-time-subtract)
+
+ (unwind-protect
+ (progn ,@body)
+ (remove-function (symbol-function 'current-time) #'org-at-current-time)
+ (remove-function (symbol-function 'current-time-string) #'org-at-current-time-string)
+ (remove-function (symbol-function 'current-time-zone) #'org-at-current-time-zone)
+ (remove-function (symbol-function 'decode-time) #'org-at-decode-time)
+ (remove-function (symbol-function 'encode-time) #'org-at-encode-time)
+ (remove-function (symbol-function 'float-time) #'org-at-float-time)
+ (remove-function (symbol-function 'format-time-string) #'org-at-format-time-string)
+ (remove-function (symbol-function 'set-file-times) #'org-at-set-file-times)
+ (remove-function (symbol-function 'time-add) #'org-at-time-add)
+ (remove-function (symbol-function 'time-equal-p) #'org-at-time-equal-p)
+ (remove-function (symbol-function 'time-less-p) #'org-at-time-less-p)
+ (remove-function (symbol-function 'time-subtract) #'org-at-time-subtract))))))
+
;;; Misc
diff --git a/lisp/org.el b/lisp/org.el
index 294a5eb52..92ef9908f 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -9918,6 +9918,36 @@ When called through Elisp, arg is also interpreted in the following way:
(run-hook-with-args 'org-trigger-hook change-plist)))
(when commentp (org-toggle-comment))))))))
+(defun org-todo-at (time &optional arg)
+ "Like `org-todo' but the time of change will be TIME.
+TIME should be a valid timestamp, e.g., 2014-03-04,
+2024-01-17 8:00, 2012-03-29 16:40, or <2025-10-18 12:00>.
+
+With `\\[universal-argument]' prefix ARG, force logging the state change \
+and take a
+logging note.
+With a `\\[universal-argument] \\[universal-argument]' prefix, switch to the \
+next set of TODO \
+keywords (nextset).
+Another way to achieve this is `S-C-<right>'.
+With a `\\[universal-argument] \\[universal-argument] \\[universal-argument]' \
+prefix, circumvent any state blocking.
+With numeric prefix arg, switch to the Nth state.
+
+With a numeric prefix arg of 0, inhibit note taking for the change.
+With a numeric prefix arg of -1, cancel repeater to allow marking as DONE.
+
+When called through Elisp, arg is also interpreted in the following way:
+`none' -> empty state
+\"\" -> switch to empty state
+`done' -> switch to DONE
+`nextset' -> switch to the next set of keywords
+`previousset' -> switch to the previous set of keywords
+\"WAITING\" -> switch to the specified keyword, but only if it
+ really is a member of `org-todo-keywords'."
+ (interactive (list (org-read-date) current-prefix-arg))
+ (org-at-time time (org-todo arg)))
+
(defun org-block-todo-from-children-or-siblings-or-parent (change-plist)
"Block turning an entry into a TODO, using the hierarchy.
This checks whether the current task should be blocked from state
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 9e9be9ebc..cbd24d7c0 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -9168,6 +9168,103 @@ SCHEDULED: <2021-06-15 Tue +1d>"
(org-add-log-note))
(buffer-string))))))
+(ert-deftest test-org/auto-repeat-maybe/org-todo-at ()
+ "Test `org-auto-repeat-maybe' with `org-todo-at'."
+ ;; Handle every repeater type using hours step.
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 02:00 +8h>")
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 +8h>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string)))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 10:00 ++8h>")
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 ++8h>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string)))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 10:35 .+8h>")
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 .+8h>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string)))))
+;; ;; Handle `org-extend-today-until'.
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 ++1d>")
+ (let ((org-extend-today-until 4))
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 ++1d>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string))))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-06 17:00 ++1d>")
+ (let ((org-extend-today-until 4))
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-04 17:00 ++1d>"
+ (org-todo-at "<2014-03-05 18:00>" "DONE")
+ (buffer-string))))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 10:00 ++8h>")
+ (let ((org-extend-today-until 4))
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 ++8h>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string))))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 18:00 .+1d>")
+ (let ((org-extend-today-until 4))
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 .+1d>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string))))))
+ (should
+ (string-match-p
+ (regexp-quote "<2014-03-04 10:35 .+8h>")
+ (let ((org-extend-today-until 4))
+ (org-test-without-dow
+ (org-test-with-temp-text "* TODO H\n<2014-03-03 18:00 .+8h>"
+ (org-todo-at "<2014-03-04 02:35>" "DONE")
+ (buffer-string))))))
+ ;; Do not repeat inactive time stamps with a repeater.
+ (should-not
+ (string-match-p
+ "\\[2014-03-29 .* \\+2y\\]"
+ (let ((org-todo-keywords '((sequence "TODO" "DONE"))))
+ (org-test-with-temp-text
+ "* TODO H\n[2012-03-29 Thu. +2y]"
+ (org-todo-at "2012-03-29 Thu" "DONE")
+ (buffer-string)))))
+ ;; Do not repeat commented time stamps.
+ (should-not
+ (string-prefix-p
+ "<2015-03-04 .* \\+1y>"
+ (let ((org-todo-keywords '((sequence "TODO" "DONE"))))
+ (org-test-with-temp-text
+ "* TODO H\n<2012-03-29 Thu +2y>\n# <2014-03-04 Tue +1y>"
+ (org-todo-at "2012-03-30 Fri" "DONE")
+ (buffer-string)))))
+ ;; Properly advance repeater even when a clock entry is specified
+ ;; and `org-log-repeat' is nil.
+ (should
+ (string-match-p
+ "SCHEDULED: <2014-03-29"
+ (let ((org-log-repeat nil)
+ (org-todo-keywords '((sequence "TODO" "DONE"))))
+ (org-test-with-temp-text
+ "* TODO H
+SCHEDULED: <2012-03-29 Thu +2y>
+CLOCK: [2012-03-29 Thu 10:00]--[2012-03-29 Thu 16:40] => 6:40"
+ (org-todo-at "2012-03-30 Thu" "DONE")
+ (buffer-string))))))
+
(ert-deftest test-org/org-log-done ()
"Test `org-log-done' specifications.
Behavior can be modified by setting `org-log-done', by keywords in
@@ -9460,6 +9557,65 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
(should (eq org-log-note-how 'note))))
(remove-hook 'post-command-hook #'org-add-log-note))))))
+(ert-deftest test-org/org-todo-at-interactive ()
+ "Test `org-todo-at' interactively."
+ (cl-macrolet ((test-org-todo-at-interactive (test-buffer-text
+ test-date
+ test-prefix-arg
+ expected)
+ (let ((current-prefix-arg test-prefix-arg))
+ `(cl-letf (((symbol-function 'org-read-date)
+ (lambda (&rest _args)
+ (org-time-string-to-time ,test-date))))
+ (org-test-with-temp-text ,test-buffer-text
+ (call-interactively #'org-todo-at)
+ (should (string-equal (buffer-string) ,expected)))))))
+
+ ;; no prefix arg
+ (test-org-todo-at-interactive
+ "* TODO H"
+ "2022-03-24 23:30"
+ nil
+ "* DONE H")
+ ;; -1 prefix arg should cancel
+ ;; repeater and mark DONE.
+ (test-org-todo-at-interactive
+ "* TODO H\n<2012-03-29 Thu +2y>"
+ "2012-03-29 Thu"
+ -1
+ "* TODO H
+:PROPERTIES:
+:LAST_REPEAT: [2012-03-29 Thu 00:00]
+:END:\n<2014-03-29 Sat +2y>")
+ (test-org-todo-at-interactive
+ "* TODO H\n<2012-03-29 Thu +2y>"
+ "2012-03-29 Thu 09:59"
+ -
+ "* TODO H
+:PROPERTIES:
+:LAST_REPEAT: [2012-03-29 Thu 09:59]
+:END:\n<2014-03-29 Sat +2y>")
+
+ ;; test with `org-inhibit-logging'
+ (let ((org-inhibit-logging nil))
+ (test-org-todo-at-interactive
+ "* TODO H\n"
+ "2024-09-07 Sat"
+ '(4)
+ "* DONE H\n"))
+ (let ((org-inhibit-logging 'note))
+ (test-org-todo-at-interactive
+ "* TODO H\n"
+ "2024-09-07 Sat"
+ '(4)
+ "* DONE H\n"))
+ (let ((org-inhibit-logging t))
+ (test-org-todo-at-interactive
+ "* TODO H\n"
+ "2024-09-07 Sat"
+ '(4)
+ "* DONE H\n"))))
+
;;; Timestamps API
diff --git a/testing/org-test.el b/testing/org-test.el
index 2928fa08c..1da9a8893 100644
--- a/testing/org-test.el
+++ b/testing/org-test.el
@@ -502,72 +502,7 @@ Load all test files first."
(ert "\\(org\\|ob\\|ox\\)"))
(org-test-kill-all-examples))
-(defmacro org-test-at-time (time &rest body)
- "Run body while pretending that the current time is TIME.
-TIME can be a non-nil Lisp time value, or a string specifying a date and time."
- (declare (indent 1))
- (let ((tm (cl-gensym))
- (at (cl-gensym)))
- `(let* ((,tm ,time)
- (,at (if (stringp ,tm)
- (org-time-string-to-time ,tm)
- ,tm)))
- (cl-flet
- ((org-test-current-time (_fun &rest _args)
- ,at)
- (org-test-current-time-string (fun &optional time &rest args)
- (apply fun
- (or time ,at) args))
- (org-test-current-time-zone (fun &optional time &rest args)
- (apply fun (or time ,at) args))
- (org-test-decode-time (fun &optional time zone form)
- (condition-case nil
- (funcall fun (or time ,at) zone form)
- (wrong-number-of-arguments
- (funcall fun (or time ,at)))))
- (org-test-encode-time (fun time &rest args)
- (apply fun (or time ,at) args))
- (org-test-float-time (fun &optional time)
- (funcall fun (or time ,at)))
- (org-test-format-time-string (fun format &optional time &rest args)
- (apply fun format (or time ,at) args))
- (org-test-set-file-times (fun file &optional time)
- (funcall fun file (or time ,at)))
- (org-test-time-add (fun a b)
- (funcall fun (or a ,at) (or b ,at)))
- (org-test-time-equal-p (fun a b)
- (funcall fun (or a ,at) (or b ,at)))
- (org-test-time-less-p (fun a b)
- (funcall fun (or a ,at) (or b ,at)))
- (org-test-time-subtract (fun a b)
- (funcall fun (or a ,at) (or b ,at))))
- (add-function :around (symbol-function 'current-time) #'org-test-current-time)
- (add-function :around (symbol-function 'current-time-string) #'org-test-current-time-string)
- (add-function :around (symbol-function 'current-time-zone) #'org-test-current-time-zone)
- (add-function :around (symbol-function 'decode-time) #'org-test-decode-time)
- (add-function :around (symbol-function 'encode-time) #'org-test-encode-time)
- (add-function :around (symbol-function 'float-time) #'org-test-float-time)
- (add-function :around (symbol-function 'format-time-string) #'org-test-format-time-string)
- (add-function :around (symbol-function 'set-file-times) #'org-test-set-file-times)
- (add-function :around (symbol-function 'time-add) #'org-test-time-add)
- (add-function :around (symbol-function 'time-equal-p) #'org-test-time-equal-p)
- (add-function :around (symbol-function 'time-less-p) #'org-test-time-less-p)
- (add-function :around (symbol-function 'time-subtract) #'org-test-time-subtract)
-
- (unwind-protect
- (progn ,@body)
- (remove-function (symbol-function 'current-time) #'org-test-current-time)
- (remove-function (symbol-function 'current-time-string) #'org-test-current-time-string)
- (remove-function (symbol-function 'current-time-zone) #'org-test-current-time-zone)
- (remove-function (symbol-function 'decode-time) #'org-test-decode-time)
- (remove-function (symbol-function 'encode-time) #'org-test-encode-time)
- (remove-function (symbol-function 'float-time) #'org-test-float-time)
- (remove-function (symbol-function 'format-time-string) #'org-test-format-time-string)
- (remove-function (symbol-function 'set-file-times) #'org-test-set-file-times)
- (remove-function (symbol-function 'time-add) #'org-test-time-add)
- (remove-function (symbol-function 'time-equal-p) #'org-test-time-equal-p)
- (remove-function (symbol-function 'time-less-p) #'org-test-time-less-p)
- (remove-function (symbol-function 'time-subtract) #'org-test-time-subtract))))))
+(defalias 'org-test-at-time 'org-at-time)
(defmacro org-test-capture-warnings (&rest body)
"Capture all warnings passed to `org-display-warning' within BODY."
--
2.54.0