org-clock-agg

org-clock-agg

Aggregate org-clock records and display the results in an interactive buffer. The records are grouped by predicates such as file name, their outline path in the file, etc. Each record is placed in a tree structure; each node of the tree shows the total time spent in that node and its children. The top-level node shows the total time spent in all records found by the query.

Installation

The package isn’t yet available anywhere but in this repository. My preferred way for such cases is use-package and straight.el:

(use-package org-clock-agg
  :straight (:host github :repo "SqrtMinusOne/org-clock-agg"))

Alternatively, clone the repository, add it to the load-path, and require the package.

Usage

Run M-x org-clock-agg to open the interactive buffer (as depicted in the screenshot above).

The interactive buffer provides the following controls:

  • Files: Specifies the org files from which to select (defaults to org-agenda).
  • Date from and To: Define the date range.
  • Group by: Determines how org-clock records are grouped.
  • Show elements: Whether to display raw org-clock records in each node.
  • Add “Ungrouped”: Option to include the “Ungrouped” node. This is particularly useful with custom grouping predicates.

Press [Refresh] to update the buffer. The initial search might take some time, but subsequent searches are generally faster due to the caching mechanism employed by org-ql.

The buffer uses outline-mode to display the tree, so each node becomes an outline-mode header. Refer to the linked manual for available commands/keybindings, or, if you use evil-mode, check the relevant evil-collection file.

Files

By default, the package selects org-clock records from (org-agenda-files). Additional options can be included by customizing the org-clock-agg-files-preset variable. For instance:

