emacs-在org-agenda中展示年度、季度、月度、周度任务
经常使用任务管理软件的朋友都知道,我们有的任务并无具体的截止日期,而是一个笼统的区间,比如年度、季度、月度、周度任务。org-mode中基于 SCHEDULED
和 DEADLINE
的传统方式,难以满足这种任务的查看与重复需求。
经过一番思索,我找到了利用Org-QL和org-repeat-by-cron实现相关任务查看重复的方法,分享与此。
提示:org-repeat-by-cron 是我修改自 https://github.com/Raemi/org-reschedule-by-rule 的包,略微修改之处在此按下不表。
1. 设置需要被追踪的任务
在需要被追踪的任务中,添加名为 PERIOD
的属性。
由于Emacs Org-Mode 重复任务在按月重复情况下无法定位于「某月的最后一天」,因此后面的季度、月度任务需要用到 cron 来帮助重复定位。
* TODO 这是一个年度任务
DEADLINE: <2025-12-31 周三 ++1y>
:PROPERTIES:
:PERIOD: year
:END:
* TODO 这是一个季度任务
DEADLINE: <2025-09-30 周二>
:PROPERTIES:
:REPEAT_CRON: L 3,6,9,12 *
:PERIOD: season
:REPEAT_DEADLINE: t
:END:
* TODO 这是一个月度任务
DEADLINE: <2025-09-30 周二>
:PROPERTIES:
:REPEAT_CRON: L * *
:PERIOD: month
:REPEAT_DEADLINE: t
:END:
* TODO 这是一个周度任务
DEADLINE: <2025-09-12 周五 ++1w>
:PROPERTIES:
:PERIOD: week
:END:
2. 设置Agenda Commands
2.1. 自定义相关函数
2.1.1. my/org-ql-ts-period
这里我们定义了两个相关函数。第一个函数 my/org-ql-ts-period
会根据输入的周期,返回当前时间所在周期的起止日期。
如在2025年9月9日执行 (my/org-ql-ts-period 's)
,则会返回 ("2025-07-01" . "2025-09-30")
(defun my/org-ql-ts-period (period)
"根据输入的 PERIOD ('y', 's', 'm', 'w') 返回当前年、季度、月或周的起止日期。
格式为 'YYYY-MM-DD'。
季节 (Season) 定义:
- 季度1: 1月 - 3月
- 季度2: 4月 - 6月
- 季度3: 7月 - 9月
- 季度4: 10月 - 12月
星期一被视为一周的开始。
参数:
PERIOD: 一个表示时间段的符号,可以是 'y', 's', 'm', 或 'w'。
返回:
一个包含起止日期的字符串,格式为 'YYYY-MM-DD - YYYY-MM-DD',
如果输入无效则返回错误信息。"
(let* ((now (current-time))
(decoded-time (decode-time now))
(sec (nth 0 decoded-time))
(min (nth 1 decoded-time))
(hour (nth 2 decoded-time))
(day (nth 3 decoded-time))
(month (nth 4 decoded-time))
(year (nth 5 decoded-time))
start-date
end-date)
(cond
;; 年 (Year)
((eq period 'y)
(setq start-date (format-time-string "%Y-01-01"))
(setq end-date (format-time-string "%Y-12-31")))
;; 季 (Season)
((eq period 's)
(let* ((start-month (cond ((<= month 3) 1)
((<= month 6) 4)
((<= month 9) 7)
(t 10)))
(end-month (+ start-month 2))
(end-day (calendar-last-day-of-month end-month year)))
(setq start-date (format-time-string "%Y-%m-01" (encode-time 0 0 0 1 start-month year)))
(setq end-date (format-time-string (format "%%Y-%%m-%d" end-day) (encode-time 0 0 0 end-day end-month year)))))
;; 月 (Month)
((eq period 'm)
(setq start-date (format-time-string "%Y-%m-01"))
(let* ((last-day (calendar-last-day-of-month month year)))
(setq end-date (format-time-string (format "%%Y-%%m-%d" last-day)))))
;; 周 (Week)
((eq period 'w)
(let* ((day-of-week (string-to-number (format-time-string "%u"))) ; 星期一为1,星期日为7
(start-offset (- day-of-week 1))
(end-offset (- 7 day-of-week))
(start-time (time-subtract now (seconds-to-time (* start-offset 24 60 60))))
(end-time (time-add now (seconds-to-time (* end-offset 24 60 60)))))
(setq start-date (format-time-string "%Y-%m-%d" start-time))
(setq end-date (format-time-string "%Y-%m-%d" end-time))))
;; 无效输入
(t (error "无效的参数,请输入 'y', 's', 'm', 或 'w'")))
(cons start-date end-date)))
2.1.2. my/org-ql-block-period
第二个函数 my/org-ql-block-period
用来简化 org-agenda-custom-commands
中可能出现的重复代码。根据输入的周期,构建不同的 org-ql-block
。
(defun my/org-ql-block-period (period)
(let ((property-string nil)
(header-string nil))
(cond
;; 年 (Year)
((eq period 'y)
(setq property-string "year"
header-string "年"))
;; 季 (Season)
((eq period 's)
(setq property-string "season"
header-string "季"))
;; 月 (Month)
((eq period 'm)
(setq property-string "month"
header-string "月"))
;; 周 (Week)
((eq period 'w)
(setq property-string "week"
header-string "周"))
;; 无效输入
(t (error "无效的参数,请输入 'y', 's', 'm', 或 'w'")))
(org-ql-block `(and (todo "TODO" "HOLD") (property "PERIOD" property-string)(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period))))
((org-ql-block-header ,(concat "🔄周期--" header-string "🔄"))))))
;; 参考设置
;; (my/org-ql-block-period 'y)
2.1.3. org-agenda-custom-commands
最后,我们需要自定义 org-agenda-custom-commands
,加入上述块函数:
(setq org-agenda-custom-commands
'(("d" "Daily Agenda"
;; 上略……
;; 周期任务拿出来
(my/org-ql-block-period 'y)
(my/org-ql-block-period 's)
(my/org-ql-block-period 'm)
(my/org-ql-block-period 'w)
;; 下略…
)))
这样一来,执行 (org-agenda nil "d")
之后,就能在Agenda 区域中看见
──────────────────────────────────────────────────────────────
🔄周期--年🔄
TODO 这是一个年度任务
───────────────────────────────────────────────────────────────
🔄周期--季🔄
TODO 这是一个季度任务
───────────────────────────────────────────────────────────────
🔄周期--月🔄
TODO 这是一个月度任务
───────────────────────────────────────────────────────────────
🔄周期--周🔄
TODO 这是一个周度任务
3. 原理解释
org-ql-block
函数可以将搜索结果展示为agenda中的一个block。这里我们搜索的内容为 (and (todo "TODO" "HOLD") (property "PERIOD" property-string)(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period))))
,其实是满足以下三个要求的任务:
(todo "TODO" "HOLD")
:TODO或者HOLD这种处于TODO状态的HEADING(property "PERIOD" property-string)
:根据上面的要求,搜索PERIOD
属性为year
season
month
week
的对应任务(ts-active :from ,(car (my/org-ql-ts-period period)) :to ,(cdr (my/org-ql-ts-period period)))
:活跃时间戳,且时间为year
season
month
week
对应的当前日期所处起始年月日里。由my/org-ql-ts-period
函数生成。
这里我们用 DEADLINE
标记,就是为了腾出空间留给 SCHEDULED
,以便我们计划这类任务应该开始完成的时间。如果一个任务被标记完成,那么它会自动标记 DEADLINE
在符合条件的下个周期最后一天,也就会从我们统计的Agenda-View里移除了(因为时间戳超过了期限)。
在org-todo标记为完成后,因为我们用的 DEADLINE
,会将 SCHEDULED
清空,并重新设定 DEADLINE
时间戳。