Org Projects with Gantt Charts

Table of Contents

Org-Mode is a great way to store TODO items, store notes, and keep track of projects. However, possibly due to ignorance of what is available, getting a concrete high-level view of a project is difficult. There are numerous different visualizations that can be created to see the state of a project. Each answers a specific question, none are entirely complete. Today, I have worked out a mechanism to generate Gantt Charts using dynamic blocks for a specific project tree. In followup posts, I may work out some other visualizations.

Agenda views can help gather and provide filtered projections of the TODO lists that comprise various projects. But getting a holistic view of the progress is not entirely captured by progress cookies and agenda views. For example, a progress cookie cannot predict a project's completion because it lacks information about size of each task and estimated complexity of each task. A project could be 98% complete, but the last 2% of tasks consist of 80% of the work necessary to complete. Arguably, this is a failure in the project breakdown, but the point remains, some amount of tasks left over as a whole do not communicate the holistic progress and prediction towards completion.


Gantt charts model project schedules as a sort of bar chart. Dependencies and links can be shown to draw the critical path of the project and the timeline of the various tasks that makeup a project. These project schedules are more common in waterfall project management techniques or resource scheduling, therefore, they are likely less popular than other visualizations. However, the critical path projection is still an important view of any project management philosophy. Furthermore, the complexity and cost of generating Gantt charts has made them unlikely visualizations even for smaller task breakdowns. Another important aspect that Gantt charts model is task duration as either an abstract unit, such as "story points", or as calendar dates with some chosen granularity of time units such as days, months, or years.

Prior Art

There are several existing projects which attempt to generate Gantt charts from Org TODO entries. Specifically, Org mode supports exporting directly to TaskJuggler, there is even a tutorial on the Org mode website. There are also third-party packages such as elgantt and Org-Gantt.


Given that there are existing solutions to generating Gantt charts from Org mode entries, why create another one? The other solutions require either the Org entries change or metadata added such that the chart generator of choice has the necessary information. Instead, it is better to leverage the existing information already present entries to generate the charts.


Currently, the implementation is not packaged, but that may change at a future date.

The basics of the implementation involves Emacs Lisp, Org mode, \LaTeX, TikZ, and pgfGantt. Using an existing (sub)tree of tasks (Org TODO entries), we create a dynamic block that processes the current tree and generates the necessary \ganttbars for generating a Gantt Chart using pgfGantt. Then, the document or subtree can be exported to PDF using \LaTeX to create Gantt chart projection.

We define a special function, org-dblock-write:gantt which is used to generate the necessary TikZ/pgfGantt commands to draw the chart.

