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
497
498
499
500
501
502
503
504
505
506
|
;;; org-agenda-config --- Org-Agenda/Todo Config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D/S.
;; Load shape: eager.
;; Eager reason: daily agenda workflow; the user expects agenda available at the
;; first session.
;; Top-level side effects: one add-hook and an idle timer that builds the agenda
;; file cache 10s after startup (guarded; spec tracks the cache lifecycle).
;; Runtime requires: user-constants, system-lib, cj-cache-lib.
;; Direct test load: yes.
;;
;; Performance:
;; - Caches agenda file list to avoid scanning projects directory on every view
;; - Cache builds asynchronously 10 seconds after Emacs startup (non-blocking)
;; - First agenda view uses cache if ready, otherwise builds synchronously
;; - Subsequent views are instant (cached)
;; - Cache auto-refreshes after 1 hour
;; - Manual refresh: M-x cj/org-agenda-refresh-files (e.g., after adding projects)
;;
;; 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
;;
;;
;; f8 - MAIN AGENDA which organizes all tasks and events into:
;; - all unfinished priority A tasks
;; - the weekly schedule, including the habit consistency graph
;; - all priority B tasks
;;
;; C-f8 - PROJECT AGENDA showing the main agenda filtered to a single project.
;; Prompts for project selection, then shows overdue/hi-pri/schedule/B tasks
;; scoped to that project's todo.org plus all calendars and inbox.
;;
;; s-f8 - TASK LIST containing all tasks from all agenda targets.
;;
;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer.
;;
;; NOTE:
;; Files that contain information relevant to the agenda will be found in the
;; following places: the schedule-file, org-roam notes tagged as 'Projects' and
;; project todo.org files found in project-dir and code-dir.
;;; Code:
(require 'user-constants)
(require 'system-lib)
(require 'cj-cache-lib)
(defcustom cj/org-agenda-window-height 0.75
"Fraction of the selected frame used for the org agenda window."
:type 'number)
(defun cj/--org-agenda-display-rule ()
"Return the display-buffer rule for the org agenda buffer."
`("\\*Org Agenda\\*"
(display-buffer-reuse-mode-window display-buffer-below-selected)
(dedicated . t)
(window-height . ,cj/org-agenda-window-height)))
;; Load debug functions if enabled
(when (or (eq cj/debug-modules t)
(memq 'org-agenda cj/debug-modules))
(require 'org-agenda-config-debug
(expand-file-name "org-agenda-config-debug.el"
(file-name-directory load-file-name))
t))
(use-package org-agenda
:ensure nil ;; built-in
:after (org)
:demand t
:config
(setq org-agenda-prefix-format '((agenda . " %i %-25:c%?-12t% s")
(timeline . " % s")
(todo . " %i %-25:c")
(tags . " %i %-12:c")
(search . " %i %-12:c")))
(setq org-agenda-dim-blocked-tasks 'invisible)
(setq org-agenda-skip-scheduled-if-done nil)
(setq org-agenda-remove-tags t)
(setq org-agenda-compact-blocks t)
;; display the agenda from the bottom
(add-to-list 'display-buffer-alist
(cj/--org-agenda-display-rule))
;; reset s-left/right each time org-agenda is enabled
(add-hook 'org-agenda-mode-hook (lambda ()
(local-set-key (kbd "s-<right>") #'org-agenda-todo-nextset)
(local-set-key (kbd "s-<left>")
#'org-agenda-todo-previousset))))
;; ----------------------- Project-name Category Override ---------------------
;; The default `org-category' for a todo.org buffer is "todo" (the filename
;; without extension), which renders as "todo:" in every agenda `%c' column
;; and tells the reader nothing useful when every project has its own
;; todo.org. Substitute the parent-directory basename instead, so
;; `~/.emacs.d/todo.org' shows "emacs.d:" and `~/projects/foo/todo.org' shows
;; "foo:". Files that aren't named todo.org are left alone, and a user-set
;; `#+CATEGORY:' (which leaves `org-category' at a non-default value) wins.
(defun cj/--org-todo-category-from-file (path)
"Return the project category for a todo.org PATH, or nil if not applicable.
For a file named todo.org, returns the basename of its parent
directory with a single leading dot stripped (so `~/.emacs.d/todo.org'
yields \"emacs.d\", not \".emacs.d\"). For any other file -- or for a
PATH that is nil, empty, or has no usable parent directory -- returns
nil so the org default category applies."
(when (and (stringp path)
(not (string-empty-p path))
(string= "todo.org" (file-name-nondirectory path)))
(let* ((dir (file-name-directory path))
(parent (and dir
(file-name-nondirectory
(directory-file-name dir))))
(clean (and parent
(if (and (> (length parent) 1)
(eq ?. (aref parent 0)))
(substring parent 1)
parent))))
(and clean (not (string-empty-p clean)) clean))))
(defun cj/--org-set-todo-category ()
"Set buffer-local `org-category' to the project name for a todo.org buffer.
Runs from `org-mode-hook'. Only overrides when `org-category' is still
the default-from-filename (\"todo\"), so an explicit `#+CATEGORY:' in
the file keeps precedence."
(when (and buffer-file-name
(boundp 'org-category)
(stringp org-category)
(string= "todo" org-category))
(when-let* ((project (cj/--org-todo-category-from-file buffer-file-name)))
(setq-local org-category project))))
(add-hook 'org-mode-hook #'cj/--org-set-todo-category)
;; ------------------------ Org Agenda File List Cache -------------------------
;; Cache agenda file list to avoid expensive directory scanning on every view.
;; The TTL+building cache lifecycle is provided by `cj-cache.el'.
(defvar cj/--org-agenda-files-cache (cj/cache-make :ttl 3600)
"Cache state for the agenda files list. See `cj-cache.el'.")
;; ------------------------ Add Files To Org Agenda List -----------------------
;; Checks immediate subdirectories of DIRECTORY for todo.org files and adds
;; them to org-agenda-files. Does NOT recurse into nested subdirectories.
(defun cj/add-files-to-org-agenda-files-list (directory)
"Add todo.org files from immediate subdirectories of DIRECTORY.
Only checks DIRECTORY/*/todo.org — does not recurse deeper."
(interactive "D")
(if (not (and (file-directory-p directory) (file-readable-p directory)))
;; Non-fatal: a missing or unreadable project root shouldn't crash the
;; whole agenda build — surface it and carry on with the other files.
(display-warning
'org-agenda
(format "Agenda scan: project directory missing or unreadable, skipped: %s"
directory)
:warning)
(let ((todo-files
(seq-filter
#'file-exists-p
(mapcar (lambda (dir) (expand-file-name "todo.org" dir))
(seq-filter #'file-directory-p
(directory-files directory t "^[^.]"))))))
(setq org-agenda-files (append todo-files org-agenda-files)))))
;; ---------------------------- Rebuild Org Agenda ---------------------------
;; builds the org agenda list from all agenda targets with caching.
;; agenda targets is the schedule, contacts, project todos,
;; inbox, and org roam projects.
(defun cj/--org-agenda-scan-files ()
"Scan disk for the agenda files list. Pure-ish: no caching, no logging.
Returns the list to assign to `org-agenda-files'. Slow -- walks
`projects-dir' for per-project todo.org files."
(let ((files (list inbox-file schedule-file gcal-file pcal-file dcal-file)))
;; cj/add-files-to-org-agenda-files-list mutates org-agenda-files; let-bind
;; it for the duration of the helper, then return whatever it produced.
(let ((org-agenda-files files))
(cj/add-files-to-org-agenda-files-list projects-dir)
org-agenda-files)))
(defun cj/build-org-agenda-list (&optional force-rebuild)
"Build org-agenda-files list with caching.
When FORCE-REBUILD is non-nil, bypass cache and rebuild from scratch.
Otherwise, returns cached list if available and not expired.
This function scans projects-dir for todo.org files, so caching
improves performance from several seconds to instant."
(interactive "P")
(when (cj/cache-building-p cj/--org-agenda-files-cache)
(cj/log-silently "Waiting for background agenda build to complete..."))
(let* ((start-time (current-time))
(files
(cj/cache-value-or-rebuild
cj/--org-agenda-files-cache
#'cj/--org-agenda-scan-files
:force-rebuild force-rebuild
:on-hit (lambda (v)
(cj/log-silently "Using cached agenda files (%d files)"
(length v)))
:on-build-success
(lambda (v)
(cj/log-silently "Built agenda files: %d files in %.3f sec"
(length v)
(- (float-time) (float-time start-time)))))))
(setq org-agenda-files files)))
;; Build cache asynchronously after startup to avoid blocking Emacs.
(unless noninteractive
(run-with-idle-timer
10 ; Wait 10 seconds after Emacs is idle
nil ; Don't repeat
(lambda ()
(cj/log-silently "Building org-agenda files cache in background...")
(cj/build-org-agenda-list))))
(defun cj/org-agenda-refresh-files ()
"Force rebuild of agenda files cache.
Use this after adding new projects or todo.org files.
Bypasses cache and scans directories from scratch."
(interactive)
(cj/build-org-agenda-list 'force-rebuild))
(defun cj/todo-list-all-agenda-files ()
"Displays an \\='org-agenda\\=' todo list.
The contents of the agenda will be built from org-project-files and org-roam
files that have project in their filetag."
(interactive)
(cj/build-org-agenda-list)
(org-agenda "a" "t"))
(global-set-key (kbd "s-<f8>") #'cj/todo-list-all-agenda-files)
;; ----------------------- Agenda List Single Project --------------------------
;; an agenda showing the main daily view filtered to a single project.
(defun cj/todo-list-single-project ()
"Display the main agenda filtered to a single project.
Prompts for a project from ~/projects/ (only those containing todo.org),
then shows the daily agenda (overdue, high-pri, schedule, priority B)
scoped to that project's todo.org plus calendars, schedule, and inbox."
(interactive)
(let* ((all-dirs (directory-files projects-dir t "^[^.]"))
(project-dirs (seq-filter
(lambda (dir)
(and (file-directory-p dir)
(file-exists-p (expand-file-name "todo.org" dir))))
all-dirs))
(project-names (mapcar #'file-name-nondirectory project-dirs))
(chosen (completing-read "Show agenda for project: " project-names nil t))
(todo-file (expand-file-name "todo.org"
(expand-file-name chosen projects-dir)))
(org-agenda-files (list todo-file
inbox-file schedule-file
gcal-file pcal-file dcal-file)))
(org-agenda "a" "d")))
(global-set-key (kbd "C-<f8>") #'cj/todo-list-single-project)
;; ------------------------- Agenda List Current Buffer ------------------------
;; an agenda listing tasks from just the current buffer.
(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 (concat "Your org agenda request based on '" (buffer-name (current-buffer))
"' failed because it's not an org buffer."))))
(global-set-key (kbd "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.")
(defvar cj/main-agenda-verify-title "VERIFICATION"
"String to announce the VERIFY section of the main agenda.
Lists tasks in the VERIFY TODO state waiting on a manual check.
Block sits above the day's schedule.")
(defvar cj/main-agenda-doing-title "IN-PROGRESS"
"String to announce the DOING section of the main agenda.
Lists tasks in the DOING TODO state -- work actively in flight.
Block sits just under the day's schedule.")
(defvar cj/--main-agenda-prefix-format " %i %-15:c%?-15t% s"
"Prefix format string shared by all blocks of the main daily agenda.
Inlined across the overdue / high-priority / VERIFICATION / schedule /
IN-PROGRESS / priority-B blocks of `org-agenda-custom-commands' before
the extraction. Keep the six blocks pointing here so a format tweak
lands in one place.")
(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."
(let* ((subtree-end (save-excursion (org-end-of-subtree t)))
(todo-state (org-get-todo-state))
(style (org-entry-get nil "STYLE"))
(deadline (org-entry-get nil "DEADLINE"))
(scheduled (org-entry-get nil "SCHEDULED"))
(today (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))))
(if (or (not todo-state) ; no todo keyword
(member todo-state org-done-keywords) ; done/completed tasks
(string= style "habit"))
subtree-end ; skip if done or habit
(let ((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)))
(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)))
(setq 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 cj/--main-agenda-prefix-format)))
(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 cj/--main-agenda-prefix-format)))
(todo "VERIFY"
((org-agenda-skip-function 'cj/org-skip-subtree-if-habit)
(org-agenda-overriding-header cj/main-agenda-verify-title)
(org-agenda-prefix-format cj/--main-agenda-prefix-format)))
(agenda ""
((org-agenda-start-day "0d")
(org-agenda-span 8)
(org-agenda-start-on-weekday nil)
;; CANCELLED entries with a SCHEDULED date shouldn't appear
;; in the forward-looking schedule -- they're dead weight.
(org-agenda-skip-function
'(org-agenda-skip-entry-if 'todo '("CANCELLED")))
(org-agenda-overriding-header cj/main-agenda-schedule-title)
(org-agenda-prefix-format cj/--main-agenda-prefix-format)))
(todo "DOING"
((org-agenda-skip-function 'cj/org-skip-subtree-if-habit)
(org-agenda-overriding-header cj/main-agenda-doing-title)
(org-agenda-prefix-format cj/--main-agenda-prefix-format)))
(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 cj/--main-agenda-prefix-format))))
((org-agenda-compact-blocks nil)))))
(defun cj/main-agenda-display ()
"Display the main daily org-agenda view.
This uses all org-agenda targets and presents three sections:
- All unfinished priority A tasks
- Today's schedule, including habits with consistency graphs
- All priority B and C unscheduled/undeadlined tasks
The agenda is rebuilt from all sources before display, including:
- inbox-file and schedule-file
- Org-roam nodes tagged as \"Project\"
- All todo.org files in immediate subdirectories of projects-dir"
(interactive)
(cj/build-org-agenda-list)
(org-agenda "a" "d"))
(global-set-key (kbd "<f8>") #'cj/main-agenda-display)
;; ------------------------- Add Timestamp To Org Entry ------------------------
;; simply adds a timestamp to put the org entry on an agenda
(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: ")
(defvar cj/timeformat "%Y-%m-%d %a")
(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
;; Batch tests load this module without package-initialize, so optional
;; notification packages may be installed but not loadable yet.
:if (or (not noninteractive)
(require 'alert nil t))
:config
(setq alert-fade-time 10) ;; seconds to vanish alert
(setq alert-default-style 'libnotify)) ;; works well with dunst
(use-package chime
:vc (:url "git@cjennings.net:chime.git"
:branch "main"
:rev :newest)
;; :load-path "~/code/chime" ;; uncomment + comment :vc above for local dev
:demand t
:after alert ; Removed org-agenda - Chime requires it internally
:init
;; Initialize org-agenda-files with base files before chime loads
;; The full list will be built asynchronously later
(setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file dcal-file))
;; Debug mode (keep set to nil, but available for troubleshooting)
(setq chime-debug nil)
:bind
("C-c A" . chime-check)
:config
;; Polling interval: check every minute
(setq chime-check-interval 60)
;; Alert intervals: 5 minutes before and at event time
;; All notifications use medium urgency
(setq chime-alert-intervals '((5 . medium) (0 . medium)))
;; Day-wide events: notify at 9 AM for birthdays/all-day events
(setq chime-day-wide-alert-times '("09:00"))
;; Modeline display: show upcoming events within 6 hours
(setq chime-modeline-lookahead-minutes (* 6 60))
;; Tooltip settings: show up to 20 upcoming events within the next 3 days
(setq chime-modeline-tooltip-max-events 20)
(setq chime-tooltip-lookahead-hours (* 3 24)) ;; today, tomorrow, and the next
;; Modeline content: show title and countdown only (omit event time)
(setq chime-notification-text-format "%t %u")
;; Time-until format: compact style like " in 10m" or " in 1h 37m"
(setq chime-time-left-formats
'((at-event . "now")
(short . " in %mm ") ; Under 1 hour: " in 10m"
(long . " in %hh %mm "))) ; 1 hour+: " in 1h 37m"
;; Title truncation: limit long event titles to 25 characters
(setq chime-max-title-length 25)
;; Notification title
(setq chime-notification-title "Reminder")
;; Calendar URL
(setq chime-calendar-url "https://calendar.google.com/calendar/u/0/r")
;; Enable chime-mode
(chime-mode 1))
;; which-key labels
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements "C-c A" "chime check"))
(provide 'org-agenda-config)
;;; org-agenda-config.el ends here
|