(setq org-clock-agg-files-preset
      `(("Org Agenda + Archive"
	 .
	 ,(append (org-agenda-files)
		  (cl-remove-if
		   (lambda (f) (string-match-p (rx "." eos) f))
		   (directory-files (concat org-directory "/archive/") t))))))

Note that after updating any of these variables, you’ll need to reopen the *org-clock-agg* buffer to view the changes.

Alternatively, you can directly specify the list of files within the buffer by selecting “Custom list” in the “Files” control.

Date Range

Dates can take the following values:

  • A number: Represents a relative number of days from the current date. E.g. the default value of -7 to 0 menas the previous week up to today.
  • A date string in the format YYYY-MM-DD HH:mm:ss, with or without the time part.

By default, the interval is inclusive. For instance, specifying an interval like 2023-12-12 .. 2023-12-13 includes all records from 2023-12-12 00:00:00 to 2023-12-13 23:59:59.

Group By

Records are grouped based on the sequence of grouping predicates.

For example, with the following content in tasks.org:

* Tasks
** DONE Thing 1
:LOGBOOK:
CLOCK: [2023-12-13 Wed 19:01]--[2023-12-13 Wed 19:29] =>  0:28
CLOCK: [2023-12-13 Wed 19:30]--[2023-12-13 Wed 19:40] =>  0:10
:END:

And predicates “Org file”, “Day”, and “Outline path”, the records for “Thing 1” will be processed as follows:

  • “Day” -> 2023-12-13
  • “Org file” -> tasks.org
  • “Outline path” -> Tasks, Thing 1

Consequently, the node will be placed at the path 2023-12-13 / tasks.org / Tasks / Thing 1 in the resulting tree:

* Results                                                  Root    0:38
** 2023-12-13                                               Day    0:38
*** tasks.org                                          Org File    0:38
**** Tasks                                         Outline path    0:38
***** Thing 1                                      Outline path    0:38
- [2023-12-13 Wed 19:01]--[2023-12-13 Wed 19:29] =>  0:28 : DONE Thing 1
- [2023-12-13 Wed 19:30]--[2023-12-13 Wed 19:40] =>  0:10 : DONE Thing 1

The following built-in predicates are currently available:

Name Comment Customization variables
Category
Org file
Outline path
Tags Sorted alphabetically
Headline Last item of the outline path
Day org-clock-agg-day-format
Week org-clock-agg-week-format
Month org-clock-agg-month-format
TODO keyword
Is done
Selected props org-clock-agg-properties

Ensure to use setopt to set the variables; otherwise, the customization logic will not be invoked:

(setopt org-clock-agg-properties '("PROJECT_NAME"))

Refer also to custom grouping predicates.

Customization

Node Formatting

The org-clock-agg-node-format variable determines the formatting of individual tree nodes. This uses a format string that with the following format specifiers avaiable:

  • %t: Node title with the level prefix, truncated to title-width characters (refer to below)
  • %c: Name of the grouping function that generated the node
  • %z: Time spent in the node, formatted according to org-clock-agg-duration-format.
  • %s: Time share of the node against the parent node
  • %S: Time share of the node against the top-level node

The default value is:

%-%(+ title-width)t %20c %8z

Where %(+ title-width) is (- (window-width) org-clock-agg-node-title-width-delta), with the default value of the latter set to 40.

Thefore, in the default configuration, the node title is truncated to title-width characters, while 40 symbols are allocated for the rest of the header, i.e. " %20c %8z" (30 symbols), along with additional space for folding symbols of outline-minor-mode, line numbers, etc.

Record Formatting

When the “Show records” flag is enabled, associated records for each node are displayed. The formatting of these is defined by org-clock-agg-elem-format, which is also a format string with the following specifiers: Customize the formatting of these records through org-clock-agg-elem-format, which also utilizes a format string comprising the following specifiers:

  • %s: Start of the time range
  • %e: End of the time range
  • %d: Duration of the time range
  • %t: Title of the record.

The default value is:

- [%s]--[%e] => %d : %t

Custom grouping predicates

It’s possible to define custom grouping predicates in addition to the default ones. In fact, it’s probably the only way to get grouping that is tailored to your particular org workflow; I haven’t included my predicates in the package because they aren’t general enough.

To create new predicates, use org-clock-agg-defgroupby:

(org-clock-agg-defgroupby <name>
  :key1 value1
  :key2 value2
  <body>)

The available keyword arguments include:

  • :readable-name: Function name for the UI.
  • :default-sort: Default sorting function.

The body binds two variables - elem and extra-params, and must return a list of strings.

The elem variable is an alist that represents one org-clock record. The keys are as follows:

  • :start: Start time in seconds since the epoch
  • :end: End time in seconds since the epoch
  • :duration: Duration in seconds
  • :headline: Instance of org-element for the headline
  • :tags: List of tags
  • :file: File name
  • :outline-path: titles of all headlines from the root to the current headline
  • :properties: List of properties; org-clock-agg-properties sets the selection list
  • :category: Category of the current headline.

The extra-params variable is an alist of global parameters controlling the function’s behavior. Additional parameters can be added by customizing org-clock-agg-extra-params. This alist has keys as parameter names and values as widget.el expressions (applied to widget-create) controlling the UI. Each widget must contain an :extras-key key.

For instance:

(setq org-clock-agg-extra-params
      '(("Events: Offline / Online" . (checkbox :extras-key :events-online))))

This adds a checkbox to the form that appears as:

Events: Offline / Online [ ]

When checked, extra-params takes the value ((:extras-keys . t)).

Here’s an example predicate. I store meetings the following way:

* Some project
** Meetings
*** Some meeting 1
*** Some meeting 2
* Another project
** Meetings
*** Another meeting 1
*** Another meeting 2 (offline)

I want to group these meetings by title, i.e. group all instances of “Some meeting”, “Another meeting”, etc. Optionally I want to group online and offline meetings.

This can be done the following way:

(org-clock-agg-defgroupby event
  :readable-name "Event"
  :default-sort total
  (let* ((title (org-element-property :raw-value (alist-get :headline elem)))
	 (is-meeting (or (string-match-p "meeting" (downcase title))
			 (seq-contains-p (alist-get :tags elem) "mt")))
	 (is-offline (or (string-match-p "offline" (downcase title))
			 (seq-contains-p (alist-get :tags elem) "offline")))
	 (title-without-stuff (string-trim
			       (replace-regexp-in-string
				(rx (or
				     (group (+ (or digit ".")))
				     "(offline)"
				     (seq "[" (+ alnum) "]") ))
				"" title))))
    (when is-meeting
      `("Meeting"
	,@(when (alist-get :events-online extra-params)
	    (if is-offline '("Offline") '("Online")))
	,title-without-stuff))))

For the following result:

* Results
** Meetings
*** Some meeting
*** Another meeting
** Ungrouped

This can be coupled with a project predicate to analyze the time spent per project in a particular kind of meeting.