(require 'org)
(require 'seq)

(defun org-dblock-write:gantt (params)
  "Create pgf Gantt Chart from subtree."
  (defun org-parse-date (date-string)
    (cond ((null date-string) nil)
          (t (seconds-to-time (org-matcher-time date-string)))))
  (defun org-duration->minutes (duration-string)
    "Parse DURATION-STRING into numerical minutes."
    (cond ((org-duration-p duration-string) (org-duration-to-minutes duration-string))
          (t 0)))
  (defun org--alist-entry (&optional filter-level)
    (let* ((props (org-entry-properties))
           (level (cl-first (org-heading-components)))
           (entry-title (cdr (assoc "ITEM" props)))
           (entry-id (cdr (assoc "ID" props)))
           (effort-string (cdr (assoc "EFFORT" props)))
           (clock-minutes (org-clock-sum-current-item))
           (scheduled (cdr (assoc "SCHEDULED" props)))
           (deadline (cdr (assoc "DEADLINE" props)))
           (effort-minutes (org-duration->minutes effort-string))
           (status (cl-third (org-heading-components)))
           (done (seq-contains-p org-done-keywords status))
           (progress (cond (done 100.0)
                           ((not (equal effort-minutes 0)) (* (/ clock-minutes effort-minutes) 100))
                           (t nil))))
      `((LEVEL . ,level)
        (TYPE . ,(cond ((< level filter-level) 'GROUP)
                       (t 'BAR)))
        (TITLE . ,entry-title)
        (ID . ,entry-id)
        (EFFORT . ,effort-minutes)
        (CLOCKED . ,clock-minutes)
        (SCHEDULED . ,scheduled)
        (DEADLINE . ,deadline)
        (PROGRESS . ,progress)
        (DONE . ,done))))
  (defun format-entry (entry)
    (defun format-progress (value)
      (if (null value) 0 value))
    (defun format-title (title)
      (let* ((replaced-title (string-replace "%" "\\%" title))
             (truncate-to (min (length replaced-title) 15)))
        (substring replaced-title 0 truncate-to)))
    (defun format-date (date)
      (format-time-string "%Y-%m-%d" date))
    (let-alist entry
      (cond ((equal 'GROUP .TYPE) (format "\\ganttgroup[progress=today]{%s}{%s}{%s} \\\\\n"
                                          (format-title .TITLE)
                                          (format-date .SCHEDULED)
                                          (format-date .DEADLINE)))
            (t (format "\\ganttbar[progress=%00.0f]{%s}{%s}{%s} \\\\\n"
                       (format-progress .PROGRESS)
                       (format-title .TITLE)
                       (format-date .SCHEDULED)
                       (format-date .DEADLINE))))))
  (let* ((start (org-parse-date (plist-get params :tstart)))
         (end (org-parse-date (plist-get params :tend)))
         (today (org-parse-date "<today>"))
         (current-level (+ 1 (cl-first (org-heading-components))))
         (level (or (plist-get params :level) current-level))
         (tunit (or (plist-get params :tunit) "month"))
         (entries (seq-filter (lambda (entry) (let-alist entry
                                                (<= .LEVEL level)))
                              (org-map-entries (lambda () (org--alist-entry level)) t 'tree))))
    (insert (format "#+begin_src latex
    expand chart=\\textwidth,
    time slot format=isodate,
    bar height=0.6,
    bar label font=\\scriptsize,
    bar/.append style={fill=green!50},
    bar incomplete/.append style={fill=red!50},
    group/.append style={fill=blue!50},
    group incomplete/.append style={fill=brown!50},
    group left shift=0,
    group right shift=0,
    group top shift=.6,
    group height=.3,
    group peaks height=.2,
    time slot unit=%s]{%s}{%s}\n"
                    (format-time-string "%Y-%m-%d" today)
                    (format-time-string "%Y-%m-%d" start)
                    (format-time-string "%Y-%m-%d" end)))
    (insert (format "\\gantttitlecalendar{year, month%s} \\\\\n"
                    (if (equal tunit "day") ", day" "")))
    (let ((previous-end (decode-time nil (current-time-zone) t)))
      (cl-map nil (lambda (entry) (let* ((id (cdr (assoc 'ID entry)))
                                         (level (cdr (assoc 'LEVEL entry)))
                                         (type (cdr (assoc 'TYPE entry)))
                                         (title (cdr (assoc 'TITLE entry)))
                                         (clocked (cdr (assoc 'CLOCKED entry)))
                                         (done (cdr (assoc 'DONE entry)))
                                         (effort (cdr (assoc 'EFFORT entry)))
                                         (scheduled (or (org-parse-date (cdr (assoc 'SCHEDULED entry)))
                                                        (encode-time previous-end)))
                                         (deadline (or (org-parse-date (cdr (assoc 'DEADLINE entry)))
                                                       (encode-time (decoded-time-add
                                                                     (decode-time scheduled)
                                                                     (make-decoded-time :minute effort)))))
                                         (progress (cdr (assoc 'PROGRESS entry)))
                                         (task `((ID . ,id)
                                                 (PROGRESS . ,progress)
                                                 (TYPE . ,type)
                                                 (LEVEL . ,level)
                                                 (TITLE . ,title)
                                                 (EFFORT . ,effort)
                                                 (CLOCKED . ,clocked)
                                                 (SCHEDULED . ,scheduled)
                                                 (DEADLINE . ,deadline)
                                                 (DONE . ,done))))
                                    (print task)
                                    (if (equal 'BAR type)
                                        (setq previous-end (decode-time deadline)))
                                    (insert (format-entry task))))
    (insert (format "\\end{ganttchart}\n#+end_src"))))

The function works by mapping over the entries of the current tree and processing each of them into either a \ganttgroup or \ganttbar. Each TODO entry has a duration equal to the estimated effort of the task. If the effort is not estimated, it is the same as 0, which pgfGantt shows as a day. While it may be fine that a task defaults to a day, it does not properly show the likely schedule given that all subsequent tasks start on the same day since there is no duration to the tasks.


Add the following block to the subtree of entries.

#+begin: gantt :tstart "<today>" :tend "<2022-07-15>" :tunit "day"

Then executing org-update-dblock or pressing C-c C-c when the point is on the block generates the necessary plotting commands, which when exported to PDF generates the Gantt chart for the subtree. The parameters :tstart and :tend can be any Org mode parseable date string, such as "<today>", "<yesterday>", or some specific time stamp in ISO-8601 format. The unit parameter, :tunit can either be "days" or "months".


