aboutsummaryrefslogtreecommitdiff
path: root/content/garden/org-mode.org
blob: c0ed2c1226f3606bfa8524c1b5c8262996860c51 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#+title: 🌱 org-mode
#+slug: org-mode
#+description: Tidbits about the best plaintext language: org-mode.
#+date: [2026-02-21 Sat 12:39:27]

* 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:

#+begin_src org
,#+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:
#+end_src

The elisp function to generate the report:

#+begin_src elisp
;; 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)
#+end_src

The output file:

#+begin_src org
,#+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 |
#+end_src