summaryrefslogtreecommitdiff
path: root/modules/org-agenda-config.el
blob: becd8f7a63412bac556376c2bfaec503840a61ea (plain)
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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
;;; org-agenda-config --- Org-Agenda/Todo Config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
;;
;; Agenda views are tied to the F8 (fate) key.
;;
;;  "We are what we repeatedly do.
;;   Excellence, then, is not an act, but a habit"
;;                        -- Aristotle
;;
;;  "...watch your actions, they become habits;
;;    watch your habits, they become character;
;;    watch your character, for it becomes your destiny."
;;                        -- Lao Tzu
;;
;; KEYBINDINGS:
;; f8     - MAIN AGENDA which organizes all tasks and events into:
;;          - overdue tasks (past scheduled/deadline dates)
;;          - all unfinished priority A tasks
;;          - the 8-day schedule, including the habit consistency graph
;;          - all priority B tasks
;; C-u f8 - Force rebuild of agenda file list (ignores cache)
;;
;; C-f8     - TASK LIST containing all tasks from all agenda targets.
;; C-u C-f8 - Force rebuild of agenda file list before showing task list
;;
;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer.
;;
;; PERFORMANCE OPTIMIZATION:
;; The agenda file list is cached to avoid expensive directory scans on every
;; F8 press. The cache is automatically rebuilt every 5 minutes (configurable
;; via `cj/org-agenda-rebuild-interval'). Use C-u f8 to force an immediate
;; rebuild if you've added new todo.org files.
;;
;; NOTE:
;; Files that contain information relevant to the agenda will be found in the
;; following places: the schedule-file, gcal-file, and project todo.org files
;; found in projects-dir (searches up to 3 levels deep).

;;; Code:

(require 'seq) ;; for seq-filter to disable tramp-archive handlers

;; Forward declarations for user-constants variables
(eval-when-compile
  (defvar inbox-file)
  (defvar schedule-file)
  (defvar gcal-file)
  (defvar projects-dir))

;; Forward declarations for org-mode variables
(defvar org-agenda-files)
(defvar org-done-keywords)
(defvar org-lowest-priority)
(defvar org-agenda-dim-blocked-tasks)
(defvar org-agenda-use-tag-inheritance)
(defvar org-agenda-ignore-properties)
(defvar org-agenda-inhibit-startup)
(defvar org-agenda-include-diary)
(defvar org-agenda-skip-deadline-if-done)
(defvar org-agenda-skip-scheduled-if-done)
(defvar org-agenda-skip-scheduled-if-deadline-is-shown)
(defvar org-habit-show-habits-only-for-today)
(defvar org-habit-graph-column)
(defvar org-habit-show-graphs)
(defvar diary-file)

;; Forward declarations for org-mode functions
(declare-function org-end-of-subtree "org" (invisible-ok))
(declare-function org-get-todo-state "org" (&optional string))
(declare-function org-entry-get "org" (pom property &optional inherit literal-nil))
(declare-function org-time-string-to-absolute "org" (s &optional daynr prefer buffer pos))
(declare-function org-get-priority "org" (s))
(declare-function org-agenda "org-agenda" (&optional arg keys restriction))
(declare-function org-end-of-line "org" (&optional n))
(declare-function org-agenda-todo-nextset "org-agenda" ())
(declare-function org-agenda-todo-previousset "org-agenda" ())
(declare-function thing-at-point "thingatpt" (thing &optional no-properties))

;; Forward declarations for seq (for filtering file-name-handler-alist)
(declare-function seq-filter "seq" (pred sequence))

;; Forward declarations for alert/org-alert variables
(defvar alert-fade-time)
(defvar alert-default-style)
(defvar org-alert-interval)
(defvar org-alert-notify-cutoff)
(defvar org-alert-notify-after-event-cutoff)
(defvar org-alert-notification-title)

;; ----------------------------- Configuration ---------------------------------

(defvar cj/org-agenda-rebuild-interval 300
  "Interval in seconds between automatic org-agenda-files rebuilds.
Set to nil to disable automatic rebuilding. Default is 300 seconds (5 minutes).")

;; ------------------------------ Cache Variables ------------------------------

(defvar cj/org-agenda-files-cache nil
  "Cached list of org-agenda files to avoid expensive directory scans.")

(defvar cj/org-agenda-rebuild-timer nil
  "Timer object for periodic org-agenda-files rebuilding.")

;; ========================= PERFORMANCE SETTINGS ==============================
;; Set these EARLY, before org-agenda loads, for maximum performance

;; CRITICAL: dim-blocked-tasks is VERY slow - checks all dependencies
(setopt org-agenda-dim-blocked-tasks nil)

;; Disable expensive features
(setopt org-agenda-use-tag-inheritance nil)          ;; don't inherit tags (much faster)
(setopt org-agenda-ignore-properties '(effort appt category))  ;; skip property lookups
(setopt org-agenda-inhibit-startup t)                ;; don't run startup hooks per file

;; CRITICAL: Disable diary integration - diary is VERY slow
;; This must be set BEFORE org-agenda loads AND in the config block
(setopt org-agenda-include-diary nil)
(setopt diary-file "/dev/null")  ;; Extra safety: point diary to /dev/null

;; Skip completed items (fewer items to process)
(setopt org-agenda-skip-deadline-if-done t)
(setopt org-agenda-skip-scheduled-if-done t)
(setopt org-agenda-skip-scheduled-if-deadline-is-shown t)

;; CRITICAL PERFORMANCE: Habit graphs are VERY expensive to render
;; Habit consistency graphs can add 5-10 seconds to agenda generation
;; Options:
;;   - org-habit-show-graphs nil = No graphs (FAST - habits still show)
;;   - org-habit-show-graphs t = Show graphs (SLOW but shows consistency)
;;   - org-habit-show-habits-only-for-today t = Graphs only for today (MEDIUM)

(setopt org-habit-show-habits nil)
(setopt org-habit-show-habits-only-for-today t)
(setopt org-habit-graph-column 50)

;; Display agenda from the bottom (must be set before org-agenda loads)
(with-eval-after-load 'org-agenda
  (add-to-list 'display-buffer-alist
               '("\\*Org Agenda\\*"
                 (display-buffer-reuse-mode-window display-buffer-below-selected)
                 (dedicated . t)
                 (window-height . fit-window-to-buffer))))

(use-package org-agenda
  :ensure nil ;; built-in
  :after org
  :config
  (require 'user-constants)
  (setopt org-agenda-prefix-format '((agenda   . " %i %-25:c%?-12t% s")
                                      (timeline . "  % s")
                                      (todo     . " %i %-25:c")
                                      (tags     . " %i %-12:c")
                                      (search   . " %i %-12:c")))

  (setopt org-agenda-remove-tags t)
  (setopt org-agenda-compact-blocks t)
  (setopt org-agenda-span 'day)  ;; show only today by default (override in custom commands)
  (setopt org-agenda-start-on-weekday nil)  ;; start on current day
  (setopt org-agenda-use-time-grid nil)  ;; disable time grid
  (setopt org-agenda-show-log nil)

  ;; Ensure diary stays disabled (set again in case something re-enabled it)
  (setopt org-agenda-include-diary nil)

  ;; reset s-left/right each time org-agenda is enabled
  (add-hook 'org-agenda-mode-hook #'cj/org-agenda-mode-keys))

;; ----------------------- Org Agenda Mode Keybindings ------------------------

(defun cj/org-agenda-mode-keys ()
  "Set up keybindings for org-agenda-mode."
  (local-set-key (kbd "s-<right>") #'org-agenda-todo-nextset)
  (local-set-key (kbd "s-<left>") #'org-agenda-todo-previousset))

;; ------------------------ Add Files To Org Agenda List -----------------------
;; finds files named 'todo.org' (case insensitive) and adds them to
;; org-agenda-files list.

(defun cj/find-todo-org-files (directory)
  "Find all todo.org files in DIRECTORY using fast wildcard matching.
Searches up to 3 levels deep for files named todo.org (case insensitive).
Returns a list of absolute file paths.

Note: Temporarily disables TRAMP archive file name handlers to prevent
file-expand-wildcards from trying to look inside .zip, .tar.gz, etc. files,
which would cause significant slowdown."
  (when (and directory (file-directory-p directory))
    (let ((dir (file-name-as-directory (expand-file-name directory)))
          (found-files '())
          ;; Temporarily disable tramp-archive to prevent looking inside archives
          (file-name-handler-alist
           (seq-filter (lambda (handler)
                         (not (eq (cdr handler) 'tramp-archive-file-name-handler)))
                       file-name-handler-alist)))
      ;; Search at multiple depth levels (faster than recursive search)
      ;; Level 0: todo.org directly in directory
      (setq found-files (append (file-expand-wildcards (concat dir "[Tt][Oo][Dd][Oo].[Oo][Rr][Gg]")) found-files))
      ;; Level 1: todo.org one level deep
      (setq found-files (append (file-expand-wildcards (concat dir "*/[Tt][Oo][Dd][Oo].[Oo][Rr][Gg]")) found-files))
      found-files)))

(defun cj/add-files-to-org-agenda-files-list (directory)
  "Search for files named `todo.org' and add them to `org-agenda-files'.
DIRECTORY is a string of the path to begin the search.
Modifies the global `org-agenda-files' variable as a side effect."
  (interactive "D")
  (setq org-agenda-files
        (append (cj/find-todo-org-files directory)
                org-agenda-files)))

;; ---------------------------- Rebuild Org Agenda ---------------------------
;; builds the org agenda list from all agenda targets.
;; agenda targets is the schedule, contacts, project todos, and inbox.org

(defun cj/build-org-agenda-list (&optional force)
  "Build the org agenda file list, using cache when possible.
With optional FORCE argument (or when called interactively with prefix arg),
ignores the cache and forces a full rebuild.
The file list is cached in `cj/org-agenda-files-cache' to avoid expensive
directory scans on every agenda display. The cache is automatically refreshed
every `cj/org-agenda-rebuild-interval' seconds.

Begins with inbox-file, schedule-file, and gcal-file.
Then adds all todo.org files from projects-dir.
Reports elapsed time in the messages buffer."
  (interactive "P")
  (if (and (not force) cj/org-agenda-files-cache)
      (progn
        ;; Use cached file list
        (setq org-agenda-files cj/org-agenda-files-cache)
        (message "Using cached org-agenda-files (%d files)" (length org-agenda-files)))
    ;; Force rebuild or no cache exists
    (let ((start-time (current-time)))
      ;; Reset org-agenda-files to core files
      (setq org-agenda-files (list inbox-file schedule-file gcal-file))

      ;; Add all todo.org files from projects
      (cj/add-files-to-org-agenda-files-list projects-dir)

      ;; Cache the result
      (setq cj/org-agenda-files-cache org-agenda-files)

      (message "Rebuilt org-agenda-files in %.3f sec (%d files)"
               (float-time (time-subtract (current-time) start-time))
               (length org-agenda-files)))))

;; ------------------------- Periodic Rebuild Timer ----------------------------

(defun cj/start-org-agenda-rebuild-timer ()
  "Start the periodic timer to rebuild org-agenda-files.
The timer interval is controlled by `cj/org-agenda-rebuild-interval'.
If the interval is nil, no periodic rebuilding occurs."
  (when cj/org-agenda-rebuild-timer
    (cancel-timer cj/org-agenda-rebuild-timer)
    (setq cj/org-agenda-rebuild-timer nil))
  (when cj/org-agenda-rebuild-interval
    (setq cj/org-agenda-rebuild-timer
          (run-with-timer cj/org-agenda-rebuild-interval
                          cj/org-agenda-rebuild-interval
                          (lambda () (cj/build-org-agenda-list t))))))