To highlight how this function works, we consider the following example Org file. At the top level, there is an entry for all projects. Because this is an example, we have a single project named, "Major Project" which has 2 of its 5 major activities completed. We can see the generated Gantt chart for the major activity below. Within each of the activities, we can have arbitrarily deep subtasks. The following chart shows the breakdown of tasks for "Activity 3".

My apologies to dark mode readers, the image text elements do not invert in dark mode.

#+COLUMNS: %40ITEM(Task) %17Effort(Estimated Effort){:} %CLOCKSUM
#+LATEX_HEADER: \usepackage{pgfgantt}
#+LATEX_HEADER: \usepackage{fullpage}
* Projects
:ID:       92c26e6f-327c-4517-ab73-000ab6b3794e
** [2/5] Major Project
DEADLINE: <2022-08-21 Sun>
:ID:       bd0c7101-6df8-4fd3-838d-3b03a0d84677
#+begin: gantt :tstart "<today>" :tend "<2022-08-21>" :tunit "month"
*** DONE Activity 1
CLOSED: [2022-06-28 Tue 18:29]
:ID:       1ba87343-1926-49c3-8dc9-f01ffde95d05
- State "DONE"       from "TODO"       [2022-06-28 Tue 18:29]
**** DONE Task 1
CLOSED: [2022-06-28 Tue 18:32]
:ID:       6436a4d7-cc68-45fc-b46c-c10395b951a0
- State "DONE"       from "TODO"       [2022-06-28 Tue 18:32]
**** DONE Task 2
CLOSED: [2022-06-28 Tue 18:32]
:ID:       cb294f7f-2e8d-49fc-9f53-b9fbdebd3f31
- State "DONE"       from "TODO"       [2022-06-28 Tue 18:32]
**** DONE Task 3
CLOSED: [2022-06-28 Tue 18:32]
:ID:       824d2572-830c-493c-bedc-b4cdcc8ce307
- State "DONE"       from "TODO"       [2022-06-28 Tue 18:32]
*** DONE Activity 2
CLOSED: [2022-06-28 Tue 18:29]
:ID:       81fb1423-447d-4fa3-92e4-cbba9d3aa7c8
- State "DONE"       from "TODO"       [2022-06-28 Tue 18:29]
*** TODO Activity 3
DEADLINE: <2022-07-28 Thu>
:ID:       ce490c67-04f2-4060-b83d-f32675cc35e6
:EFFORT:   26d 0h 0min

#+begin: gantt :tstart "<today>" :tend "<2022-07-28>" :tunit "day"

**** TODO Task 1
:ID:       f41e0083-7642-44a8-b653-f13124a1bcb9
:Effort:   5d
**** TODO Task 2
:ID:       e22aca56-18d9-4b43-b754-d11eadd73a97
:Effort:   7d
**** TODO Task 3
:ID:       c6bd2a10-dfcc-4e26-aa57-6629b6aa047c
:Effort:   14d
**** TODO Task 4
:ID:       78c88e86-da8a-41ba-991d-acfad713d4d1
:Effort:   4d
*** TODO Activity 4
:ID:       6b92c6d0-ff88-4dbe-b9e9-da2b2e7b0fd9
:Effort:   14d
*** TODO Activity 5
:ID:       cabc7511-5430-42c6-86ea-f59899fc3875
:Effort:   14d

Sorry, your browser does not support SVG.

Sorry, your browser does not support SVG.


This process is currently pretty limited, see future work for improvements. One notable limitation, however, to make the charts fit on the page, the titles of the tasks are truncated to 15 characters. Furthermore, the code is fairly dense at the moment. It is certainly not the best code, but it serves the purpose. Perhaps with some more cycles, the quality and readability can be improved.

Future Work/Improvements

The tasks can be automatically linked via \ganttlinkedbar and this would be natively supported by TODO dependencies. Alternatively, adding links could be possible after parsing dependency information available via org-depend or similar. Currently, neither of these are supported. The former would be relatively straightforward to toggle by another parameter.

With respect to other visualizations, a burndown chart or even a process of generating evidence based schedules and velocity tables should be possible. Working on slow incremental changes to processes, going to start small.

Finally, there are always more ways to improve the usage of existing metadata associated with the task. Currently, the SCHEDULED property is not used to select a start date, but if it is set, it would act as a better start date than whatever the previous task's end date is.

The function is available on GitHub and Sourcehut.