cmc/cleberg.net

My personal web garden & blog.

clone: git clone https://gitbay.org/cmc/cleberg.net.git

main: content/garden/org-mode.org · raw

  1#+title: 🌱 org-mode
  2#+slug: org-mode
  3#+description: Tidbits about the best plaintext language: org-mode.
  4#+date: [2026-02-21 Sat 12:39:27]
  5
  6* Time Reporting
  7
  8If you maintain a log of time clocks, you can create a weekly report that will
  9break down time per heading, per day, per week.
 10
 11The time tracker file:
 12
 13#+begin_src org
 14,#+title: Time Tracker
 15,#+startup: showall
 16
 17# To generate a time report:
 18# 1. Open Emacs with this file
 19# 2. SPC z t (or M-x tr/time-report-interactive)
 20# 3. Enter this file as input, a path for output, and optional date range (YYYY-MM-DD)
 21
 22,* Project 1
 23:LOGBOOK:
 24CLOCK: [2026-02-19 Thu 09:00]--[2026-02-19 Thu 12:00] =>  3:00
 25:END:
 26,* Project 2
 27:LOGBOOK:
 28CLOCK: [2026-02-20 Fri 10:00]--[2026-02-20 Fri 16:00] =>  6:00
 29:END:
 30#+end_src
 31
 32The elisp function to generate the report:
 33
 34#+begin_src elisp
 35;; Time Report
 36(defun tr/parse-org-clock-entries (file)
 37  "Return list of (date heading minutes) from FILE's CLOCK entries."
 38  (let (entries)
 39    (with-current-buffer (find-file-noselect file)
 40      (org-with-wide-buffer
 41       (goto-char (point-min))
 42       (while (re-search-forward
 43               "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]+\\)"
 44               nil t)
 45         (let* ((date (match-string 1))
 46                (h    (string-to-number (match-string 4)))
 47                (m    (string-to-number (match-string 5)))
 48                (mins (+ (* h 60) m))
 49                (heading (save-excursion
 50                           (org-back-to-heading t)
 51                           (org-get-heading t t t t))))
 52           (push (list date heading mins) entries)))))
 53    (nreverse entries)))
 54
 55(defun tr/format-minutes (mins)
 56  (format "%d:%02d" (/ mins 60) (mod mins 60)))
 57
 58(defun tr/generate-report (input-file output-file &optional tstart tend)
 59  "Parse INPUT-FILE and write time report to OUTPUT-FILE."
 60  (let* ((entries (tr/parse-org-clock-entries input-file))
 61         (entries (if (or tstart tend)
 62                      (cl-remove-if-not
 63                       (lambda (e)
 64                         (let ((d (car e)))
 65                           (and (or (null tstart) (not (string< d tstart)))
 66                                (or (null tend)   (not (string> d tend))))))
 67                       entries)
 68                    entries))
 69         (totals (make-hash-table :test #'equal)))
 70    (dolist (e entries)
 71      (let ((key (cons (nth 0 e) (nth 1 e))))
 72        (puthash key (+ (gethash key totals 0) (nth 2 e)) totals)))
 73    (let* ((keys (hash-table-keys totals))
 74           (keys (sort keys (lambda (a b)
 75                              (or (string< (car a) (car b))
 76                                  (and (string= (car a) (car b))
 77                                       (string< (cdr a) (cdr b)))))))
 78           (current-week nil))
 79      (with-temp-file output-file
 80        (insert "#+title: Time Report\n\n")
 81        (insert (format "| %-12s | %-45s | %5s |\n" "Date" "Code" "Hours"))
 82        (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
 83        (let (week-mins week-start)
 84          (dolist (k keys)
 85            (let* ((date (car k))
 86                   (heading (cdr k))
 87                   (mins (gethash k totals))
 88                   (time (date-to-time (concat date " 00:00:00")))
 89                   (week (format-time-string "%Y-W%V" time)))
 90              (when (and week-start (not (string= week current-week)))
 91                (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
 92                (insert (format "| %-12s | %-45s | %5s |\n" current-week "WEEK TOTAL" (tr/format-minutes week-mins)))
 93                (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
 94                (setq week-mins 0))
 95              (unless (string= week current-week)
 96                (setq current-week week week-start date week-mins 0))
 97              (setq week-mins (+ week-mins mins))
 98              (insert (format "| %-12s | %-45s | %5s |\n" date heading (tr/format-minutes mins)))))
 99          (when week-mins
100            (insert (concat "|" (make-string 14 ?-) "+" (make-string 47 ?-) "+" (make-string 7 ?-) "|\n"))
101            (insert (format "| %-12s | %-45s | %5s |\n" current-week "WEEK TOTAL" (tr/format-minutes week-mins)))))
102        (insert "\n")))))
103
104(defun tr/time-report-interactive ()
105  "Interactively generate a time report from an org file."
106  (interactive)
107  (let* ((input  (read-file-name "Time tracking file: " nil nil t))
108         (output (read-file-name "Output report file: "))
109         (tstart (read-string "Start date (YYYY-MM-DD, blank for none): "))
110         (tend   (read-string "End date   (YYYY-MM-DD, blank for none): "))
111         (tstart (if (string-empty-p tstart) nil tstart))
112         (tend   (if (string-empty-p tend)   nil tend)))
113    (tr/generate-report input output tstart tend)
114    (find-file output)
115    (message "Report written to %s" output)))
116
117;; Map time report to keybindings
118(map! :leader
119      :desc "Generate time report" "z t" #'tr/time-report-interactive)
120#+end_src
121
122The output file:
123
124#+begin_src org
125,#+title: Time Report
126
127|       Date | Code       | Hours |
128|------------+------------+-------|
129| 2026-02-19 | Project 1  |  3:00 |
130| 2026-02-20 | Project 2  |  6:00 |
131|------------+------------+-------|
132|   2026-W08 | WEEK TOTAL |  9:00 |
133#+end_src