summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 15:01:14 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 15:01:14 -0500
commit082037ad75e21405655adb492d0c85e342f4f771 (patch)
tree67b239762729034b12304c71d945206a587c65e0
parent030bb13ba5742f331dd30a55bbce5a13917d4d63 (diff)
downloaddotemacs-082037ad75e21405655adb492d0c85e342f4f771.tar.gz
dotemacs-082037ad75e21405655adb492d0c85e342f4f771.zip
feat(org-config): add cj/org-finalize-task with tests
I added a command on C-; O d that finalizes the task at point. It prompts for a finalized keyword from org-done-keywords, so the picker tracks org-todo-keywords automatically. Marking the task done fires the org-roam journal-copy hook, so the completed task lands in today's daily. Then the heading is reshaped by depth. A sub-task (level 3 or deeper, or a VERIFY at any depth) becomes a dated log entry: the keyword and priority cookie are stripped, a sortable timestamp is prepended, and the tags are kept. A top-level task keeps its keyword and gains a date-only CLOSED line. The command binds org-inhibit-logging around the org-todo call so it owns the CLOSED line rather than depending on org-log-done, which is set inconsistently across two modules. The journal hook keys off org-state, not org-log-done, so the copy still fires. Tests run in org temp-buffers with the journal hook bound to nil, exercise the real org primitives, and inject a fixed time so the stamp shape is deterministic.
-rw-r--r--modules/org-config.el70
-rw-r--r--tests/test-org-config-finalize-task.el140
2 files changed, 210 insertions, 0 deletions
diff --git a/modules/org-config.el b/modules/org-config.el
index c04683c0..31ed7f6c 100644
--- a/modules/org-config.el
+++ b/modules/org-config.el
@@ -348,6 +348,76 @@ status to preserve priority ordering within TODO groups."
(user-error nil)))
(message "Sorted entries by TODO status and priority"))
+;; --------------------- Finalize Task (done + journal + log) ------------------
+;; `cj/org-finalize-task' (C-; O d) marks the task at point done with a chosen
+;; finalized keyword -- which fires the org-roam journal-copy hook -- then
+;; reshapes the heading per todo-format: a dated log entry for sub-tasks (and
+;; VERIFY at any depth), or a kept keyword plus a date-only CLOSED line for
+;; top-level tasks.
+
+(defun cj/--org-finalize-dated-p (level keyword)
+ "Return non-nil when a finalized heading should become a dated log entry.
+Per todo-format: sub-tasks at LEVEL 3 or deeper flip to a dated entry, and a
+VERIFY (by KEYWORD) flips at any depth. Top-level tasks stay task-shaped."
+ (or (>= level 3)
+ (equal keyword "VERIFY")))
+
+(defun cj/--org-finalize-rewrite-dated (&optional time)
+ "Rewrite the heading at point as a dated log entry.
+Strip the todo keyword and [#X] priority cookie, prepend a sortable timestamp
+built from TIME (default now), and keep the tags."
+ (org-back-to-heading t)
+ (let ((stamp (format-time-string "%Y-%m-%d %a @ %H:%M:%S %z" time))
+ (org-inhibit-logging t))
+ (when (org-get-todo-state) (org-todo 'none))
+ (when (nth 3 (org-heading-components)) ; only if a [#X] cookie is present
+ (org-priority 'remove))
+ (org-edit-headline (concat stamp " " (org-get-heading t t t t)))))
+
+(defun cj/--org-finalize-close-in-place (&optional time)
+ "Add a date-only CLOSED line under the heading at point, keeping the keyword.
+TIME defaults to now. Used for top-level tasks that stay task-shaped."
+ (org-back-to-heading t)
+ (let ((org-inhibit-logging t))
+ (org-add-planning-info 'closed (or time (current-time))))
+ ;; org's CLOSED stamp may carry HH:MM; normalize to date-only per todo-format.
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((end (save-excursion (outline-next-heading) (point)))
+ (stamp (format-time-string "[%Y-%m-%d %a]" time)))
+ (when (re-search-forward "CLOSED: \\[[^]]*\\]" end t)
+ (replace-match (concat "CLOSED: " stamp) t t)))))
+
+(defun cj/org-finalize-task (&optional state time)
+ "Finalize the task at point: mark it done and reshape it per todo-format.
+Prompt for a finalized keyword from `org-done-keywords' (STATE skips the
+prompt, TIME sets the timestamp -- both for testing). Marking the task done
+fires the journal-copy hook, then a sub-task (level >= 3, or a VERIFY at any
+depth) becomes a dated log entry while a top-level task keeps its keyword and
+gains a date-only CLOSED line."
+ (interactive)
+ (unless (derived-mode-p 'org-mode)
+ (user-error "Only available in Org buffers"))
+ (org-back-to-heading t)
+ (let ((keyword (org-get-todo-state))
+ (level (org-current-level)))
+ (unless (member keyword org-not-done-keywords)
+ (user-error "Not on an open task (no actionable TODO keyword)"))
+ (let ((finalized
+ (or state
+ (let ((default (if (member "DONE" org-done-keywords)
+ "DONE"
+ (car org-done-keywords))))
+ (completing-read "Finalize as: " org-done-keywords
+ nil t nil nil default)))))
+ (let ((org-inhibit-logging t))
+ (org-todo finalized)) ; fires the journal-copy hook
+ (if (cj/--org-finalize-dated-p level keyword)
+ (cj/--org-finalize-rewrite-dated time)
+ (cj/--org-finalize-close-in-place time)))))
+
+(keymap-set cj/org-map "d" #'cj/org-finalize-task)
+
;; ------------------------------ Org Keybindings ------------------------------
;; which-key labels for org keymaps
diff --git a/tests/test-org-config-finalize-task.el b/tests/test-org-config-finalize-task.el
new file mode 100644
index 00000000..a0932919
--- /dev/null
+++ b/tests/test-org-config-finalize-task.el
@@ -0,0 +1,140 @@
+;;; test-org-config-finalize-task.el --- Tests for cj/org-finalize-task -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Covers `cj/org-finalize-task' and its helpers: the dated-rewrite vs
+;; close-in-place dispatch, the two heading transforms, and the guard.
+;;
+;; The journal-copy hook (`org-after-todo-state-change-hook') is bound to
+;; nil in every buffer test so the org-roam daily side effect never fires --
+;; that hook is the external boundary, mocked out here. `org-todo-keywords'
+;; is set to the project's full sequence so DOING / VERIFY / CANCELLED resolve
+;; as keywords inside the temp buffers regardless of load state.
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-config)
+
+;; Fixed instant so the stamp is deterministic in shape. The exact value and
+;; tz offset are still system-dependent, so assertions match the FORMAT, not a
+;; literal string (per testing.md: assert behavior, not exact text).
+(defconst test-finalize--time (encode-time 24 18 14 22 5 2026)
+ "2026-05-22 14:18:24, local time, for deterministic stamp shape.")
+
+(defmacro test-finalize--with-heading (text &rest body)
+ "Insert TEXT in a temp Org buffer, point on the first heading, run BODY.
+Returns the resulting buffer string. Binds the project keyword set, nils
+the todo-state-change hook (no journal side effect), and inhibits logging."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (let ((org-todo-keywords
+ '((sequence "TODO" "PROJECT" "DOING" "WAITING" "VERIFY"
+ "STALLED" "DELEGATED" "|" "FAILED" "DONE" "CANCELLED")))
+ (org-mode-hook nil) ; isolate from unrelated hooks (org-tidy etc.)
+ (org-after-todo-state-change-hook nil)
+ (org-inhibit-logging t))
+ (insert ,text)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body
+ (buffer-string))))
+
+;; ---------------------------- predicate --------------------------------------
+
+(ert-deftest test-org-finalize-dated-p-level3-true ()
+ "Normal: a level-3 sub-task takes the dated rewrite."
+ (should (cj/--org-finalize-dated-p 3 "TODO")))
+
+(ert-deftest test-org-finalize-dated-p-level2-false ()
+ "Normal: a level-2 task stays task-shaped (close in place)."
+ (should-not (cj/--org-finalize-dated-p 2 "TODO")))
+
+(ert-deftest test-org-finalize-dated-p-verify-true-at-level2 ()
+ "Boundary: VERIFY flips to dated at all depths (todo-format exception)."
+ (should (cj/--org-finalize-dated-p 2 "VERIFY")))
+
+(ert-deftest test-org-finalize-dated-p-level1-false ()
+ "Boundary: a level-1 heading is not dated."
+ (should-not (cj/--org-finalize-dated-p 1 "TODO")))
+
+;; ------------------------- dated rewrite -------------------------------------
+
+(ert-deftest test-org-finalize-rewrite-dated-strips-and-prepends ()
+ "Normal: keyword and cookie gone, tag kept, stamp prepended in exact format."
+ (let ((out (test-finalize--with-heading "*** TODO [#A] Buy milk :shop:\nbody\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p
+ "^\\*\\*\\* [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Z][a-z]\\{2\\} @ [0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\} [-+][0-9]\\{4\\} Buy milk[ \t]+:shop:"
+ out))
+ (should-not (string-match-p "TODO" out))
+ (should-not (string-match-p "\\[#A\\]" out))))
+
+(ert-deftest test-org-finalize-rewrite-dated-no-priority ()
+ "Boundary: a heading without a priority cookie still rewrites."
+ (let ((out (test-finalize--with-heading "*** DOING Refactor thing\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p "Refactor thing" out))
+ (should-not (string-match-p "DOING" out))))
+
+(ert-deftest test-org-finalize-rewrite-dated-no-tags ()
+ "Boundary: a heading without tags rewrites cleanly."
+ (let ((out (test-finalize--with-heading "*** TODO Plain task\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p "Plain task" out))
+ (should-not (string-match-p "TODO" out))))
+
+;; ------------------------- close in place ------------------------------------
+
+(ert-deftest test-org-finalize-close-in-place-adds-date-only-closed ()
+ "Normal: a date-only CLOSED line is added; keyword retained, no time part."
+ (let ((out (test-finalize--with-heading "** DONE [#A] Ship it :rel:\n"
+ (cj/--org-finalize-close-in-place test-finalize--time))))
+ (should (string-match-p
+ "CLOSED: \\[[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Z][a-z]\\{2\\}\\]" out))
+ (should (string-match-p "DONE" out))
+ (should-not (string-match-p "CLOSED: \\[[0-9-]+ [A-Z][a-z]+ [0-9]+:[0-9]+" out))))
+
+;; ------------------------- command / guard -----------------------------------
+
+(ert-deftest test-org-finalize-task-errors-outside-org ()
+ "Error: the command refuses to run outside Org."
+ (with-temp-buffer
+ (fundamental-mode)
+ (should-error (cj/org-finalize-task "DONE") :type 'user-error)))
+
+(ert-deftest test-org-finalize-task-errors-on-non-task-heading ()
+ "Error: a heading with no actionable keyword cannot be finalized."
+ (with-temp-buffer
+ (let ((org-mode-hook nil)
+ (org-after-todo-state-change-hook nil))
+ (insert "* Just a section\n")
+ (org-mode)
+ (goto-char (point-min))
+ (should-error (cj/org-finalize-task "DONE") :type 'user-error))))
+
+(ert-deftest test-org-finalize-task-level3-produces-dated-entry ()
+ "Integration: finalizing a level-3 task yields a dated entry, no CLOSED cruft."
+ (let ((out (test-finalize--with-heading "*** TODO [#B] Wire the thing :feat:\n"
+ (cj/org-finalize-task "DONE" test-finalize--time))))
+ (should (string-match-p "^\\*\\*\\* [0-9]\\{4\\}-.*Wire the thing" out))
+ (should-not (string-match-p "TODO\\|\\[#B\\]\\|CLOSED" out))))
+
+(ert-deftest test-org-finalize-task-level2-keeps-keyword-adds-closed ()
+ "Integration: finalizing a level-2 task keeps the keyword and adds CLOSED."
+ (let ((out (test-finalize--with-heading "** TODO [#A] Big task :proj:\n"
+ (cj/org-finalize-task "DONE" test-finalize--time))))
+ (should (string-match-p "DONE" out))
+ (should (string-match-p "CLOSED: \\[" out))
+ (should-not (string-match-p "TODO" out))))
+
+;; ------------------------- keybinding wiring ---------------------------------
+
+(ert-deftest test-org-finalize-task-bound-on-org-map ()
+ "Normal: the command is bound to `d' under the org prefix (C-; O d)."
+ (should (eq (keymap-lookup cj/org-map "d") #'cj/org-finalize-task)))
+
+(provide 'test-org-config-finalize-task)
+;;; test-org-config-finalize-task.el ends here