;; Initial build on startup (after 1 second idle) and start periodic timer
(add-hook 'emacs-startup-hook
          (lambda ()
            (run-with-idle-timer 1 nil
                                 (lambda ()
                                   (cj/build-org-agenda-list t)
                                   (cj/start-org-agenda-rebuild-timer)))))

;;;###autoload
(defun cj/todo-list-all-agenda-files (&optional force-rebuild)
  "Display an `org-agenda' todo list from all agenda files.
With prefix argument FORCE-REBUILD, forces a complete rebuild of the
agenda file list instead of using the cache."
  (interactive "P")
  (cj/build-org-agenda-list force-rebuild)
  (org-agenda "a" "t"))

;;;###autoload (keymap-global-set "C-<f8>" #'cj/todo-list-all-agenda-files)

;; ------------------------- Agenda List Current Buffer ------------------------
;; an agenda listing tasks from just the current buffer.

;;;###autoload
(defun cj/todo-list-from-this-buffer ()
  "Displays an `org-agenda' todo list built from the current buffer.
If the current buffer isn't an org buffer, inform the user."
  (interactive)
  (if (eq major-mode 'org-mode)
      (let ((org-agenda-files (list buffer-file-name)))
        (org-agenda "a" "t"))
    (message (format "Your org agenda request based on '%s' failed because it's not an org buffer."
                     (buffer-name)))))

;;;###autoload (keymap-global-set "M-<f8>" #'cj/todo-list-from-this-buffer)

;; -------------------------------- Main Agenda --------------------------------
;; my custom agenda command from all available agenda targets. adapted from:
;; https://blog.aaronbieber.com/2016/09/24/an-agenda-for-life-with-org-mode.html

(defvar cj/main-agenda-hipri-title "HIGH PRIORITY UNRESOLVED TASKS"
  "String to announce the high priority section of the main agenda.")

(defvar cj/main-agenda-overdue-title "OVERDUE"
  "String to announce the overdue section of the main agenda.")

(defvar cj/main-agenda-schedule-title "SCHEDULE"
  "String to announce the schedule section of the main agenda.")

(defvar cj/main-agenda-tasks-title "PRIORITY B"
  "String to announce the schedule section of the main agenda.")

;; Cache "today" to avoid recalculating for every heading
(defvar cj/org-agenda-today-absolute nil
  "Cached absolute day number for today, reset each agenda generation.")

(defun cj/org-agenda-reset-today-cache ()
  "Reset the cached `today' value before generating agenda."
  (setq cj/org-agenda-today-absolute
        (org-time-string-to-absolute (format-time-string "%Y-%m-%d"))))

;; Reset cache before each agenda generation
(add-hook 'org-agenda-mode-hook #'cj/org-agenda-reset-today-cache)

(defun cj/org-skip-subtree-if-habit ()
  "Skip an agenda entry if it has a STYLE property equal to \"habit\"."
  (let ((subtree-end (save-excursion (org-end-of-subtree t))))
    (if (string= (org-entry-get nil "STYLE") "habit")
        subtree-end
	  nil)))

(defun cj/org-agenda-skip-subtree-if-not-overdue ()
  "Skip an agenda subtree if it is not an overdue deadline or scheduled task.

An entry is considered overdue if it has a scheduled or deadline date strictly
before today, is not marked as done, and is not a habit.
Performance: Uses cached `today' value to avoid recalculation per heading."
  (let* ((subtree-end (save-excursion (org-end-of-subtree t)))
		 (todo-state (org-get-todo-state)))
	(if (or (not todo-state) ; no todo keyword
			(member todo-state org-done-keywords)) ; done/completed tasks
		subtree-end  ; skip if done
	  ;; Only check dates for non-done tasks
	  (let* ((deadline (org-entry-get nil "DEADLINE"))
	         (scheduled (org-entry-get nil "SCHEDULED"))
	         (today (or cj/org-agenda-today-absolute
	                    (org-time-string-to-absolute (format-time-string "%Y-%m-%d"))))
	         (deadline-day (and deadline (org-time-string-to-absolute deadline)))
	         (scheduled-day (and scheduled (org-time-string-to-absolute scheduled)))
	         (overdue (or (and deadline-day (< deadline-day today))
	                      (and scheduled-day (< scheduled-day today)))))
		(if overdue
			nil  ; do not skip, keep this entry
		  subtree-end)))))  ; skip if not overdue

(defun cj/org-skip-subtree-if-priority (priority)
  "Skip an agenda subtree if it has a priority of PRIORITY.
PRIORITY may be one of the characters ?A, ?B, or ?C."
  (let ((subtree-end (save-excursion (org-end-of-subtree t)))
        ;; org-get-priority returns value * 1000, so we must match that scale
        (pri-value (* 1000 (- org-lowest-priority priority)))
        (pri-current (org-get-priority (thing-at-point 'line t))))
    (if (= pri-value pri-current)
        subtree-end
      nil)))

(defun cj/org-skip-subtree-if-keyword (keywords)
  "Skip an agenda subtree if it has a TODO keyword in KEYWORDS.
KEYWORDS must be a list of strings."
  (let ((subtree-end (save-excursion (org-end-of-subtree t))))
    (if (member (org-get-todo-state) keywords)
        subtree-end
      nil)))

(setopt org-agenda-custom-commands
	  '(("d" "Daily Agenda with Tasks"
		 ((alltodo ""
				   ((org-agenda-skip-function #'cj/org-agenda-skip-subtree-if-not-overdue)
					(org-agenda-overriding-header cj/main-agenda-overdue-title)
					(org-agenda-prefix-format "  %i %-15:c%?-15t% s")))
		  (tags "PRIORITY=\"A\""
				((org-agenda-skip-function '(org-agenda-skip-entry-if 'todo 'done))
				 (org-agenda-overriding-header cj/main-agenda-hipri-title)
				 (org-agenda-prefix-format "  %i %-15:c%?-15t% s")))
		  (agenda ""
				  ((org-agenda-start-day "0d")
                   (org-agenda-span 7)  ;; 7-day view
				   (org-agenda-start-on-weekday nil)
				   (org-agenda-include-diary nil)  ;; explicitly disable diary in this view
				   (org-agenda-overriding-header cj/main-agenda-schedule-title)
				   (org-agenda-prefix-format "  %i %-15:c%?-15t% s")))
		  (alltodo ""
				   ((org-agenda-skip-function '(or (cj/org-skip-subtree-if-habit)
												   (cj/org-skip-subtree-if-priority ?A)
												   (cj/org-skip-subtree-if-priority ?C)
												   (cj/org-skip-subtree-if-priority ?D)
												   (cj/org-skip-subtree-if-keyword '("PROJECT"))
												   (org-agenda-skip-if nil '(scheduled deadline))))
					(org-agenda-overriding-header cj/main-agenda-tasks-title)
					(org-agenda-prefix-format "  %i %-15:c%?-15t% s"))))
		 ((org-agenda-compact-blocks nil)))))


;;;###autoload
(defun cj/main-agenda-display (&optional force-rebuild)
  "Display the main daily org-agenda view.
With prefix argument FORCE-REBUILD, forces a complete rebuild of the
agenda file list instead of using the cache.
This uses all org-agenda targets and presents four sections:
- Overdue tasks (past scheduled/deadline dates)
- All unfinished priority A tasks
- Today's schedule (7-8 days), including habits
- All priority B unscheduled/undeadlined tasks

The agenda file list is cached and rebuilt automatically every
`cj/org-agenda-rebuild-interval' seconds (default 5 minutes).

Performance: Temporarily increases GC threshold during generation to reduce
GC pauses, then restores it after 10 seconds.

The agenda is built from all sources including:
- inbox-file, schedule-file, and gcal-file
- All todo.org files in projects-dir"
  (interactive "P")
  ;; Save current GC threshold and temporarily disable GC during agenda generation
  (let ((gc-cons-threshold-original gc-cons-threshold))
    (setq gc-cons-threshold most-positive-fixnum)

    ;; Generate the agenda
    (cj/build-org-agenda-list force-rebuild)
    (org-agenda "a" "d")

    ;; Restore GC threshold after 10 seconds (gives user time to view agenda)
    (run-at-time 10 nil
                 (lambda ()
                   (setq gc-cons-threshold gc-cons-threshold-original)
                   (garbage-collect)))))

;;;###autoload (keymap-global-set "<f8>" #'cj/main-agenda-display)

;; Set keybindings immediately when module loads
(keymap-global-set "<f8>" #'cj/main-agenda-display)
(keymap-global-set "C-<f8>" #'cj/todo-list-all-agenda-files)
(keymap-global-set "M-<f8>" #'cj/todo-list-from-this-buffer)

;; ------------------------- Add Timestamp To Org Entry ------------------------
;; simply adds a timestamp to put the org entry on an agenda

(defvar cj/timeformat "%Y-%m-%d %a"
  "Time format for org entry timestamps.")

(defun cj/add-timestamp-to-org-entry (s)
  "Add an event with time S to appear underneath the line-at-point.
This allows a line to show in an agenda without being scheduled or a deadline."
  (interactive "sTime: ")
  (org-end-of-line)
  (save-excursion
    (open-line 1)
    (forward-line 1)
    (insert (concat "<" (format-time-string cj/timeformat (current-time)) " " s ">" ))))

;; --------------------------- Notifications / Alerts --------------------------
;; send libnotify notifications for agenda items

(use-package alert
  :config
  (setq alert-fade-time 10) ;; seconds to vanish alert
  (setq alert-default-style 'libnotify)) ;; works well with dunst

(use-package org-alert
  :after alert org-agenda
  :commands (org-alert-enable org-alert-check)
  :bind
  ("C-c A" . org-alert-check)
  :config
  ;; Set org-alert settings
  (setq org-alert-interval 300) ;; seconds between agenda checks (5 minutes)
  (setq org-alert-notify-cutoff 10) ;; minutes before a deadline to notify
  (setq org-alert-notify-after-event-cutoff 5)  ;; stop alerts 5 mins after deadline
  (setq org-alert-notification-title "Reminder"))

;; Enable org-alert timer with message
(defun cj/org-alert-enable-with-message ()
  (org-alert-enable)
  (message "org-alert timer enabled with interval %d seconds" org-alert-interval))

;; Alert when idle post Emacs startup
(add-hook 'emacs-startup-hook
		  (lambda ()
			(run-with-idle-timer 1 nil #'cj/org-alert-enable-with-message)))


(provide 'org-agenda-config)
;;; org-agenda-config.el ends here