;;; chronometrist-statistics.el --- View statistics from timeclock.el data -*- lexical-binding: t; -*- ;;; Commentary: ;; (require 'chronometrist-common) (require 'chronometrist-timer) (require 'chronometrist-events) (require 'chronometrist-statistics-custom) ;; 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-date->time (date) "Convert DATE to a time value (see (info \"(elisp)Time of Day\")). DATE must be a list in the form (YEAR MONTH DAY)." (->> date (reverse) (apply #'encode-time 0 0 0))) (defun chronometrist-date-less-p (date1 date2) "Like `time-less-p' but for dates. Return t if DATE1 is less than DATE2. Both must be lists in the form (YEAR MONTH DAY)." (time-less-p (chronometrist-date->time date1) (chronometrist-date->time date2))) (defun chronometrist-statistics-count-active-days (project &optional table) "Return the number of days the user spent any time on PROJECT. 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 ((count 0) (table (if table table chronometrist-events))) (maphash (lambda (date events) (when (seq-find (lambda (event) (and (equal (elt event 0) "i") (equal (elt event 7) project))) events) (setq count (1+ count)))) table) count)) (defun chronometrist-project-events-in-day (project date) "Get events for PROJECT on DATE. DATE must be in the form (YEAR MONTH DAY). Returns a list of events, where each event is a vector in the form [CODE YEAR MONTH DAY HOURS MINUTES SECONDS PROJECT-OR-COMMENT]. This will not return correct results if TABLE contains records which span midnights. (see `chronometrist-events-clean')" (let ((save-next) (results)) (seq-do (lambda (event) (cond ((and (equal "i" (chronometrist-vfirst event)) (equal project (chronometrist-vlast event))) (->> event (list) (append results) (setq results)) (setq save-next t)) (save-next (->> event (list) (append results) (setq results)) (setq save-next nil)) (t nil))) (gethash date chronometrist-events)) results)) (defun chronometrist-events->time-list (events) "Convert EVENTS to a list of time values. EVENTS must be a list of vectors in the form [\"CODE\" YEAR MONTH DATE HOUR MINUTES SECONDS \"PROJECT-NAME-OR-COMMENT\"] (see `chronometrist-events' and `chronometrist-events-populate') For time value format, see (info \"(elisp)Time of Day\")." (--map (pcase it (`[,_ ,year ,month ,day ,h ,m ,s ,_] (encode-time s m h day month year))) events)) (defun chronometrist-time-list->sum-of-intervals (time-values) "From a list of start/end timestamps TIME-VALUES, get the total time interval. TIME-VALUES are a list of time values (see (info \"(elisp)Time of Day\")), assumed to be alternating start/end times. 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-values (->> (-partition 2 time-values) (--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-project-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-calendrical->date (date) "Convert calendrical information DATE to a date in the form (YEAR MONTH DAY). For input format, see (info \"(elisp)Time of Day\")." (-> date (-slice 3 6) (reverse))) (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))) timeclock-project-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" (chronometrist-statistics-format-date (plist-get chronometrist-statistics--ui-state :start)) (chronometrist-statistics-format-date (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-timeclock-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) (let* ((buffer (get-buffer-create chronometrist-statistics-buffer-name)) (today (decode-time)) (week-start (chronometrist-calendrical->date (chronometrist-report-previous-week-start today))) (week-end (chronometrist-date-op week-start #'+ 6))) (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 :end ,week-end))) (chronometrist-common-create-timeclock-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 (plist-get chronometrist-statistics--ui-state :start)) (end (plist-get chronometrist-statistics--ui-state :end))) (case (plist-get chronometrist-statistics--ui-state :mode) ('week (let* ((new-start (chronometrist-date-op start #'- (* 7 arg))) (new-end (chronometrist-date-op new-start #'+ 6))) (plist-put chronometrist-statistics--ui-state :start new-start) (plist-put chronometrist-statistics--ui-state :end 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 (plist-get chronometrist-statistics--ui-state :start)) (end (plist-get chronometrist-statistics--ui-state :end))) (case (plist-get chronometrist-statistics--ui-state :mode) ('week (let* ((new-start (chronometrist-date-op start #'+ (* 7 arg))) (new-end (chronometrist-date-op new-start #'+ 6))) (plist-put chronometrist-statistics--ui-state :start new-start) (plist-put chronometrist-statistics--ui-state :end 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