Formatting timesheet tables in org-mode

Posted on February 12, 2017

This post is originally adapted from this stackoverflow answer.

Org-mode is a great productivity tool, and has a lot of functionality out of the box, including utilities for tracking time spent on a task/project. For example, by pressing C-c C-x C-i on a heading, a timer is added (clock-in), and by pressing C-c C-x C-o the timer stops (clock-out). Org-mode also supports adding a report with org-clock-report (C-c C-x C-r), but, even with the customization it offers, it is not ideal for having a fully custom report and be able to export it to csv and whatnot.

Fortunately, newer versions of org-mode come with a utility called org-element, which adds the ability to parse org-mode buffers as trees. This proves really helpful to take all clock elements from a buffer and put them in a table.

To do this, we use the following elisp code block in the respective buffer:

#+BEGIN_SRC elisp
  (nconc
   '(("date" "project" "hours" "task"))
   '(hline)
   (let ((ast (org-element-parse-buffer 'element)))
     (org-element-map ast 'clock
       (lambda (x)
         (let* ((val (org-element-property :value x))
                (task (org-element-property
                       :parent
                       (org-element-property :parent x))))
           `(,(let ((year (org-element-property :year-start val))
                    (month (calendar-month-name
                            (org-element-property :month-start val)))
                    (day (org-element-property :day-start val)))
                (format "%s %s, %s" month day year))
             ,(org-element-property :PROJECT task)
             ,(org-element-property :duration x)
             ,(org-element-property :title task)
             )))))
   '(hline)
   '(("" "total:" ":=vsum(@2..@-1);T" "")))
#+END_SRC

What this code block does is parse the buffer, and map the lambda function over all of the clock elements in the buffer. For each element, we get its value (which is the datetime range), and its header, which contains some properties, such as the project name. The name of the task is the header name.

By prepending a list, we can also add headers. At the end, a calc formula can be used for adding the times to get a total. This formula sums the current column (hence the empty strings before and after), from the second row (not including the header), up to the second to last row (not including the formula itself). The ;T after the formula instructs it to add time, rather than numbers. Finally, the hline function inserts a dashed line for making the table more presentable.

Suppose the buffer looks like this:

#+BEGIN_SRC
  * project 1

  ** Task 1
    :PROPERTIES:
    :PROJECT:  project_1
    :END:
    CLOCK: [2017-06-24 Sat 19:15]--[2017-06-24 Sat 22:05] =>  2:50


  ** Task 2
    :PROPERTIES:
    :PROJECT:  project_1
    :END:
    CLOCK: [2017-06-28 Wed 00:35]--[2017-06-28 Wed 03:35] =>  3:00
    CLOCK: [2017-06-27 Tue 16:55]--[2017-06-27 Tue 19:00] =>  2:05
    CLOCK: [2017-06-26 Mon 21:30]--[2017-06-27 Tue 01:00] =>  3:30

  * project 2

  ** Task 1
    :PROPERTIES:
    :PROJECT:  project_2
    :END:
    CLOCK: [2017-06-29 Thu 15:18]--[2017-06-29 Thu 18:18] =>  3:00
#+END_SRC

If the above code is wrapped in an elisp block and executed (C-c C-c), it outputs the following table:

#+BEGIN_SRC
  | date          | project   |             hours | task   |
  |---------------+-----------+-------------------+--------|
  | June 24, 2017 | project_1 |              2:50 | Task 1 |
  | June 28, 2017 | project_1 |              3:00 | Task 2 |
  | June 27, 2017 | project_1 |              2:05 | Task 2 |
  | June 26, 2017 | project_1 |              3:30 | Task 2 |
  | June 29, 2017 | project_2 |              3:00 | Task 1 |
  |---------------+-----------+-------------------+--------|
  |               | total:    | :=vsum(@2..@-1);T |        |
#+END_SRC

To run the formula, press TAB while in its cell.

Afterwards, the table can be exported to csv with org-table-export.

NOTE: To make all of the above work, set

(setq org-clock-into-drawer nil)

in your config, otherwise clock entries will go into a LOGBOOK drawer and I haven’t yet figured out how to parse them from there.