This repository has been archived on 2022-05-13. You can view files and clone it, but cannot push or open issues or pull requests.
chronometrist/chronometrist-statistics.el

373 lines
16 KiB
EmacsLisp

;;; chronometrist-statistics.el --- View statistics from timeclock.el data -*- lexical-binding: t; -*-
;;; Commentary:
;;
(require 'chronometrist-common)
(require 'chronometrist-time)
(require 'chronometrist-timer)
(require 'chronometrist-events)
(require 'chronometrist-statistics-custom)
(require 'chronometrist-migrate)
;; for each activity -
;; [x] days active - int (float percent)
;; - get days in range - count days on which worked on activity
;; [x] average time - HH:MM:SS
;; - get days in range - get time spent per day for activity in range - get the length and the total -> (/ (+ durations) length)
;; [ ] current streak - [int years, int months,] int days
;; - get days in range - calculate all streaks - see if last streak is on current day
;; [ ] last streak - [int years, int months,] int days
;; - get days in range - calculate all streaks - if current streak, return second-last streak, else return last streak
;; [ ] longest streak - [int years, int months,] int days
;; - get days in range - calculate all streaks - find longest streak
;; [ ] days since last active - int
;; ...where "range" is a week, a month, a year, the entire range of records, or an arbitrary date range
;; details!
;; for each activity, spent most time on doing X (where X is a
;; comment, assuming you use comments to detail what you did)
;; Really might need emacs-async for this...buttloads of big
;; calculations which will only get bigger as the timelog file grows,
;; and the more the activities, the more the calculations! I'm
;; visualizing the table loading gradually, field by field, like an
;; image in a browser.
;; TODO -
;; 1. [x] show dash instead of zero
;; 2. [x] percent for active days
;; 3. buttons
;; 4. [x] display date ranges in a nicer way
;; 5. month and year ranges
;; 6. totals for each column
;; 7. (maybe) jump between chronometrist-report and chronometrist-statistics for viewing the same week's data
;; - in chronometrist-statistics, this only makes sense in week mode
;; 8. a 'counter' - if I have ten weeks of data and I'm on the latest,
;; show 10/10; update this as we scroll
;; - don't scroll past the end, as currently happens
;; - also applicable to chronometrist-report
;; TODO - convert all functions which take dates as arguments to use
;; the (YEAR MONTH DAY) format
;;; Code:
(defun chronometrist-events->time-list (events)
"Convert EVENTS to a list of time values.
EVENTS must be a list of valid Chronometrist property lists (see `chronometrist-file').
For time value format, see (info \"(elisp)Time of Day\")."
(let ((index 0)
(length (length events))
result)
(while (not (= index length))
(let* ((elt (elt events index))
(start-iso (parse-iso8601-time-string (plist-get elt :start)))
(stop (plist-get elt :stop))
(stop-iso (if stop
(parse-iso8601-time-string stop)
(current-time))))
(incf index)
(setq result (append result `((,start-iso ,stop-iso))))))
result))
(defun chronometrist-time-list->sum-of-intervals (time-value-lists)
"From a list of start/end timestamps TIME-VALUES, get the total time interval.
TIME-VALUE-LISTS is a list in the form
\((START STOP) ...)
where START and STOP are time values (see (info \"(elisp)Time of Day\")).
This function obtains the intervals between them, and adds the
intervals to return a single time value.
If TIME-VALUES is nil, return '(0 0)."
(if time-value-lists
(->> time-value-lists
(--map (time-subtract (cadr it) (car it)))
(-reduce #'time-add))
'(0 0)))
(defun chronometrist-statistics-count-average-time-spent (project &optional table)
"Return the average time the user has spent on PROJECT from TABLE.
TABLE must be a hash table - if not supplied,
`chronometrist-events' is used.
This will not return correct results if TABLE contains records
which span midnights. (see `chronometrist-events-clean')"
(let ((table (if table table chronometrist-events))
(days 0)
(per-day-time-list))
(maphash (lambda (key value)
(let ((events-in-day (chronometrist-task-events-in-day project key)))
(when events-in-day
(setq days (1+ days))
(->> events-in-day
(chronometrist-events->time-list)
(chronometrist-time-list->sum-of-intervals)
(list)
(append per-day-time-list)
(setq per-day-time-list)))))
table)
(if per-day-time-list
(--> per-day-time-list
(-reduce #'time-add it)
(cadr it)
(/ it days))
0)))
;; ## VARIABLES ##
(defvar chronometrist-statistics--ui-state nil
"Stores the display state for `chronometrist-statistics'.
This must be a plist in the form (:MODE :START :END).
:MODE is either 'week, 'month, 'year, 'full, or 'custom. 'week,
'month, and 'year mean display statistics weekly/monthly/yearly
respectively.
'full means display statistics from the beginning to the end of
the `timeclock-file'.
'custom means display statistics from an arbitrary date range.
:START and :END are the start and end of the date range to be
displayed. They must be dates in the form (YEAR MONTH DAY).")
(defvar chronometrist-statistics--point nil)
;; ## FUNCTIONS ##
(defun chronometrist-statistics-entries-internal (table)
"Helper function for `chronometrist-statistics-entries'.
It simply operates on the entire hash table TABLE (see
`chronometrist-events' for table format), so ensure that TABLE is
reduced to the desired range using
`chronometrist-events-subset'."
(mapcar (lambda (project)
(let* ((active-days (chronometrist-statistics-count-active-days project table))
(active-percent (case (plist-get chronometrist-statistics--ui-state :mode)
('week (* 100 (/ active-days 7.0)))))
(active-percent (if (zerop active-days)
(format " % 6s" "-")
(format " %05.2f%%" active-percent)))
(active-days (format "% 5s"
(if (zerop active-days)
"-"
active-days)))
(average-time (->> (chronometrist-statistics-count-average-time-spent project table)
(chronometrist-seconds-to-hms)
(chronometrist-format-time)
(format "% 5s")))
(content (vector project
active-days
active-percent
average-time)))
(list project content)))
chronometrist-task-list))
(defun chronometrist-statistics-entries ()
"Create entries to be displayed in the buffer created by `chronometrist-statistics'."
;; We assume that all fields in `chronometrist-statistics--ui-state' are set, so they must
;; be changed by the view-changing functions.
(case (plist-get chronometrist-statistics--ui-state :mode)
('week
(let* ((start (plist-get chronometrist-statistics--ui-state :start))
(end (plist-get chronometrist-statistics--ui-state :end))
(table (chronometrist-events-subset start end)))
(chronometrist-statistics-entries-internal table)))
(t ;; `chronometrist-statistics--ui-state' is nil, show current week's data
(let* ((start-long (chronometrist-report-previous-week-start (decode-time)))
(end-long (chronometrist-date-op start-long '+ 7))
(start (chronometrist-calendrical->date start-long))
(end (chronometrist-calendrical->date end-long))
(table (chronometrist-events-subset start end)))
(setq chronometrist-statistics--ui-state `(:mode week :start ,start :end ,end))
(chronometrist-statistics-entries-internal table)))))
(defun chronometrist-statistics-print-keybind (command &optional description firstonly)
"Insert the keybindings for COMMAND.
If DESCRIPTION is non-nil, insert that too.
If FIRSTONLY is non-nil, return only the first keybinding found."
(insert "\n "
(chronometrist-format-keybinds command
chronometrist-statistics-mode-map
firstonly)
" - "
(if description description "")))
(defun chronometrist-statistics-format-date (date)
"Return DATE (YEAR MONTH DAY) as a string in the form \"YYYY-MM-DD\"."
(-let [(year month day) date]
(format "%04d-%02d-%02d" year month day)))
(defun chronometrist-statistics-print-non-tabular ()
"Print the non-tabular part of the buffer in `chronometrist-statistics'."
(let ((w "\n ")
(inhibit-read-only t))
(goto-char (point-max))
(insert w)
(insert-text-button (case (plist-get chronometrist-statistics--ui-state :mode)
('week "Weekly view"))
;; 'action #'chronometrist-report-previous-week ;; TODO - make interactive function to accept new mode from user
'follow-link t)
(insert ", from")
(insert
(format " %s to %s\n"
(plist-get chronometrist-statistics--ui-state :start)
(plist-get chronometrist-statistics--ui-state :end)))))
(defun chronometrist-statistics-refresh (&optional ignore-auto noconfirm)
"Refresh the `chronometrist-statistics' buffer, without re-reading `timeclock-file'.
The optional arguments IGNORE-AUTO and NOCONFIRM are ignored, and
are present solely for the sake of using this function as a value
of `revert-buffer-function'."
(let* ((w (get-buffer-window chronometrist-statistics-buffer-name t))
(p (point)))
(with-current-buffer chronometrist-statistics-buffer-name
(tabulated-list-print t nil)
(chronometrist-statistics-print-non-tabular)
(chronometrist-maybe-start-timer)
(set-window-point w p))))
;; ## MAJOR MODE ##
(defvar chronometrist-statistics-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "l") #'chronometrist-open-file)
(define-key map (kbd "b") #'chronometrist-statistics-previous-range)
(define-key map (kbd "f") #'chronometrist-statistics-next-range)
map)
"Keymap used by `chronometrist-statistics-mode'.")
(define-derived-mode chronometrist-statistics-mode tabulated-list-mode "Chronometrist-Statistics"
"Major mode for `chronometrist-statistics'."
(timeclock-reread-log)
(make-local-variable 'tabulated-list-format)
(setq tabulated-list-format
[("Project" 25 t)
("Active days" 12 t)
("%% of days active" 17 t)
("Average time" 12 t)
;; ("Current streak" 10 t)
;; ("Last streak" 10 t)
;; ("Longest streak" 10 t)
])
(make-local-variable 'tabulated-list-entries)
(setq tabulated-list-entries 'chronometrist-statistics-entries)
(make-local-variable 'tabulated-list-sort-key)
(setq tabulated-list-sort-key '("Project" . nil))
(tabulated-list-init-header)
;; (chronometrist-maybe-start-timer)
(setq revert-buffer-function #'chronometrist-statistics-refresh)
(file-notify-add-watch timeclock-file '(change) #'chronometrist-refresh-file))
;; ## COMMANDS ##
;;;###autoload
(defun chronometrist-statistics (&optional preserve-state)
"Display statistics based on data from the user's `timeclock-file'.
This is the 'listing command' for `chronometrist-statistics-mode'.
If a buffer called `chronometrist-statistics-buffer-name' already
exists and is visible, kill the buffer.
If PRESERVE-STATE is nil (the default when not supplied), display
data from the current week. Otherwise, display data from the week
specified by `chronometrist-statistics--ui-state'."
(interactive)
(chronometrist-migrate-check)
(let* ((buffer (get-buffer-create chronometrist-statistics-buffer-name))
(today (chronometrist-date))
(week-start (chronometrist-report-previous-week-start today))
(week-end (time-add week-start
`(0 ,(* 6 chronometrist-seconds-in-day))))
(week-start-iso (chronometrist-date week-start))
(week-end-iso (chronometrist-date week-end)))
(with-current-buffer buffer
(cond ((get-buffer-window chronometrist-statistics-buffer-name)
(kill-buffer buffer))
(t ;; (delete-other-windows)
(when (not preserve-state)
(setq chronometrist-statistics--ui-state `(:mode week
:start ,week-start-iso
:end ,week-end-iso)))
(chronometrist-common-create-chronometrist-file)
(chronometrist-statistics-mode)
(switch-to-buffer buffer)
(chronometrist-statistics-refresh))))))
(defun chronometrist-statistics-previous-range (arg)
"View the statistics in the previous time range.
If ARG is a numeric argument, go back that many times."
(interactive "P")
(let* ((arg (if (and arg (numberp arg))
(abs arg)
1))
(start-unix (->> (plist-get chronometrist-statistics--ui-state :start)
(chronometrist-iso-date->timestamp)
(parse-iso8601-time-string)))
(end-unix (->> (plist-get chronometrist-statistics--ui-state :end)
(chronometrist-iso-date->timestamp)
(parse-iso8601-time-string))))
(case (plist-get chronometrist-statistics--ui-state :mode)
('week
(let* ((new-start (time-subtract start-unix
(* 7 chronometrist-seconds-in-day arg)))
(new-end (time-add new-start
(* 6 chronometrist-seconds-in-day))))
(plist-put chronometrist-statistics--ui-state
:start
(chronometrist-date new-start))
(plist-put chronometrist-statistics--ui-state
:end
(chronometrist-date new-end)))))
(setq chronometrist-statistics--point (point))
(kill-buffer)
(chronometrist-statistics t)))
(defun chronometrist-statistics-next-range (arg)
"View the statistics in the next time range.
If ARG is a numeric argument, go forward that many times."
(interactive "P")
(let* ((arg (if (and arg (numberp arg))
(abs arg)
1))
(start-unix (->> (plist-get chronometrist-statistics--ui-state :start)
(chronometrist-iso-date->timestamp)
(parse-iso8601-time-string)))
(end-unix (->> (plist-get chronometrist-statistics--ui-state :end)
(chronometrist-iso-date->timestamp)
(parse-iso8601-time-string))))
(case (plist-get chronometrist-statistics--ui-state :mode)
('week
(let* ((new-start (time-add start-unix
(* 7 chronometrist-seconds-in-day arg)))
(new-end (time-add new-start
(* 6 chronometrist-seconds-in-day))))
(plist-put chronometrist-statistics--ui-state
:start
(chronometrist-date new-start))
(plist-put chronometrist-statistics--ui-state
:end
(chronometrist-date new-end)))))
(setq chronometrist-statistics--point (point))
(kill-buffer)
(chronometrist-statistics t)))
;; Local Variables:
;; nameless-current-name: "chronometrist-statistics"
;; End:
(provide 'chronometrist-statistics)
;;; chronometrist-statistics.el ends here