Using Org Mode to keep track of exercise

Introduction

I have been using Org mode to keep a daily journal of useful notes for around a year now. One such type of note is the amount of exercise I’ve done on a particular day. While this is a useful record, I wanted to expand upon it and to produce a table at the end of each month so that I can track how I’m (hopefully) progressing.

This post details how I used Emacs, Org mode and a sprinkling of Elisp to do this.

Org mode journals

My journals have the following format: -

* [2020-08-30]
** General
   General notes
** Exercise
*** Crunches: 100
*** Press-ups: 30
    My right shoulder started aching after 20 press-ups

There is a top-level heading for each day of the month and this is broken down into a number of sub-headings. One of these is always “Exercise”, which contains headings for each type of exercise and a value relating to that exercise (e.g. number of press-ups). I sometimes write notes under each exercise heading if I want to make a note of something relating to that specific exercise, such as in the example above.

Because of this consistent format, I was able to write a set of functions for extracting this information in the form of a summary table, such as this: -

Date Crunches Plank Press-ups Run
[2020-08-01 Sat] 0 0 0 0
[2020-08-02 Sun] 0 0 0 8.75km in 00:51:52
[2020-08-03 Mon] 100 120s 50 0
[2020-08-04 Tue] 100 120s 50 0
[2020-08-05 Wed] 100 130s 60 0
[2020-08-06 Thu] 100 120s 60 0
[2020-08-07 Fri] 125 140s 60 0

The rest of this post details how I did it.

Processing an Org mode document tree

To obtain a table such as the one above for an Org mode document, I wrote a series of functions which convert the data extracted from the elements to a format which Org mode can display as a table. Then it is simply a case of running the function within a SRC block within the Org mode document to produce the table.

Utility functions to extract date and exercise lists

This function takes an “Exercise” headline and returns the corresponding date from its parent element.

(defun p64/get-exercise-date-from-item (item)
  "Return the date of an exercise headline.

ITEM is an Org headline \"Exercise\" element from a journal
buffer."

  ; Return the raw value of the item's parent element (e.g. "[2020-08-30]").
  (org-element-property :raw-value (org-element-property :parent item)))

This function takes an “Exercise” headline and returns an alist of each type (e.g. “Press-ups”) and value (e.g. 40). It relies on the fact that I always write the type and value as “Type: value”.

(defun p64/get-exercise-data-from-item (item)
  "Return an alist of (exercise-type . value) for all child
headlines of an item.

ITEM is an Org headline \"Exercise\" element from a journal
buffer."

  ; Map each headline to an alist of exercise type and value.
  (org-element-map (org-element-contents item) 'headline
    (lambda (exercise-headline)

      ; Get the raw string (e.g. "Crunches: 123")
      (let ((raw-str (org-element-property :raw-value exercise-headline)))

        ; Match the string and capture two groups (exercise type and value)
        (string-match "\\(.*\\): +\\(.*\\)$" raw-str)

        ; Build an alist element for (type . value)
        `(,(match-string 1 raw-str) . ,(match-string 2 raw-str))))))

Parsing a buffer to extract exercise headlines

This function uses org-element-map to map each “Exercise” headline to an alist containing the date and the list of exercise values for that date. It uses the two previously defined utility functions to get the date and exercise list for each heading.

(defun p64/get-exercises-from-buffer ()
  "Parse the current Org mode buffer and return all exercises.

The returned list is an alist with (date . exercise-list), where
exercise-list is an alist of (exercise-type . value) returned
from p64/get-exercise-data-from-item."

  ; Map each headline
  (org-element-map (org-element-parse-buffer) 'headline
    (lambda (item)

      ; Only process headlines matching "Exercise" exactly
      (when (string-match "^Exercise$" (org-element-property :raw-value item))

        ; Map each "Exercise" headline to the parent date and alist of exercise
        ; type and value
        (let ((date (p64/get-exercise-date-from-item item)))
          `(,date . ,(p64/get-exercise-data-from-item item)))))))

Utility function to create an ordered set of exercise types

This function takes a list of exercise results and produces a sorted set of exercise types. For example, if one day had “Crunches” and “Press-ups” and another day had “Press-ups” and “Run”, then the result would be ("Crunches" "Press-ups" "Run").

