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.el

470 lines
19 KiB
EmacsLisp
Raw Normal View History

2019-08-09 06:19:26 +00:00
;;; chronometrist.el --- A time tracker for Emacs with a nice interface, built timeclock.el -*- lexical-binding: t; -*-
2019-07-29 10:59:17 +00:00
2019-04-17 18:21:01 +00:00
(require 'filenotify)
2018-11-02 01:59:24 +00:00
(require 'chronometrist-common)
2019-04-08 16:40:26 +00:00
(require 'chronometrist-timer)
(require 'chronometrist-custom)
(require 'chronometrist-report)
2018-11-04 17:40:42 +00:00
(require 'chronometrist-statistics)
2018-08-27 07:56:04 +00:00
2019-08-04 19:27:22 +00:00
;;; Commentary:
;;
2019-01-08 08:03:12 +00:00
;; modifiers to toggling -
;; Nth task
;; reason (ask on start/ask on end/don't ask on end)
;; run/don't run hooks (maybe there should be a function to toggle this)
2018-08-28 03:47:35 +00:00
;; Style issues
2018-09-01 12:31:25 +00:00
;; 1. Uses Scheme-style ? and x->y naming conventions instead of
;; Elisp/CL-style "-p" and "x-to-y"
2018-09-01 12:31:25 +00:00
;; - ido uses ? for 'completion help', so you can't type ? unless
;; you unset that o\
;; 2. Should use *earmuffs* for global variables for clarity
;; 3. Should names of major modes (chronometrist-mode,
;; chronometrist-report-mode) end with -major-mode ?
;; Limitations of timeclock.el
;; 1. Concurrent tasks not permitted
;; 2. timeclock-project-list contains only the projects found in the
;; timeclock-file - no way for a user to specify tasks beforehand.
2018-09-01 12:31:25 +00:00
;; 3. Uses non-standard slashes in the date instead of dashes (e.g.
;; "2018/01/01" instead of "2018-01-01") and a space for the
;; date-time separator instead of T
;; Limitations of tabulated-list-mode
;; 1. Can't mix tabulated and non-tabulated data!!! What if I want
;; some buttons, separate from the data but part of the same
;; buffer?!
;; - adding non-tabular data after calling `tabulated-list-print' -
;; as we do - works, but is hacky and doesn't always print (e.g.
;; it vanishes when you sort). Then, you have to ensure you call
;; it after each time you call `tabulated-list-print' :\
;; - a post-print hook could help
;; - maybe use advice?
;; 2. Can't have multi-line headers
;; 3. Can't have multiple tables in a buffer
2018-09-01 12:31:25 +00:00
;; ## VARIABLES ##
2019-07-29 10:59:17 +00:00
;;; Code:
(defvar chronometrist--timer-object nil)
(defvar chronometrist--project-history nil)
2018-10-03 19:23:37 +00:00
(defvar chronometrist--point nil)
2018-09-01 12:31:25 +00:00
;; ## FUNCTIONS ##
(defun chronometrist-current-task ()
"Return the name of the currently clocked-in task, or nil if not clocked in."
(let ((last-event (gethash (hash-table-count chronometrist-events)
chronometrist-events)))
(if (plist-member last-event :stop)
nil
(plist-get last-event :name))))
(defun chronometrist-project-active? (project)
2018-09-03 05:25:25 +00:00
"Return t if PROJECT is currently clocked in, else nil."
(equal (chronometrist-current-task) project))
2018-08-27 07:56:04 +00:00
(defun chronometrist-seconds-to-hms (seconds)
2019-07-29 10:59:17 +00:00
"Convert SECONDS to a vector in the form [HOURS MINUTES SECONDS].
SECONDS must be a positive integer."
2018-08-27 07:58:35 +00:00
(setq seconds (truncate seconds))
(let* ((s (% seconds 60))
(m (% (/ seconds 60) 60))
(h (/ seconds 3600)))
(vector h m s)))
2018-08-27 07:56:04 +00:00
(defun chronometrist-entries ()
"Create entries to be displayed in the buffer created by `chronometrist', in the format specified by `tabulated-list-entries'."
2019-09-05 18:57:48 +00:00
;; HACK - these calls are commented out, because `chronometrist-entries' is
;; called by both `chronometrist-refresh' and `chronometrist-refresh-file', and only the
;; latter should refresh from a file.
;; (timeclock-reread-log)
;; (chronometrist-events-populate)
;; (chronometrist-events-clean)
2018-09-02 10:55:25 +00:00
(->> timeclock-project-list
(-sort #'string-lessp)
(--map-indexed
(list it
(vector (number-to-string (1+ it-index))
(list it
'action 'chronometrist-toggle-project-button
'follow-link t)
(-> (chronometrist-project-time-one-day it)
(chronometrist-format-time))
(if (chronometrist-project-active? it)
"*" ""))))))
2018-09-11 12:27:34 +00:00
(defun chronometrist-project-at-point ()
2019-08-06 11:36:38 +00:00
"Return the project at point in the `chronometrist' buffer, or nil if there is no project at point."
2018-09-02 10:55:25 +00:00
(save-excursion
(beginning-of-line)
(if (re-search-forward "[0-9]+ +" nil t)
(--> (buffer-substring-no-properties
(point)
(progn
(re-search-forward chronometrist-time-re-ui nil t)
(match-beginning 0)))
(replace-regexp-in-string "[ \t]*$" "" it))
nil)))
2018-09-02 05:45:46 +00:00
(defun chronometrist-goto-last-project ()
2019-08-06 11:36:38 +00:00
"In the `chronometrist' buffer, move point to the line containing the last active project."
(goto-char (point-min))
(re-search-forward timeclock-last-project nil t)
(beginning-of-line))
(defun chronometrist-time-add (a b)
2019-08-06 11:36:38 +00:00
"Add two vectors A and B in the form [HOURS MINUTES SECONDS] and return a vector in the same form."
(let ((h1 (elt a 0))
(m1 (elt a 1))
(s1 (elt a 2))
(h2 (elt b 0))
(m2 (elt b 1))
(s2 (elt b 2)))
(chronometrist-seconds-to-hms (+ (* h1 3600) (* h2 3600)
(* m1 60) (* m2 60)
s1 s2))))
(defun chronometrist-total-time-one-day (&optional date)
2019-08-06 11:36:38 +00:00
"Return the total active time on DATE (if non-nil) or today.
Return value is a vector in the form [HOURS MINUTES SECONDS].
DATE must be calendrical information calendrical
information (see (info \"(elisp)Time Conversion\"))."
(->> timeclock-project-list
(--map (chronometrist-project-time-one-day it date))
(-reduce #'chronometrist-time-add)))
(defun chronometrist-print-keybind (command &optional description firstonly)
2019-08-06 11:36:38 +00:00
"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"
(format "% 18s - %s"
2019-08-09 02:05:47 +00:00
(chronometrist-format-keybinds command
chronometrist-mode-map
firstonly)
(if description description ""))))
(defun chronometrist-print-non-tabular ()
"Print the non-tabular part of the buffer in `chronometrist'."
(with-current-buffer chronometrist-buffer-name
(let ((inhibit-read-only t)
(w "\n ")
2019-08-09 02:05:47 +00:00
(keybind-start-new (chronometrist-format-keybinds 'chronometrist-add-new-project
chronometrist-mode-map))
(keybind-toggle (chronometrist-format-keybinds 'chronometrist-toggle-project
chronometrist-mode-map
t)))
(goto-char (point-max))
(-->
(chronometrist-total-time-one-day)
(chronometrist-format-time it)
(format "%s%- 26s%s" w "Total" it)
(insert it))
(insert "\n")
(insert w (format "% 17s" "Keys")
w (format "% 17s" "----"))
(chronometrist-print-keybind 'chronometrist-add-new-project)
(insert-text-button "start a new project"
'action #'chronometrist-add-new-project-button
'follow-link t)
(chronometrist-print-keybind 'chronometrist-toggle-project
"toggle project at point")
(chronometrist-print-keybind 'chronometrist-toggle-project-no-reason
"toggle without asking for reason")
(insert "\n " (format "%s %s - %s"
"<numeric argument N>"
keybind-toggle
"toggle <N>th project"))
(chronometrist-print-keybind 'chronometrist-report)
(insert-text-button "see weekly report"
'action #'chronometrist-report
'follow-link t)
(chronometrist-print-keybind 'chronometrist-open-timeclock-file)
(insert-text-button "open log file"
'action #'chronometrist-open-timeclock-file
2019-01-08 08:03:54 +00:00
'follow-link t)
(insert "\n"))))
(defun chronometrist-goto-nth-project (n)
2019-08-06 11:36:38 +00:00
"Move point to the line containing the Nth project.
Return the project at point, or nil if there is no corresponding
project. N must be a positive integer."
(goto-char (point-min))
(when (re-search-forward (format "^%d" n) nil t)
(beginning-of-line)
(chronometrist-project-at-point)))
(defun chronometrist-refresh (&optional ignore-auto noconfirm)
"Refresh the `chronometrist' 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-buffer-name t))
(p (window-point w)))
(with-current-buffer chronometrist-buffer-name
(tabulated-list-print t nil)
(chronometrist-print-non-tabular)
(chronometrist-maybe-start-timer)
(set-window-point w p))))
(defun chronometrist-refresh-file (fs-event)
"Re-read `timeclock-file' and refresh the `chronometrist' buffer.
Argument FS-EVENT is ignored."
(chronometrist-events-populate)
(chronometrist-events-clean)
(timeclock-reread-log)
(chronometrist-refresh))
2019-08-06 11:36:38 +00:00
;; HACK - has some duplicate logic with `chronometrist-project-events-in-day'
(defun chronometrist-reason-list (project)
"Filters `timeclock-reason-list' to only return reasons for PROJECT."
(let (save-next results)
(maphash (lambda (date events)
(seq-do (lambda (event)
(cond ((and (equal "i" (chronometrist-vfirst event))
(equal project (chronometrist-vlast event)))
(setq save-next t))
(save-next
(->> (chronometrist-vlast event)
(list)
(append results)
(setq results))
(setq save-next nil))
(t nil)))
events))
chronometrist-events)
(->> results
;; the order of reverse and seq-uniq is important, so that
;; the most recent reasons come first in the history
(reverse)
(seq-uniq)
(seq-remove (lambda (elt)
(equal elt ""))))))
(defun chronometrist-ask-for-reason ()
2019-08-06 11:36:38 +00:00
"Replacement for `timeclock-ask-for-reason'.
Uses `read-from-minibuffer' instead of `completing-read'. \(see
`timeclock-get-reason-function')
Additionally, it uses `chronometrist-reason-list' to only suggest
reasons used for the relevant project, instead of all reasons as
in `timeclock-reason-list'."
(setq chronometrist--project-history (chronometrist-reason-list timeclock-last-project))
(read-from-minibuffer "Reason for clocking out (optional): " nil nil nil
'chronometrist--project-history))
;; ## HOOKS ##
(defvar chronometrist-project-start-functions nil
"Functions to run before a project is clocked in.
2019-08-06 11:36:38 +00:00
Each function in this hook must accept a single argument, which
is the project to be clocked-in.
The commands `chronometrist-toggle-project-button',
`chronometrist-add-new-project-button',
`chronometrist-toggle-project',
`chronometrist-add-new-project', and
`chronometrist-toggle-project-no-reason' will run this hook.")
(defvar chronometrist-before-project-stop-functions nil
"Functions to run before a project is clocked out.
Each function in this hook must accept a single argument, which
is the clocked-out project.
The project will be stopped only if all functions in this list
return a non-nil value.")
(defvar chronometrist-after-project-stop-functions nil
"Functions to run after a project is clocked out.
Each function in this hook must accept a single argument, which
is the clocked-out project.")
(defun chronometrist-run-project-start-functions (project)
"Call each function in `chronometrist-project-start-functions' with PROJECT."
(run-hook-with-args 'chronometrist-project-start-functions
project))
(defun chronometrist-run-after-project-stop-functions (project)
"Call each function in `chronometrist-after-project-stop-functions' with PROJECT."
(run-hook-with-args 'chronometrist-after-project-stop-functions
project))
2019-08-09 03:38:28 +00:00
(defun chronometrist-run-functions-and-clock-out (project ask)
"Run hooks and clock out of PROJECT.
ASK is used like in `timeclock-out'."
(when (run-hook-with-args-until-failure 'chronometrist-before-project-stop-functions
project)
(timeclock-out nil nil ask)
(chronometrist-run-after-project-stop-functions project)))
2018-09-01 12:31:25 +00:00
;; ## MAJOR-MODE ##
(defvar chronometrist-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'chronometrist-toggle-project)
(define-key map (kbd "M-RET") #'chronometrist-toggle-project-no-reason)
(define-key map (kbd "l") #'chronometrist-open-timeclock-file)
(define-key map (kbd "r") #'chronometrist-report)
(define-key map [mouse-1] #'chronometrist-toggle-project)
(define-key map [mouse-3] #'chronometrist-toggle-project-no-reason)
(define-key map (kbd "a") #'chronometrist-add-new-project)
map)
"Keymap used by `chronometrist-mode'.")
(define-derived-mode chronometrist-mode tabulated-list-mode "Chronometrist"
"Major mode for `chronometrist'."
2018-09-11 12:39:38 +00:00
(timeclock-reread-log)
(make-local-variable 'tabulated-list-format)
(setq tabulated-list-format [("#" 3 t)
2018-09-02 10:55:25 +00:00
("Project" 25 t)
("Time" 10 t)
("Active" 3 t)])
2018-09-11 12:39:38 +00:00
(make-local-variable 'tabulated-list-entries)
(setq tabulated-list-entries 'chronometrist-entries)
2018-09-11 12:39:38 +00:00
(make-local-variable 'tabulated-list-sort-key)
(setq tabulated-list-sort-key '("Project" . nil))
(tabulated-list-init-header)
(setq revert-buffer-function #'chronometrist-refresh
timeclock-get-reason-function #'chronometrist-ask-for-reason))
;; ## BUTTONS ##
2018-09-01 12:31:25 +00:00
2019-01-08 08:03:12 +00:00
;; FIXME - there is duplication between this function and `chronometrist-toggle-project's logic
(defun chronometrist-toggle-project-button (button)
2019-08-06 11:36:38 +00:00
"Button action to toggle a project."
(let ((current (chronometrist-current-task))
(at-point (chronometrist-project-at-point)))
;; clocked in + point on current = clock out
;; clocked in + point on some other project = clock out, clock in to project
;; clocked out = clock in
(when current
(chronometrist-run-functions-and-clock-out current t))
(unless (equal at-point current)
(chronometrist-run-project-start-functions at-point)
(timeclock-in nil at-point nil))
(chronometrist-refresh)))
(defun chronometrist-add-new-project-button (button)
2019-08-06 11:36:38 +00:00
"Button action to add a new project."
(let ((current (chronometrist-current-task)))
(when current
(chronometrist-run-functions-and-clock-out current t))
(let ((p (read-from-minibuffer "New project name: " nil nil nil nil nil t)))
(chronometrist-run-project-start-functions p)
(timeclock-in nil p nil))
(chronometrist-refresh)))
;; ## COMMANDS ##
2018-10-19 18:06:07 +00:00
;; TODO - if clocked in and point not on a project, just clock out
2019-08-04 19:27:22 +00:00
;; PROFILE
(defun chronometrist-toggle-project (&optional prefix no-prompt)
2019-08-06 11:36:38 +00:00
"Start or stop the project at point.
2019-08-06 11:36:38 +00:00
If there is no project at point, do nothing.
2019-08-30 17:10:02 +00:00
With numeric prefix argument PREFIX, toggle the Nth project. If
there is no corresponding project, do nothing.
2019-08-06 11:36:38 +00:00
If NO-PROMPT is non-nil, don't ask for a reason."
(interactive "P")
(let* ((empty-file (chronometrist-common-file-empty-p chronometrist-file))
(nth (when prefix (chronometrist-goto-nth-project prefix)))
2018-09-26 17:06:26 +00:00
(at-point (chronometrist-project-at-point))
(target (or nth at-point))
(current (chronometrist-current-task))
2018-09-26 17:06:26 +00:00
(ask (not no-prompt)))
(cond (empty-file (chronometrist-add-new-project)) ;; do not run hooks - chronometrist-add-new-project will do it
;; What should we do if the user provides an invalid argument? Currently - nothing.
2018-09-26 17:06:26 +00:00
((and prefix (not nth)))
(target ;; do nothing if there's no project at point
;; clocked in + target is current = clock out
;; clocked in + target is some other project = clock out, clock in to project
;; clocked out = clock in
(when current
(chronometrist-run-functions-and-clock-out current ask))
2018-09-26 17:06:26 +00:00
(unless (equal target current)
(chronometrist-run-project-start-functions target)
2018-09-26 17:06:26 +00:00
(timeclock-in nil target nil))))
(chronometrist-refresh)))
2018-09-11 12:27:34 +00:00
(defun chronometrist-toggle-project-no-reason (&optional prefix)
2019-08-06 11:36:38 +00:00
"Like `chronometrist-toggle-project', but don't ask for a reason.
With numeric prefix argument PREFIX, toggle the Nth project. If there
is no corresponding project, do nothing."
(interactive "P")
(chronometrist-toggle-project prefix t))
(defun chronometrist-add-new-project ()
2019-08-06 11:36:38 +00:00
"Add a new project."
(interactive)
(chronometrist-add-new-project-button nil))
2018-09-23 19:24:34 +00:00
2019-08-04 19:27:22 +00:00
;;;###autoload
(defun chronometrist (&optional arg)
2019-08-06 11:36:38 +00:00
"Display the user's timeclock.el projects and the time spent on them today.
Based on their timelog file `timeclock-file'. This is the
'listing command' for `chronometrist-mode'.
2019-08-06 11:36:38 +00:00
If numeric argument ARG is 1, run `chronometrist-report'.
If numeric argument ARG is 2, run `chronometrist-statistics'."
(interactive "P")
(let ((buffer (get-buffer-create chronometrist-buffer-name))
(w (get-buffer-window chronometrist-buffer-name t)))
(cond
(arg (case arg
(1 (chronometrist-report))
(2 (chronometrist-statistics))))
(w (with-current-buffer buffer
(setq chronometrist--point (point))
(kill-buffer chronometrist-buffer-name)))
(t (with-current-buffer buffer
(cond ((or (not (file-exists-p chronometrist-file))
(chronometrist-common-file-empty-p chronometrist-file))
;; first run
(chronometrist-common-create-chronometrist-file)
(let ((inhibit-read-only t))
(chronometrist-common-clear-buffer buffer)
(insert "Welcome to Chronometrist! Hit RET to ")
(insert-text-button "start a new project."
'action #'chronometrist-add-new-project-button
'follow-link t)
(chronometrist-mode)
(switch-to-buffer buffer)))
(t (chronometrist-mode)
(when chronometrist-hide-cursor
(make-local-variable 'cursor-type)
(setq cursor-type nil)
(hl-line-mode))
(switch-to-buffer buffer)
2019-08-08 08:19:06 +00:00
(if (hash-table-keys chronometrist-events)
(chronometrist-refresh)
(chronometrist-refresh-file nil))
(if chronometrist--point
(goto-char chronometrist--point)
(chronometrist-goto-last-project))))
(file-notify-add-watch chronometrist-file '(change) #'chronometrist-refresh-file))))))
2018-09-11 12:27:34 +00:00
;; Local Variables:
;; nameless-current-name: "chronometrist"
;; End:
2019-07-29 10:59:17 +00:00
(provide 'chronometrist)
;;; chronometrist.el ends here