Merge branch 'dev' into sqlite

This commit is contained in:
contrapunctus 2022-01-31 22:52:58 +05:30
commit cd7eb9c726
11 changed files with 427 additions and 280 deletions

23
Cask
View File

@ -1,23 +0,0 @@
(source gnu)
(source melpa)
(package
"Chronometrist"
"0.9.0"
"A time tracker for Emacs with a nice interface")
(depends-on "cl-lib")
(depends-on "literate-elisp" "0.1")
(depends-on "dash" "2.16.0")
(depends-on "seq" "2.20")
(depends-on "ts" "0.2")
(files "elisp/*.el")
(development
(depends-on "f")
(depends-on "ecukes")
(depends-on "ert-runner")
(depends-on "el-mock")
(depends-on "elsa")
(depends-on "buttercup"))

View File

@ -65,6 +65,8 @@ lint-package-lint: setup tangle
lint-relint: setup tangle
cd elisp/ && \
emacs -q -Q --batch \
--eval="(package-initialize)" \
--eval="(require 'relint)" \
--eval='(relint-file "chronometrist.el")' \
--eval='(relint-file "chronometrist-key-values.el")' \
--eval='(relint-file "chronometrist-spark.el")' \
@ -74,16 +76,12 @@ lint-relint: setup tangle
lint: lint-check-declare lint-checkdoc lint-package-lint lint-relint
clean-tangle:
-cd elisp/ && \
rm elisp/chronometrist.el \
elisp/chronometrist-key-values.el \
elisp/chronometrist-spark.el \
elisp/chronometrist-sqlite.el ; \
cd ..
elisp/chronometrist-sqlite.el ;
clean-elc:
-cd elisp/ && \
rm elisp/*.elc ; \
cd ..
rm elisp/*.elc
clean: clean-elc

View File

@ -427,7 +427,7 @@ Return t, to permit use in `chronometrist-before-out-functions'."
(chronometrist-plist-update
latest
(read (completing-read (format "Key-values for %s: " task)
key-values))))))
key-values nil nil nil 'chronometrist-key-values-unified-prompt-history))))))
t)
(provide 'chronometrist-key-values)

View File

@ -731,7 +731,7 @@ Return t, to permit use in `chronometrist-before-out-functions'."
(chronometrist-plist-update
latest
(read (completing-read (format "Key-values for %s: " task)
key-values))))))
key-values nil nil nil 'chronometrist-key-values-unified-prompt-history))))))
t)
#+END_SRC
@ -740,8 +740,8 @@ Return t, to permit use in `chronometrist-before-out-functions'."
(provide 'chronometrist-key-values)
;;; chronometrist-key-values.el ends here
#+END_SRC
* Local variables :NOEXPORT:
# Local Variables:
# nameless-aliases: (("cb" . "chronometrist-backend") ("c" . "chronometrist"))
# nameless-current-name: "chronometrist"
# eval: (when (package-installed-p 'literate-elisp) (require 'literate-elisp) (literate-elisp-load (buffer-file-name)))
# End:

View File

@ -1,10 +1,10 @@
;;; chronometrist-spark.el --- Show sparklines in Chronometrist -*- lexical-binding: t; -*-
;;; chronometrist-spark.el --- Show sparklines in Chronometrist buffers -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "24.3") (chronometrist "0.7.0") (spark "0.1"))
;; Package-Requires: ((emacs "25.1") (chronometrist "0.7.0") (spark "0.1"))
;; Version: 0.1.0
;; This is free and unencumbered software released into the public domain.
@ -21,6 +21,7 @@
;; This package adds a column to Chronometrist displaying sparklines for each task.
;;; Code:
;; This file was automatically generated from chronometrist-spark.org.
(require 'chronometrist)
(require 'spark)

View File

@ -5,13 +5,13 @@
* Library headers and commentary
#+BEGIN_SRC emacs-lisp
;;; chronometrist-spark.el --- Show sparklines in Chronometrist -*- lexical-binding: t; -*-
;;; chronometrist-spark.el --- Show sparklines in Chronometrist buffers -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "24.3") (chronometrist "0.7.0") (spark "0.1"))
;; Package-Requires: ((emacs "25.1") (chronometrist "0.7.0") (spark "0.1"))
;; Version: 0.1.0
;; This is free and unencumbered software released into the public domain.
@ -31,12 +31,15 @@
;;
;; This package adds a column to Chronometrist displaying sparklines for each task.
#+END_SRC
* Dependencies
#+BEGIN_SRC emacs-lisp
;;; Code:
;; This file was automatically generated from chronometrist-spark.org.
(require 'chronometrist)
(require 'spark)
#+END_SRC
* Code
** custom group :custom:group:
#+BEGIN_SRC emacs-lisp
@ -167,3 +170,8 @@ SCHEMA should be a vector as specified by `tabulated-list-format'."
(provide 'chronometrist-spark)
;;; chronometrist-spark.el ends here
#+END_SRC
* Local variables :NOEXPORT:
# Local Variables:
# eval: (when (package-installed-p 'literate-elisp) (require 'literate-elisp) (literate-elisp-load (buffer-file-name)))
# End:

View File

@ -1,4 +1,4 @@
;;; chronometrist.el --- A time tracker with a nice interface -*- lexical-binding: t; -*-
;;; chronometrist.el --- Friendly and powerful personal time tracker and analyzer -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
@ -18,7 +18,7 @@
;;; Commentary:
;;
;; A time tracker in Emacs with a nice interface
;; Friendly and powerful personal time tracker and analyzer.
;; Largely modelled after the Android application, [A Time Tracker](https://github.com/netmackan/ATimeTracker)
@ -53,7 +53,7 @@
;; For information on usage and customization, see https://tildegit.org/contrapunctus/chronometrist or the included manual.org
;;; Code:
;; This file was automatically generated from chronometrist.org
;; This file was automatically generated from chronometrist.org.
(require 'dash)
(require 'ts)
@ -188,7 +188,11 @@ TS must be a ts struct (see `ts.el')."
;; plist-p:1 ends here
;; [[file:chronometrist.org::*plist type][plist type:1]]
(cl-deftype chronometrist-plist () '(satisfies chronometrist-plist-p))
(cl-deftype chronometrist-plist ()
'(satisfies chronometrist-plist-p)
'(satisfies (lambda (plist)
(and (plist-get plist :name)
(plist-get plist :start)))))
;; plist type:1 ends here
;; [[file:chronometrist.org::*delete-list][delete-list:1]]
@ -256,6 +260,7 @@ Return new position of point."
FORMAT-STRING and ARGS are passed to `format'."
(when chronometrist-debug-enable
(with-current-buffer (get-buffer-create chronometrist-debug-buffer)
(goto-char (point-max))
(chronometrist-debug-log-mode)
(let ((inhibit-read-only t))
(insert
@ -270,7 +275,7 @@ FORMAT-STRING and ARGS are passed to `format'."
(defun chronometrist-reset ()
"Reset Chronometrist's internal state."
(interactive)
(chronometrist-debug-message "Command: reset")
(chronometrist-debug-message "[Command] reset")
(chronometrist-reset-backend (chronometrist-active-backend))
(chronometrist-refresh))
;; reset:1 ends here
@ -758,7 +763,7 @@ Value must be a keyword corresponding to a key in
;; [[file:chronometrist.org::*switch-backend][switch-backend:1]]
(defun chronometrist-switch-backend ()
(interactive)
(chronometrist-debug-message "Command: switch-backend")
(chronometrist-debug-message "[Command] switch-backend")
(let* ((prompt (format "Switch to backend (current - %s): "
chronometrist-active-backend))
(choice (chronometrist-read-backend-name prompt
@ -864,7 +869,7 @@ a user.")
(cl-defgeneric chronometrist-create-file (backend &optional file)
"Create file associated with BACKEND.
Use FILE as a path, if provided.
Return non-nil if FILE is successfully created, and nil if it already exists.")
Return path of new file if successfully created, and nil if it already exists.")
;; create-file:1 ends here
;; [[file:chronometrist.org::*latest-date-records][latest-date-records:1]]
@ -877,6 +882,10 @@ Return nil if BACKEND contains no records.")
(cl-defgeneric chronometrist-insert (backend plist)
"Insert PLIST as new record in BACKEND.
Return non-nil if record is inserted successfully.")
(cl-defmethod chronometrist-insert :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
(error "Not a valid plist: %S" plist)))
;; insert:1 ends here
;; [[file:chronometrist.org::*remove-last][remove-last:1]]
@ -898,11 +907,22 @@ Return value may be active, i.e. it may or may not have a :stop key-value.")
DATE-TS must be a `ts.el' struct.
Return nil if BACKEND contains no records.")
(cl-defmethod chronometrist-task-records-for-date :before ((_backend t) task date-ts &key &allow-other-keys)
(unless (cl-typep task 'string)
(error "task %S is not a string" task))
(unless (cl-typep date-ts 'ts)
(error "date-ts %S is not a `ts' struct" date-ts)))
;; task-records-for-date:1 ends here
;; [[file:chronometrist.org::*replace-last][replace-last:1]]
(cl-defgeneric chronometrist-replace-last (backend plist)
"Replace last record in BACKEND with PLIST.")
"Replace last record in BACKEND with PLIST.
Return non-nil if successful.")
(cl-defmethod chronometrist-replace-last :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
(error "Not a valid plist: %S" plist)))
;; replace-last:1 ends here
;; [[file:chronometrist.org::*to-file][to-file:1]]
@ -1057,7 +1077,8 @@ hash table values must be in chronological order.")
rest-start rest-end rest-hash
file-length last-hash) backend
(chronometrist-reset-task-list backend)
(file-notify-rm-watch file-watch)
(when file-watch
(file-notify-rm-watch file-watch))
(setf hash-table (chronometrist-to-hash-table backend)
file-watch nil
rest-start nil
@ -1097,7 +1118,7 @@ hash table values must be in chronological order.")
;; on-file-path-change:1 ends here
;; [[file:chronometrist.org::*elisp-sexp-backend][elisp-sexp-backend:1]]
(defclass chronometrist-elisp-sexp-backend (chronometrist-backend)
(defclass chronometrist-elisp-sexp-backend (chronometrist-backend chronometrist-file-backend-mixin)
((rest-start :initarg :rest-start
:initform nil
:accessor chronometrist-backend-rest-start
@ -1137,7 +1158,7 @@ hash table values must be in chronological order.")
(goto-char (point-min))
(insert ";;; -*- mode: chronometrist-sexp; -*-\n\n")
(write-file file))
t)))
file)))
;; create-file:1 ends here
;; [[file:chronometrist.org::*in-file][in-file:1]]
@ -1325,7 +1346,7 @@ FS-EVENT is the event passed by the `filenotify' library (see `file-notify-add-w
(chronometrist-file-change-type backend)))
(reset-watch-p (or (eq action 'deleted)
(eq action 'renamed))))
(chronometrist-debug-message "File change type %s" change)
(chronometrist-debug-message "[Method] on-change: file change type %s" change)
;; If only the last plist was changed, update hash table and
;; task list, otherwise clear and repopulate hash table.
(cond ((or reset-watch-p
@ -1349,7 +1370,7 @@ FS-EVENT is the event passed by the `filenotify' library (see `file-notify-add-w
;; on-change:1 ends here
;; [[file:chronometrist.org::*backend][backend:1]]
(defclass chronometrist-plist-backend (chronometrist-elisp-sexp-backend chronometrist-file-backend-mixin)
(defclass chronometrist-plist-backend (chronometrist-elisp-sexp-backend)
((extension :initform "plist"
:accessor chronometrist-backend-ext
:custom 'string)))
@ -1408,9 +1429,9 @@ STREAM (which is the value of `current-buffer')."
;; to-hash-table:1 ends here
;; [[file:chronometrist.org::*insert][insert:1]]
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-backend) plist)
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-backend) plist &key (save t))
(chronometrist-backend-run-assertions backend)
(chronometrist-debug-message "Insert plist %s" plist)
(chronometrist-debug-message "[Method] insert: %s" plist)
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (point-max))
;; If we're adding the first s-exp in the file, don't add a
@ -1418,13 +1439,17 @@ STREAM (which is the value of `current-buffer')."
(unless (bobp) (insert "\n"))
(unless (bolp) (insert "\n"))
(funcall chronometrist-sexp-pretty-print-function plist (current-buffer))
(save-buffer)))
(when save (save-buffer))
t))
;; insert:1 ends here
;; [[file:chronometrist.org::*remove-last][remove-last:1]]
(cl-defmethod chronometrist-remove-last ((backend chronometrist-plist-backend))
(chronometrist-debug-message "[Method] remove-last")
(chronometrist-backend-run-assertions backend)
(chronometrist-debug-message "Remove last plist")
(when (chronometrist-backend-empty-p backend)
(error "chronometrist-remove-last has nothing to remove in %s"
(eieio-object-class-name backend)))
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (point-max))
;; this condition should never really occur, since we insert a
@ -1439,7 +1464,7 @@ STREAM (which is the value of `current-buffer')."
"Reindent the current buffer.
This is meant to be run in `chronometrist-file' when using an s-expression backend."
(interactive)
(chronometrist-debug-message "Command: reindent-buffer")
(chronometrist-debug-message "[Command] reindent-buffer")
(let (expr)
(goto-char (point-min))
(while (setq expr (ignore-errors (read (current-buffer))))
@ -1531,11 +1556,12 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
;; [[file:chronometrist.org::*replace-last][replace-last:1]]
(cl-defmethod chronometrist-replace-last ((backend chronometrist-plist-backend) plist)
(chronometrist-debug-message "Replace last plist with %s" plist)
(chronometrist-debug-message "[Method] replace-last with %s" plist)
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (chronometrist-remove-last backend))
(funcall chronometrist-sexp-pretty-print-function plist (current-buffer))
(save-buffer)))
(save-buffer)
t))
;; replace-last:1 ends here
;; [[file:chronometrist.org::*count-records][count-records:1]]
@ -1549,7 +1575,7 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
;; count-records:1 ends here
;; [[file:chronometrist.org::*backend][backend:1]]
(defclass chronometrist-plist-group-backend (chronometrist-elisp-sexp-backend chronometrist-file-backend-mixin)
(defclass chronometrist-plist-group-backend (chronometrist-elisp-sexp-backend)
((extension :initform "plg"
:accessor chronometrist-backend-ext
:custom 'string)))
@ -1585,7 +1611,7 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
;; [[file:chronometrist.org::*insert][insert:1]]
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-group-backend) plist &key (save t))
(cl-check-type plist chronometrist-plist)
(chronometrist-debug-message "Insert %S" plist)
(chronometrist-debug-message "[Method] insert: %S" plist)
(chronometrist-backend-run-assertions backend)
(if (not plist)
(error "%s" "`chronometrist-insert' was called with an empty plist")
@ -1828,7 +1854,8 @@ Return value is either a list in the form
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(chronometrist-remove-last backend :save nil)
(chronometrist-insert backend plist :save nil)
(save-buffer)))
(save-buffer)
t))
;; replace-last:1 ends here
;; [[file:chronometrist.org::*remove-prefix][remove-prefix:1]]
@ -2458,7 +2485,7 @@ If INHIBIT-HOOKS is non-nil, the hooks
`chronometrist-before-out-functions', and
`chronometrist-after-out-functions' will not be run."
(interactive "P")
(chronometrist-debug-message "Command: toggle-task %s" (if inhibit-hooks "(without hooks)" ""))
(chronometrist-debug-message "[Command] toggle-task %s" (if inhibit-hooks "(without hooks)" ""))
(let* ((empty-file (chronometrist-backend-empty-p (chronometrist-active-backend)))
(nth (when prefix (chronometrist-goto-nth-task prefix)))
(at-point (chronometrist-task-at-point))
@ -2495,7 +2522,7 @@ there is no corresponding task, do nothing."
(defun chronometrist-add-new-task ()
"Add a new task."
(interactive)
(chronometrist-debug-message "Command: add-new-task")
(chronometrist-debug-message "[Command] add-new-task")
(chronometrist-add-new-task-button nil))
;; add-new-task:1 ends here
@ -2508,7 +2535,7 @@ INHIBIT-HOOKS is non-nil or prefix argument is supplied.
Has no effect if no task is active."
(interactive "P")
(chronometrist-debug-message "Command: restart-task")
(chronometrist-debug-message "[Command] restart-task")
(if (chronometrist-current-task)
(let* ((latest (chronometrist-latest-record (chronometrist-active-backend)))
(plist (plist-put latest :start (chronometrist-format-time-iso8601)))
@ -2530,7 +2557,7 @@ INHIBIT-HOOKS is non-nil or prefix argument is supplied.
Has no effect if a task is active."
(interactive "P")
(chronometrist-debug-message "Command: extend-task")
(chronometrist-debug-message "[Command] extend-task")
(if (chronometrist-current-task)
(message "Cannot extend an active task - use this after clocking out.")
(let* ((latest (chronometrist-latest-record (chronometrist-active-backend)))
@ -2547,7 +2574,7 @@ Has no effect if a task is active."
(defun chronometrist-discard-active ()
"Remove active interval from the active backend."
(interactive)
(chronometrist-debug-message "Command: discard-active")
(chronometrist-debug-message "[Command] discard-active")
(let ((backend (chronometrist-active-backend)))
(if (chronometrist-current-task backend)
(chronometrist-remove-last backend)

View File

@ -1,5 +1,6 @@
#+TITLE: Chronometrist - an extensible time tracker for Emacs
#+SUBTITLE: Developer manual and program source
#+TITLE: Chronometrist
#+SUBTITLE: Friendly and powerful personal time tracker and analyzer for Emacs
#+DESCRIPTION: Developer manual and program source
#+AUTHOR: contrapunctus
#+TODO: TODO TEST WIP EXTEND CLEANUP FIXME HACK REVIEW |
#+PROPERTY: header-args :tangle yes :load yes :comments link
@ -67,15 +68,15 @@ Thus, I have considered various optimization strategies, and so far implemented
*** Prevent excess creation of file watchers
One of the earliest 'optimizations' of great importance turned out to simply be a bug - turns out, if you run an identical call to [[elisp:(describe-function 'file-notify-add-watch)][=file-notify-add-watch=]] twice, you create /two/ file watchers and your callback will be called /twice./ We were creating a file watcher /each time the =chronometrist= command was run./ 🤦 This was causing humongous slowdowns each time the file changed. 😅
+ It was fixed in v0.2.2 by making the watch creation conditional, using [[* fs-watch][=chronometrist--fs-watch=]] to store the watch object.
+ It was fixed in v0.2.2 by making the watch creation conditional, using =chronometrist--fs-watch= to store the watch object.
*** Preserve hash table state for some commands
NOTE - this has been replaced with a more general optimization - see next section.
The next one was released in v0.5. Till then, any time the [[* chronometrist-file][=chronometrist-file=]] was modified, we'd clear the [[* chronometrist-events][=chronometrist-events=]] hash table and read data into it again. The reading itself is nearly-instant, even with ~2 years' worth of data [fn:2] (it uses Emacs' [[elisp:(describe-function 'read)][=read=]], after all), but the splitting of [[#explanation-midnight-spanning-intervals][midnight-spanning events]] is the real performance killer.
The next one was released in v0.5. Till then, any time the [[* chronometrist-file][=chronometrist-file=]] was modified, we'd clear the =chronometrist-events= hash table and read data into it again. The reading itself is nearly-instant, even with ~2 years' worth of data [fn:2] (it uses Emacs' [[elisp:(describe-function 'read)][=read=]], after all), but the splitting of [[#explanation-midnight-spanning-intervals][midnight-spanning events]] is the real performance killer.
After the optimization...
1. Two backend functions ([[* new][=chronometrist-sexp-new=]] and [[* replace-last][=chronometrist-sexp-replace-last=]]) were modified to set a flag (=chronometrist--inhibit-read-p=) before saving the file.
1. Two backend functions (=chronometrist-sexp-new= and =chronometrist-sexp-replace-last=) were modified to set a flag (=chronometrist--inhibit-read-p=) before saving the file.
2. If this flag is non-nil, [[* refresh-file][=chronometrist-refresh-file=]] skips the expensive calls to =chronometrist-events-populate=, =chronometrist-tasks-from-table=, and =chronometrist-tags-history-populate=, and resets the flag.
3. Instead, the aforementioned backend functions modify the relevant variables - =chronometrist-events=, =chronometrist-task-list=, and =chronometrist-tags-history= - via...
* =chronometrist-events-add= / =chronometrist-events-replace-last=
@ -384,7 +385,7 @@ Once, for sake of neatness, I made the value of =Package-Requires:= multiline -
But I discovered that if I do that, =package-lint= says - =error: Couldn't parse "Package-Requires" header: End of file during parsing=.
#+BEGIN_SRC emacs-lisp :comments no
;;; chronometrist.el --- A time tracker with a nice interface -*- lexical-binding: t; -*-
;;; chronometrist.el --- Friendly and powerful personal time tracker and analyzer -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
@ -408,7 +409,7 @@ But I discovered that if I do that, =package-lint= says - =error: Couldn't parse
#+BEGIN_SRC emacs-lisp :comments no
;;; Commentary:
;;
;; A time tracker in Emacs with a nice interface
;; Friendly and powerful personal time tracker and analyzer.
;; Largely modelled after the Android application, [A Time Tracker](https://github.com/netmackan/ATimeTracker)
@ -446,7 +447,7 @@ But I discovered that if I do that, =package-lint= says - =error: Couldn't parse
** Dependencies
#+BEGIN_SRC emacs-lisp :comments no
;;; Code:
;; This file was automatically generated from chronometrist.org
;; This file was automatically generated from chronometrist.org.
(require 'dash)
(require 'ts)
@ -512,7 +513,7 @@ If FIRSTONLY is non-nil, return only the first keybinding found."
(apply #'concat))))
#+END_SRC
*** day-start-time :custom:variable:
[[* events-maybe-split][=chronometrist-events-maybe-split=]] refers to this, but I'm not sure this has the desired effect at the moment—haven't even tried using it.
=chronometrist-events-maybe-split= refers to this, but I'm not sure this has the desired effect at the moment—haven't even tried using it.
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-day-start-time "00:00:00"
"The time at which a day is considered to start, in \"HH:MM:SS\".
@ -608,7 +609,10 @@ TS must be a ts struct (see `ts.el')."
"Return user key-values from PLIST."
(chronometrist-plist-remove plist :name :tags :start :stop))
#+END_SRC
*** plist-p :function:
[[file:../tests/chronometrist-tests.org::#tests-common-plist-p][tests]]
#+BEGIN_SRC emacs-lisp
(defun chronometrist-plist-p (list)
"Return non-nil if LIST is a property list, i.e. (:KEYWORD VALUE ...)"
@ -622,7 +626,11 @@ TS must be a ts struct (see `ts.el')."
*** plist type :type:
#+BEGIN_SRC emacs-lisp
(cl-deftype chronometrist-plist () '(satisfies chronometrist-plist-p))
(cl-deftype chronometrist-plist ()
'(satisfies chronometrist-plist-p)
'(satisfies (lambda (plist)
(and (plist-get plist :name)
(plist-get plist :start)))))
#+END_SRC
*** delete-list :writer:
@ -647,6 +655,8 @@ Return new position of point."
#+END_SRC
*** current-task :reader:method:
[[file:../tests/chronometrist-tests.org::#tests-common-current-task][tests]]
#+BEGIN_SRC emacs-lisp
(cl-defun chronometrist-current-task (&optional (backend (chronometrist-active-backend)))
"Return the name of the active task as a string, or nil if not clocked in."
@ -697,6 +707,7 @@ Return new position of point."
FORMAT-STRING and ARGS are passed to `format'."
(when chronometrist-debug-enable
(with-current-buffer (get-buffer-create chronometrist-debug-buffer)
(goto-char (point-max))
(chronometrist-debug-log-mode)
(let ((inhibit-read-only t))
(insert
@ -721,7 +732,7 @@ The data from =chronometrist-events= is used by most (all?) interval-consuming f
(defun chronometrist-reset ()
"Reset Chronometrist's internal state."
(interactive)
(chronometrist-debug-message "Command: reset")
(chronometrist-debug-message "[Command] reset")
(chronometrist-reset-backend (chronometrist-active-backend))
(chronometrist-refresh))
#+END_SRC
@ -1362,7 +1373,7 @@ Value must be a keyword corresponding to a key in
#+BEGIN_SRC emacs-lisp
(defun chronometrist-switch-backend ()
(interactive)
(chronometrist-debug-message "Command: switch-backend")
(chronometrist-debug-message "[Command] switch-backend")
(let* ((prompt (format "Switch to backend (current - %s): "
chronometrist-active-backend))
(choice (chronometrist-read-backend-name prompt
@ -1475,11 +1486,13 @@ a user.")
**** file operations
***** create-file :generic:function:
[[file:../tests/chronometrist-tests.org::#tests-backend-create-file][tests]]
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-create-file (backend &optional file)
"Create file associated with BACKEND.
Use FILE as a path, if provided.
Return non-nil if FILE is successfully created, and nil if it already exists.")
Return path of new file if successfully created, and nil if it already exists.")
#+END_SRC
***** latest-date-records :generic:function:
@ -1494,6 +1507,10 @@ Return nil if BACKEND contains no records.")
(cl-defgeneric chronometrist-insert (backend plist)
"Insert PLIST as new record in BACKEND.
Return non-nil if record is inserted successfully.")
(cl-defmethod chronometrist-insert :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
(error "Not a valid plist: %S" plist)))
#+END_SRC
***** remove-last :generic:function:
@ -1518,12 +1535,23 @@ Return value may be active, i.e. it may or may not have a :stop key-value.")
DATE-TS must be a `ts.el' struct.
Return nil if BACKEND contains no records.")
(cl-defmethod chronometrist-task-records-for-date :before ((_backend t) task date-ts &key &allow-other-keys)
(unless (cl-typep task 'string)
(error "task %S is not a string" task))
(unless (cl-typep date-ts 'ts)
(error "date-ts %S is not a `ts' struct" date-ts)))
#+END_SRC
***** replace-last :generic:function:
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-replace-last (backend plist)
"Replace last record in BACKEND with PLIST.")
"Replace last record in BACKEND with PLIST.
Return non-nil if successful.")
(cl-defmethod chronometrist-replace-last :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
(error "Not a valid plist: %S" plist)))
#+END_SRC
***** to-file :generic:function:
@ -1705,7 +1733,8 @@ These can be implemented in terms of the minimal protocol above.
rest-start rest-end rest-hash
file-length last-hash) backend
(chronometrist-reset-task-list backend)
(file-notify-rm-watch file-watch)
(when file-watch
(file-notify-rm-watch file-watch))
(setf hash-table (chronometrist-to-hash-table backend)
file-watch nil
rest-start nil
@ -1750,7 +1779,7 @@ These can be implemented in terms of the minimal protocol above.
**** elisp-sexp-backend :class:
#+BEGIN_SRC emacs-lisp
(defclass chronometrist-elisp-sexp-backend (chronometrist-backend)
(defclass chronometrist-elisp-sexp-backend (chronometrist-backend chronometrist-file-backend-mixin)
((rest-start :initarg :rest-start
:initform nil
:accessor chronometrist-backend-rest-start
@ -1792,7 +1821,7 @@ These can be implemented in terms of the minimal protocol above.
(goto-char (point-min))
(insert ";;; -*- mode: chronometrist-sexp; -*-\n\n")
(write-file file))
t)))
file)))
#+END_SRC
**** in-file :macro:
@ -2085,7 +2114,7 @@ FS-EVENT is the event passed by the `filenotify' library (see `file-notify-add-w
(chronometrist-file-change-type backend)))
(reset-watch-p (or (eq action 'deleted)
(eq action 'renamed))))
(chronometrist-debug-message "File change type %s" change)
(chronometrist-debug-message "[Method] on-change: file change type %s" change)
;; If only the last plist was changed, update hash table and
;; task list, otherwise clear and repopulate hash table.
(cond ((or reset-watch-p
@ -2209,7 +2238,7 @@ Boilerplate for updating state between file operations in tests.
#+END_SRC
**** backend :class:
#+BEGIN_SRC emacs-lisp
(defclass chronometrist-plist-backend (chronometrist-elisp-sexp-backend chronometrist-file-backend-mixin)
(defclass chronometrist-plist-backend (chronometrist-elisp-sexp-backend)
((extension :initform "plist"
:accessor chronometrist-backend-ext
:custom 'string)))
@ -2274,9 +2303,9 @@ In this backend, it's easier to implement this in terms of [[#program-backend-pl
**** insert :writer:method:
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-backend) plist)
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-backend) plist &key (save t))
(chronometrist-backend-run-assertions backend)
(chronometrist-debug-message "Insert plist %s" plist)
(chronometrist-debug-message "[Method] insert: %s" plist)
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (point-max))
;; If we're adding the first s-exp in the file, don't add a
@ -2284,14 +2313,18 @@ In this backend, it's easier to implement this in terms of [[#program-backend-pl
(unless (bobp) (insert "\n"))
(unless (bolp) (insert "\n"))
(funcall chronometrist-sexp-pretty-print-function plist (current-buffer))
(save-buffer)))
(when save (save-buffer))
t))
#+END_SRC
**** remove-last :writer:method:
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-remove-last ((backend chronometrist-plist-backend))
(chronometrist-debug-message "[Method] remove-last")
(chronometrist-backend-run-assertions backend)
(chronometrist-debug-message "Remove last plist")
(when (chronometrist-backend-empty-p backend)
(error "chronometrist-remove-last has nothing to remove in %s"
(eieio-object-class-name backend)))
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (point-max))
;; this condition should never really occur, since we insert a
@ -2307,7 +2340,7 @@ In this backend, it's easier to implement this in terms of [[#program-backend-pl
"Reindent the current buffer.
This is meant to be run in `chronometrist-file' when using an s-expression backend."
(interactive)
(chronometrist-debug-message "Command: reindent-buffer")
(chronometrist-debug-message "[Command] reindent-buffer")
(let (expr)
(goto-char (point-min))
(while (setq expr (ignore-errors (read (current-buffer))))
@ -2411,11 +2444,12 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
***** replace-last :writer:method:
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-replace-last ((backend chronometrist-plist-backend) plist)
(chronometrist-debug-message "Replace last plist with %s" plist)
(chronometrist-debug-message "[Method] replace-last with %s" plist)
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(goto-char (chronometrist-remove-last backend))
(funcall chronometrist-sexp-pretty-print-function plist (current-buffer))
(save-buffer)))
(save-buffer)
t))
#+END_SRC
***** count-records :reader:method:
@ -2454,7 +2488,7 @@ Concerns specific to the plist group backend -
**** backend :class:
#+BEGIN_SRC emacs-lisp
(defclass chronometrist-plist-group-backend (chronometrist-elisp-sexp-backend chronometrist-file-backend-mixin)
(defclass chronometrist-plist-group-backend (chronometrist-elisp-sexp-backend)
((extension :initform "plg"
:accessor chronometrist-backend-ext
:custom 'string)))
@ -2507,7 +2541,7 @@ Situations -
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-insert ((backend chronometrist-plist-group-backend) plist &key (save t))
(cl-check-type plist chronometrist-plist)
(chronometrist-debug-message "Insert %S" plist)
(chronometrist-debug-message "[Method] insert: %S" plist)
(chronometrist-backend-run-assertions backend)
(if (not plist)
(error "%s" "`chronometrist-insert' was called with an empty plist")
@ -2555,6 +2589,8 @@ Situations -
#+END_SRC
**** plists-split-p :function:
[[file:../tests/chronometrist-tests.org::#tests-common-plists-split-p][tests]]
#+BEGIN_SRC emacs-lisp
(defun chronometrist-plists-split-p (old-plist new-plist)
"Return t if OLD-PLIST and NEW-PLIST are split plists.
@ -2766,7 +2802,7 @@ Return value is either a list in the form
***** replace-last :writer:method:
=chronometrist-replace-last= is what is used for clocking out, so we split midnight-spanning intervals in this operation.
We apply the same hack as in the [[<<hack-note-plist-group-insert>>][insert]] method, removing and inserting the plist group instead of just the specific plist, to avoid having to update the pretty printer.
We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method, removing and inserting the plist group instead of just the specific plist, to avoid having to update the pretty printer.
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-replace-last ((backend chronometrist-plist-group-backend) plist)
@ -2776,7 +2812,8 @@ We apply the same hack as in the [[<<hack-note-plist-group-insert>>][insert]] me
(chronometrist-sexp-in-file (chronometrist-backend-file backend)
(chronometrist-remove-last backend :save nil)
(chronometrist-insert backend plist :save nil)
(save-buffer)))
(save-buffer)
t))
#+END_SRC
***** count-records :reader:method:NOEXPORT:
@ -3474,7 +3511,7 @@ If INHIBIT-HOOKS is non-nil, the hooks
`chronometrist-before-out-functions', and
`chronometrist-after-out-functions' will not be run."
(interactive "P")
(chronometrist-debug-message "Command: toggle-task %s" (if inhibit-hooks "(without hooks)" ""))
(chronometrist-debug-message "[Command] toggle-task %s" (if inhibit-hooks "(without hooks)" ""))
(let* ((empty-file (chronometrist-backend-empty-p (chronometrist-active-backend)))
(nth (when prefix (chronometrist-goto-nth-task prefix)))
(at-point (chronometrist-task-at-point))
@ -3513,7 +3550,7 @@ there is no corresponding task, do nothing."
(defun chronometrist-add-new-task ()
"Add a new task."
(interactive)
(chronometrist-debug-message "Command: add-new-task")
(chronometrist-debug-message "[Command] add-new-task")
(chronometrist-add-new-task-button nil))
#+END_SRC
@ -3527,7 +3564,7 @@ INHIBIT-HOOKS is non-nil or prefix argument is supplied.
Has no effect if no task is active."
(interactive "P")
(chronometrist-debug-message "Command: restart-task")
(chronometrist-debug-message "[Command] restart-task")
(if (chronometrist-current-task)
(let* ((latest (chronometrist-latest-record (chronometrist-active-backend)))
(plist (plist-put latest :start (chronometrist-format-time-iso8601)))
@ -3550,7 +3587,7 @@ INHIBIT-HOOKS is non-nil or prefix argument is supplied.
Has no effect if a task is active."
(interactive "P")
(chronometrist-debug-message "Command: extend-task")
(chronometrist-debug-message "[Command] extend-task")
(if (chronometrist-current-task)
(message "Cannot extend an active task - use this after clocking out.")
(let* ((latest (chronometrist-latest-record (chronometrist-active-backend)))
@ -3568,7 +3605,7 @@ Has no effect if a task is active."
(defun chronometrist-discard-active ()
"Remove active interval from the active backend."
(interactive)
(chronometrist-debug-message "Command: discard-active")
(chronometrist-debug-message "[Command] discard-active")
(let ((backend (chronometrist-active-backend)))
(if (chronometrist-current-task backend)
(chronometrist-remove-last backend)
@ -4587,9 +4624,13 @@ Return value is a list as specified by `tabulated-list-entries'."
;;; chronometrist.el ends here
#+END_SRC
** Local variables :NOEXPORT:
* Local variables :NOEXPORT:
Evaluate this to be able to insert the package prefix via =nameless-insert-name=, at the cost of having all =nameless-aliases= break (= less readable code).
#+BEGIN_SRC emacs-lisp :load no :tangle no
(setq nameless-current-name "chronometrist")
#+END_SRC
# Local Variables:
# eval: (when (package-installed-p 'literate-elisp) (require 'literate-elisp) (literate-elisp-load (buffer-file-name)))
# End:

128
manual.md
View File

@ -13,7 +13,7 @@
1. [chronometrist](#usage-chronometrist)
2. [chronometrist-report](#usage-chronometrist-report)
3. [chronometrist-statistics](#usage-chronometrist-statistics)
4. [chronometrist-details](#org533cd01)
4. [chronometrist-details](#org690c2df)
5. [common commands](#usage-common-commands)
6. [Time goals/targets](#time-goals)
6. [How-to](#how-to)
@ -21,17 +21,19 @@
2. [How to load the program using literate-elisp](#how-to-literate-elisp)
3. [How to attach tags to time intervals](#how-to-tags)
4. [How to attach key-values to time intervals](#how-to-key-value-pairs)
5. [How to skip running hooks/attaching tags and key values](#org0e10cef)
5. [How to skip running hooks/attaching tags and key values](#org63b85c6)
6. [How to open certain files when you start a task](#how-to-open-files-on-task-start)
7. [How to warn yourself about uncommitted changes](#how-to-warn-uncommitted-changes)
8. [How to display the current time interval in the activity indicator](#how-to-activity-indicator)
9. [How to back up your Chronometrist data](#how-to-backup)
7. [Explanation](#orgd02a44a)
10. [How to configure Vertico for use with Chronometrist](#howto-vertico)
7. [Explanation](#org4f83f29)
1. [Literate Program](#explanation-literate-program)
8. [User's reference](#org215fb52)
8. [User's reference](#org07932c3)
9. [Contributions and contact](#contributions-contact)
10. [License](#license)
11. [Thanks](#thanks)
12. [Local variables](#org6ca2f77):NOEXPORT:
<a href="https://liberapay.com/contrapunctus/donate"><img alt="Donate using Liberapay" src="https://img.shields.io/liberapay/receives/contrapunctus.svg?logo=liberapay"></a>
@ -51,15 +53,14 @@ Largely modelled after the Android application, [A Time Tracker](https://github.
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
6. Fancy graphs with chronometrist-sparkline extension
6. Fancy graphs with the `chronometrist-spark` extension
<a id="limitations"></a>
# Limitations
1. No support (yet) for adding a task without clocking into it.
2. No support for concurrent tasks.
1. No support for concurrent tasks.
<a id="comparisons"></a>
@ -88,7 +89,6 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
- 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.
@ -103,7 +103,7 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
## from MELPA
1. Set up MELPA - <https://melpa.org/#/getting-started>
(Chronometrist uses Semantic Versioning and the developer is accident-prone, so using MELPA Stable is suggested 😏)
2. `M-x package-install RET chronometrist RET`
@ -112,7 +112,7 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
## from Git
You can get `chronometrist` from <https://tildegit.org/contrapunctus/chronometrist>
You can get `chronometrist` from <https://tildegit.org/contrapunctus/chronometrist> or <https://codeberg.org/contrapunctus/chronometrist>
`chronometrist` requires
@ -159,7 +159,7 @@ Run `M-x chronometrist-statistics` (or `chronometrist` with a prefix argument of
Press `b` to look at past time ranges, and `f` for future ones.
<a id="org533cd01"></a>
<a id="org690c2df"></a>
## chronometrist-details
@ -202,7 +202,7 @@ Evaluate or add to your init.el the following -
## How to load the program using literate-elisp
(add-to-list 'load-path "<directory containing chronometrist.org>")
(require 'literate-elisp) ;; or autoload, use-package, ...
(literate-elisp-load "chronometrist.org")
@ -211,13 +211,13 @@ Evaluate or add to your init.el the following -
## How to attach tags to time intervals
1. Add `chronometrist-tags-add` to one or more of these hooks <sup><a id="fnr.1" class="footref" href="#fn.1">1</a></sup> -
1. Add `chronometrist-tags-add` to one or more of these hooks <sup><a id="fnr.1" class="footref" href="#fn.1" role="doc-backlink">1</a></sup> -
(add-to-list 'chronometrist-after-in-functions 'chronometrist-tags-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-tags-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-tags-add)
2. clock in/clock out to trigger the hook.
The prompt suggests past combinations you used for the current task, which you can browse with `M-p=/=M-n`. You can leave it blank by pressing `RET`.
@ -225,16 +225,16 @@ Evaluate or add to your init.el the following -
## How to attach key-values to time intervals
1. Add `chronometrist-kv-add` to one or more of these hooks <sup><a id="fnr.2" class="footref" href="#fn.2">2</a></sup> -
1. Add `chronometrist-kv-add` to one or more of these hooks <sup><a id="fnr.2" class="footref" href="#fn.2" role="doc-backlink">2</a></sup> -
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
To exit the prompt, press the key it indicates for quitting - you can then edit the resulting key-values by hand if required. Press `C-c C-c` to accept the key-values, or `C-c C-k` to cancel.
<a id="org0e10cef"></a>
<a id="org63b85c6"></a>
## How to skip running hooks/attaching tags and key values
@ -253,7 +253,7 @@ An idea from the author's own init -
(find-file-other-window "~/repertoire.org"))
;; ...
))
(add-hook 'chronometrist-before-in-functions 'my-start-project)
@ -264,19 +264,19 @@ An idea from the author's own init -
Another one, prompting the user if they have uncommitted changes in a git repository (assuming they use [Magit](https://magit.vc/)) -
(autoload 'magit-anything-modified-p "magit")
(defun my-commit-prompt ()
"Prompt user if `default-directory' is a dirty Git repository.
Return t if the user answers yes, if the repository is clean, or
if there is no Git repository.
Return nil (and run `magit-status') if the user answers no."
(cond ((not (magit-anything-modified-p)) t)
((yes-or-no-p
(format "You have uncommitted changes in %S. Really clock out? "
default-directory)) t)
(t (magit-status) nil)))
((yes-or-no-p
(format "You have uncommitted changes in %S. Really clock out? "
default-directory)) t)
(t (magit-status) nil)))
(add-hook 'chronometrist-before-out-functions 'my-commit-prompt)
@ -285,14 +285,14 @@ Another one, prompting the user if they have uncommitted changes in a git reposi
## How to display the current time interval in the activity indicator
(defun my-activity-indicator ()
(thread-last (plist-put (chronometrist-last)
:stop (chronometrist-format-time-iso8601))
list
chronometrist-events-to-durations
(-reduce #'+)
truncate
chronometrist-format-time))
(--> (chronometrist-latest-record (chronometrist-active-backend))
(plist-put it :stop (chronometrist-format-time-iso8601))
(list it)
(chronometrist-events-to-durations it)
(-reduce #'+ it)
(truncate it)
(chronometrist-format-duration it)))
(setq chronometrist-activity-indicator #'my-activity-indicator)
@ -300,27 +300,42 @@ Another one, prompting the user if they have uncommitted changes in a git reposi
## How to back up your Chronometrist data
I suggest backing up Chronometrist data on each save. Here's how you can do that.
I suggest backing up Chronometrist data on each save using the [async-backup](https://tildegit.org/contrapunctus/async-backup) package.<sup><a id="fnr.3" class="footref" href="#fn.3" role="doc-backlink">3</a></sup> Here's how you can do that.
1. Add the following to your init.
(setq backup-by-copying t
kept-new-versions 10
kept-old-versions 10
version-control t)
(defun my-force-backup ()
(setq buffer-backed-up nil))
2. Open your Chronometrist file and add the function to `before-save-hook`.
(use-package async-backup)
2. Open your Chronometrist file and add `async-backup` to a buffer-local `after-save-hook`.
M-x chronometrist-open-log
M-x add-file-local-variable-prop-line RET eval RET (add-hook 'before-save-hook #'my-force-backup nil t) RET
M-x add-file-local-variable-prop-line RET eval RET (add-hook 'after-save-hook #'async-backup nil t) RET
3. Optionally, configure `backup-directory-alist` to set a specific directory for the backups.
Adapted from this [StackOverflow answer](https://stackoverflow.com/questions/6916529/how-can-i-make-emacs-backup-every-time-i-save).
<a id="orgd02a44a"></a>
<a id="howto-vertico"></a>
## How to configure Vertico for use with Chronometrist
By default, [Vertico](https://github.com/minad/vertico) uses its own sorting function - for some commands (such as `chronometrist-key-values-unified-prompt`) this results in *worse* suggestions, since Chronometrist sorts suggestions in most-recent-first order.
You can either disable Vertico's sorting entirely -
(setq vertico-sort-function nil)
Or use `vertico-multiform` to disable sorting for only specific commands -
(use-package vertico-multiform
:init (vertico-multiform-mode)
:config
(setq vertico-multiform-commands
'((chronometrist-toggle-task (vertico-sort-function . nil))
(chronometrist-toggle-task-no-hooks (vertico-sort-function . nil))
(chronometrist-key-values-unified-prompt (vertico-sort-function . nil)))))
<a id="org4f83f29"></a>
# Explanation
@ -331,12 +346,12 @@ Adapted from this [StackOverflow answer](https://stackoverflow.com/questions/691
Chronometrist is a literate program, made using Org - the canonical source is the `chronometrist.org` file, which contains source blocks. These are provided to users after *tangling* (extracting the source into an Emacs Lisp file).
The Org file can also be loaded directly using the [literate-elisp](https://github.com/jingtaozf/literate-elisp) package, so that all source links (e.g. `xref`, `describe-function`) lead to the Org file, within the context of the concerned documentation. See [How to load the program using literate-elisp](#how-to-literate-elisp).
The Org file can also be loaded directly using the [literate-elisp](https://github.com/jingtaozf/literate-elisp) package, so that all source links (e.g. `xref`, `describe-function`) lead to the Org file, within the context of the concerned documentation. See [How to load the program using literate-elisp](#how-to-literate-elisp).
`chronometrist.org` is also included in MELPA installs, although not used directly by default, since doing so would interfere with automatic generation of autoloads.
<a id="org215fb52"></a>
<a id="org07932c3"></a>
# User's reference
@ -393,7 +408,7 @@ I'd rather make a request - please do everything you can to help that dream come
Chronometrist is released under your choice of [Unlicense](https://unlicense.org/) or the [WTFPL](http://www.wtfpl.net/).
(See files <UNLICENSE> and <WTFPL>).
(See files [UNLICENSE](UNLICENSE) and [WTFPL](WTFPL)).
<a id="thanks"></a>
@ -402,15 +417,22 @@ Chronometrist is released under your choice of [Unlicense](https://unlicense.org
wasamasa, bpalmer, aidalgol, pjb and the rest of #emacs for their tireless help and support
jwiegley for timeclock.el, which we used as a backend in earlier versions
jwiegley for `timeclock.el`, which we used as a backend in earlier versions
blandest for helping me with the name
fiete and wu-lee for testing and bug reports
<a id="org6ca2f77"></a>
# Local variables :NOEXPORT:
# Footnotes
<sup><a id="fn.1" href="#fnr.1">1</a></sup> but not `chronometrist-before-in-functions`
<sup><a id="fn.2" href="#fnr.2">2</a></sup> but not `chronometrist-before-in-functions`
<sup><a id="fn.3" href="#fnr.3">3</a></sup> It is possible to use Emacs' built-in backup system to do it, but since it is synchronous, doing so will greatly slow down saving of the Chronometrist file.

View File

@ -1,5 +1,6 @@
#+TITLE: Chronometrist - an extensible time tracker for Emacs
#+SUBTITLE: User Manual
#+SUBTITLE: Friendly and powerful personal time tracker and analyzer for Emacs
#+DESCRIPTION: User Manual
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="style.css" />
#+BEGIN_EXPORT html
@ -21,14 +22,13 @@ Largely modelled after the Android application, [[https://github.com/netmackan/A
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
6. Fancy graphs with chronometrist-sparkline extension
6. Fancy graphs with the =chronometrist-spark= extension
* Limitations
:PROPERTIES:
:CUSTOM_ID: limitations
:END:
1. No support (yet) for adding a task without clocking into it.
2. No support for concurrent tasks.
1. No support for concurrent tasks.
* Comparisons
:PROPERTIES:
@ -54,7 +54,6 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
+ 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.
@ -77,7 +76,7 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
:CUSTOM_ID: install-from-git
:END:
You can get =chronometrist= from https://tildegit.org/contrapunctus/chronometrist
You can get =chronometrist= from https://tildegit.org/contrapunctus/chronometrist or https://codeberg.org/contrapunctus/chronometrist
=chronometrist= requires
+ Emacs v25 or higher
@ -173,7 +172,7 @@ Evaluate or add to your init.el the following -
:CUSTOM_ID: how-to-tags
:END:
1. Add =chronometrist-tags-add= to one or more of these hooks [fn:1] -
1. Add =chronometrist-tags-add= to one or more of these hooks [fn:2] -
#+BEGIN_SRC emacs-lisp
(add-to-list 'chronometrist-after-in-functions 'chronometrist-tags-add)
@ -184,24 +183,24 @@ Evaluate or add to your init.el the following -
The prompt suggests past combinations you used for the current task, which you can browse with =M-p=/=M-n=. You can leave it blank by pressing =RET=.
[fn:1] but not =chronometrist-before-in-functions=
[fn:2] but not =chronometrist-before-in-functions=
** How to attach key-values to time intervals
:PROPERTIES:
:CUSTOM_ID: how-to-key-value-pairs
:END:
1. Add =chronometrist-kv-add= to one or more of these hooks [fn:2] -
1. Add =chronometrist-kv-add= to one or more of these hooks [fn:3] -
#+BEGIN_SRC emacs-lisp
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
#+END_SRC
#+BEGIN_SRC emacs-lisp
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
#+END_SRC
To exit the prompt, press the key it indicates for quitting - you can then edit the resulting key-values by hand if required. Press =C-c C-c= to accept the key-values, or =C-c C-k= to cancel.
[fn:2] but not =chronometrist-before-in-functions=
[fn:3] but not =chronometrist-before-in-functions=
** How to skip running hooks/attaching tags and key values
Use =M-RET= (=chronometrist-toggle-task-no-hooks=) to clock in/out.
@ -256,13 +255,13 @@ Return nil (and run `magit-status') if the user answers no."
#+BEGIN_SRC emacs-lisp
(defun my-activity-indicator ()
(thread-last (plist-put (chronometrist-last)
:stop (chronometrist-format-time-iso8601))
list
chronometrist-events-to-durations
(-reduce #'+)
truncate
chronometrist-format-time))
(--> (chronometrist-latest-record (chronometrist-active-backend))
(plist-put it :stop (chronometrist-format-time-iso8601))
(list it)
(chronometrist-events-to-durations it)
(-reduce #'+ it)
(truncate it)
(chronometrist-format-duration it)))
(setq chronometrist-activity-indicator #'my-activity-indicator)
#+END_SRC
@ -271,25 +270,43 @@ Return nil (and run `magit-status') if the user answers no."
:PROPERTIES:
:CUSTOM_ID: how-to-backup
:END:
I suggest backing up Chronometrist data on each save. Here's how you can do that.
I suggest backing up Chronometrist data on each save using the [[https://tildegit.org/contrapunctus/async-backup][async-backup]] package.[fn:1] Here's how you can do that.
1. Add the following to your init.
#+BEGIN_SRC emacs-lisp
(setq backup-by-copying t
kept-new-versions 10
kept-old-versions 10
version-control t)
(defun my-force-backup ()
(setq buffer-backed-up nil))
(use-package async-backup)
#+END_SRC
2. Open your Chronometrist file and add the function to =before-save-hook=.
2. Open your Chronometrist file and add =async-backup= to a buffer-local =after-save-hook=.
: M-x chronometrist-open-log
: M-x add-file-local-variable-prop-line RET eval RET (add-hook 'before-save-hook #'my-force-backup nil t) RET
: M-x add-file-local-variable-prop-line RET eval RET (add-hook 'after-save-hook #'async-backup nil t) RET
3. Optionally, configure =backup-directory-alist= to set a specific directory for the backups.
Adapted from this [[https://stackoverflow.com/questions/6916529/how-can-i-make-emacs-backup-every-time-i-save][StackOverflow answer]].
[fn:1] It is possible to use Emacs' built-in backup system to do it, but since it is synchronous, doing so will greatly slow down saving of the Chronometrist file.
** How to configure Vertico for use with Chronometrist
:PROPERTIES:
:CUSTOM_ID: howto-vertico
:END:
By default, [[https://github.com/minad/vertico][Vertico]] uses its own sorting function - for some commands (such as =chronometrist-key-values-unified-prompt=) this results in /worse/ suggestions, since Chronometrist sorts suggestions in most-recent-first order.
You can either disable Vertico's sorting entirely -
#+BEGIN_SRC emacs-lisp
(setq vertico-sort-function nil)
#+END_SRC
Or use =vertico-multiform= to disable sorting for only specific commands -
#+BEGIN_SRC emacs-lisp
(use-package vertico-multiform
:init (vertico-multiform-mode)
:config
(setq vertico-multiform-commands
'((chronometrist-toggle-task (vertico-sort-function . nil))
(chronometrist-toggle-task-no-hooks (vertico-sort-function . nil))
(chronometrist-key-values-unified-prompt (vertico-sort-function . nil)))))
#+END_SRC
* Explanation
** Literate Program
:PROPERTIES:
@ -297,7 +314,7 @@ Adapted from this [[https://stackoverflow.com/questions/6916529/how-can-i-make-e
:END:
Chronometrist is a literate program, made using Org - the canonical source is the =chronometrist.org= file, which contains source blocks. These are provided to users after /tangling/ (extracting the source into an Emacs Lisp file).
The Org file can also be loaded directly using the [[https://github.com/jingtaozf/literate-elisp][literate-elisp]] package, so that all source links (e.g. =xref=, =describe-function=) lead to the Org file, within the context of the concerned documentation. See [[#how-to-literate-elisp][How to load the program using literate-elisp]].
The Org file can also be loaded directly using the [[https://github.com/jingtaozf/literate-elisp][literate-elisp]] package, so that all source links (e.g. =xref=, =describe-function=) lead to the Org file, within the context of the concerned documentation. See [[#how-to-literate-elisp][How to load the program using literate-elisp]].
=chronometrist.org= is also included in MELPA installs, although not used directly by default, since doing so would interfere with automatic generation of autoloads.
@ -352,7 +369,7 @@ I'd rather make a request - please do everything you can to help that dream come
Chronometrist is released under your choice of [[https://unlicense.org/][Unlicense]] or the [[http://www.wtfpl.net/][WTFPL]].
(See files [[file:UNLICENSE]] and [[file:WTFPL]]).
(See files [[file:UNLICENSE][UNLICENSE]] and [[file:WTFPL][WTFPL]]).
* Thanks
:PROPERTIES:
@ -361,8 +378,14 @@ Chronometrist is released under your choice of [[https://unlicense.org/][Unlicen
wasamasa, bpalmer, aidalgol, pjb and the rest of #emacs for their tireless help and support
jwiegley for timeclock.el, which we used as a backend in earlier versions
jwiegley for =timeclock.el=, which we used as a backend in earlier versions
blandest for helping me with the name
fiete and wu-lee for testing and bug reports
* Local variables :NOEXPORT:
# Local Variables:
# eval: (progn (require 'ox-md) (add-hook 'after-save-hook (lambda () (org-export-to-file 'md "manual.md")) nil t))
# my-org-src-default-lang: "emacs-lisp"
# End:

View File

@ -1,3 +1,4 @@
#+PROPERTY: header-args :tangle yes :load yes :comments link
#+BEGIN_SRC emacs-lisp :load no :tangle no
(setq nameless-current-name "chronometrist")
#+END_SRC
@ -16,9 +17,11 @@
(defvar chronometrist-test-backend
(make-instance 'chronometrist-plist-group-backend :path chronometrist-test-file-path-stem))
(defvar chronometrist-test-backends
(defun chronometrist-make-test-backends ()
(cl-loop for backend in '(chronometrist-plist-backend chronometrist-plist-group-backend)
collect (make-instance backend :path chronometrist-test-file-path-stem)))
(defvar chronometrist-test-backends (chronometrist-make-test-backends))
#+END_SRC
** test-first-record
@ -90,22 +93,78 @@
:stop "2020-05-10T17:10:48+0530")))
#+END_SRC
** cleanup
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-backend-test-cleanup (backend)
"Delete any files created by BACKEND during testing.")
(cl-defmethod chronometrist-backend-test-cleanup ((backend chronometrist-elisp-sexp-backend))
(with-slots (file hash-table) backend
(when file
(when (file-exists-p file)
(delete-file file))
(with-current-buffer (get-file-buffer file)
;; (erase-buffer)
;; Unsuccessful attempt to inhibit "Delete excess backup versions of <file>?" prompts
(defvar delete-old-versions)
(let ((delete-old-versions t))
(set-buffer-modified-p nil)
(kill-buffer))))
(setf hash-table (clrhash hash-table))))
(cl-defmethod chronometrist-backend-test-cleanup :after ((backend t))
(setf chronometrist-test-backends (chronometrist-make-test-backends)))
#+END_SRC
** ert-deftest :macro:
:PROPERTIES:
:CUSTOM_ID: chronometrist-ert-deftest
:END:
#+BEGIN_SRC emacs-lisp
(defmacro chronometrist-ert-deftest (name backend-var &rest test-forms)
"Generate test groups containing TEST-FORMS for each backend.
BACKEND-VAR is bound to each backend in
`chronometrist-test-backends'. TEST-FORMS are passed to
`ert-deftest'."
(declare (indent defun) (debug t))
(cl-loop for backend in chronometrist-test-backends collect
(let* ((backend-name (string-remove-suffix
"-backend"
(string-remove-prefix "chronometrist"
(symbol-name
(eieio-object-class-name backend)))))
(test-name (concat "chronometrist-" (symbol-name name) backend-name)))
`(ert-deftest ,(intern test-name) ()
(let ((,backend-var ,backend))
(unwind-protect
(progn ,@test-forms)
;; cleanup - remove test backend file
(chronometrist-backend-test-cleanup ,backend)))))
into test-groups
finally return (cons 'progn test-groups)))
#+END_SRC
* Tests
** common
*** current-task
:PROPERTIES:
:CUSTOM_ID: tests-common-current-task
:END:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-current-task ()
(chronometrist-insert chronometrist-test-backend '(:name "Test"))
(should (equal (chronometrist-current-task chronometrist-test-backend) "Test"))
(chronometrist-sexp-in-file (chronometrist-backend-file chronometrist-test-backend)
(goto-char (point-max))
(backward-list)
(chronometrist-sexp-delete-list)
(save-buffer))
(should (not (chronometrist-current-task chronometrist-test-backend))))
(chronometrist-ert-deftest current-task b
;; (message "current-task test - hash-table-count %s" (hash-table-count (chronometrist-backend-hash-table b)))
(chronometrist-create-file b)
(should (not (chronometrist-current-task b)))
(chronometrist-insert b (list :name "Test" :start (chronometrist-format-time-iso8601)))
(should (equal "Test" (chronometrist-current-task b)))
(chronometrist-remove-last b)
(should (not (chronometrist-current-task b))))
#+END_SRC
*** plist-p
:PROPERTIES:
:CUSTOM_ID: tests-common-plist-p
:END:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-plist-p ()
(should (eq t (chronometrist-plist-p '(:a 1 :b 2))))
@ -115,6 +174,9 @@
#+END_SRC
*** plists-split-p
:PROPERTIES:
:CUSTOM_ID: tests-common-plists-split-p
:END:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-plists-split-p ()
(should
@ -163,13 +225,16 @@
** data structures
*** list-tasks
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-list-tasks ()
(let ((task-list (chronometrist-list-tasks chronometrist-test-backend)))
(chronometrist-ert-deftest list-tasks b
;; (message "list-tasks test - hash-table-count %s" (hash-table-count (chronometrist-backend-hash-table b)))
(chronometrist-create-file b)
(let ((task-list (chronometrist-list-tasks b)))
(should (listp task-list))
(should (seq-every-p #'stringp task-list))))
#+END_SRC
** time functions
*** format-duration-long :pure:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-format-duration-long ()
(should (equal (chronometrist-format-duration-long 5) ""))
@ -341,98 +406,83 @@ Situations
5. multiple records
6. +[plist-group] latest plist is split+ (covered in #4)
*** ert-deftest :macro:
:PROPERTIES:
:CUSTOM_ID: chronometrist-ert-deftest
:END:
#+BEGIN_SRC emacs-lisp :load no :tangle no
(defmacro chronometrist-ert-deftest (backend-var &rest deftest-forms)
"Generate test groups for each backend in `chronometrist-test-backends'.
Each backend object is bound to BACKEND-VAR. DEFTEST-FORMS are
passed to `ert-deftest'."
(declare (indent defun) (debug t))
(cl-loop for backend in chronometrist-test-backends collect
(let ((backend-name (symbol-name (eieio-object-class-name backend))))
`(ert-deftest ,(intern (concat backend-name "-tests")) ()
(let ((,backend-var ,backend))
,@deftest-forms)))
into test-groups
finally return (cons 'progn test-groups)))
#+END_SRC
Tests to be added -
1. to-hash-table
2. to-file
The order of these tests is important - the last test for each case is one which moves into the next case.
*** create-file
:PROPERTIES:
:CUSTOM_ID: tests-backend-create-file
:END:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-backend-tests ()
(let ((plist-1 (car chronometrist-test-records))
(chronometrist-ert-deftest create-file b
;; (message "create-file test - hash-table-count %s" (hash-table-count (chronometrist-backend-hash-table b)))
;; * file does not exist *
(should (chronometrist-create-file b))
;; * file exists but has no records *
(should (not (chronometrist-create-file b))))
#+END_SRC
*** latest-date-records
#+BEGIN_SRC emacs-lisp
(chronometrist-ert-deftest latest-date-records b
;; (message "latest-date-records test - hash-table-count %s" (hash-table-count (chronometrist-backend-hash-table b)))
(let ((plist-1 (cl-first chronometrist-test-records))
(plist-2 (cl-second chronometrist-test-records))
(today-ts (chronometrist-date-ts)))
(cl-loop for b in chronometrist-test-backends do
(unwind-protect
(progn
;; * file does not exist *
(should-error (chronometrist-latest-date-records b))
(should-error (chronometrist-insert b plist-1))
(should-error (chronometrist-remove-last b))
(should-error (chronometrist-to-list b))
;; extended protocol
(should-error (chronometrist-latest-record b))
(should-error (chronometrist-task-records-for-date b "Test" today-ts))
(should-error (chronometrist-replace-last b plist-2))
(should (chronometrist-create-file b))
;; * file does not exist *
(should-error (chronometrist-latest-date-records b))
(should (chronometrist-create-file b))
;; (message "latest-date-records: %S" (chronometrist-latest-date-records b))
;; * file exists but has no records *
(should (not (chronometrist-latest-date-records b)))
(should (chronometrist-insert b plist-1))
;; * backend has a single active record *
;; * file exists but has no records *
(should (not (chronometrist-create-file b)))
(should (not (chronometrist-latest-date-records b)))
(should-error (chronometrist-remove-last b))
(should (not (chronometrist-to-list b)))
;; extended protocol
(should (not (chronometrist-latest-record b)))
(should (not (chronometrist-task-records-for-date b "Test" today-ts)))
(should-error (chronometrist-replace-last b plist-2))
(should (chronometrist-insert b plist-1))
;; * backend has a single inactive record *
;; * backend has a single active day-crossing record *
;; * backend has a single inactive day-crossing record *
;; * backend has a single active record *
;; * backend has a single inactive record *
;; * backend has a single active day-crossing record *
;; * backend has a single inactive day-crossing record *
;; * backend has two records and one is active *
;; * backend has two records and both are inactive *
;; * backend has two day-crossing records and one is active *
;; * backend has two day-crossing records and both are inactive *
)
;; cleanup - remove test backend file
(let ((file (chronometrist-backend-file b)))
(chronometrist-reset-internal b)
(when (and file (file-exists-p file))
(delete-file file)
(kill-buffer (get-file-buffer file))))))))
;; * backend has two records and one is active *
;; * backend has two records and both are inactive *
;; * backend has two day-crossing records and one is active *
;; * backend has two day-crossing records and both are inactive *
))
#+END_SRC
*** count-records
*** insert
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-count-records ()
(should (= (chronometrist-count-records chronometrist-test-backend) 12)))
(chronometrist-ert-deftest insert b
;; (message "insert test - hash-table-count %s" (hash-table-count (chronometrist-backend-hash-table b)))
(let* ((plist1 (list :name "Test" :start (chronometrist-format-time-iso8601)))
(plist2 (append plist1 (list :stop (chronometrist-format-time-iso8601)))))
;; * file does not exist *
(should-error (chronometrist-insert b plist1))
(should (chronometrist-create-file b))
;; * file exists but has no records *
(should (chronometrist-insert b plist1))
(should (equal (progn (chronometrist-reset-backend b)
(chronometrist-latest-date-records b))
(list (chronometrist-date-iso) plist1)))
;; * backend has a single active record *
(should (chronometrist-replace-last b plist2))
(should (equal (progn (chronometrist-reset-backend b)
(chronometrist-latest-date-records b))
(list (chronometrist-date-iso) plist2)))
;; * backend has a single inactive record *
;; * backend has a single active day-crossing record *
;; * backend has a single inactive day-crossing record *
;; * backend has two records and one is active *
;; * backend has two records and both are inactive *
;; * backend has two day-crossing records and one is active *
;; * backend has two day-crossing records and both are inactive *
))
#+END_SRC
*** latest-record
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-latest-record ()
(should (equal (chronometrist-latest-record chronometrist-test-backend)
chronometrist-test-latest-record)))
#+END_SRC
*** task-records-for-date
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-task-records-for-date ()
(should (equal (chronometrist-task-records-for-date chronometrist-test-backend
"Programming"
(chronometrist-iso-to-ts "2020-05-10"))
(list chronometrist-test-latest-record))))
#+END_SRC
* Local Variables
# Local Variables:
# delete-old-versions: t
# End: