Emacs Org-Mode: Automatic Item TODO/DOING/DONE State Transitions for Checkbox Changes

In Emacs org-mode, you start with two states for your outline headings by default to manage tasks: TODO and DONE.

I recently introduced a new state in between: DOING. That helped me come back to stuff I had to let lie for a while.

In code, that means at least:

(setq org-todo-keywords
      (quote ((sequence "TODO(t)" "DOING(g)" "|" "DONE(d)"))))

I actually have multiple sequences, but these don’t matter for this demonstrations.

Thanks to StackExchange, I had automatic parent-child-updates and “statistics cookie” actions for a while.

Video Demo

Check out a video demo of the TODO/DOING/DONE state transitions [on YouTube.](https://www.youtube.com/watch?v=W9R_JXCWORI]

Cookies in Org-Mode

The “cookies” part is a summary of sub-items: of N TODO/DONE items, how many are done? That’s displayed on the parent with a cookie

It works both for checkbox list items and for nested outlines. To illustrate this, let me quote another, very short StackExchange answer:

In this org file:

* TODO Organize party [2/4]
  - [-] call people [33%]
    - [ ] Peter
    - [X] Sarah
    - [ ] Sam
  - [X] order food
  - [ ] think about what music to play
  - [X] talk to the neighbors

The [2/4] and [33%] are statistics cookies. They describe the completion status of the children of the thing they’re on.

They’re pretty useful, because they update automatically as you update the status of children. You can also use them to show the status of child TODO tasks:

* TODO bake a cake [3/6]
** DONE buy ingredients
** DONE start cooking
** DONE realize you forgot eggs, dammit
** TODO drive back to the store and buy eggs
** TODO wait, I needed THREE sticks of butter?
** TODO drive back to the store and just buy a damn cake

The [2/4] and 33% and [3/6] parts are all cookies. See the docs for a longer explanation.

Of course I don’t want to update them manually! org-mode does that for me, and there’s C-c # (invoking org-update-statistics-cookies) just in case. Cookie updates happen as I complete items, both in checkbox lists and TODO items.

Automatic Parent-Child State Updates

Very closely tied to this is an addition in my code that, when the cookie is updated, automatically completes the parent item.

* TODO complete these! [2/3]
- [X] first step
- [X] second step
- [ ] last step

Once I hit C-c C-c with the cursor in the last line to tick off the checkbox, the cookie is updated to [3/3] and the whole item changes to this state:

* DONE complete these! [3/3]
- [X] first step
- [X] second step
- [X] last step

It transitions from TODO to DONE automatically.

When I untick a checkbox, it switches back to TODO again, too.

This works the same when you replace checkboxes with sub-items where some are DONE and some are still TODO. The cookie policies shown above apply.

Introducing an Intermediate State to the Auto-Update

With the new DOING state, I needed the cookie update code to change a bit, because when I set an item to DOING and ticked off a checkbox, my code would see that it wasn’t yet finished and needed to transition to TODO. The whole point of the DOING state is to mark an item as work-in-progress and then keep it in that state.

So I changed the policy to take all three states into account. The implementation for sub-items with their own DONE states is simpler, so I’ll show it first:

(defun ct/org-summary-todo-cookie (n-done n-not-done)
  "Switch header state to DONE when all subentries are DONE, to TODO when none are DONE, and to DOING otherwise"
  (let (org-log-done org-log-states)   ; turn off logging
    (org-todo (cond ((= n-done 0)
                     "TODO")
                    ((= n-not-done 0)
                     "DONE")
                    (t
                     "DOING")))))
(add-hook 'org-after-todo-statistics-hook #'ct/org-summary-todo-cookie)

It simple because the hook already reports how many sub-items are done, and how many aren’t. (For checkboxes, I am going to need to parse the [x/y] cookie value.)

  • org-log-done and org-log-states are declared as local variables, overriding the global settings, and thus effectively turning off org-mode’s logging. I don’t use these and found it worked well to disable it here.
  • org-todo is a function that takes a new state and in this case updates the parent item. You don’t need to tell the function which item to update. The correct item is being activated, i.e. point moved there if needed, when the hook is called.
  • The trigger is in org-after-todo-statistics-hook, when the cookie is updated.

The three conditions are:

  • When the n-done count of DONE sub-items is 0 after the last change, i.e. when I uncomplete the last completed sub-item, it may be not a work-in-progress anymore. I change the state to TODO then. This doesn’t activate when there are no sub-items at all, because I need to toggle a sub-items state programmatically to make the hook execute.
  • When the n-not-done count of sub-items with other states, like TODO or DOING, is 0, that means we’ve completed everything. This might be a bit roundabout because of the negation you have to do in your head: When there are no not-done items, all items are done. There’s no other way to express “are all items completed” available here. (If we had n-total and n-done, we could test (= n-total n-done).)
  • The fallback/else clause: When 1 or more, but not all items are complete, then it’s a work in progress, so apply the DOING state.

How to Approach Auto-Updating the State (TODO/DOING/DONE) Based on Checkboxed

I already mentioned in passing that it’s a bit more work we have to do to achieve the same for checkboxes, because the built-in hooks don’t provide the same convenience. We don’t get a n-done count for a “checkbox changed” hook.

Instead, we have to rely on the “cookie updated” hook. This requires use of a cookie in the first place. The sub-item approach above works with and without cookies.

I’ll show the complete code for everything below, but here’s the approach I stole from the aforementioned StackExchange post years ago and adapted to my 3-state requirements:

  • Find the affected item’s line;
  • Use regular expressions to extract the [x%] or [n/m] cookies from the heading line;
  • Handle both percent and fractional cookies separately and update the state via org-todo like above.

The regex handling and the two cookie variants make the code a bit longer. Please see below for the implementation.

Complete Code

If you paste this into your init.el, you’ll get my whole implementation:

  • Change item state to TODO if no sub-items are DONE, or if the cookie reports [0/m] or [0%] completion.
  • Change item state to DOING when one but not all sub-items are DONE, or when the cookie contains a value above 0% and below 100% (aka for [n/m] where n < m and n > 0).
  • Change item state to DONE when all sub-items are DONE, or if the cookie reports [100%] or [m/m].

Please note that none of the hooks this relies on are called if you type the changes. If you type D-O-N-E for DONE, none of the org-mode facilities will note the state change.

You need to go through the interactive org-todo state change function (C-c C-t) or the Shift+Arrow_keys based state cycling to trigger hooks on the parent item in the outline.

For checkboxes, you need to tick them off with C-c C-c.

In case you forgot this and now nothing’s up-to-date anymore, you can trigger a cookie refresh by hitting C-c C-c with the cursor inside the cookie. So don’t be afraid.

(defun org-todo-if-needed (state)
  "Change header state to STATE unless the current item is in STATE already."
  (unless (string-equal (org-get-todo-state) state)
    (org-todo state)))

(defun ct/org-summary-todo-cookie (n-done n-not-done)
  "Switch header state to DONE when all subentries are DONE, to TODO when none are DONE, and to DOING otherwise"
  (let (org-log-done org-log-states)   ; turn off logging
    (org-todo-if-needed (cond ((= n-done 0)
                               "TODO")
                              ((= n-not-done 0)
                               "DONE")
                              (t
                               "DOING")))))
(add-hook 'org-after-todo-statistics-hook #'ct/org-summary-todo-cookie)

(defun ct/org-summary-checkbox-cookie ()
  "Switch header state to DONE when all checkboxes are ticked, to TODO when none are ticked, and to DOING otherwise"
  (let (beg end)
    (unless (not (org-get-todo-state))
      (save-excursion
        (org-back-to-heading t)
        (setq beg (point))
        (end-of-line)
        (setq end (point))
        (goto-char beg)
        ;; Regex group 1: %-based cookie
        ;; Regex group 2 and 3: x/y cookie
        (if (re-search-forward "\\[\\([0-9]*%\\)\\]\\|\\[\\([0-9]*\\)/\\([0-9]*\\)\\]"
                               end t)
            (if (match-end 1)
                ;; [xx%] cookie support
                (cond ((equal (match-string 1) "100%")
                       (org-todo-if-needed "DONE"))
                      ((equal (match-string 1) "0%")
                       (org-todo-if-needed "TODO"))
                      (t
                       (org-todo-if-needed "DOING")))
              ;; [x/y] cookie support
              (if (> (match-end 2) (match-beginning 2)) ; = if not empty
                  (cond ((equal (match-string 2) (match-string 3))
                         (org-todo-if-needed "DONE"))
                        ((or (equal (string-trim (match-string 2)) "")
                             (equal (match-string 2) "0"))
                         (org-todo-if-needed "TODO"))
                        (t
                         (org-todo-if-needed "DOING")))
                (org-todo-if-needed "DOING"))))))))
(add-hook 'org-checkbox-statistics-hook #'ct/org-summary-checkbox-cookie)

Possible Improvements

The hook for checkbox state updates is different. But to figure out if the item holding all checkboxes is complete, a cookie is required. Checkbox-ticking should also trigger the cookie statistics hook, though. So I think both implementations could be merged into one callback.

The basic code is 3 years old now. I guess org-mode v9.4 comes with new stuff that helps dealing with this, but which I haven’t discovered yet.

Are there any suggestions from your side, dear reader?