123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- ;;; org-agenda-dych-mode.el --- Dynamic scheduling for your daily agenda! -*- lexical-binding: t; -*-
- ;; Copyright (C) 2022 c1-g
- ;; Author: c1-g <char1iegordon@protonmail.com>
- ;; Keywords:
- ;; This program is free software; you can redistribute it and/or modify
- ;; it under the terms of the GNU General Public License as published by
- ;; the Free Software Foundation, either version 3 of the License, or
- ;; (at your option) any later version.
- ;; This program is distributed in the hope that it will be useful,
- ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
- ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- ;; GNU General Public License for more details.
- ;; You should have received a copy of the GNU General Public License
- ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
- ;;; Commentary:
- ;;
- ;;; Code:
- (require 'org-agenda)
- (require 'calc)
- (require 'cl-lib)
- (define-minor-mode org-agenda-dych-mode
- "Keep optimum proportions of time devoted to particular tasks in your agenda."
- :global nil
- :init-value nil
- :lighter " Dych"
- :keymap '(([remap org-agenda-schedule] . org-agenda-dych)
- ([remap org-agenda-set-effort] . org-agenda-dych-set-effort)
- ([remap org-agenda-redo] . org-agenda-dych-recompute)
- ([remap org-agenda-redo-all] . org-agenda-dych-redo-all)
- ([remap org-agenda-clock-in] . org-agenda-dych-begin-entry)))
- (defcustom org-agenda-dych-default-work-hour "8h"
- "The length of your schedule in hours.")
- (defcustom org-agenda-dych-default-end "16:00"
- "The length of your schedule in hours.")
- (defcustom org-agenda-dych-default-start "08:00"
- "The default start time to start the working day."
- :group 'org-properties
- :type 'string)
- (defvar org-agenda-dych-overriding-start nil
- "When set, override any other start time for the agenda.")
- ;; Borrowed from Org-ql; org-ql-regexp-part-ts-repeaters.
- (defvar org-agenda-dych-repeater-re
- (rx (repeat 1 2 (seq " " (repeat 1 2 (any "-+:.")) (1+ digit) (any "hdwmy")
- (optional "/" (1+ digit) (any "hdwmy")))))
- "Matches the repeater part of an Org timestamp.
- Includes leading space character.")
- (defun org-agenda-dych-get-start ()
- "Get the schedule time of the earliest task."
- (or (bound-and-true-p org-agenda-dych-overriding-start)
- (car (alist-get 'org-agenda-dych-overriding-start
- (nth 2 (org-get-at-bol 'org-series-cmd))))
- org-agenda-dych-default-start))
- ;;; Utilities
- (defun org-agenda-dych-next-item (n)
- "The essential form of `org-agenda-next-item' that doesn't do anything extra."
- (dotimes (_ n)
- (if (next-single-property-change (point-at-eol) 'org-marker)
- (progn
- (move-end-of-line 1)
- (goto-char (next-single-property-change (point) 'org-marker)))
- (goto-char (point-max))
- nil)))
- (defun org-agenda-dych-previous-item (n)
- "The essential form of `org-agenda-previous-item' that doesn't do anything extra."
- (interactive "p")
- (dotimes (_ n)
- (let ((col (current-column))
- (goto (save-excursion
- (move-end-of-line 0)
- (previous-single-property-change (point) 'org-marker))))
- (when goto (goto-char goto))
- (org-move-to-column col))))
- (defun org-agenda-dych-map-entries (cmd &optional beg end match-fn)
- "Call CMD on entries between BEG END.
- CMD is called on the beginning of the line.
- For example,
- (org-agenda-dych-map-entries
- (lambda ()
- (org-get-at-bol 'priority))
- 1 500)
- will list all priorities of every entry that lays between the 1st
- character of the agenda buffer and the 500th character."
- (setq beg (or beg (point-min)))
- (setq end (or end (point-max)))
- (save-excursion
- (goto-char beg)
- (let ((mend (move-marker (make-marker) end))
- (index 0)
- (res))
- (while (< (point) mend)
- (if (or (not (org-get-at-bol 'org-marker))
- (and match-fn
- (not (funcall match-fn))))
- (org-agenda-dych-next-item 1)
- (push (funcall cmd) res)
- (org-agenda-dych-next-item 1)))
- (nreverse res))))
- (defun org-agenda-dych-fix ()
- "Prevent a task from being automatically optimized by Dych.
- If some tasks must start at a specific hour, user can schedule them
- with `org-agenda-dych'."
- (interactive)
- (when-let* ((hdmarker (org-get-at-bol 'org-marker))
- (inhibit-read-only t))
- (org-entry-put hdmarker "FIXED" "t")))
- (defun org-agenda-dych-make-rigid ()
- "Prevent a task's effort estimate from being automatically shrink or expanded."
- (interactive)
- (when-let* ((hdmarker (org-get-at-bol 'org-marker))
- (inhibit-read-only t))
- (org-entry-put hdmarker "RIGID" "t")
- (org-entry-put hdmarker "EFFORT" (org-entry-get hdmarker "TIME_ESTIMATE"))))
- (defun org-agenda-dych-fixed-indicator (&optional marker boolean)
- "Return a string \"F\" when a task in MARKER is fixed. Or a `t' when BOOLEAN is non-nil.
- This should be used in `org-agenda-prefix-format'."
- (let (fixed s)
- (setq fixed (org-entry-get marker "FIXED"))
- (if (and fixed
- (setq fixed (not (string-empty-p fixed))))
- (setq s (propertize "F" 'fixed t))
- (setq s (propertize "-" 'fixed nil)))
- (if boolean
- fixed
- s)))
- (defun org-agenda-dych-rigid-indicator (&optional marker boolean)
- "Return a string \"R\" when a task in MARKER is rigid. Or a `t' when BOOLEAN is non-nil.
- This should be used in `org-agenda-prefix-format'."
- (let (rigid s)
- (setq rigid (org-entry-get marker "RIGID"))
- (if (and rigid
- (setq rigid (not (string-empty-p rigid))))
- (setq s (propertize "R" 'rigid t))
- (setq s (propertize "-" 'rigid nil)))
- (if boolean
- rigid
- s)))
- (defun org-agenda-dych-maybe-schedule (arg &optional time)
- "Schedule the item at point when TIME is not on the same date as the old one.
- ARG is passed through to `org-agenda-schedule'."
- (if (and time (equal (apply #'encode-time (org-read-date-analyze time (decode-time) (decode-time)))
- (org-get-scheduled-time (org-get-at-bol 'org-hd-marker))))
- (setq org-last-inserted-timestamp
- (org-agenda-dych-h:mm-to-full-ts time)))
- (org-agenda-schedule arg time))
- ;; TODO: Expand this
- (defun org-agenda-dych-get-workhours ()
- "Get the length of your schedule in hours."
- (cond
- ((bound-and-true-p org-overriding-work-hours))
- ((let ((m (org-get-at-bol 'org-hd-marker)))
- (and m (with-current-buffer (marker-buffer m)
- (- (org-duration-to-minutes org-agenda-dych-default-end)
- (org-duration-to-minutes (org-agenda-dych-get-start)))))))
- ((let ((m (next-single-property-change (point-min) 'org-hd-marker)))
- (and m (let ((m (get-text-property m 'org-hd-marker)))
- (with-current-buffer (marker-buffer m)
- (- (org-duration-to-minutes org-agenda-dych-default-end)
- (org-duration-to-minutes (org-agenda-dych-get-start))))))))
- (t (- (org-duration-to-minutes org-agenda-dych-default-end)
- (org-duration-to-minutes (org-agenda-dych-get-start))))))
- (defun org-agenda-dych-get-dotime (&optional point)
- "Get the scheduled time of the current entry or the entry at POINT."
- (setq point (or point (point-at-bol)))
- (if (and (get-text-property point 'org-habit-p)
- (stringp (get-text-property point 'dotime)))
- (replace-regexp-in-string org-agenda-dych-repeater-re ""
- (get-text-property point 'dotime))
- (get-text-property point 'dotime)))
- (defun org-agenda-dych-time-less-than-tomorrow-p (time)
- "Return true when TIME is in today."
- (time-less-p time (apply #'encode-time (org-read-date-analyze "24:00" (decode-time) (decode-time)))))
- (defun org-agenda-dych-entry-eligible-p ()
- (or (and (member (org-get-at-bol 'type) (list "scheduled" "past-scheduled"))
- (not (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))))
- (= (point) (point-max))))
- (defun org-agenda-dych-h:mm-to-full-ts (time)
- "Convert HH:MM to full org time stamp."
- (format-time-string
- (org-time-stamp-format 'long)
- (apply #'encode-time
- (org-read-date-analyze time (decode-time) (decode-time)))))
- (defun org-agenda-dych-time-of-day-to-hh:mm (time-of-day)
- "A reverse of `org-get-time-of-day' which convert TIME-OF-DAY to HH:MM format
- TIME-OF-DAY can be a string or a number from `org-get-time-of-day'.
- This function returns a string."
- (when (numberp time-of-day)
- (setq time-of-day (number-to-string time-of-day)))
- (pcase (string-width time-of-day)
- (3 (concat "0" (substring time-of-day 0 1) ":" (substring time-of-day 1)))
- (4 (concat (substring time-of-day 0 2) ":" (substring time-of-day 2)))))
- (defun org-agenda-dych-first-entry-check ()
- "Check the first (earliest) task of the day in the agenda.
- First, it checks if whether or not the first task is fixed, if
- not, ask user to fix it.
- Second, checks if the task starts at `org-agenda-dych-default-start', if not,
- have the user confirm to set its scheduled time as the start of the day."
- (goto-char (point-min))
- (let ((pos (point))
- (last-time 2400)
- (earliest-task)
- (tasks))
- (while (setq pos (next-single-property-change pos 'time-of-day))
- (when (get-text-property pos 'org-hd-marker)
- (push (list
- :start (get-text-property pos 'time-of-day)
- :txt (get-text-property pos 'txt)
- :marker (get-text-property pos 'org-hd-marker)
- :line (org-current-line pos))
- tasks)))
- (setq earliest-task
- (car (sort tasks (lambda (e1 e2)
- (< (plist-get e1 :start)
- (plist-get e2 :start))))))
- (goto-line (plist-get earliest-task :line))
- (if (org-agenda-dych-fixed-indicator (plist-get earliest-task :marker) t)
- (if (= (plist-get earliest-task :start)
- (org-get-time-of-day (org-agenda-dych-get-start)))
- (setq org-last-inserted-timestamp
- (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start)))
- (if (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))
- (setq org-last-inserted-timestamp
- (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start)))
- (if (eq (org-get-at-bol 'face) (org-get-at-bol 'done-face))
- (setq org-last-inserted-timestamp
- (format-time-string
- (org-time-stamp-format 'long)
- (org-get-scheduled-time (plist-get earliest-task :marker)))))
- (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? "
- (plist-get earliest-task :txt)
- (org-agenda-dych-time-of-day-to-hh:mm
- (plist-get earliest-task :start))
- (org-agenda-dych-get-start)
- (org-agenda-dych-time-of-day-to-hh:mm
- (plist-get earliest-task :start))))
- (and (set (make-local-variable 'org-agenda-overriding-cmd)
- (progn (push `(org-agenda-dych-overriding-start
- ,(org-agenda-dych-time-of-day-to-hh:mm
- (plist-get earliest-task :start)))
- (car (nthcdr 2 (org-get-at-bol 'org-series-cmd))))
- (org-get-at-bol 'org-series-cmd)))
- (setq org-agenda-dych-current-start
- (org-agenda-dych-time-of-day-to-hh:mm
- (plist-get earliest-task :start))))
- (user-error "Please schedule a task to start at %s" (org-agenda-dych-get-start))))
- (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)))
- (progn (org-agenda-dych-maybe-schedule nil (org-agenda-dych-get-start))
- (goto-line (plist-get earliest-task :line)) (org-agenda-dych-fix))
- (user-error "Please schedule a task to start at %s" (org-agenda-dych-get-start)))))))
- ;; TODO: Documentation
- (defun org-agenda-dych-recompute ()
- "Keep all effort estimates in the agenda equal."
- (interactive)
- (save-excursion
- (let ((block-start (goto-char (point-min)))
- (bound (point-max))
- (rigid-minute 0) (rigid-count 0)
- (effort-plists) (count 0)
- (neffort)
- (lrigid)
- (leffort))
- (org-agenda-dych-first-entry-check)
-
- (setq lrigid (org-agenda-dych-rigid-indicator
- (org-get-at-bol 'org-hd-marker) t))
- (setq leffort (or (org-get-at-bol 'effort-minutes) 0.0))
- (setq block-start (point))
-
- (while (text-property-search-forward 'fixed t)
- (when (org-agenda-dych-entry-eligible-p)
- (setq count 0)
- (setq rigid-count 0)
- (setq rigid-minute 0)
- (setq effort-plists
- (org-agenda-dych-map-entries
- (lambda ()
- (cl-incf count)
- (list
- :index (org-current-line)
- :effort (org-get-at-bol 'effort)
- :dotime (org-agenda-dych-get-dotime)
- :fixed (org-agenda-dych-fixed-indicator
- (org-get-at-bol 'org-hd-marker) t)
- :rigid (and (org-agenda-dych-rigid-indicator
- (org-get-at-bol 'org-hd-marker) t)
- (setq rigid-minute
- (string-to-number
- (calc-eval (format "%f+%f" rigid-minute
- (org-get-at-bol 'effort-minutes)))))
- (cl-incf rigid-count))))
- block-start
- (setq bound (save-excursion (org-agenda-dych-previous-item 1)
- (point-at-eol)))
- #'org-agenda-dych-entry-eligible-p))
-
- (setq neffort (abs (string-to-number
- (calc-eval (format "(%f-%f-%f)/%d"
- (or (ignore-errors
- (org-duration-to-minutes
- (and (not (eq (org-agenda-dych-get-dotime) 'time))
- (org-agenda-dych-get-dotime))))
- (org-agenda-dych-get-workhours))
- (org-duration-to-minutes
- (or (org-agenda-dych-get-dotime block-start)
- "0:00"))
- rigid-minute
- (- count rigid-count))))))
- (setq block-start (point))
-
- (save-excursion
- (dolist (pl effort-plists)
- (goto-line (plist-get pl :index))
- (if (and (plist-get pl :fixed)
- (string= (org-get-at-bol 'type) "scheduled"))
- (setq org-last-inserted-timestamp
- (org-agenda-dych-h:mm-to-full-ts
- (plist-get pl :dotime)))
- (let ((ntime (seconds-to-time
- (string-to-number
- (calc-eval (format "(%f*60)+%f"
- (if lrigid leffort neffort)
- (org-time-string-to-seconds
- (if org-last-inserted-timestamp
- (substring org-last-inserted-timestamp 1 -1)
- (org-agenda-dych-h:mm-to-full-ts (org-agenda-dych-get-start))))))))))
- (when (org-agenda-dych-time-less-than-tomorrow-p ntime)
- (org-agenda-dych-maybe-schedule
- nil (format-time-string (org-time-stamp-format t) ntime)))))
-
- (unless (org-entry-get (org-get-at-bol 'org-hd-marker) "TIME_ESTIMATE")
- (org-entry-put (org-get-at-bol 'org-hd-marker) "TIME_ESTIMATE" (plist-get pl :effort)))
- (setq lrigid (plist-get pl :rigid))
- (if (plist-get pl :rigid)
- (setq leffort (org-get-at-bol 'effort-minutes))
- (org-entry-put (org-get-at-bol 'org-hd-marker) "EFFORT" (org-duration-from-minutes
- neffort)))))))))
- (org-agenda-redo)
- (org-agenda-dych-mode 1))
- ;;; Agenda
- (defun org-agenda-dych-add-properties ()
- "Add FIXED and RIGID property to entries in agenda."
- (goto-char (point-min))
- (while (not (eobp))
- (forward-line 1)
- (when (org-get-at-bol 'org-marker)
- (put-text-property (point-at-bol) (point-at-eol) 'fixed (org-agenda-dych-fixed-indicator (org-get-at-bol 'org-marker) t))
- (put-text-property (point-at-bol) (point-at-eol) 'rigid (org-agenda-dych-rigid-indicator (org-get-at-bol 'org-marker) t)))))
- (add-hook 'org-agenda-finalize-hook #'org-agenda-dych-add-properties)
- ;;; Wrappers
- ;; Wrappers are necessary because I want the minor mode to be togglable.
- ;;;###autoload
- (defun org-agenda-dych (arg &optional time)
- "Like `org-agenda-schedule' but fixes the entry at point & recompute afterwards.
- When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-schedule'
- \\<org-agenda-mode-map> when the user presses '\\[org-agenda-schedule]' in the agenda buffer"
- (interactive "P")
- (org-agenda-schedule arg time)
- (org-agenda-dych-fix)
- (org-agenda-dych-recompute))
- ;;;###autoload
- (defun org-agenda-dych-begin-entry (&optional arg)
- "Schedule the current entry to now."
- (interactive "P")
- (org-agenda-schedule nil (format-time-string "%H:%M" (current-time)))
- (org-agenda-dych-fix)
- (org-agenda-clock-in arg)
- (org-agenda-dych-recompute))
- ;;;###autoload
- (defun org-agenda-dych-redo-all (&optional exhaustive)
- "Like `org-agenda-redo-all' but call `org-agenda-dych-recompute' afterwards.
- When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-redo-all'
- \\<org-agenda-mode-map> when the user presses '\\[org-agenda-redo-all]' in the agenda buffer"
- (interactive "P")
- (org-agenda-dych-recompute)
- (org-agenda-redo-all exhaustive)
- (org-agenda-dych-mode 1))
- ;;;###autoload
- (defun org-agenda-dych-set-effort ()
- "Like `org-agenda-set-effort' but call `org-agenda-dych-recompute' afterwards.
- When `org-agenda-dych-mode' is enabled, this command will replace `org-agenda-set-effort'
- \\<org-agenda-mode-map> when the user presses '\\[org-agenda-set-effort]' in the agenda buffer"
- (interactive)
- (org-agenda-set-effort)
- (org-agenda-dych-recompute))
- (provide 'org-agenda-dych-mode)
- ;;; org-agenda-dych-mode.el ends here
|