| @@ -0,0 +1,133 @@ |
| 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 | |
| 8 | If you maintain a log of time clocks, you can create a weekly report that will |
| 9 | break down time per heading, per day, per week. |
| 10 | |
| 11 | The 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: |
| 24 | CLOCK: [2026-02-19 Thu 09:00]--[2026-02-19 Thu 12:00] => 3:00 |
| 25 | :END: |
| 26 | ,* Project 2 |
| 27 | :LOGBOOK: |
| 28 | CLOCK: [2026-02-20 Fri 10:00]--[2026-02-20 Fri 16:00] => 6:00 |
| 29 | :END: |
| 30 | #+end_src |
| 31 | |
| 32 | The 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 | |
| 122 | The 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 |