org-agenda-dych-mode.el 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. ;;; org-agenda-dych-mode.el --- Dynamic scheduling for your daily agenda! -*- lexical-binding: t; -*-
  2. ;; Copyright (C) 2022 c1-g
  3. ;; Author: c1-g <char1iegordon@protonmail.com>
  4. ;; Keywords:
  5. ;; This program is free software; you can redistribute it and/or modify
  6. ;; it under the terms of the GNU General Public License as published by
  7. ;; the Free Software Foundation, either version 3 of the License, or
  8. ;; (at your option) any later version.
  9. ;; This program is distributed in the hope that it will be useful,
  10. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. ;; GNU General Public License for more details.
  13. ;; You should have received a copy of the GNU General Public License
  14. ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
  15. ;;; Commentary:
  16. ;;
  17. ;;; Code:
  18. (require 'org-agenda)
  19. (require 'calc)
  20. (require 'cl-lib)
  21. (define-minor-mode org-agenda-dych-mode
  22. "Keep optimum proportions of time devoted to particular tasks in your agenda."
  23. :global nil
  24. :init-value nil
  25. :lighter " Dych"
  26. :keymap '(([remap org-agenda-schedule] . org-agenda-dych)
  27. ([remap org-agenda-set-effort] . org-agenda-dych-set-effort)
  28. ([remap org-agenda-redo] . org-agenda-dych-recompute)
  29. ([remap org-agenda-redo-all] . org-agenda-dych-redo-all)
  30. ([remap org-agenda-clock-in] . org-agenda-dych-begin-entry)))
  31. (defcustom org-agenda-dych-default-work-hour "8h"
  32. "The length of your schedule in hours.")
  33. (defcustom org-agenda-dych-default-end "16:00"
  34. "The length of your schedule in hours.")
  35. (defcustom org-agenda-dych-default-start "08:00"
  36. "The default start time to start the working day."
  37. :group 'org-properties
  38. :type 'string)
  39. (defvar org-agenda-dych-overriding-start nil
  40. "When set, override any other start time for the agenda.")
  41. ;; Borrowed from Org-ql; org-ql-regexp-part-ts-repeaters.
  42. (defvar org-agenda-dych-repeater-re
  43. (rx (repeat 1 2 (seq " " (repeat 1 2 (any "-+:.")) (1+ digit) (any "hdwmy")
  44. (optional "/" (1+ digit) (any "hdwmy")))))
  45. "Matches the repeater part of an Org timestamp.
  46. Includes leading space character.")
  47. (defun org-agenda-dych-get-start ()
  48. "Get the schedule time of the earliest task."
  49. (or (bound-and-true-p org-agenda-dych-overriding-start)
  50. (car (alist-get 'org-agenda-dych-overriding-start
  51. (nth 2 (org-get-at-bol 'org-series-cmd))))
  52. org-agenda-dych-default-start))
  53. ;;; Utilities
  54. (defun org-agenda-dych-next-item (n)
  55. "The essential form of `org-agenda-next-item' that doesn't do anything extra."
  56. (dotimes (_ n)
  57. (if (next-single-property-change (point-at-eol) 'org-marker)
  58. (progn
  59. (move-end-of-line 1)
  60. (goto-char (next-single-property-change (point) 'org-marker)))
  61. (goto-char (point-max))
  62. nil)))
  63. (defun org-agenda-dych-previous-item (n)
  64. "The essential form of `org-agenda-previous-item' that doesn't do anything extra."
  65. (interactive "p")
  66. (dotimes (_ n)
  67. (let ((col (current-column))
  68. (goto (save-excursion
  69. (move-end-of-line 0)
  70. (previous-single-property-change (point) 'org-marker))))
  71. (when goto (goto-char goto))
  72. (org-move-to-column col))))
  73. (defun org-agenda-dych-map-entries (cmd &optional beg end match-fn)
  74. "Call CMD on entries between BEG END.
  75. CMD is called on the beginning of the line.
  76. For example,
  77. (org-agenda-dych-map-entries
  78. (lambda ()
  79. (org-get-at-bol 'priority))
  80. 1 500)
  81. will list all priorities of every entry that lays between the 1st
  82. character of the agenda buffer and the 500th character."
  83. (setq beg (or beg (point-min)))
  84. (setq end (or end (point-max)))
  85. (save-excursion
  86. (goto-char beg)
  87. (let ((mend (move-marker (make-marker) end))
  88. (index 0)
  89. (res))
  90. (while (< (point) mend)
  91. (if (or (not (org-get-at-bol 'org-marker))
  92. (and match-fn
  93. (not (funcall match-fn))))
  94. (org-agenda-dych-next-item 1)
  95. (push (funcall cmd) res)
  96. (org-agenda-dych-next-item 1)))
  97. (nreverse res))))
  98. (defun org-agenda-dych-fix ()
  99. "Prevent a task from being automatically optimized by Dych.
  100. If some tasks must start at a specific hour, user can schedule them
  101. with `org-agenda-dych'."
  102. (interactive)
  103. (when-let* ((hdmarker (org-get-at-bol 'org-marker))
  104. (inhibit-read-only t))
  105. (org-entry-put hdmarker "FIXED" "t")))
  106. (defun org-agenda-dych-make-rigid ()
  107. "Prevent a task's effort estimate from being automatically shrink or expanded."
  108. (interactive)
  109. (when-let* ((hdmarker (org-get-at-bol 'org-marker))
  110. (inhibit-read-only t))
  111. (org-entry-put hdmarker "RIGID" "t")
  112. (org-entry-put hdmarker "EFFORT" (org-entry-get hdmarker "TIME_ESTIMATE"))))
  113. (defun org-agenda-dych-fixed-indicator (&optional marker boolean)
  114. "Return a string \"F\" when a task in MARKER is fixed. Or a `t' when BOOLEAN is non-nil.
  115. This should be used in `org-agenda-prefix-format'."
  116. (let (fixed s)
  117. (setq fixed (org-entry-get marker "FIXED"))
  118. (if (and fixed
  119. (setq fixed (not (string-empty-p fixed))))
  120. (setq s (propertize "F" 'fixed t))
  121. (setq s (propertize "-" 'fixed nil)))
  122. (if boolean
  123. fixed
  124. s)))
  125. (defun org-agenda-dych-rigid-indicator (&optional marker boolean)
  126. "Return a string \"R\" when a task in MARKER is rigid. Or a `t' when BOOLEAN is non-nil.
  127. This should be used in `org-agenda-prefix-format'."
  128. (let (rigid s)
  129. (setq rigid (org-entry-get marker "RIGID"))
  130. (if (and rigid
  131. (setq rigid (not (string-empty-p rigid))))
  132. (setq s (propertize "R" 'rigid t))
  133. (setq s (propertize "-" 'rigid nil)))
  134. (if boolean
  135. rigid
  136. s)))
  137. (defun org-agenda-dych-maybe-schedule (arg &optional time)
  138. "Schedule the item at point when TIME is not on the same date as the old one.
  139. ARG is passed through to `org-agenda-schedule'."
  140. (if (and time (equal (apply #'encode-time (org-read-date-analyze time (decode-time) (decode-time)))
  141. (org-get-scheduled-time (org-get-at-bol 'org-hd-marker))))
  142. (setq org-last-inserted-timestamp
  143. (org-agenda-dych-h:mm-to-full-ts time)))
  144. (org-agenda-schedule arg time))
  145. ;; TODO: Expand this
  146. (defun org-agenda-dych-get-workhours ()
  147. "Get the length of your schedule in hours."
  148. (cond
  149. ((bound-and-true-p org-overriding-work-hours))
  150. ((let ((m (org-get-at-bol 'org-hd-marker)))
  151. (and m (with-current-buffer (marker-buffer m)
  152. (- (org-duration-to-minutes org-agenda-dych-default-end)
  153. (org-duration-to-minutes (org-agenda-dych-get-start)))))))
  154. ((let ((m (next-single-property-change (point-min) 'org-hd-marker)))
  155. (and m (let ((m (get-text-property m 'org-hd-marker)))
  156. (with-current-buffer (marker-buffer m)
  157. (- (org-duration-to-minutes org-agenda-dych-default-end)
  158. (org-duration-to-minutes (org-agenda-dych-get-start))))))))
  159. (t (- (org-duration-to-minutes org-agenda-dych-default-end)
  160. (org-duration-to-minutes (org-agenda-dych-get-start))))))
  161. (defun org-agenda-dych-get-dotime (&optional point)
  162. "Get the scheduled time of the current entry or the entry at POINT."
  163. (setq point (or point (point-at-bol)))
  164. (if (and (get-text-property point 'org-habit-p)
  165. (stringp (get-text-property point 'dotime)))
  166. (replace-regexp-in-string org-agenda-dych-repeater-re ""
  167. (get-text-property point 'dotime))
  168. (get-text-property point 'dotime)))
  169. (defun org-agenda-dych-time-less-than-tomorrow-p (time)
  170. "Return true when TIME is in today."
  171. (time-less-p time (apply #'encode-time (org-read-date-analyze "24:00" (decode-time) (decode-time)))))
  172. (defun org-agenda-dych-entry-eligible-p ()
  173. (or (and (member (org-get-at-bol 'type) (list "scheduled" "past-scheduled"))
  174. (not (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))))
  175. (= (point) (point-max))))
  176. (defun org-agenda-dych-h:mm-to-full-ts (time)
  177. "Convert HH:MM to full org time stamp."
  178. (format-time-string
  179. (org-time-stamp-format 'long)
  180. (apply #'encode-time
  181. (org-read-date-analyze time (decode-time) (decode-time)))))
  182. (defun org-agenda-dych-time-of-day-to-hh:mm (time-of-day)
  183. "A reverse of `org-get-time-of-day' which convert TIME-OF-DAY to HH:MM format
  184. TIME-OF-DAY can be a string or a number from `org-get-time-of-day'.
  185. This function returns a string."
  186. (when (numberp time-of-day)
  187. (setq time-of-day (number-to-string time-of-day)))
  188. (pcase (string-width time-of-day)
  189. (3 (concat "0" (substring time-of-day 0 1) ":" (substring time-of-day 1)))
  190. (4 (concat (substring time-of-day 0 2) ":" (substring time-of-day 2)))))
  191. (defun org-agenda-dych-first-entry-check ()
  192. "Check the first (earliest) task of the day in the agenda.
  193. First, it checks if whether or not the first task is fixed, if
  194. not, ask user to fix it.
  195. Second, checks if the task starts at `org-agenda-dych-default-start', if not,
  196. have the user confirm to set its scheduled time as the start of the day."
  197. (goto-char (point-min))
  198. (let ((pos (point))
  199. (last-time 2400)
  200. (earliest-task)
  201. (tasks))
  202. (while (setq pos (next-single-property-change pos 'time-of-day))
  203. (when (get-text-property pos 'org-hd-marker)
  204. (push (list
  205. :start (get-text-property pos 'time-of-day)
  206. :txt (get-text-property pos 'txt)
  207. :marker (get-text-property pos 'org-hd-marker)
  208. :line (org-current-line pos))
  209. tasks)))
  210. (setq earliest-task
  211. (car (sort tasks (lambda (e1 e2)
  212. (< (plist-get e1 :start)
  213. (plist-get e2 :start))))))
  214. (goto-line (plist-get earliest-task :line))
  215. (if (org-agenda-dych-fixed-indicator (plist-get earliest-task :marker) t)
  216. (if (= (plist-get earliest-task :start)
  217. (org-get-time-of-day (org-agenda-dych-get-start)))
  218. (setq org-last-inserted-timestamp
  219. (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start)))
  220. (if (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))
  221. (setq org-last-inserted-timestamp
  222. (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start)))
  223. (if (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))
  224. (setq org-last-inserted-timestamp
  225. (format-time-string
  226. (org-time-stamp-format 'long)
  227. (org-get-scheduled-time (plist-get earliest-task :marker)))))
  228. (if (yes-or-no-p (format "The earliest task (%s) starts at %s which are not the same as the default (%s). Want to start working at %s for this buffer only? "
  229. (plist-get earliest-task :txt)
  230. (org-agenda-dych-time-of-day-to-hh:mm
  231. (plist-get earliest-task :start))
  232. (org-agenda-dych-get-start)
  233. (org-agenda-dych-time-of-day-to-hh:mm
  234. (plist-get earliest-task :start))))
  235. (and (set (make-local-variable 'org-agenda-overriding-cmd)
  236. (progn (push `(org-agenda-dych-overriding-start
  237. ,(org-agenda-dych-time-of-day-to-hh:mm
  238. (plist-get earliest-task :start)))
  239. (car (nthcdr 2 (org-get-at-bol 'org-series-cmd))))
  240. (org-get-at-bol 'org-series-cmd)))
  241. (setq org-agenda-dych-current-start
  242. (org-agenda-dych-time-of-day-to-hh:mm
  243. (plist-get earliest-task :start))))
  244. (user-error "Please schedule a task to start at %s" (org-agenda-dych-get-start))))
  245. (if (yes-or-no-p (format "The first entry is not fixed to be the start of the working day (%s). Want to set it as the first task of the day? " (org-agenda-dych-get-start)))
  246. (progn (org-agenda-dych-maybe-schedule nil (org-agenda-dych-get-start))
  247. (goto-line (plist-get earliest-task :line)) (org-agenda-dych-fix))
  248. (user-error "Please schedule a task to start at %s" (org-agenda-dych-get-start)))))))
  249. ;; TODO: Documentation
  250. (defun org-agenda-dych-recompute ()
  251. "Keep all effort estimates in the agenda equal."
  252. (interactive)
  253. (save-excursion
  254. (let ((block-start (goto-char (point-min)))
  255. (bound (point-max))
  256. (rigid-minute 0) (rigid-count 0)
  257. (effort-plists) (count 0)
  258. (neffort)
  259. (lrigid)
  260. (leffort))
  261. (org-agenda-dych-first-entry-check)
  262. (setq lrigid (org-agenda-dych-rigid-indicator
  263. (org-get-at-bol 'org-hd-marker) t))
  264. (setq leffort (or (org-get-at-bol 'effort-minutes) 0.0))
  265. (setq block-start (point))
  266. (while (text-property-search-forward 'fixed t)
  267. (when (org-agenda-dych-entry-eligible-p)
  268. (setq count 0)
  269. (setq rigid-count 0)
  270. (setq rigid-minute 0)
  271. (setq effort-plists
  272. (org-agenda-dych-map-entries
  273. (lambda ()
  274. (cl-incf count)
  275. (list
  276. :index (org-current-line)
  277. :effort (org-get-at-bol 'effort)
  278. :dotime (org-agenda-dych-get-dotime)
  279. :fixed (org-agenda-dych-fixed-indicator
  280. (org-get-at-bol 'org-hd-marker) t)
  281. :rigid (and (org-agenda-dych-rigid-indicator
  282. (org-get-at-bol 'org-hd-marker) t)
  283. (setq rigid-minute
  284. (string-to-number
  285. (calc-eval (format "%f+%f" rigid-minute
  286. (org-get-at-bol 'effort-minutes)))))
  287. (cl-incf rigid-count))))
  288. block-start
  289. (setq bound (save-excursion (org-agenda-dych-previous-item 1)
  290. (point-at-eol)))
  291. #'org-agenda-dych-entry-eligible-p))
  292. (setq neffort (abs (string-to-number
  293. (calc-eval (format "(%f-%f-%f)/%d"
  294. (or (ignore-errors
  295. (org-duration-to-minutes
  296. (and (not (eq (org-agenda-dych-get-dotime) 'time))
  297. (org-agenda-dych-get-dotime))))
  298. (org-agenda-dych-get-workhours))
  299. (org-duration-to-minutes
  300. (or (org-agenda-dych-get-dotime block-start)
  301. "0:00"))
  302. rigid-minute
  303. (- count rigid-count))))))
  304. (setq block-start (point))
  305. (save-excursion
  306. (dolist (pl effort-plists)
  307. (goto-line (plist-get pl :index))
  308. (if (and (plist-get pl :fixed)
  309. (string= (org-get-at-bol 'type) "scheduled"))
  310. (setq org-last-inserted-timestamp
  311. (org-agenda-dych-h:mm-to-full-ts
  312. (plist-get pl :dotime)))
  313. (let ((ntime (seconds-to-time
  314. (string-to-number
  315. (calc-eval (format "(%f*60)+%f"
  316. (if lrigid leffort neffort)
  317. (org-time-string-to-seconds
  318. (if org-last-inserted-timestamp
  319. (substring org-last-inserted-timestamp 1 -1)
  320. (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start))))))))))
  321. (when (org-agenda-dych-time-less-than-tomorrow-p ntime)
  322. (org-agenda-dych-maybe-schedule
  323. nil (format-time-string (org-time-stamp-format t) ntime)))))
  324. (unless (org-entry-get (org-get-at-bol 'org-hd-marker) "TIME_ESTIMATE")
  325. (org-entry-put (org-get-at-bol 'org-hd-marker) "TIME_ESTIMATE" (plist-get pl :effort)))
  326. (setq lrigid (plist-get pl :rigid))
  327. (if (plist-get pl :rigid)
  328. (setq leffort (org-get-at-bol 'effort-minutes))
  329. (org-entry-put (org-get-at-bol 'org-hd-marker) "EFFORT" (org-duration-from-minutes
  330. neffort)))))))))
  331. (org-agenda-redo)
  332. (org-agenda-dych-mode 1))
  333. ;;; Agenda
  334. (defun org-agenda-dych-add-properties ()
  335. "Add FIXED and RIGID property to entries in agenda."
  336. (goto-char (point-min))
  337. (while (not (eobp))
  338. (forward-line 1)
  339. (when (org-get-at-bol 'org-marker)
  340. (put-text-property (point-at-bol) (point-at-eol) 'fixed (org-agenda-dych-fixed-indicator (org-get-at-bol 'org-marker) t))
  341. (put-text-property (point-at-bol) (point-at-eol) 'rigid (org-agenda-dych-rigid-indicator (org-get-at-bol 'org-marker) t)))))
  342. (add-hook 'org-agenda-finalize-hook #'org-agenda-dych-add-properties)
  343. ;;; Wrappers
  344. ;; Wrappers are necessary because I want the minor mode to be togglable.
  345. ;;;###autoload
  346. (defun org-agenda-dych (arg &optional time)
  347. "Like `org-agenda-schedule' but fixes the entry at point & recompute afterwards.
  348. When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-schedule'
  349. \\<org-agenda-mode-map> when the user presses '\\[org-agenda-schedule]' in the agenda buffer"
  350. (interactive "P")
  351. (org-agenda-schedule arg time)
  352. (org-agenda-dych-fix)
  353. (org-agenda-dych-recompute))
  354. ;;;###autoload
  355. (defun org-agenda-dych-begin-entry (&optional arg)
  356. "Schedule the current entry to now."
  357. (interactive "P")
  358. (org-agenda-schedule nil (format-time-string "%H:%M" (current-time)))
  359. (org-agenda-dych-fix)
  360. (org-agenda-clock-in arg)
  361. (org-agenda-dych-recompute))
  362. ;;;###autoload
  363. (defun org-agenda-dych-redo-all (&optional exhaustive)
  364. "Like `org-agenda-redo-all' but call `org-agenda-dych-recompute' afterwards.
  365. When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-redo-all'
  366. \\<org-agenda-mode-map> when the user presses '\\[org-agenda-redo-all]' in the agenda buffer"
  367. (interactive "P")
  368. (org-agenda-dych-recompute)
  369. (org-agenda-redo-all exhaustive)
  370. (org-agenda-dych-mode 1))
  371. ;;;###autoload
  372. (defun org-agenda-dych-set-effort ()
  373. "Like `org-agenda-set-effort' but call `org-agenda-dych-recompute' afterwards.
  374. When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-set-effort'
  375. \\<org-agenda-mode-map> when the user presses '\\[org-agenda-set-effort]' in the agenda buffer"
  376. (interactive)
  377. (org-agenda-set-effort)
  378. (org-agenda-dych-recompute))
  379. (provide 'org-agenda-dych-mode)
  380. ;;; org-agenda-dych-mode.el ends here