(defun p64/get-exercise-set (exercises)
  "Return a sorted set of all exercise types from a list of
exercise data.

EXERCISES is an alist of exercise type (string) and value (e.g.
number of sets for that exercise)."

  ; Create an empty set
  (let ((exercise-set '()))

    ; Loop through all daily exercise records. car is the date, cdr is an alist
    ; of (type . value).
    (dolist (rec exercises)

      ; Loop through all exercise types for the daily record
      (dolist (ex (cdr rec))

        ; Add the type (car) to the set if it does not already exist
        (unless (member (car ex) exercise-set) (push (car ex) exercise-set))))

    ; Return the sorted set
    (sort exercise-set #'string-lessp)))

Table output

This function builds the header row for the table, which is simply a list of column headings. These are “Date”, followed by a heading for each exercise type.

(defun p64/get-exercise-table-header (exercise-set)
  "Return a list for the exercise table header.

This list consists of \"Date\" followed by a string for each
exercise type in the set.

EXERCISE-SET is a sorted list of distinct exercise types as
obtained from p64/get-exercise-set."
  (let ((header '("Date")))
    (dolist (x exercise-set)
      (add-to-list 'header x t))
    header))

This function takes an exercise list and a list of types and maps results for each day and exercise type to the correct column. If there was no exercise of a given type on a given day, the default value 0 will be placed into that cell.

(defun p64/build-exercise-data (exercises types)
  "Return a list of table rows containing the date and values for
each exercise type.

EXERCISES is a list obtained from p64/get-exercises-from-buffer.

TYPES is a set obtained from p64/get-exercise-set."

  ; Map each exercise record to a row
  (mapcar (lambda (rec)
            (let (
                  ; Generate the row by looking up values for each exercise type
                  ; in the exercise record's alist (cdr)
                  (row (mapcar (lambda (type)
                                 (alist-get type (cdr rec) 0 nil 'string-equal))
                               types)))

              ; Prepend the record date (car) to the row
              (push (car rec) row)

              row))
          exercises))

Main function

Finally, this function combines all of the above and produces a table of results from the current buffer.

(defun p64/output-exercise-table-for-buffer ()
  "Build and return the complete exercise data for the current
Org mode buffer.

When evaluated in an Org mode code block, this function will
produce an exercise table."
  (let* (
         (exercises (p64/get-exercises-from-buffer))
         (types (p64/get-exercise-set exercises))
         (header (p64/get-exercise-table-header types))
         (data (p64/build-exercise-data exercises types)))
    `(,header . ,data)))

Running the function

With all of the above functions defined, I can now add a code block to my journal with the following function call: -

#+BEGIN_SRC emacs-lisp
(p64/output-exercise-table-for-buffer)
#+END_SRC

Evaluating this SRC block will cause Org to output the result of this function as the table seen earlier.

Plotting results

A table is certainly a useful way to see a summary of exercises over the month, however we can go even further. With GNUPlot installed, this table can also be plotted as a graph showing my performance over time.

To do this, a couple of simple modifications to the output are required.

Table headings

When the results are output, the table looks something like this: -

| Date             | Crunches | Plank | Press-ups | Run |
| [2020-08-03 Mon] |      100 |  120s |        50 |   0 |

In order for GNUPlot to assign labels to the series automatically I just add a line under the heading row: -

| Date             | Crunches | Plank | Press-ups | Run |
|------------------+----------+-------+-----------+-----|
| [2020-08-03 Mon] |      100 | 120s  |        50 |   0 |

Plot options

I also add the following above the #+RESULTS line: -

#+PLOT: ind:1 deps:(1 2 3 4)

ind:1 causes column 1 (the date) to be used as the X-axis and deps:(1 2 3 4) limits the series in the graph to columns 2, 3 and 4, effectively removing “Run” from the graph.

Improvements

This was just a quick experiment so there is room for improvement. Specifically I’d like: -

  • To add the split between the header row and the actual data rows automatically;
  • To find a smart way to include running data on the same graph, perhaps by calculating a separate metric based on distance and time; and
  • To add another function which takes a directory and outputs a combined table from all journals within.

Conclusion

I managed to achieve my goal of easily producing a summary of exercise over a month. But of course there’s plenty more that can be done with this data that what was presented here. As the summary is output as an Org mode table, formulae can be added to calculate the total distance run, average number of press-ups, etc.

Yet again Emacs and Org mode show their power and flexibility! Not only was I able to produce a table but I was also able to output a graph of these results, allowing my to visualise my performance over the month



Links