2023-01-02 Computing Org mode TODO stats

When I started using Org mode, I followed the very common (and very sound) advice and did not try to learn it all at once. This means that I didn’t use clock tables at all.

Well, some time has passed, and I learned that they are actually pretty useful. One obvious application is to get a report for the sake of invoicing, but I also use them to gain knowledge about where I spend how much time.

Recently I realized that I miss another, similar feature in Org. Much like having a clock table – which is a summary of time spent on tasks in a file or subtree – I would like to be able to see a report telling me how many tasks I have in a subtree, and how many of them are in various states.

Creating a feature similar to the Org mode clock table is possible, but it’s a subject for another time. Today, let me show how to do the computation itself. The main two ingredients are the org-todo-keywords-1 variable and the org-map-entries function, paired with the Org element API.

(defun org-subtree-todo-stats ()
  "Get stats about tasks in the current subtree."
  (let ((stats (mapcar (lambda (keyword) (cons keyword 0))
                       org-todo-keywords-1)))
    (org-map-entries
     (lambda ()
       (let ((keyword (org-element-property
                       :todo-keyword
                       (org-element-at-point))))
         (when keyword
           (cl-incf (alist-get keyword stats nil nil #'string=)))))
     t
     'tree 'archive 'comment)
    stats))

The (undocumented and admittedly poorly named) org-todo-keywords-1 variable is a list of strings – all the TODO keywords active in the current buffer. Unlike org-todo-keywords, which shows the structure behind them (so it contains data about their sequences, progress logging status etc.), it is just a plain list of plain strings. This is exactly what I need here – I don’t need any structure, just a list of the keywords. (The first version of my code created this list itself, which had two drawbacks. First, if some keyword did not appear in the subtree at all, neither did it appear in the stats. Also, the order of the keywords in the stats depended on the order they were encountered in the subtree.)

The Org element API is a known thing, and here it lets us iterate over all the headlines and get the TODO keyword (if it exists) of every headline in the current tree (here, excluding archived and commented subtrees).

As a final note, if anyone is offended;-) by the imperative cl-incf, feel free to rewrite org-subtree-todo-stats in a more functional way, e.g. using cl-reduce. To be honest, I started doing just that, and then decided it’s not worth the effort – yes, I would gain more functional purity, but at the expense of (possibly) slightly longer and (definitely) more complicated code.

Until next time, when I show how to format this information in a nice way in the Org buffer!

Edit: as yantar92 mentioned in the comment section, I probably should have used org-element-at-point instead of org-element-at-point-no-context, and I definitely should have avoided plist-get in favor of org-element-property. I edited the code above; for reference, here is the previous version:

;; Inferior first version
(defun org-subtree-todo-stats ()
  "Get stats about tasks in the current subtree."
  (let ((stats (mapcar (lambda (keyword) (cons keyword 0))
                       org-todo-keywords-1)))
    (org-map-entries
     (lambda ()
       (let ((keyword (plist-get
                       (cadr (org-element-at-point-no-context))
                       :todo-keyword)))
         (when keyword
           (cl-incf (alist-get keyword stats nil nil #'string=)))))
     t
     'tree 'archive 'comment)
    stats))

CategoryEnglish, CategoryBlog, CategoryEmacs, CategoryOrgMode