🌱 org-mode

· Tidbits about the best plaintext language: org-mode.

Table of Contents

1. Time Reporting

If you maintain a log of time clocks, you can create a weekly report that will break down time per heading, per day, per week.

The time tracker file:

#+title: Time Tracker
#+startup: showall

# To generate a time report:
# 1. Open Emacs with this file
# 2. SPC z t (or M-x tr/time-report-interactive)
# 3. Enter this file as input, a path for output, and optional date range (YYYY-MM-DD)

* Project 1
:LOGBOOK:
CLOCK: [2026-02-19 Thu 09:00]--[2026-02-19 Thu 12:00] =>  3:00
:END:
* Project 2
:LOGBOOK:
CLOCK: [2026-02-20 Fri 10:00]--[2026-02-20 Fri 16:00] =>  6:00
:END:

The elisp function to generate the report:

;; Time Report
(defun tr/parse-org-clock-entries (file)
  "Return list of (date heading minutes) from FILE's CLOCK entries."
  (let (entries)
    (with-current-buffer (find-file-noselect file)
      (org-with-wide-buffer
       (goto-char (point-min))
       (while (re-search-forward
               "CLOCK: \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\) [A-Za-z]\\{3\\} [0-9:]\\{5\\}\\]--\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\) [A-Za-z]\\{3\\} \\([0-9:]\\{5\\}\\)\\] =>  *\\([0-9]+\\):\\([0-9]+\\)"
               nil t)
         (let* ((date (match-string 1))
                (h    (string-to-number (match-string 4)))
                (m    (string-to-number (match-string 5)))
                (mins (+ (* h 60) m))
                (heading (save-excursion
                           (org-back-to-heading t)
                           (org-get-heading t t t t))))
           (push (list date heading mins) entries)))))
    (nreverse entries)))

(defun tr/format-minutes (mins)
  (format "%d:%02d" (/ mins 60) (mod mins 60)))

(defun tr/generate-report (input-file output-file &optional tstart tend)
  "Parse INPUT-FILE and write time report to OUTPUT-FILE."
  (let* ((entries (tr/parse-org-clock-entries input-file))
         (entries (if (or tstart tend)
                      (cl-remove-if-not
                       (lambda (e)
                         (let ((d (car e)))
                           (and (or (null tstart) (not (string< d tstart)))
                                (or (null tend)   (not (string> d tend))))))
                       entries)
                    entries))
         (totals (make-hash-table :test #'equal)))
    (dolist (e entries)
      (let ((key (cons (nth 0 e) (nth 1 e))))
        (puthash key (+ (gethash key totals 0) (nth 2 e)) totals)))
    (let* ((keys (hash-table-keys totals))
           (keys (sort keys (lambda (a b)
                              (or (string< (car a) (car b))
                                  (and (string= (car a) (car b))
                                       (string< (cdr a) (cdr b)))))))
           (current-week nil))
      (with-temp-file output-file
        (insert "#+title: Time Report\n\n")
        (insert (format "| %-12s | %-45s | %5s |\n" "Date" "Code" "Hours"))
        (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
        (let (week-mins week-start)
          (dolist (k keys)
            (let* ((date (car k))
                   (heading (cdr k))
                   (mins (gethash k totals))
                   (time (date-to-time (concat date " 00:00:00")))
                   (week (format-time-string "%Y-W%V" time)))
              (when (and week-start (not (string= week current-week)))
                (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
                (insert (format "| %-12s | %-45s | %5s |\n" current-week "WEEK TOTAL" (tr/format-minutes week-mins)))
                (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
                (setq week-mins 0))
              (unless (string= week current-week)
                (setq current-week week week-start date week-mins 0))
              (setq week-mins (+ week-mins mins))
              (insert (format "| %-12s | %-45s | %5s |\n" date heading (tr/format-minutes mins)))))
          (when week-mins
            (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
            (insert (format "| %-12s | %-45s | %5s |\n" current-week "WEEK TOTAL" (tr/format-minutes week-mins)))))
        (insert "\n")))))

(defun tr/time-report-interactive ()
  "Interactively generate a time report from an org file."
  (interactive)
  (let* ((input  (read-file-name "Time tracking file: " nil nil t))
         (output (read-file-name "Output report file: "))
         (tstart (read-string "Start date (YYYY-MM-DD, blank for none): "))
         (tend   (read-string "End date   (YYYY-MM-DD, blank for none): "))
         (tstart (if (string-empty-p tstart) nil tstart))
         (tend   (if (string-empty-p tend)   nil tend)))
    (tr/generate-report input output tstart tend)
    (find-file output)
    (message "Report written to %s" output)))

;; Map time report to keybindings
(map! :leader
      :desc "Generate time report" "z t" #'tr/time-report-interactive)

The output file:

#+title: Time Report

|       Date | Code       | Hours |
|------------+------------+-------|
| 2026-02-19 | Project 1  |  3:00 |
| 2026-02-20 | Project 2  |  6:00 |
|------------+------------+-------|
|   2026-W08 | WEEK TOTAL |  9:00 |