Include computation of already spent work time in overview report

This change is an experimental, as quite a bit of code had to be
touched.  Further testing and bug hunting will be necessary.

Workload overview reports now only allow increments of one day
(`+1d`), since the current implementation cannot accomodate for arbitray
time ranges anymore (my fault, sorry).  Also, the default start time for
workload overview reports has been changed to 23:59 on the previous
day (`"-1d 23:59"`) to include the current day in the report.  The
rational behind this is that since the remaining work hours of today are
now included in the computation for the report, showing the entry for
today will show an accurate estimation of the remaining work time in
contrast to a constant value.

There are now additional parameters named `:work-hours` and
`:work-items-match` that specify how much work time is available on a
day and which Org items are considered work, respectively.
`:work-hours` can also take a function to compute for a given date how
much time can be spent on work.  An example could be something like
this:

```emacs-lisp
(defun db/get-worktime-for-date (date)
  "Return planned working time for DATE."
  ;; This is a simplification, as `date' might be the start of the day or the end.
  (cond
   ((string-match-p "Sa" date) "6:00")
   ((string-match-p "So\\|Sun" date) "4:00")
   ((string-match-p "Wed\\|Mi" date) "3:00")
   (t "2:00")))
```

An example report is then the following:

```
|               End Time | Planned Work | Work Hours | Utilization |
|                    <r> |          <r> |        <r> |         <r> |
|------------------------+--------------+------------+-------------|
| [2024-10-06 Sun 23:59] |         1:00 |       1:00 |   *100.00%* |
| [2024-10-07 Mon 23:59] |         4:15 |       3:00 |   *141.67%* |
| [2024-10-08 Tue 23:59] |         7:00 |       5:00 |   *140.00%* |
| [2024-10-09 Wed 23:59] |         9:15 |       8:00 |   *115.62%* |
| [2024-10-10 Thu 23:59] |        12:30 |      10:00 |   *125.00%* |
| [2024-10-11 Fri 23:59] |        13:15 |      12:00 |   *110.42%* |
| [2024-10-12 Sat 23:59] |        19:30 |      18:00 |   *108.33%* |
| [2024-10-13 Sun 23:59] |      1d 1:30 |      22:00 |   *115.91%* |
| [2024-10-14 Mon 23:59] |      1d 1:45 |    1d 0:00 |   *107.29%* |
| [2024-10-15 Tue 23:59] |      1d 2:30 |    1d 2:00 |   *101.92%* |
|------------------------+--------------+------------+-------------|
```
This commit is contained in:
Daniel Borchmann 2024-10-06 15:16:41 +02:00
parent 344dde2821
commit a67e64a906
No known key found for this signature in database
GPG Key ID: 784AA8DF0CCDF625

View File

@ -707,34 +707,32 @@ everything understood by `org-read-date'."
(defun org-dblock-write:db/org-workload-overview-report (params) (defun org-dblock-write:db/org-workload-overview-report (params)
"Write an overview workload report for all tasks. "Write an overview workload report for all tasks.
Tasks are read from files in the variable `org-agenda-files'.
This overview report will list the amount of work planned for This overview report will list the amount of work planned for
increasing intervals of time until a given end date is reached. increasing intervals from a fixed start time until a fixed end
For example, if the amount to increase the intervals is two date, devided in intervals of fixed increment. The intervals are
days (+2d) and the report is meant to start from today (.), then determined in increments of +1d. Tasks are read from files in
this report will list the total amount of work planned for the the variable `org-agenda-files'.
days .+2d, .+4d, .+6d, until the end date is reached.
Next to the planned work, the accumulated available work hours
are displayed until the end of each interval, as well as the
utilization for each interval. The available work hours are
computed by the value of `:work-hours' as described below. Hours
already spent are subtracted from this value as computed as sum
of the clock time of all Org items as given by
`:work-items-match'.
PARAMS is a property list of the following parameters: PARAMS is a property list of the following parameters:
`:start-date': `:start-date':
Start date for the workload report. When not provided, will Start date for the workload report. When not provided, will
default to today at 00:00. When provided, must be in a format default to yesterday at 23:59. When provided, must be in a format
understood by `org-read-date'. understood by `org-read-date'.
`:end-date': `:end-date':
End date of the workload report. Must be provided and provided End date of the workload report. Must be provided, in a format
in a format understood by `org-read-date'. The end date is understood by `org-read-date'. The end date is inclusive.
inclusive.
`:increment':
Amount of days to increase the intervals. Defaults to \"+1d\"
and must be provided in a format understandable by
`org-read-date'.
`:org-ql-match' `:org-ql-match'
@ -753,16 +751,24 @@ PARAMS is a property list of the following parameters:
`:work-hours' `:work-hours'
The time available per day for work, given as duration string. The time available per day for work, given as duration string,
Defauls to \"8:00\"." defauling to \"8:00\". This can also be a function that
(let* ((start-date (org-read-date t t returns the work hours for a given date. In this case, this
(or (plist-get params :start-date) function receives the date formatted as an Org time stamp with
"00:00"))) time and without brackets.
`:work-items-match'
An Org properties match that determines what constitutes when
an Org item should be considered work and for which time
spent today should be substracted from the available work hours."
(let* ((start-date (org-read-date t t (or (plist-get params :start-date)
"-1d 23:59")))
(end-date (or (--if-let (plist-get params :end-date) (end-date (or (--if-let (plist-get params :end-date)
(org-read-date t t it)) (org-read-date t t it))
(user-error "No valid end-date provided"))) (user-error "No valid end-date provided")))
(increment (or (plist-get params :increment) (increment "+1d")
"+1d"))
(org-ql-match (or (plist-get params :org-ql-match) (org-ql-match (or (plist-get params :org-ql-match)
'(todo))) '(todo)))
(timestamp-format "%Y-%m-%d %a %H:%M") (timestamp-format "%Y-%m-%d %a %H:%M")
@ -772,23 +778,27 @@ PARAMS is a property list of the following parameters:
#'(lambda (x) (string-match arg x))) #'(lambda (x) (string-match arg x)))
((and (pred functionp) fun) fun) ((and (pred functionp) fun) fun)
(arg (user-error "Invalid argument to :skip-matches: %s" arg)))) (arg (user-error "Invalid argument to :skip-matches: %s" arg))))
(work-items-match (plist-get params :work-items-match))
(work-hours (pcase (plist-get params :work-hours) (work-hours (pcase (plist-get params :work-hours)
((pred null) #'(lambda (_) "8:00")) ((pred null)
#'(lambda (date)
(db/remaining-work-hours-for-date date work-items-match "8:00")))
((and (pred org-duration-p) arg) ((and (pred org-duration-p) arg)
#'(lambda (_) arg)) #'(lambda (date)
((and (pred functionp) fun) fun) (db/remaining-work-hours-for-date date work-items-match arg)))
((and (pred functionp) fun)
#'(lambda (date)
(db/remaining-work-hours-for-date
date
work-items-match
(funcall fun (format-time-string (org-time-stamp-format t 'no-brackets)
date)))))
(arg (user-error "Invalid argument to :work-hours: %s" arg)))) (arg (user-error "Invalid argument to :work-hours: %s" arg))))
date-range) date-range)
;; Check input ;; Compute range of dates to check; simple but potentially costly approach taken from
;; https://sachachua.com/blog/2015/08/org-mode-date-arithmetic/; maybe consider
(unless (string-match-p (rx bos "+" (+ digit) (in "dwmy") eos) ;; `org-read-date-get-relative' as well?
increment)
(user-error "Increment must be of the form +1d, +2m, +3y, …, but it's %s" increment))
;; Compute range of dates to check; simple but potentially costly approach
;; taken from https://sachachua.com/blog/2015/08/org-mode-date-arithmetic/;
;; maybe consider `org-read-date-get-relative' as well?
(let ((current start-date) (let ((current start-date)
current-formatted) current-formatted)
(while (or (time-less-p current end-date) (while (or (time-less-p current end-date)
@ -800,34 +810,33 @@ PARAMS is a property list of the following parameters:
nil current) nil current)
current-formatted (format-time-string timestamp-format current)) current-formatted (format-time-string timestamp-format current))
(unless (funcall skip-date-p current-formatted) (unless (funcall skip-date-p current-formatted)
(push current-formatted date-range)))) (push (cons current current-formatted) date-range))))
(setq date-range (nreverse (cdr date-range))) (setq date-range (nreverse (cdr date-range)))
(insert (format "#+CAPTION: Workload Overview Report at [%s] with start date [%s]\n" (insert (format "#+CAPTION: Workload Overview Report at [%s] with start date [%s]\n"
(format-time-string timestamp-format (current-time)) (format-time-string timestamp-format (current-time))
(format-time-string timestamp-format start-date))) (format-time-string timestamp-format start-date)))
(insert "| End Time | Planned Work | Work Hours | Utilization |\n| <r> | <r> | <r> | <r> |\n|---|\n") (insert "| End Time | Planned Work | Work Hours | Utilization |\n| <r> | <r> | <r> | <r> |\n|---|\n")
;; Compute workload report for each date and record the total time;
;; Compute workload report for each date and record the total time
(let ((total-work-hours 0)) (let ((total-work-hours 0))
(dolist (interval-end-date date-range) (pcase-dolist (`(,interval-end-date . ,interval-end-date-formatted) date-range)
;; XXX: repeatedly calling `db/org-planned-tasks-in-range' is a waste of resources
(let* ((total-time-duration (car (let* ((total-time-duration (car
;; XXX: repeatedly calling `db/org-planned-tasks-in-range might ;; Set start date to nil to also include tasks scheduled or
;; be slow, try to reduce the numer of calls ;; deadlined before `start-date', as those are also still open
(db/org-planned-tasks-in-range ;; and need to be done somewhen.
;; Set start date to nil to also include tasks scheduled or (db/org-planned-tasks-in-range nil
;; deadlined before `start-date', as those are also still open interval-end-date-formatted
;; and need to be done somewhen. org-ql-match))))
nil
interval-end-date (cl-incf total-work-hours (org-duration-to-minutes (funcall work-hours interval-end-date)))
org-ql-match))))
(cl-incf total-work-hours
(org-duration-to-minutes
(funcall work-hours interval-end-date)))
(insert (format "| [%s] | %s | %s | %s |\n" (insert (format "| [%s] | %s | %s | %s |\n"
interval-end-date interval-end-date-formatted
total-time-duration total-time-duration
(org-duration-from-minutes total-work-hours) (org-duration-from-minutes total-work-hours)
;; XXX: the following code might better to into a spearate function to ;; XXX: the following code might better go to a spearate function to
;; increase comprehension ;; increase comprehension
(let* ((total-time-minutes (org-duration-to-minutes total-time-duration))) (let* ((total-time-minutes (org-duration-to-minutes total-time-duration)))
(cond (cond
@ -838,6 +847,7 @@ PARAMS is a property list of the following parameters:
;; When utilization is above 80%, mark entry in bold ;; When utilization is above 80%, mark entry in bold
(format "*%.2f%%*" utilization) (format "*%.2f%%*" utilization)
(format "%.2f%%" utilization))))))))))) (format "%.2f%%" utilization)))))))))))
(insert "|--|") (insert "|--|")
(org-table-align))) (org-table-align)))
@ -846,40 +856,61 @@ PARAMS is a property list of the following parameters:
(interactive) (interactive)
(org-create-dblock (org-create-dblock
(list :name "db/org-workload-overview-report" (list :name "db/org-workload-overview-report"
:end-date (org-read-date nil nil nil "End date: ") :end-date (org-read-date nil nil nil "End date: ")))
:increment (read-string "Increment (default: +1d): " nil nil "+1d")))
(org-update-dblock)) (org-update-dblock))
(org-dynamic-block-define "db/org-workload-overview-report" (org-dynamic-block-define "db/org-workload-overview-report"
#'db/org-insert-workload-overview-report) #'db/org-insert-workload-overview-report)
(defun db/spent-work-hours-today (&optional match) (defun db/spent-work-hours-on-date (date match)
"Return the time spent today on work. "Return the work time spent on DATE.
Relevant clock times are taken from the file in DATE must be given in a format understood by
`org-agenda-files'. The result returned is given as integer in `format-time-string'. The return value is the number of minutes
minutes. clocked in on items on DATE from the files in `org-agenda-files'
that satisfy MATCH. MATCH is a Org properties match expression."
MATCH is a Org properties match that determines what constitutes ;; Code inspired by `org-dblock-write:clocktable'.
as work item. When not given, defaults to all tasks except those
given by `org-home-task-id' and `org-break-task-id'."
(unless match
(when (and (stringp org-home-task-id)
(not (zerop (length org-home-task-id))))
(setq match (concat match (format "-ID=%s" org-home-task-id))))
(when (and (stringp org-break-task-id)
(not (zerop (length org-break-task-id))))
(setq match (concat match (format "-ID=%s" org-break-task-id)))))
(->> org-agenda-files (->> org-agenda-files
(-map #'(lambda (file) ; taken from `org-dblock-write:clocktable' (-map #'(lambda (file)
(with-current-buffer (find-buffer-visiting file) (with-current-buffer (find-buffer-visiting file)
(save-excursion (save-mark-and-excursion
(save-restriction (save-restriction
(org-clock-get-table-data file (list :match match (org-clock-get-table-data file
:block 'today))))))) (list :match match
:tstart (format-time-string "%Y-%m-%d 00:00" date)
:tend (format-time-string "%Y-%m-%d 00:00" (+ 86400.0 (float-time date))))))))))
(-map #'cl-second) (-map #'cl-second)
-sum)) -sum))
(defun db/remaining-work-hours-for-date (date work-items-match default-work-hours)
"Return time left for work on DATE.
DATE must be given in a format understood by
`format-time-string'. The return value is an Org duration
string.
The available work time is as given by DEFAULT-WORK-HOURS as Org
duration string. The time already spent on work is computed from
the clock time on DATE (from midnight to midnight) of all items
matched by WORK-ITEMS-MATCH. This time already spent is
subtracted from the available time to yield the final result. If
the result is negative, \"0:00\" is returned.
As an optimization, if DATE is in the future at least one day
from now, the time spent on work is assumed to be zero."
(let ((allotted-time (if (org-duration-p default-work-hours)
default-work-hours
(user-error "Invalid value for `default-work-hours' given: %s"
default-work-hours)))
;; If is date is in the future, don't bother computing spent time.
(worked-today (if (>= (float-time date)
(+ 86400.0 (float-time (current-time))))
0
(db/spent-work-hours-on-date date work-items-match))))
(org-duration-from-minutes (max (- (org-duration-to-minutes allotted-time)
worked-today)
0))))
;;; Fixes ;;; Fixes