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/elisp/chronometrist.el

473 lines
20 KiB
EmacsLisp
Raw Normal View History

;;; chronometrist.el --- A time tracker with a nice interface -*- lexical-binding: t; -*-
2019-07-29 10:59:17 +00:00
2019-11-05 01:41:10 +00:00
;; Author: contrapunctus <xmpp:contrapunctus@jabber.fr>
2019-11-21 09:32:16 +00:00
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabber.fr>
2019-11-05 01:41:10 +00:00
;; Keywords: calendar
2020-08-11 19:01:53 +00:00
;; Homepage: https://github.com/contrapunctus-1/chronometrist
;; Package-Requires: ((emacs "25.1") (dash "2.16.0") (seq "2.20") (s "1.12.0") (ts "0.2") (anaphora "1.0.4"))
2020-07-19 12:36:11 +00:00
;; Version: 0.5.4
2019-11-05 01:41:10 +00:00
2019-04-17 18:21:01 +00:00
(require 'filenotify)
(require 'cl-lib)
(require 'subr-x)
2018-11-02 01:59:24 +00:00
(require 'chronometrist-common)
(require 'chronometrist-custom)
(require 'chronometrist-key-values)
(require 'chronometrist-queries)
2019-10-25 07:47:27 +00:00
(require 'chronometrist-migrate)
(require 'chronometrist-sexp)
2018-08-27 07:56:04 +00:00
;; This is free and unencumbered software released into the public domain.
;;
;; Anyone is free to copy, modify, publish, use, compile, sell, or
;; distribute this software, either in source code form or as a compiled
;; binary, for any purpose, commercial or non-commercial, and by any
;; means.
;;
;; For more information, please refer to <https://unlicense.org>
2019-08-04 19:27:22 +00:00
;;; Commentary:
;;
;; A time tracker in Emacs with a nice interface
2019-01-08 08:03:12 +00:00
;; Largely modelled after the Android application, [A Time Tracker](https://github.com/netmackan/ATimeTracker)
2019-01-08 08:03:12 +00:00
;; * Benefits
;; 1. Extremely simple and efficient to use
;; 2. Displays useful information about your time usage
;; 3. Support for both mouse and keyboard
;; 4. Human errors in tracking are easily fixed by editing a plain text file
;; 5. Hooks to let you perform arbitrary actions when starting/stopping tasks
;; * Limitations
;; 1. No support (yet) for adding a task without clocking into it.
;; 2. No support for concurrent tasks.
;; ## Comparisons
;; ### timeclock.el
;; Compared to timeclock.el, Chronometrist
;; * stores data in an s-expression format rather than a line-based one
;; * supports attaching tags and arbitrary key-values to time intervals
;; * has commands to shows useful summaries
;; * has more hooks
;; ### Org time tracking
;; Chronometrist and Org time tracking seem to be equivalent in terms of capabilities, approaching the same ends through different means.
;; * Chronometrist doesn't have a mode line indicator at the moment. (planned)
;; * Chronometrist doesn't have Org's sophisticated querying facilities. (an SQLite backend is planned)
;; * Org does so many things that keybindings seem to necessarily get longer. Chronometrist has far fewer commands than Org, so most of the keybindings are single keys, without modifiers.
;; * Chronometrist's UI makes keybindings discoverable - they are displayed in the buffers themselves.
;; * Chronometrist's UI is cleaner, since the storage is separate from the display. It doesn't show tasks as trees like Org, but it uses tags and key-values to achieve that. Additionally, navigating a flat list takes fewer user operations than navigating a tree.
;; * Chronometrist data is just s-expressions (plists), and may be easier to parse than a complex text format with numerous use-cases.
;; For information on usage and customization, see https://github.com/contrapunctus-1/chronometrist/blob/master/README.md
2019-07-29 10:59:17 +00:00
;;; Code:
;; `chronometrist-goal' is an optional extension. But even these don't make the
;; warnings go away :\
(eval-when-compile
(defvar chronometrist-goal-list)
(defvar chronometrist-mode-map))
(declare-function 'chronometrist-goal-get "chronometrist-goal")
(autoload 'chronometrist-maybe-start-timer "chronometrist-timer" nil t)
(autoload 'chronometrist-report "chronometrist-report" nil t)
(autoload 'chronometrist-statistics "chronometrist-statistics" nil t)
2020-07-08 08:52:54 +00:00
;; ## VARIABLES ##
2019-11-29 04:41:14 +00:00
(defvar chronometrist--task-history nil)
2018-10-03 19:23:37 +00:00
(defvar chronometrist--point nil)
(defvar chronometrist--inhibit-read-p nil)
2018-10-03 19:23:37 +00:00
2018-09-01 12:31:25 +00:00
;; ## FUNCTIONS ##
(defun chronometrist-open-log (&optional _button)
"Open `chronometrist-file' in another window.
Argument _BUTTON is for the purpose of using this command as a
button action."
(interactive)
(chronometrist-sexp-open-log))
(defun chronometrist-common-create-file ()
"Create `chronometrist-file' if it doesn't already exist."
(chronometrist-sexp-create-file))
(defun chronometrist-task-active? (task)
"Return t if TASK is currently clocked in, else nil."
(equal (chronometrist-current-task) task))
2018-08-27 07:56:04 +00:00
(defun chronometrist-use-goals? ()
2020-09-02 13:54:13 +00:00
"Return t if `chronometrist-goal' is available and `chronometrist-goal-list' is bound."
(and (featurep 'chronometrist-goal) (bound-and-true-p chronometrist-goal-list)))
(defun chronometrist-activity-indicator ()
"Return a string to indicate that a task is active.
See custom variable `chronometrist-activity-indicator'."
(if (functionp chronometrist-activity-indicator)
(funcall chronometrist-activity-indicator)
chronometrist-activity-indicator))
(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.
;; (chronometrist-events-populate)
;; (chronometrist-events-clean)
2019-09-13 19:35:54 +00:00
(->> chronometrist-task-list
2018-09-02 10:55:25 +00:00
(-sort #'string-lessp)
(--map-indexed
2020-04-14 08:53:57 +00:00
(let* ((task it)
(index (number-to-string (1+ it-index)))
2020-09-02 13:54:13 +00:00
(task-button `(,task action chronometrist-toggle-task-button follow-link t))
(task-time (chronometrist-format-time (chronometrist-task-time-one-day task)))
2020-04-14 08:53:57 +00:00
(indicator (if (chronometrist-task-active? task)
(chronometrist-activity-indicator)
""))
(use-goals (chronometrist-use-goals?))
(target (when use-goals
;; this can return nil if there is no goal for a task
(chronometrist-goal-get task)))
2020-04-14 08:53:57 +00:00
(target-str (if target
(format "% 4d" target)
"")))
(list task
(vconcat (vector index task-button task-time indicator)
(when use-goals
(vector target-str))))))))
2018-09-11 12:27:34 +00:00
2019-11-29 04:41:14 +00:00
(defun chronometrist-task-at-point ()
"Return the task at point in the `chronometrist' buffer, or nil if there is no task 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
2019-11-29 04:41:14 +00:00
(defun chronometrist-goto-last-task ()
"In the `chronometrist' buffer, move point to the line containing the last active task."
(goto-char (point-min))
(re-search-forward (plist-get (chronometrist-last) :name) nil t)
(beginning-of-line))
(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
2020-09-02 13:54:13 +00:00
(format "\n% 18s - %s"
(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 ")
;; (keybind-start-new (chronometrist-format-keybinds 'chronometrist-add-new-task chronometrist-mode-map))
(keybind-toggle (chronometrist-format-keybinds 'chronometrist-toggle-task chronometrist-mode-map t)))
(goto-char (point-max))
2020-09-02 13:54:13 +00:00
(--> (chronometrist-active-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" "----"))
2019-11-29 04:41:14 +00:00
(chronometrist-print-keybind 'chronometrist-add-new-task)
(insert-text-button "start a new task" 'action #'chronometrist-add-new-task-button 'follow-link t)
(chronometrist-print-keybind 'chronometrist-toggle-task "toggle task at point")
(chronometrist-print-keybind 'chronometrist-toggle-task-no-hooks "toggle without running hooks")
(insert "\n " (format "%s %s - %s" "<numeric argument N>" keybind-toggle "toggle <N>th task"))
(chronometrist-print-keybind 'chronometrist-report)
(insert-text-button "see weekly report" 'action #'chronometrist-report 'follow-link t)
(chronometrist-print-keybind 'chronometrist-open-log)
(insert-text-button "view/edit log file" 'action #'chronometrist-open-log 'follow-link t)
2019-01-08 08:03:54 +00:00
(insert "\n"))))
2019-11-29 04:41:14 +00:00
(defun chronometrist-goto-nth-task (n)
"Move point to the line containing the Nth task.
Return the task at point, or nil if there is no corresponding
task. N must be a positive integer."
(goto-char (point-min))
(when (re-search-forward (format "^%d" n) nil t)
(beginning-of-line)
2019-11-29 04:41:14 +00:00
(chronometrist-task-at-point)))
(defun chronometrist-refresh (&optional _ignore-auto _noconfirm)
2019-09-07 07:08:16 +00:00
"Refresh the `chronometrist' buffer, without re-reading `chronometrist-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* ((window (get-buffer-window chronometrist-buffer-name t))
(point (window-point window)))
(when window
(with-current-buffer chronometrist-buffer-name
(tabulated-list-print t nil)
(chronometrist-print-non-tabular)
(chronometrist-maybe-start-timer)
(set-window-point window point)))))
(defun chronometrist-refresh-file (_fs-event)
2019-09-07 07:08:16 +00:00
"Re-read `chronometrist-file' and refresh the `chronometrist' buffer.
Argument _FS-EVENT is ignored."
;; (chronometrist-file-clean)
2020-05-01 10:26:25 +00:00
(run-hooks 'chronometrist-file-change-hook)
;; REVIEW - can we move most/all of this to the `chronometrist-file-change-hook'?
(if chronometrist--inhibit-read-p
(setq chronometrist--inhibit-read-p nil)
(chronometrist-events-populate)
(setq chronometrist-task-list (chronometrist-tasks-from-table))
(chronometrist-tags-history-populate chronometrist-events chronometrist-tags-history))
(chronometrist-key-history-populate chronometrist-events chronometrist-key-history)
(chronometrist-value-history-populate chronometrist-events chronometrist-value-history)
(chronometrist-refresh))
2019-11-29 11:08:44 +00:00
(defun chronometrist-query-stop ()
"Ask the user if they would like to clock out."
(let ((task (chronometrist-current-task)))
(and task
(yes-or-no-p (format "Stop tracking time for %s? " task))
2019-11-29 11:08:44 +00:00
(chronometrist-out))
t))
(defun chronometrist-in (task &optional _prefix)
"Clock in to TASK; record current time in `chronometrist-file'.
TASK is the name of the task, a string. PREFIX is ignored."
(interactive "P")
(let ((plist `(:name ,task :start ,(chronometrist-format-time-iso8601))))
(chronometrist-sexp-new plist)
(chronometrist-refresh)))
(defun chronometrist-out (&optional _prefix)
"Record current moment as stop time to last s-exp in `chronometrist-file'.
PREFIX is ignored."
(interactive "P")
(let ((plist (plist-put (chronometrist-last) :stop (chronometrist-format-time-iso8601))))
(chronometrist-sexp-replace-last plist)))
;; ## HOOKS ##
2019-09-11 18:00:52 +00:00
(defvar chronometrist-before-in-functions nil
2019-11-29 04:41:14 +00:00
"Functions to run before a task is clocked in.
2019-08-06 11:36:38 +00:00
Each function in this hook must accept a single argument, which
2019-11-29 04:41:14 +00:00
is the name of the task to be clocked-in.
2019-11-29 04:41:14 +00:00
The commands `chronometrist-toggle-task-button',
`chronometrist-add-new-task-button', `chronometrist-toggle-task',
and `chronometrist-add-new-task' will run this hook.")
2019-09-11 18:00:52 +00:00
(defvar chronometrist-after-in-functions nil
2019-11-29 04:41:14 +00:00
"Functions to run after a task is clocked in.
2019-09-11 18:00:52 +00:00
Each function in this hook must accept a single argument, which
2019-11-29 04:41:14 +00:00
is the name of the task to be clocked-in.
2019-09-11 18:00:52 +00:00
2019-11-29 04:41:14 +00:00
The commands `chronometrist-toggle-task-button',
`chronometrist-add-new-task-button', `chronometrist-toggle-task',
and `chronometrist-add-new-task' will run this hook.")
2019-09-11 18:00:52 +00:00
(defvar chronometrist-before-out-functions nil
2019-11-29 04:41:14 +00:00
"Functions to run before a task is clocked out.
Each function in this hook must accept a single argument, which
2019-11-29 04:41:14 +00:00
is the name of the task to be clocked out of.
2019-11-29 04:41:14 +00:00
The task will be stopped only if all functions in this list
return a non-nil value.")
2019-09-11 18:00:52 +00:00
(defvar chronometrist-after-out-functions nil
2019-11-29 04:41:14 +00:00
"Functions to run after a task is clocked out.
Each function in this hook must accept a single argument, which
2019-11-29 04:41:14 +00:00
is the name of the task to be clocked out of.")
(defvar chronometrist-file-change-hook nil
"Functions to be run after `chronometrist-file' is changed on disk.")
2019-09-11 18:00:52 +00:00
(defun chronometrist-run-functions-and-clock-in (task)
"Run hooks and clock in to TASK."
(run-hook-with-args 'chronometrist-before-in-functions task)
(chronometrist-in task)
(run-hook-with-args 'chronometrist-after-in-functions task))
2019-09-11 18:00:52 +00:00
(defun chronometrist-run-functions-and-clock-out (task)
"Run hooks and clock out of TASK."
2019-09-11 18:00:52 +00:00
(when (run-hook-with-args-until-failure 'chronometrist-before-out-functions task)
(chronometrist-out)
(run-hook-with-args 'chronometrist-after-out-functions task)))
2019-08-09 03:38:28 +00:00
2018-09-01 12:31:25 +00:00
;; ## MAJOR-MODE ##
(defvar chronometrist-mode-map
(let ((map (make-sparse-keymap)))
2019-11-29 04:41:14 +00:00
(define-key map (kbd "RET") #'chronometrist-toggle-task)
(define-key map (kbd "M-RET") #'chronometrist-toggle-task-no-hooks)
(define-key map (kbd "l") #'chronometrist-open-log)
(define-key map (kbd "r") #'chronometrist-report)
2019-11-29 04:41:14 +00:00
(define-key map [mouse-1] #'chronometrist-toggle-task)
(define-key map [mouse-3] #'chronometrist-toggle-task-no-hooks)
2019-11-29 04:41:14 +00:00
(define-key map (kbd "a") #'chronometrist-add-new-task)
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
(make-local-variable 'tabulated-list-format)
2020-04-13 18:54:40 +00:00
(setq tabulated-list-format
(vconcat [("#" 3 t)
("Task" 25 t)
("Time" 10 t)
("Active" 10 t)]
(when (chronometrist-use-goals?)
2020-04-13 18:54:40 +00:00
[("Target" 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)
2019-11-29 04:41:14 +00:00
(setq tabulated-list-sort-key '("Task" . nil))
(tabulated-list-init-header)
2019-10-31 13:33:18 +00:00
(setq revert-buffer-function #'chronometrist-refresh))
;; ## BUTTONS ##
2018-09-01 12:31:25 +00:00
2019-11-29 04:41:14 +00:00
(defun chronometrist-toggle-task-button (_button)
"Button action to toggle a task.
2019-10-31 15:13:50 +00:00
Argument _BUTTON is for the purpose of using this as a button
2019-10-31 15:13:50 +00:00
action, and is ignored."
(when current-prefix-arg
(chronometrist-goto-nth-task (prefix-numeric-value current-prefix-arg)))
(let ((current (chronometrist-current-task))
2019-11-29 04:41:14 +00:00
(at-point (chronometrist-task-at-point)))
;; clocked in + point on current = clock out
2019-11-29 04:41:14 +00:00
;; clocked in + point on some other task = clock out, clock in to task
;; clocked out = clock in
(when current
(chronometrist-run-functions-and-clock-out current))
(unless (equal at-point current)
(chronometrist-run-functions-and-clock-in at-point))))
2019-11-29 04:41:14 +00:00
(defun chronometrist-add-new-task-button (_button)
"Button action to add a new task.
2019-10-31 15:13:50 +00:00
Argument _BUTTON is for the purpose of using this as a button
2019-10-31 15:13:50 +00:00
action, and is ignored."
(let ((current (chronometrist-current-task)))
(when current
(chronometrist-run-functions-and-clock-out current))
2019-09-07 16:48:19 +00:00
(let ((task (read-from-minibuffer "New task name: " nil nil nil nil nil t)))
(chronometrist-run-functions-and-clock-in task))))
;; ## COMMANDS ##
2019-11-29 04:41:14 +00:00
;; TODO - if clocked in and point not on a task, just clock out
(defun chronometrist-toggle-task (&optional prefix inhibit-hooks)
2019-11-29 04:41:14 +00:00
"Start or stop the task at point.
2019-11-29 04:41:14 +00:00
If there is no task at point, do nothing.
2019-08-06 11:36:38 +00:00
2019-11-29 04:41:14 +00:00
With numeric prefix argument PREFIX, toggle the Nth task in
the buffer. If there is no corresponding task, do nothing.
If INHIBIT-HOOKS is non-nil, the hooks
`chronometrist-before-in-functions',
`chronometrist-after-in-functions',
`chronometrist-before-out-functions', and
`chronometrist-after-out-functions' will not be run."
(interactive "P")
(let* ((empty-file (chronometrist-common-file-empty-p chronometrist-file))
(nth (when prefix (chronometrist-goto-nth-task prefix)))
(at-point (chronometrist-task-at-point))
(target (or nth at-point))
(current (chronometrist-current-task))
(in-function (if inhibit-hooks
#'chronometrist-in
#'chronometrist-run-functions-and-clock-in))
(out-function (if inhibit-hooks
#'chronometrist-out
#'chronometrist-run-functions-and-clock-out)))
;; do not run hooks - chronometrist-add-new-task will do it
(cond (empty-file (chronometrist-add-new-task))
;; 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)))
2019-11-29 04:41:14 +00:00
(target ;; do nothing if there's no task at point
2018-09-26 17:06:26 +00:00
;; clocked in + target is current = clock out
2019-11-29 04:41:14 +00:00
;; clocked in + target is some other task = clock out, clock in to task
2018-09-26 17:06:26 +00:00
;; clocked out = clock in
(when current
(funcall out-function current))
2018-09-26 17:06:26 +00:00
(unless (equal target current)
(funcall in-function target))))))
2018-09-11 12:27:34 +00:00
(defun chronometrist-toggle-task-no-hooks (&optional prefix)
2020-06-26 21:17:27 +00:00
"Like `chronometrist-toggle-task', but don't run hooks.
2019-08-06 11:36:38 +00:00
2019-11-29 04:41:14 +00:00
With numeric prefix argument PREFIX, toggle the Nth task. If there
is no corresponding task, do nothing."
(interactive "P")
(chronometrist-toggle-task prefix t))
2019-11-29 04:41:14 +00:00
(defun chronometrist-add-new-task ()
"Add a new task."
(interactive)
2019-11-29 04:41:14 +00:00
(chronometrist-add-new-task-button nil))
2018-09-23 19:24:34 +00:00
2019-08-04 19:27:22 +00:00
;;;###autoload
(defun chronometrist (&optional arg)
2019-10-31 13:33:18 +00:00
"Display the user's tasks and the time spent on them today.
2019-08-06 11:36:38 +00:00
2019-10-31 13:33:18 +00:00
Based on their timelog file `chronometrist-file'. This is the
2019-08-06 11:36:38 +00:00
'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")
2019-10-25 07:47:27 +00:00
(chronometrist-migrate-check)
(let ((buffer (get-buffer-create chronometrist-buffer-name))
2020-05-14 04:18:37 +00:00
(w (save-excursion
(get-buffer-window chronometrist-buffer-name t))))
(cond
(arg (cl-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
2020-05-14 04:17:20 +00:00
(chronometrist-common-create-file)
(let ((inhibit-read-only t))
(chronometrist-common-clear-buffer buffer)
(insert "Welcome to Chronometrist! Hit RET to ")
2019-11-29 04:41:14 +00:00
(insert-text-button "start a new task."
'action #'chronometrist-add-new-task-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)
2019-11-29 04:41:14 +00:00
(chronometrist-goto-last-task))))
(unless chronometrist--fs-watch
(setq chronometrist--fs-watch
(file-notify-add-watch chronometrist-file '(change) #'chronometrist-refresh-file))))))))
2018-09-11 12:27:34 +00:00
(provide 'chronometrist)
2019-07-29 10:59:17 +00:00
;;; chronometrist.el ends here