diff --git a/cl/chronometrist.org b/cl/chronometrist.org index ac5ff81..3844395 100644 --- a/cl/chronometrist.org +++ b/cl/chronometrist.org @@ -7,6 +7,9 @@ #+PROPERTY: header-args :tangle yes :comments link * Introduction +:PROPERTIES: +:CUSTOM_ID: introduction +:END: This is a book about Chronometrist, a time tracker for Emacs, written by a humble hobbyist hacker. It also happens to contain the canonical copy of the source code, and can be loaded as an Emacs Lisp program using the =literate-elisp= library. I hope this book—when completed—passes Tim Daly's [[https://www.youtube.com/watch?v=Av0PQDVTP4A&t=8m52s]["Hawaii Test"]], in which a programmer with no knowledge of this program whatsover can read this book end-to-end, and come out as much of an expert in its maintenance as the original author. @@ -16,8 +19,12 @@ I hope this book—when completed—passes Tim Daly's [[https://www.youtube.com/ * Explanation :PROPERTIES: :DESCRIPTION: The design, the implementation, and a little history +:CUSTOM_ID: explanation :END: ** Why I wrote Chronometrist +:PROPERTIES: +:CUSTOM_ID: why-i-wrote-chronometrist +:END: It probably started off with a desire for introspection and self-awareness - what did I do all day? How much time have I spent doing X? It is also a tool to help me stay focused on a single task at a time, and to not go overboard and work on something for 7 hours straight. At first I tried an Android application, "A Time Tracker". The designs of Chronometrist's main buffer and the =report= buffer still resemble that of A Time Tracker. However, every now and then I'd forget to start or stop tracking. Each time I did, I had to open an SQLite database and edit UNIX timestamps to correct it, which was not fun :\ @@ -29,6 +36,7 @@ Quite recently, after around two years of Chronometrist developement and use, I ** Design goals :PROPERTIES: :DESCRIPTION: Some vague objectives which guided the project +:CUSTOM_ID: design-goals :END: 1. Don't make assumptions about the user's profession - e.g. timeclock seems to assume you're using it for a 9-to-5/contractor job @@ -47,6 +55,7 @@ Quite recently, after around two years of Chronometrist developement and use, I ** Terminology :PROPERTIES: :DESCRIPTION: Explanation of some terms used later +:CUSTOM_ID: terminology :END: Chronometrist records /time intervals/ (earlier called "events") as plists. Each plist contains at least a =:name ""=, a =:start ""=, and (except in case of an ongoing task) a =:stop ""=. @@ -56,6 +65,9 @@ Chronometrist records /time intervals/ (earlier called "events") as plists. Each See also [[#explanation-time-formats][Currently-Used Time Formats]] ** Overview +:PROPERTIES: +:CUSTOM_ID: overview +:END: At its most basic, we read data from a [[#program-backend][backend]], store it in a [[#program-data-structures][hash table]], and [[#program-frontend-chronometrist][display it]] as a [[elisp:(find-library "tabulated-list-mode")][=tabulated-list-mode=]] buffer. When the file is changed—whether by the program or the user—we [[refresh-file][update the hash table]] and the [[#program-frontend-chronometrist-refresh][buffer]]. In addition, we implement a [[#program-pretty-printer][plist pretty-printer]] and some [[#program-migration][migration commands]]. @@ -66,16 +78,25 @@ Extensions exist for - 3. [[https://tildegit.org/contrapunctus/chronometrist-goal][time goals and alerts]] ** Optimization +:PROPERTIES: +:CUSTOM_ID: optimization +:END: It is of great importance that Chronometrist be responsive - + A responsive program is more likely to be used; recall our design goal of 'incentivizing use'. + Being an Emacs program, freezing the UI for any human-noticeable length of time is unacceptable - it prevents the user from working on anything in their environment. Thus, I have considered various optimization strategies, and so far implemented two. *** Prevent excess creation of file watchers +:PROPERTIES: +:CUSTOM_ID: prevent-excess-creation-of-file-watchers +:END: 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= to store the watch object. *** Preserve hash table state for some commands +:PROPERTIES: +:CUSTOM_ID: preserve-hash-table-state-for-some-commands +:END: 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][=file=]] was modified, we'd clear the =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. @@ -93,6 +114,9 @@ There are still some operations which [[* refresh-file][=refresh-file=]] runs un [fn:2] As indicated by exploratory work in the =parsimonious-reading= branch, where I made a loop to only =read= and collect s-expressions from the file. It was near-instant...until I added event splitting to it. *** Determine type of change made to file +:PROPERTIES: +:CUSTOM_ID: determine-type-of-change-made-to-file +:END: Most changes, whether made through user-editing or by Chronometrist commands, happen at the end of the file. We try to detect the kind of change made - whether the last expression was modified, removed, or whether a new expression was added to the end - and make the corresponding change to =events=, instead of doing a full parse again (=events-populate=). The increase in responsiveness has been significant. When =refresh-file= is run by the file system watcher, it uses =file-hash= to assign indices and a hash to =-file-state=. The next time the file changes, =file-change-type= compares this state to the current state of the file to determine the type of change made. @@ -116,7 +140,7 @@ Effects on the task list ** Midnight-spanning intervals :PROPERTIES: :DESCRIPTION: Events starting on one day and ending on another -:CUSTOM_ID: explanation-midnight-spanning-intervals +:CUSTOM_ID: midnight-spanning-intervals :END: A unique problem in working with Chronometrist, one I had never foreseen, was tasks which start on one day and end on another. For instance, you start working on something at =2021-01-01 23:00= hours and stop on =2021-01-02 01:00=. @@ -129,11 +153,15 @@ There are a few different approaches of dealing with them. (Currently, Chronomet *** Check the code of the first event of the day (timeclock format) :PROPERTIES: :DESCRIPTION: When the code of the first event in the day is "o", it's a midnight-spanning event. +:CUSTOM_ID: check-the-code-of-the-first-event-of-the-day-(timeclock-format) :END: + Advantage - very simple to detect + Disadvantage - "in" and "out" events must be represented separately *** Split them at the file level +:PROPERTIES: +:CUSTOM_ID: split-them-at-the-file-level +:END: + Advantage - operation is performed only once for each such event + simpler data-consuming code + reduced post-parsing load. + What happens when the user changes their day-start-time? The split-up events are now split wrongly, and the second event may get split /again./ @@ -147,15 +175,22 @@ Possible solutions - This strategy is implemented in the [[#program-backend-plist-group][plist-group]] backend. Custom day-start time is not yet implemented - if ever implemented, it will probably require exporting the file, with all split intervals being combined and re-split. *** Split them at the hash-table-level +:PROPERTIES: +:CUSTOM_ID: split-them-at-the-hash-table-level +:END: Handled by ~sexp-events-populate~ + Advantage - simpler data-consuming code. *** Split them at the data-consumer level (e.g. when calculating time for one day/getting events for one day) +:PROPERTIES: +:CUSTOM_ID: split-them-at-the-data-consumer-level-(e.g.-when-calculating-time-for-one-day-getting-events-for-one-day) +:END: + Advantage - reduced repetitive post-parsing load. ** Point restore behaviour :PROPERTIES: :DESCRIPTION: The desired behaviour of point in Chronometrist +:CUSTOM_ID: point-restore-behaviour :END: After hacking, always test for and ensure the following - 1. Toggling the buffer via [[#program-frontend-chronometrist-command][chronometrist]]/[[#program-frontend-report-command][report]]/[[#program-frontend-statistics-command][statistics]] should preserve point @@ -166,6 +201,7 @@ After hacking, always test for and ensure the following - ** report date range logic :PROPERTIES: :DESCRIPTION: Deriving dates in the current week +:CUSTOM_ID: report-date-range-logic :END: A quick description, starting from the first time [[#program-frontend-report-command][report]] is run in an Emacs session - 1. We get the current date as a =ts= struct, using date. @@ -180,11 +216,14 @@ A quick description, starting from the first time [[#program-frontend-report-com ** Literate programming :PROPERTIES: -:CUSTOM_ID: explanation-literate-programming +:CUSTOM_ID: literate-programming :END: The shift from a bunch of Elisp files to a single Org literate program was born out of frustration with programs stored as text files, which are expensive to restructure (especially in the presence of a VCS). While some dissatisfactions remain, I generally prefer the outcome - tree and source-block folding, tags, properties, and =org-match= have made it trivial to get different views of the program, and literate programming may allow me to express the "explanation" documentation in the same context as the program, without having to try to link between documentation and source. *** Tangling +:PROPERTIES: +:CUSTOM_ID: tangling +:END: At first, I tried tangling. Back when I used =benchmark.el= to test it, =org-babel-tangle= took about 30 seconds to tangle this file. Thus, I wrote a little sed one-liner (in the file-local variables) to do the tangling, which was nearly instant. It emitted anything between lines matching the exact strings ="#+BEGIN_SRC lisp"= and ="#+END_SRC"= - #+BEGIN_SRC org :tangle no @@ -192,6 +231,9 @@ At first, I tried tangling. Back when I used =benchmark.el= to test it, =org-bab #+END_SRC *** literate-elisp-load +:PROPERTIES: +:CUSTOM_ID: literate-elisp-load +:END: Later, we switched from tangling to using the =literate-elisp= package to loading this Org file directly - a file =chronometrist.el= would be used to load =chronometrist.org=. #+BEGIN_SRC lisp :tangle no :load no @@ -202,11 +244,17 @@ Later, we switched from tangling to using the =literate-elisp= package to loadin This way, source links (e.g. help buffers, stack traces) would lead to this Org file, and this documentation was available to each user, within the comfort of their Emacs. The presence of the =.el= file meant that users of =use-package= did not need to make any changes to their configuration. *** Reject modernity, return to tangling +:PROPERTIES: +:CUSTOM_ID: reject-modernity,-return-to-tangling +:END: For all its benefits, the previous approach broke autoloads and no sane way could be devised to make them work, so back we came to tangling. =org-babel-tangle-file= seems to be quicker when run as a Git pre-commit hook - a few seconds' delay before I write a commit message. Certain tools like =checkdoc= remain a pain to use with any kind of literate program. This will probably continue to be the case until these tools are fixed or extended. *** Definition metadata +:PROPERTIES: +:CUSTOM_ID: definition-metadata +:END: Each definition has its own heading. The type of definition is stored in tags - 1. custom group 2. [custom|hook|internal] variable @@ -235,6 +283,9 @@ Further details are stored in properties - 3. :STATE: *** TODO Issues [40%] +:PROPERTIES: +:CUSTOM_ID: issues +:END: 1. [X] When opening this file, Emacs may freeze at the prompt for file-local variable values; if so, C-g will quit the prompt, and permanently marking them as safe will make the freezing stop. [fn:3] 2. [ ] I like =visual-fill-column-mode= for natural language, but I don't want it applied to code blocks. =polymode.el= may hold answers. 3. [X] Is there a tangling solution which requires only one command (e.g. currently we use two =sed= s) but is equally fast? [fn:3] @@ -247,40 +298,59 @@ Further details are stored in properties - ** Currently-Used Time Formats :PROPERTIES: -:CUSTOM_ID: explanation-time-formats +:CUSTOM_ID: currently-used-time-formats :END: *** ts +:PROPERTIES: +:CUSTOM_ID: ts +:END: ts.el struct * Used by nearly all internal functions *** iso-timestamp +:PROPERTIES: +:CUSTOM_ID: iso-timestamp +:END: ="YYYY-MM-DDTHH:MM:SSZ"= * Used in the s-expression file format * Read by sexp-events-populate * Used in the plists in the events hash table values *** iso-date +:PROPERTIES: +:CUSTOM_ID: iso-date +:END: ="YYYY-MM-DD"= * Used as hash table keys in events - can't use ts structs for keys, you'd have to make a hash table predicate which uses ts= *** seconds +:PROPERTIES: +:CUSTOM_ID: seconds +:END: integer seconds as duration * Used for most durations * May be changed to floating point to allow larger durations. The minimum range of =most-positive-fixnum= is 536870911, which seems to be enough to represent durations of 17 years. * Used for update intervals (update-interval, change-update-interval) *** minutes +:PROPERTIES: +:CUSTOM_ID: minutes +:END: integer minutes as duration * Used by [[https://tildegit.org/contrapunctus/chronometrist-goal][goal]] (goals-list, get-goal) - minutes seems like the ideal unit for users to enter *** list-duration +:PROPERTIES: +:CUSTOM_ID: list-duration +:END: =(hours minute seconds)= * Only returned by =seconds-to-hms=, called by =format-time= ** Backend protocol :PROPERTIES: :CREATED: 2022-01-05T19:00:12+0530 +:CUSTOM_ID: backend-protocol :END: The protocol as of now, with remarks - 1. =backend-run-assertions (backend)= @@ -309,6 +379,9 @@ The protocol as of now, with remarks - There are many operations which are file-oriented, whereas I have tried to treat files as implementation details. =create-file=, for instance, is used by =to-file=; I could make creation of files implicit by moving it into =initialize-instance=, but that would mean creation of files in =to-file= would require creation of a backend object. That seems to me to be an abuse of implicit behaviour; and what would backends which are not file-backed do in =to-file=, then? There's probably a way to do it, but I had other things I preferred to tackle first. ** generic loop interface for iterating over records +:PROPERTIES: +:CUSTOM_ID: generic-loop-interface-for-iterating-over-records +:END: Of all the ways to work with Chronometrist data, both as part of the program and as part of my occasional "queries", my favorite was to use =cl-loop=. First, there was the =loop-file= macro, which handled the sole backend at that time - the plist backend. It took care of the common logic (=read= ing each plist in the file, checking loop termination conditions), and let the client code specify (with the terseness of =cl-loop=) what they wanted to do with the data. @@ -329,7 +402,13 @@ In addition, with this approach, client code can use any kind of iteration const The macro still exists in its non-generic form as =loop-sexp-file=, providing a common way to loop over s-expressions in a text file, used by =to-list= in both backends and =to-hash-table= in the plist group backend. * How-to guides for maintainers +:PROPERTIES: +:CUSTOM_ID: how-to-guides-for-maintainers +:END: ** How to set up Emacs to contribute +:PROPERTIES: +:CUSTOM_ID: how-to-set-up-emacs-to-contribute +:END: # Different approaches to this setup - # 1. Document it and let contributors do it voluntarily. # * Downside - if a contributor does not do it, it can lead to inconsistency and an additional headache for the maintainer. @@ -388,10 +467,19 @@ The macro still exists in its non-generic form as =loop-sexp-file=, providing a #+END_SRC ** How to tangle this file +:PROPERTIES: +:CUSTOM_ID: how-to-tangle-this-file +:END: Use =org-babel= (=org-babel-tangle= / =org-babel-tangle-file=), /not/ =literate-elisp-tangle=. The file emitted by the latter does not contain comments - thus, it does not contain library headers or abide by =checkdoc='s comment conventions. * The Program +:PROPERTIES: +:CUSTOM_ID: the-program +:END: ** chronometrist :package: +:PROPERTIES: +:CUSTOM_ID: chronometrist +:END: #+BEGIN_SRC lisp (in-package :cl) (defpackage :chronometrist @@ -440,7 +528,13 @@ Use =org-babel= (=org-babel-tangle= / =org-babel-tangle-file=), /not/ =literate- #+END_SRC ** Common definitions +:PROPERTIES: +:CUSTOM_ID: common-definitions +:END: *** create XDG directories +:PROPERTIES: +:CUSTOM_ID: create-xdg-directories +:END: #+BEGIN_SRC lisp (defvar *xdg-config-dir* (merge-pathnames "chronometrist/" (uiop:xdg-config-home))) @@ -451,18 +545,27 @@ Use =org-babel= (=org-babel-tangle= / =org-babel-tangle-file=), /not/ =literate- #+END_SRC *** *user-configuration-file* :custom:variable: +:PROPERTIES: +:CUSTOM_ID: *user-configuration-file* +:END: #+BEGIN_SRC lisp (defvar *user-configuration-file* (merge-pathnames "config.lisp" *xdg-config-dir*)) #+END_SRC *** *user-data-file* :custom:variable: +:PROPERTIES: +:CUSTOM_ID: *user-data-file* +:END: #+BEGIN_SRC lisp (defvar *user-data-file* (merge-pathnames "chronometrist" *xdg-data-dir*) "Absolute path and file name (without extension) for the Chronometrist database.") #+END_SRC *** make-hash-table-1 :function: +:PROPERTIES: +:CUSTOM_ID: make-hash-table-1 +:END: #+BEGIN_SRC lisp (defun make-hash-table-1 () "Return an empty hash table with `equal' as test." @@ -470,6 +573,9 @@ Use =org-babel= (=org-babel-tangle= / =org-babel-tangle-file=), /not/ =literate- #+END_SRC *** *day-start-time* :custom:variable: +:PROPERTIES: +:CUSTOM_ID: *day-start-time* +:END: =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 lisp (defvar *day-start-time* "00:00:00" @@ -479,6 +585,9 @@ The default is midnight, i.e. \"00:00:00\".") #+END_SRC *** plist-remove :function: +:PROPERTIES: +:CUSTOM_ID: plist-remove +:END: #+BEGIN_SRC lisp (defun plist-remove (plist &rest keys) "Return PLIST with KEYS and their associated values removed." @@ -487,6 +596,9 @@ The default is midnight, i.e. \"00:00:00\".") #+END_SRC **** tests +:PROPERTIES: +:CUSTOM_ID: tests +:END: #+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test (ert-deftest plist-remove () (should @@ -520,6 +632,9 @@ The default is midnight, i.e. \"00:00:00\".") #+END_SRC *** plist-key-values :function: +:PROPERTIES: +:CUSTOM_ID: plist-key-values +:END: #+BEGIN_SRC lisp (defun plist-key-values (plist) "Return user key-values from PLIST." @@ -527,6 +642,9 @@ The default is midnight, i.e. \"00:00:00\".") #+END_SRC *** plist-p :function: +:PROPERTIES: +:CUSTOM_ID: plist-p +:END: [[file:../tests/chronometrist-tests.org::#tests-common-plist-p][tests]] #+BEGIN_SRC lisp @@ -542,7 +660,7 @@ The default is midnight, i.e. \"00:00:00\".") ** Data structures :PROPERTIES: -:CUSTOM_ID: program-data-structures +:CUSTOM_ID: data-structures :END: Reading directly from the file could be difficult, especially when your most common query is "get all intervals recorded on " [fn:4] - and so, we maintain the hash table =chronometrist-events=, where each key is a date in the ISO-8601 format. The plists in this hash table are free of [[#explanation-midnight-spanning-intervals][midnight-spanning intervals]], making code which consumes it easier to write. @@ -551,6 +669,9 @@ The data from =chronometrist-events= is used by most (all?) interval-consuming f [fn:4] it might be the case that the [[#program-backend][file format]] is not suited to our most frequent operation... *** apply-time :function: +:PROPERTIES: +:CUSTOM_ID: apply-time +:END: #+BEGIN_SRC lisp (defun apply-time (time timestamp) "Return TIMESTAMP with time modified to TIME. @@ -566,6 +687,9 @@ Return value is a ts struct (see `ts.el')." #+END_SRC **** tests +:PROPERTIES: +:CUSTOM_ID: tests-1 +:END: #+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test (ert-deftest apply-time () (should @@ -574,6 +698,9 @@ Return value is a ts struct (see `ts.el')." #+END_SRC *** split-plist :function: +:PROPERTIES: +:CUSTOM_ID: split-plist +:END: #+BEGIN_SRC lisp (defun split-plist (plist) "Return a list of two split plists if PLIST spans a midnight, else nil." @@ -596,6 +723,9 @@ Return value is a ts struct (see `ts.el')." #+END_SRC **** tests +:PROPERTIES: +:CUSTOM_ID: tests-2 +:END: #+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test (ert-deftest split-plist () (should @@ -620,6 +750,9 @@ Return value is a ts struct (see `ts.el')." #+END_SRC *** ht-update :writer: +:PROPERTIES: +:CUSTOM_ID: ht-update +:END: #+BEGIN_SRC lisp (defun ht-update (plist hash-table &optional replace) "Return HASH-TABLE with PLIST added as the latest interval. @@ -635,6 +768,9 @@ If REPLACE is non-nil, replace the last interval with PLIST." #+END_SRC *** ht-last-date :reader: +:PROPERTIES: +:CUSTOM_ID: ht-last-date +:END: #+BEGIN_SRC lisp (defun ht-last-date (hash-table) "Return an ISO-8601 date string for the latest date present in `chronometrist-events'." @@ -645,6 +781,9 @@ If REPLACE is non-nil, replace the last interval with PLIST." #+END_SRC *** ht-last :reader: +:PROPERTIES: +:CUSTOM_ID: ht-last +:END: #+BEGIN_SRC lisp (defun ht-last (&optional (backend (chronometrist:active-backend))) "Return the last plist from `chronometrist-events'." @@ -658,7 +797,7 @@ If REPLACE is non-nil, replace the last interval with PLIST." *** ht-subset :reader: :PROPERTIES: :VALUE: hash table -:CUSTOM_ID: program-data-structures-ht-subset +:CUSTOM_ID: ht-subset :END: #+BEGIN_SRC lisp (defun ht-subset (start end hash-table) @@ -679,6 +818,9 @@ treated as though their time is 00:00:00." #+END_SRC *** task-duration-one-day :reader: +:PROPERTIES: +:CUSTOM_ID: task-duration-one-day +:END: #+BEGIN_SRC lisp (defun task-duration-one-day (task &optional (date (timestamp-to-unix (today))) (backend (active-backend))) @@ -692,6 +834,9 @@ The return value is seconds, as an integer." #+END_SRC *** active-time-on :reader: +:PROPERTIES: +:CUSTOM_ID: active-time-on +:END: #+BEGIN_SRC lisp (defvar *task-list*) (defun active-time-on (&optional (date (date-ts))) @@ -703,6 +848,9 @@ Return value is seconds as an integer." #+END_SRC *** count-active-days :function: +:PROPERTIES: +:CUSTOM_ID: count-active-days +:END: #+BEGIN_SRC lisp (defun statistics-count-active-days (task table) "Return the number of days the user spent any time on TASK. @@ -719,6 +867,7 @@ which span midnights." *** *task-list* :custom:variable: :PROPERTIES: :VALUE: list +:CUSTOM_ID: *task-list* :END: #+BEGIN_SRC lisp (defvar *task-list* nil @@ -731,13 +880,22 @@ active backend.") #+END_SRC ** Time functions +:PROPERTIES: +:CUSTOM_ID: time-functions +:END: *** date-iso :function: +:PROPERTIES: +:CUSTOM_ID: date-iso +:END: #+BEGIN_SRC lisp (defun date-iso (&optional (ts (ts-now))) (ts-format "%F" ts)) #+END_SRC *** date-ts :function: +:PROPERTIES: +:CUSTOM_ID: date-ts +:END: #+BEGIN_SRC lisp (defun date-ts (&optional (ts (ts-now))) "Return a ts struct representing the time 00:00:00 on today's date. @@ -747,6 +905,9 @@ TS should be a ts struct (see `ts.el')." #+END_SRC *** format-time-iso8601 :function: +:PROPERTIES: +:CUSTOM_ID: format-time-iso8601 +:END: #+BEGIN_SRC lisp (defun format-time-iso8601 (&optional unix-time) "Return current date and time as an ISO-8601 timestamp. @@ -758,6 +919,9 @@ Optional argument UNIX-TIME should be a time value (see #+END_SRC *** FIXME split-time :reader: +:PROPERTIES: +:CUSTOM_ID: split-time +:END: It does not matter here that the =:stop= dates in the returned plists are different from the =:start=, because =chronometrist-events-populate= uses only the date segment of the =:start= values as hash table keys. (The hash table keys form the rest of the program's notion of "days", and that of which plists belong to which day.) Note - this assumes that an event never crosses >1 day. This seems sufficient for all conceivable cases. @@ -790,6 +954,9 @@ Return a list in the form #+END_SRC **** tests +:PROPERTIES: +:CUSTOM_ID: tests-3 +:END: #+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test (ert-deftest split-time () (should @@ -818,6 +985,9 @@ Return a list in the form #+END_SRC *** seconds-to-hms :function: +:PROPERTIES: +:CUSTOM_ID: seconds-to-hms +:END: #+BEGIN_SRC lisp (defun seconds-to-hms (seconds) "Convert SECONDS to a vector in the form [HOURS MINUTES SECONDS]. @@ -830,6 +1000,9 @@ SECONDS must be a positive integer." #+END_SRC *** interval :function: +:PROPERTIES: +:CUSTOM_ID: interval +:END: #+BEGIN_SRC lisp (defun interval (plist) "Return the period of time covered by EVENT as a time value. @@ -842,6 +1015,9 @@ EVENT should be a plist (see `*user-data-file*')." #+END_SRC *** format-duration-long :function: +:PROPERTIES: +:CUSTOM_ID: format-duration-long +:END: #+BEGIN_SRC lisp (defun format-duration-long (seconds) "Return SECONDS as a human-friendly duration string. @@ -862,6 +1038,9 @@ SECONDS is less than 60, return a blank string." #+END_SRC **** tests +:PROPERTIES: +:CUSTOM_ID: tests-4 +:END: #+BEGIN_SRC lisp :tangle ../tests/chronometrist-tests.el :load test (ert-deftest format-duration-long () (should (equal (format-duration-long 5) "")) @@ -878,6 +1057,9 @@ SECONDS is less than 60, return a blank string." #+END_SRC *** iso-to-date :function: +:PROPERTIES: +:CUSTOM_ID: iso-to-date +:END: #+BEGIN_SRC lisp (defun iso-to-date (timestamp) (first (split-string timestamp :separator "T"))) @@ -885,10 +1067,16 @@ SECONDS is less than 60, return a blank string." ** Backends :PROPERTIES: -:CUSTOM_ID: program-backend +:CUSTOM_ID: backends :END: *** protocol +:PROPERTIES: +:CUSTOM_ID: protocol +:END: **** backend :class: +:PROPERTIES: +:CUSTOM_ID: backend +:END: The backend may use no files, a single file, or multiple files. Thus, =backend= makes no reference to files, and [[#file-backend-mixin][=file-backend-mixin=]] may be used by single file backends. #+BEGIN_SRC lisp @@ -899,6 +1087,9 @@ The backend may use no files, a single file, or multiple files. Thus, =backend= #+END_SRC **** *backends-alist* :variable: +:PROPERTIES: +:CUSTOM_ID: *backends-alist* +:END: #+BEGIN_SRC lisp (defvar *backends-alist* nil "Alist of Chronometrist backends. @@ -908,6 +1099,9 @@ EIEIO object such as one returned by `make-instance'.") #+END_SRC **** *active-backend* :custom:variable: +:PROPERTIES: +:CUSTOM_ID: *active-backend* +:END: #+BEGIN_SRC lisp (defvar *active-backend* :sqlite "The backend currently in use. @@ -916,6 +1110,9 @@ Value must be a keyword corresponding to a key in #+END_SRC **** active-backend :reader: +:PROPERTIES: +:CUSTOM_ID: active-backend +:END: #+BEGIN_SRC lisp (defun active-backend () "Return an object representing the currently active backend." @@ -923,6 +1120,9 @@ Value must be a keyword corresponding to a key in #+END_SRC **** register-backend :writer: +:PROPERTIES: +:CUSTOM_ID: register-backend +:END: #+BEGIN_SRC lisp (defun register-backend (keyword tag object) "Add backend to `*backends-alist*'. @@ -935,6 +1135,9 @@ be replaced." #+END_SRC **** task-list :function: +:PROPERTIES: +:CUSTOM_ID: task-list +:END: #+BEGIN_SRC lisp (defun task-list () "Return the list of tasks to be used. @@ -947,6 +1150,9 @@ return a list of tasks from the active backend." #+END_SRC **** day :class: +:PROPERTIES: +:CUSTOM_ID: day +:END: #+BEGIN_SRC lisp (defclass day () ((properties :initarg :properties :accessor properties) @@ -963,6 +1169,9 @@ return a list of tasks from the active backend." #+END_SRC **** interval :class: +:PROPERTIES: +:CUSTOM_ID: interval-1 +:END: #+BEGIN_SRC lisp (defclass interval () ((properties :initarg :properties :accessor properties) @@ -985,6 +1194,9 @@ return a list of tasks from the active backend." #+END_SRC ***** make-interval :constructor:function: +:PROPERTIES: +:CUSTOM_ID: make-interval +:END: #+BEGIN_SRC lisp (defun make-interval (name start &optional stop properties-string) (make-instance 'chronometrist:interval @@ -994,6 +1206,9 @@ return a list of tasks from the active backend." #+END_SRC ***** interval-to-duration :function: +:PROPERTIES: +:CUSTOM_ID: interval-to-duration +:END: #+BEGIN_SRC lisp (defun interval-to-duration (interval) (with-slots (start stop) interval @@ -1002,6 +1217,9 @@ return a list of tasks from the active backend." #+END_SRC **** event :class: +:PROPERTIES: +:CUSTOM_ID: event +:END: #+BEGIN_SRC lisp (defclass event () ((properties :initarg :properties :accessor properties) @@ -1018,6 +1236,9 @@ return a list of tasks from the active backend." #+END_SRC **** run-assertions :generic:function: +:PROPERTIES: +:CUSTOM_ID: run-assertions +:END: #+BEGIN_SRC lisp (defgeneric backend-run-assertions (backend) (:documentation "Check common preconditions for any operations on BACKEND. @@ -1025,24 +1246,36 @@ Signal errors for any unmet preconditions.")) #+END_SRC **** view-backend :generic:function: +:PROPERTIES: +:CUSTOM_ID: view-backend +:END: #+BEGIN_SRC lisp (defgeneric view-backend (backend) (:documentation "Open BACKEND for interactive viewing.")) #+END_SRC **** edit-backend :generic:function: +:PROPERTIES: +:CUSTOM_ID: edit-backend +:END: #+BEGIN_SRC lisp (defgeneric edit-backend (backend) (:documentation "Open BACKEND for interactive editing.")) #+END_SRC **** backend-empty-p :generic:function: +:PROPERTIES: +:CUSTOM_ID: backend-empty-p +:END: #+BEGIN_SRC lisp (defgeneric backend-empty-p (backend) (:documentation "Return non-nil if BACKEND contains no records, else nil.")) #+END_SRC **** backend-modified-p :generic:function: +:PROPERTIES: +:CUSTOM_ID: backend-modified-p +:END: #+BEGIN_SRC lisp (defgeneric backend-modified-p (backend) (:documentation "Return non-nil if BACKEND is being modified. @@ -1051,6 +1284,9 @@ a user.")) #+END_SRC **** create-file :generic:function: +:PROPERTIES: +:CUSTOM_ID: create-file +:END: [[file:../tests/tests.org::#tests-backend-create-file][tests]] #+BEGIN_SRC lisp @@ -1061,6 +1297,9 @@ Return path of new file if successfully created, and nil if it already exists.") #+END_SRC **** get-day :generic:function: +:PROPERTIES: +:CUSTOM_ID: get-day +:END: #+BEGIN_SRC lisp (defgeneric get-day (date backend) (:documentation "Return day associated with DATE from BACKEND, or nil if no such day exists. @@ -1068,6 +1307,9 @@ DATE should be an integer representing the UNIX epoch time at the start of the d #+END_SRC **** insert-day :generic:function: +:PROPERTIES: +:CUSTOM_ID: insert-day +:END: #+BEGIN_SRC lisp (defgeneric insert-day (day backend &key &allow-other-keys) (:documentation "Insert PLIST as new record in BACKEND. @@ -1082,6 +1324,9 @@ PLIST may be an interval which crosses days.")) #+END_SRC **** remove-day :generic:function: +:PROPERTIES: +:CUSTOM_ID: remove-day +:END: #+BEGIN_SRC lisp (defgeneric remove-day (date backend) (:documentation "Remove day associated with DATE from BACKEND, or nil if no such day exists. @@ -1089,6 +1334,9 @@ DATE should be an integer representing the UNIX epoch time at the start of the d #+END_SRC **** remove-last :generic:function: +:PROPERTIES: +:CUSTOM_ID: remove-last +:END: #+BEGIN_SRC lisp (defgeneric remove-last (backend &key &allow-other-keys) (:documentation "Remove last record from BACKEND. @@ -1097,6 +1345,9 @@ Signal an error if there is no record to remove.")) #+END_SRC **** latest-record :generic:function: +:PROPERTIES: +:CUSTOM_ID: latest-record +:END: #+BEGIN_SRC lisp (defgeneric latest-record (backend) (:documentation "Return the latest record from BACKEND as a plist, or nil if BACKEND contains no records. @@ -1108,6 +1359,9 @@ entire (unsplit) record must be returned.")) #+END_SRC **** task-records-for-date :generic:function: +:PROPERTIES: +:CUSTOM_ID: task-records-for-date +:END: #+BEGIN_SRC lisp (declaim (ftype (function (chronometrist:backend string integer &key &allow-other-keys)) @@ -1127,6 +1381,9 @@ Return nil if BACKEND contains no records.") #+END_SRC ***** task-records-for-date :before:method: +:PROPERTIES: +:CUSTOM_ID: task-records-for-date-1 +:END: #+BEGIN_SRC lisp #+(or) (defmethod task-records-for-date :before @@ -1138,6 +1395,9 @@ Return nil if BACKEND contains no records.") #+END_SRC **** replace-last :generic:function: +:PROPERTIES: +:CUSTOM_ID: replace-last +:END: #+BEGIN_SRC lisp (defgeneric replace-last (backend plist &key &allow-other-keys) (:documentation "Replace last record in BACKEND with PLIST. @@ -1149,6 +1409,9 @@ Return non-nil if successful.")) #+END_SRC **** to-file :generic:function: +:PROPERTIES: +:CUSTOM_ID: to-file +:END: #+BEGIN_SRC lisp (defgeneric to-file (input-hash-table output-backend output-file) (:documentation "Save data from INPUT-HASH-TABLE to OUTPUT-FILE, in OUTPUT-BACKEND format. @@ -1156,6 +1419,9 @@ Any existing data in OUTPUT-FILE is overwritten.")) #+END_SRC **** on-add :generic:function: +:PROPERTIES: +:CUSTOM_ID: on-add +:END: #+BEGIN_SRC lisp (defgeneric on-add (backend) (:documentation "Function called when data is added to BACKEND. @@ -1167,6 +1433,9 @@ NEW-DATA is the data that was added.")) #+END_SRC **** on-modify :generic:function: +:PROPERTIES: +:CUSTOM_ID: on-modify +:END: #+BEGIN_SRC lisp (defgeneric on-modify (backend) (:documentation "Function called when data in BACKEND is modified (rather than added or removed). @@ -1179,6 +1448,9 @@ respectively.")) #+END_SRC **** on-remove :generic:function: +:PROPERTIES: +:CUSTOM_ID: on-remove +:END: #+BEGIN_SRC lisp (defgeneric on-remove (backend) (:documentation "Function called when data is removed from BACKEND. @@ -1190,6 +1462,9 @@ OLD-DATA is the data that was modified.")) #+END_SRC **** on-change :generic:function: +:PROPERTIES: +:CUSTOM_ID: on-change +:END: #+BEGIN_SRC lisp (defgeneric on-change (backend &rest args) (:documentation "Function to be run when BACKEND changes on disk. @@ -1199,6 +1474,9 @@ backend file).")) #+END_SRC **** verify :generic:function: +:PROPERTIES: +:CUSTOM_ID: verify +:END: #+BEGIN_SRC lisp (defgeneric verify (backend) (:documentation "Check BACKEND for errors in data. @@ -1208,6 +1486,9 @@ If an error is found, return (LINE-NUMBER . COLUMN-NUMBER) for file-based backen #+END_SRC **** on-file-path-change :generic:function: +:PROPERTIES: +:CUSTOM_ID: on-file-path-change +:END: #+BEGIN_SRC lisp (defgeneric on-file-path-change (backend old-path new-path) (:documentation "Function run when the value of `file' is changed. @@ -1216,12 +1497,18 @@ OLD-PATH and NEW-PATH are the old and new values of #+END_SRC **** reset-backend :generic:function: +:PROPERTIES: +:CUSTOM_ID: reset-backend +:END: #+BEGIN_SRC lisp (defgeneric reset-backend (backend) (:documentation "Reset data structures for BACKEND.")) #+END_SRC **** to-hash-table :generic:function: +:PROPERTIES: +:CUSTOM_ID: to-hash-table +:END: #+BEGIN_SRC lisp (defgeneric to-hash-table (backend) (:documentation "Return data in BACKEND as a hash table in chronological order. @@ -1231,33 +1518,51 @@ hash table values must be in chronological order.")) #+END_SRC **** to-list :generic:function: +:PROPERTIES: +:CUSTOM_ID: to-list +:END: #+BEGIN_SRC lisp (defgeneric to-list (backend) (:documentation "Return all records in BACKEND as a list of plists.")) #+END_SRC **** memory-layer-empty-p :generic:function: +:PROPERTIES: +:CUSTOM_ID: memory-layer-empty-p +:END: #+BEGIN_SRC lisp (defgeneric memory-layer-empty-p (backend) (:documentation "Return non-nil if memory layer of BACKEND contains no records, else nil.")) #+END_SRC **** extended protocol +:PROPERTIES: +:CUSTOM_ID: extended-protocol +:END: These can be implemented in terms of the minimal protocol above. ***** list-tasks :generic:function: +:PROPERTIES: +:CUSTOM_ID: list-tasks +:END: #+BEGIN_SRC lisp (defgeneric list-tasks (backend) (:documentation "Return all tasks recorded in BACKEND as a list of strings.")) #+END_SRC ***** active-days (unimplemented) :generic:function: +:PROPERTIES: +:CUSTOM_ID: active-days-(unimplemented) +:END: #+BEGIN_SRC lisp (defgeneric active-days (backend task &key start end) (:documentation "From BACKEND, return number of days on which TASK had recorded time.")) #+END_SRC ***** count-records (unimplemented) :generic:function: +:PROPERTIES: +:CUSTOM_ID: count-records-(unimplemented) +:END: #+BEGIN_SRC lisp (defgeneric count-records (backend) (:documentation "Return number of records in BACKEND.")) @@ -1265,7 +1570,7 @@ These can be implemented in terms of the minimal protocol above. *** Common definitions for s-expression backends :PROPERTIES: -:CUSTOM_ID: program-backend-sexp-common +:CUSTOM_ID: common-definitions-for-s-expression-backends :END: **** file-backend-mixin :mixin:class: :PROPERTIES: @@ -1297,6 +1602,9 @@ These can be implemented in terms of the minimal protocol above. #+END_SRC **** backend-file :function: +:PROPERTIES: +:CUSTOM_ID: backend-file +:END: #+BEGIN_SRC lisp (defmethod backend-file ((backend file-backend-mixin)) "Return the value of the file slot from BACKEND, or a file name @@ -1308,6 +1616,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** setup-file-watch :writer: +:PROPERTIES: +:CUSTOM_ID: setup-file-watch +:END: #+BEGIN_SRC lisp (defun setup-file-watch (&optional (callback #'refresh-file)) "Arrange for CALLBACK to be called when the backend file changes." @@ -1320,6 +1631,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** edit-backend :method: +:PROPERTIES: +:CUSTOM_ID: edit-backend-1 +:END: #+BEGIN_SRC lisp (defmethod edit-backend ((backend file-backend-mixin)) (find-file-other-window (chronometrist:backend-file backend)) @@ -1327,6 +1641,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** reset-backend :writer:method: +:PROPERTIES: +:CUSTOM_ID: reset-backend-1 +:END: #+BEGIN_SRC lisp (defmethod reset-backend ((backend file-backend-mixin)) (with-slots (hash-table file-watch @@ -1346,6 +1663,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** backend-empty-p :reader:method: +:PROPERTIES: +:CUSTOM_ID: backend-empty-p-1 +:END: #+BEGIN_SRC lisp (defmethod backend-empty-p ((backend file-backend-mixin)) (let ((file (chronometrist:backend-file backend))) @@ -1354,6 +1674,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** memory-layer-empty-p :reader:method: +:PROPERTIES: +:CUSTOM_ID: memory-layer-empty-p-1 +:END: #+BEGIN_SRC lisp (defmethod memory-layer-empty-p ((backend file-backend-mixin)) (with-slots (hash-table) backend @@ -1361,6 +1684,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** backend-modified-p :reader:method: +:PROPERTIES: +:CUSTOM_ID: backend-modified-p-1 +:END: #+BEGIN_SRC lisp (defmethod backend-modified-p ((backend file-backend-mixin)) (with-slots (file) backend @@ -1370,6 +1696,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** elisp-sexp-backend :class: +:PROPERTIES: +:CUSTOM_ID: elisp-sexp-backend +:END: #+BEGIN_SRC lisp (defclass elisp-sexp-backend (backend file-backend-mixin) ((rest-start :initarg :rest-start @@ -1402,6 +1731,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** create-file :writer:method: +:PROPERTIES: +:CUSTOM_ID: create-file-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:create-file ((backend elisp-sexp-backend) &optional file) (let ((file (or file (chronometrist:backend-file backend)))) @@ -1415,6 +1747,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** in-file :macro: +:PROPERTIES: +:CUSTOM_ID: in-file +:END: #+BEGIN_SRC lisp (defmacro sexp-in-file (file &rest body) "Run BODY in a buffer visiting FILE, restoring point afterwards." @@ -1424,6 +1759,9 @@ based on `*user-data-file*' and the BACKEND extension slot." #+END_SRC **** pre-read-check :procedure: +:PROPERTIES: +:CUSTOM_ID: pre-read-check +:END: #+BEGIN_SRC lisp (defun sexp-pre-read-check (buffer) "Return non-nil if there is an s-expression before point in BUFFER. @@ -1436,6 +1774,9 @@ Move point to the start of this s-expression." #+END_SRC **** loop-sexp-file :macro: +:PROPERTIES: +:CUSTOM_ID: loop-sexp-file +:END: #+BEGIN_SRC lisp (defmacro loop-sexp-file (_for sexp _in file &rest loop-clauses) "`loop' LOOP-CLAUSES over s-expressions in FILE. @@ -1452,6 +1793,9 @@ expression first)." #+END_SRC **** backend-empty-p :reader:method: +:PROPERTIES: +:CUSTOM_ID: backend-empty-p-2 +:END: #+BEGIN_SRC lisp (defmethod backend-empty-p ((backend elisp-sexp-backend)) (sexp-in-file (chronometrist:backend-file backend) @@ -1461,6 +1805,9 @@ expression first)." #+END_SRC **** indices and hashes +:PROPERTIES: +:CUSTOM_ID: indices-and-hashes +:END: #+BEGIN_SRC lisp (defun rest-start (file) (sexp-in-file file @@ -1478,6 +1825,9 @@ expression first)." #+END_SRC **** file-hash :reader: +:PROPERTIES: +:CUSTOM_ID: file-hash +:END: #+BEGIN_SRC lisp (defun file-hash (start end &optional (file (chronometrist:backend-file (active-backend)))) "Calculate hash of `file' between START and END." @@ -1487,6 +1837,9 @@ expression first)." #+END_SRC ***** tests +:PROPERTIES: +:CUSTOM_ID: tests-5 +:END: #+BEGIN_SRC lisp :tangle tests.el :load test (ert-deftest file-hash () (let-match* ((file test-file) @@ -1502,6 +1855,9 @@ expression first)." #+END_SRC **** file-change-type :reader: +:PROPERTIES: +:CUSTOM_ID: file-change-type +:END: + rest-start - start of first sexp + rest-end - end of second last sexp + file-length - end of file @@ -1568,6 +1924,9 @@ We avoid comparing s-expressions in the file with the contents of the hash table #+END_SRC ***** tests +:PROPERTIES: +:CUSTOM_ID: tests-6 +:END: #+BEGIN_SRC lisp :tangle tests.el :load test (ert-deftest file-change-type () (with-slots (file hash-table file-state) plist-test-backend @@ -1628,6 +1987,9 @@ We avoid comparing s-expressions in the file with the contents of the hash table #+END_SRC **** reset-task-list :writer: +:PROPERTIES: +:CUSTOM_ID: reset-task-list +:END: #+BEGIN_SRC lisp (defun reset-task-list (backend) "Regenerate BACKEND's task list from its data. @@ -1638,6 +2000,9 @@ user has not defined their own task list)." #+END_SRC **** add-to-task-list :writer: +:PROPERTIES: +:CUSTOM_ID: add-to-task-list +:END: #+BEGIN_SRC lisp (defun add-to-task-list (task backend) "Add TASK to BACKEND's task list, if it is not already present. @@ -1652,6 +2017,9 @@ user has not defined their own task list)." #+END_SRC **** remove-from-task-list :writer: +:PROPERTIES: +:CUSTOM_ID: remove-from-task-list +:END: #+BEGIN_SRC lisp (defun remove-from-task-list (task backend) "Remove TASK from BACKEND's task list if necessary. @@ -1686,6 +2054,9 @@ unchanged." #+END_SRC **** on-change :writer:method: +:PROPERTIES: +:CUSTOM_ID: on-change-1 +:END: #+BEGIN_SRC lisp (defmethod on-change ((backend elisp-sexp-backend) &rest fs-event) "Function called when BACKEND file is changed. @@ -1729,7 +2100,7 @@ FS-EVENT is the event passed by the `filenotify' library (see `file-notify-add-w *** plist group backend :PROPERTIES: -:CUSTOM_ID: program-backend-plist-group +:CUSTOM_ID: plist-group-backend :END: This is largely like the plist backend, but plists are grouped by date by wrapping them in a tagged list - @@ -1754,6 +2125,9 @@ Concerns specific to the plist group backend - * restarting is unaffected, since that only applies to the active interval, and split intervals are always inactive ones **** chronometrist.plist-group :package: +:PROPERTIES: +:CUSTOM_ID: chronometrist.plist-group +:END: #+BEGIN_SRC lisp (in-package :cl) (defpackage :chronometrist.plist-group @@ -1776,6 +2150,9 @@ Concerns specific to the plist group backend - #+END_SRC **** plist-group-backend :class: +:PROPERTIES: +:CUSTOM_ID: plist-group-backend-1 +:END: #+BEGIN_SRC lisp (defclass plist-group-backend (elisp-sexp-backend) () (:default-initargs :extension "plg")) @@ -1786,6 +2163,9 @@ Concerns specific to the plist group backend - #+END_SRC **** backward-read-sexp :reader: +:PROPERTIES: +:CUSTOM_ID: backward-read-sexp +:END: #+BEGIN_SRC lisp (defun backward-read-sexp (buffer) (backward-list) @@ -1793,6 +2173,9 @@ Concerns specific to the plist group backend - #+END_SRC **** run-assertions :reader:method: +:PROPERTIES: +:CUSTOM_ID: run-assertions-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:backend-run-assertions ((backend file-backend-mixin)) (with-slots (file) backend @@ -1801,6 +2184,9 @@ Concerns specific to the plist group backend - #+END_SRC **** latest-date-records :reader:method: +:PROPERTIES: +:CUSTOM_ID: latest-date-records +:END: #+BEGIN_SRC lisp (defmethod chronometrist:latest-date-records ((backend plist-group-backend)) (backend-run-assertions backend) @@ -1811,6 +2197,9 @@ Concerns specific to the plist group backend - #+END_SRC **** HACK insert :writer:method: +:PROPERTIES: +:CUSTOM_ID: insert +:END: <> We just want to insert a plist, but as a hack to avoid updating the pretty-printer to handle indentation of plists being inserted into an outer list, we =append= the plist to a plist group and insert/replace the plist group instead. @@ -1883,7 +2272,7 @@ Situations - **** plists-split-p :function: :PROPERTIES: -:CUSTOM_ID: program-data-structures-plists-split-p +:CUSTOM_ID: plists-split-p :END: [[file:../tests/tests.org::#tests-common-plists-split-p][tests]] @@ -1906,6 +2295,9 @@ keyword-values (except :start and :stop)." #+END_SRC **** last-two-split-p :procedure: +:PROPERTIES: +:CUSTOM_ID: last-two-split-p +:END: #+BEGIN_SRC lisp (defun last-two-split-p (file) "Return non-nil if the latest two plists in FILE are split. @@ -1932,6 +2324,9 @@ Return value is either a list in the form #+END_SRC **** plist-unify :function: +:PROPERTIES: +:CUSTOM_ID: plist-unify +:END: #+BEGIN_SRC lisp (defun plist-unify (old-plist new-plist) "Return a plist with the :start of OLD-PLIST and the :stop of NEW-PLIST." @@ -1951,6 +2346,9 @@ Return value is either a list in the form #+END_SRC **** remove-last :writer:method: +:PROPERTIES: +:CUSTOM_ID: remove-last-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:remove-last ((backend plist-group-backend) &key (save t) &allow-other-keys) (with-slots (file) backend @@ -1981,6 +2379,9 @@ Return value is either a list in the form #+END_SRC **** to-list :reader:method: +:PROPERTIES: +:CUSTOM_ID: to-list-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:to-list ((backend plist-group-backend)) (backend-run-assertions backend) @@ -1989,6 +2390,9 @@ Return value is either a list in the form #+END_SRC **** to-hash-table :reader:method: +:PROPERTIES: +:CUSTOM_ID: to-hash-table-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:to-hash-table ((backend plist-group-backend)) (let ((file (chronometrist:backend-file backend))) @@ -2007,6 +2411,9 @@ Return value is either a list in the form #+END_SRC **** to-file :writer:method: +:PROPERTIES: +:CUSTOM_ID: to-file-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:to-file (hash-table (backend plist-group-backend) file) (check-type hash-table hash-table) @@ -2023,6 +2430,9 @@ Return value is either a list in the form #+END_SRC **** on-add :writer:method: +:PROPERTIES: +:CUSTOM_ID: on-add-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:on-add ((backend plist-group-backend)) "Function run when a new plist-group is added at the end of a @@ -2034,6 +2444,9 @@ Return value is either a list in the form #+END_SRC **** on-modify :writer:method: +:PROPERTIES: +:CUSTOM_ID: on-modify-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:on-modify ((backend plist-group-backend)) "Function run when the newest plist-group in a @@ -2050,6 +2463,9 @@ Return value is either a list in the form #+END_SRC **** on-remove :writer:method: +:PROPERTIES: +:CUSTOM_ID: on-remove-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:on-remove ((backend plist-group-backend)) "Function run when the newest plist-group in a @@ -2063,6 +2479,9 @@ Return value is either a list in the form #+END_SRC **** verify :reader:method: +:PROPERTIES: +:CUSTOM_ID: verify-1 +:END: #+BEGIN_SRC lisp :load no :tangle no (defmethod chronometrist:verify ((backend plist-group-backend)) (with-slots (file hash-table) backend @@ -2084,7 +2503,13 @@ Return value is either a list in the form #+END_SRC **** extended protocol +:PROPERTIES: +:CUSTOM_ID: extended-protocol-1 +:END: ***** list-tasks :method: +:PROPERTIES: +:CUSTOM_ID: list-tasks-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:list-tasks ((backend backend)) (loop for plist in (to-list backend) @@ -2095,6 +2520,9 @@ Return value is either a list in the form #+END_SRC ***** latest-record :reader:method: +:PROPERTIES: +:CUSTOM_ID: latest-record-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:latest-record ((backend plist-group-backend)) (with-slots (file) backend @@ -2104,6 +2532,9 @@ Return value is either a list in the form #+END_SRC ***** task-records-for-date :reader:method: +:PROPERTIES: +:CUSTOM_ID: task-records-for-date-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:task-records-for-date ((backend plist-group-backend) task date-ts &key &allow-other-keys) @@ -2117,6 +2548,9 @@ Return value is either a list in the form #+END_SRC ***** TODO active-days :reader:method:noexport: +:PROPERTIES: +:CUSTOM_ID: active-days +:END: #+BEGIN_SRC lisp :tangle no (defmethod chronometrist:active-days ((backend plist-group-backend) task &key start end) (check-type task string) @@ -2124,6 +2558,9 @@ Return value is either a list in the form #+END_SRC ***** replace-last :writer:method: +:PROPERTIES: +:CUSTOM_ID: replace-last-1 +:END: =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. @@ -2143,12 +2580,21 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method #+END_SRC ***** count-records :reader:method:noexport: +:PROPERTIES: +:CUSTOM_ID: count-records +:END: #+BEGIN_SRC lisp :tangle no (defmethod chronometrist:count-records ((backend plist-group-backend))) #+END_SRC *** sqlite backend +:PROPERTIES: +:CUSTOM_ID: sqlite-backend +:END: **** chronometrist.sqlite :package: +:PROPERTIES: +:CUSTOM_ID: chronometrist.sqlite +:END: #+BEGIN_SRC lisp (in-package :cl) (defpackage :chronometrist.sqlite @@ -2180,6 +2626,9 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method #+END_SRC **** aliases +:PROPERTIES: +:CUSTOM_ID: aliases +:END: #+BEGIN_SRC lisp :load no (loop for (fn . alias) in '((sqlite:execute-non-query . execute-statement) (sqlite:execute-single . query-cell) @@ -2189,6 +2638,9 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method #+END_SRC **** sqlite-backend :class: +:PROPERTIES: +:CUSTOM_ID: sqlite-backend-1 +:END: #+BEGIN_SRC lisp (defclass sqlite-backend (chronometrist:backend chronometrist:file-backend-mixin) ((connection :initform nil @@ -2198,6 +2650,9 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method #+END_SRC **** initialize-instance :method: +:PROPERTIES: +:CUSTOM_ID: initialize-instance +:END: #+BEGIN_SRC lisp (defmethod initialize-instance :after ((backend sqlite-backend) &rest initargs) "Initialize connection for BACKEND based on its file." @@ -2214,6 +2669,9 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method #+END_SRC **** execute-sxql :function: +:PROPERTIES: +:CUSTOM_ID: execute-sxql +:END: #+BEGIN_SRC lisp (defun execute-sxql (exec-fn sxql database) "Execute SXQL statement on DATABASE using EXEC-FN. @@ -2232,6 +2690,9 @@ that returned by `sqlite:connect'." #+END_SRC **** create-file :writer:method: +:PROPERTIES: +:CUSTOM_ID: create-file-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:create-file ((backend sqlite-backend) &optional file) "Create file for BACKEND if it does not already exist. @@ -2307,6 +2768,9 @@ ORDER BY interval_id DESC;") #+END_SRC **** get-day :method: +:PROPERTIES: +:CUSTOM_ID: get-day-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:get-day (date (backend sqlite-backend)) (let-match* @@ -2362,12 +2826,18 @@ ORDER BY interval_id DESC;") #+END_SRC **** iso-to-unix :function: +:PROPERTIES: +:CUSTOM_ID: iso-to-unix +:END: #+BEGIN_SRC lisp (defun iso-to-unix (timestamp) (timestamp-to-unix (parse-timestring timestamp))) #+END_SRC **** to-file :writer:method: +:PROPERTIES: +:CUSTOM_ID: to-file-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:to-file (hash-table (backend sqlite-backend) file) (with-slots (connection) backend @@ -2385,6 +2855,9 @@ ORDER BY interval_id DESC;") #+END_SRC **** to-list :reader:method: +:PROPERTIES: +:CUSTOM_ID: to-list-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:to-list ((backend sqlite-backend)) (with-slots (connection) backend @@ -2392,6 +2865,9 @@ ORDER BY interval_id DESC;") #+END_SRC **** insert-properties :writer: +:PROPERTIES: +:CUSTOM_ID: insert-properties +:END: #+BEGIN_SRC lisp (defun sqlite-insert-properties (backend plist) "Insert properties from PLIST to (SQLite) BACKEND. @@ -2416,6 +2892,9 @@ prop-id of the inserted or existing property." #+END_SRC ***** properties-to-json :function: +:PROPERTIES: +:CUSTOM_ID: properties-to-json +:END: #+BEGIN_SRC lisp (defun sqlite-properties-to-json (plist) "Return PLIST as a JSON string." @@ -2434,6 +2913,9 @@ prop-id of the inserted or existing property." #+END_SRC ***** properties-function :custom:variable: +:PROPERTIES: +:CUSTOM_ID: properties-function +:END: #+BEGIN_SRC lisp (defvar *sqlite-properties-function* nil "Function used to control the encoding of user key-values. @@ -2444,6 +2926,9 @@ s-expressions in a text column.") #+END_SRC **** insert :writer:method: +:PROPERTIES: +:CUSTOM_ID: insert-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:insert-day ((backend sqlite-backend) plist &key &allow-other-keys) @@ -2496,6 +2981,9 @@ s-expressions in a text column.") #+END_SRC **** list-tasks :reader:method: +:PROPERTIES: +:CUSTOM_ID: list-tasks-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:list-tasks ((backend sqlite-backend)) ;; (format *debug-io* "list-tasks (sqlite)") @@ -2507,6 +2995,9 @@ s-expressions in a text column.") #+END_SRC **** open-file +:PROPERTIES: +:CUSTOM_ID: open-file +:END: #+BEGIN_SRC lisp (defmethod edit-backend ((backend sqlite-backend)) (require 'sql) @@ -2515,6 +3006,9 @@ s-expressions in a text column.") #+END_SRC **** latest-record +:PROPERTIES: +:CUSTOM_ID: latest-record-2 +:END: #+BEGIN_SRC lisp ;; SELECT * FROM TABLE WHERE ID = (SELECT MAX(ID) FROM TABLE); ;; SELECT * FROM tablename ORDER BY column DESC LIMIT 1; @@ -2524,6 +3018,9 @@ s-expressions in a text column.") #+END_SRC **** task-records-for-date +:PROPERTIES: +:CUSTOM_ID: task-records-for-date-3 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:task-records-for-date ((backend sqlite-backend) task date &key &allow-other-keys) @@ -2547,11 +3044,17 @@ s-expressions in a text column.") #+END_SRC **** active-days +:PROPERTIES: +:CUSTOM_ID: active-days-1 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:active-days ((backend sqlite-backend) task &key start end)) #+END_SRC **** replace-last +:PROPERTIES: +:CUSTOM_ID: replace-last-2 +:END: #+BEGIN_SRC lisp (defmethod chronometrist:replace-last ((backend sqlite-backend) plist &key &allow-other-keys) @@ -2561,16 +3064,28 @@ s-expressions in a text column.") ** Migration :PROPERTIES: -:CUSTOM_ID: program-migration +:CUSTOM_ID: migration :END: *** remove-prefix :function: +:PROPERTIES: +:CUSTOM_ID: remove-prefix +:END: #+BEGIN_SRC lisp (defun remove-prefix (string) (replace-regexp-in-string "^" "" string)) #+END_SRC ** Frontends +:PROPERTIES: +:CUSTOM_ID: frontends +:END: *** CLIM +:PROPERTIES: +:CUSTOM_ID: clim +:END: **** chronometrist.clim :package: +:PROPERTIES: +:CUSTOM_ID: chronometrist.clim +:END: #+BEGIN_SRC lisp (in-package :cl) (defpackage :chronometrist.clim @@ -2582,6 +3097,9 @@ s-expressions in a text column.") #+END_SRC **** chronometrist :application:frame: +:PROPERTIES: +:CUSTOM_ID: chronometrist-1 +:END: #+BEGIN_SRC lisp (define-application-frame chronometrist () () (:pointer-documentation t) @@ -2597,8 +3115,14 @@ s-expressions in a text column.") (:layouts (default (vertically () task-duration int)))) #+END_SRC -**** task-duration-table +**** task-duration table pane +:PROPERTIES: +:CUSTOM_ID: task-duration-table-pane +:END: ***** column-specifier :class: +:PROPERTIES: +:CUSTOM_ID: column-specifier +:END: #+BEGIN_SRC lisp (defclass column-specifier () ((name :initarg :name :type keyword :accessor column-name) @@ -2606,12 +3130,18 @@ s-expressions in a text column.") #+END_SRC ***** make-column-specifier :constructor:function: +:PROPERTIES: +:CUSTOM_ID: make-column-specifier +:END: #+BEGIN_SRC lisp (defun make-column-specifier (name label) (make-instance 'column-specifier :name name :label label)) #+END_SRC ***** cell-data :method: +:PROPERTIES: +:CUSTOM_ID: cell-data +:END: #+BEGIN_SRC lisp (defgeneric cell-data (name index task date) (:documentation "Function to determine the data of cell NAME. @@ -2628,6 +3158,9 @@ DATE is the date, as integer seconds since the UNIX epoch.")) #+END_SRC ***** cell-print :method: +:PROPERTIES: +:CUSTOM_ID: cell-print +:END: #+BEGIN_SRC lisp (defgeneric cell-print (name index task date cell-data frame pane) (:documentation "Function to determine the data of cell NAME. @@ -2657,6 +3190,9 @@ FRAME and PANE are the CLIM frame and pane as passed to the display function.")) #+END_SRC ***** *task-duration-table-spec* :custom:variable: +:PROPERTIES: +:CUSTOM_ID: *task-duration-table-spec* +:END: #+BEGIN_SRC lisp (defvar *task-duration-table-spec* (loop for (keyword . string) in '((:index . "#") @@ -2669,6 +3205,9 @@ FRAME and PANE are the CLIM frame and pane as passed to the display function.")) #+END_SRC ***** task-duration-table-function :function: +:PROPERTIES: +:CUSTOM_ID: task-duration-table-function +:END: #+BEGIN_SRC lisp (defun task-duration-table-function (table-specification) (loop with date = (timestamp-to-unix (today)) @@ -2683,24 +3222,39 @@ FRAME and PANE are the CLIM frame and pane as passed to the display function.")) #+END_SRC **** display +:PROPERTIES: +:CUSTOM_ID: display +:END: ***** display :function: +:PROPERTIES: +:CUSTOM_ID: display-1 +:END: #+BEGIN_SRC lisp (defun display (frame stream) (display-pane frame stream (pane-name stream))) #+END_SRC ***** task-name :class: +:PROPERTIES: +:CUSTOM_ID: task-name +:END: #+BEGIN_SRC lisp (defclass task-name () ()) #+END_SRC ***** display-pane :generic:function: +:PROPERTIES: +:CUSTOM_ID: display-pane +:END: #+BEGIN_SRC lisp (defgeneric display-pane (frame stream pane-name) (:documentation "Display a Chronometrist application pane.")) #+END_SRC ***** display-pane :method: +:PROPERTIES: +:CUSTOM_ID: display-pane-1 +:END: #+BEGIN_SRC lisp (defmethod display-pane (frame pane (pane-name (eql 'task-duration))) "Display the task-duration pane, using `*task-duration-table-spec*'." @@ -2726,11 +3280,17 @@ FRAME and PANE are the CLIM frame and pane as passed to the display function.")) #+END_SRC **** refresh :command: +:PROPERTIES: +:CUSTOM_ID: refresh +:END: #+BEGIN_SRC lisp (define-chronometrist-command (com-refresh :name t) ()) #+END_SRC **** run-chronometrist +:PROPERTIES: +:CUSTOM_ID: run-chronometrist +:END: #+BEGIN_SRC lisp (defun run-chronometrist (&optional dir) (run-frame-top-level (make-application-frame 'chronometrist))) diff --git a/manual.org b/manual.org index 330ee1f..7fa40ff 100644 --- a/manual.org +++ b/manual.org @@ -14,6 +14,9 @@ #+END_EXPORT * Explanation +:PROPERTIES: +:CUSTOM_ID: explanation +:END: Chronometrist is a friendly and powerful personal time tracker and analyzer. It has frontends for Emacs and [[https://mcclim.common-lisp.dev/][CLIM]]. #+CAPTION: The main Chronometrist buffer, with the enabled extensions [[#time-goals][chronometrist-goal]] ("Targets" column + alerts) and chronometrist-spark ("Graph" column displaying the activity for the past 4 weeks). @@ -61,6 +64,9 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti + Chronometrist data is just s-expressions (plists), and may be easier to parse than a complex text format with numerous use-cases. ** Common Lisp port +:PROPERTIES: +:CUSTOM_ID: common-lisp-port +:END: In March 2022, work began on the long-awaited Common Lisp port of Chronometrist, which aims to create - 1. a greater variety of backends (e.g. SQLite) 2. a common reusable library for frontends to use, @@ -102,6 +108,9 @@ The Org literate program can also be loaded directly using the [[https://github. [fn:2] the literate source is also included in MELPA installs, although not loaded through =literate-elisp-load= by default, since doing so would interfere with automatic generation of autoloads. ** Source code overview +:PROPERTIES: +:CUSTOM_ID: source-code-overview +:END: At its most basic, we read data from a [[file:elisp/chronometrist.org::#program-backend][backend]] and [[file:elisp/chronometrist.org::#program-frontend-chronometrist][display it]] as a [[elisp:(find-library "tabulated-list")][=tabulated-list-mode=]] buffer. The plist and plist-group backends (collectively known as the s-expression backends) =read= a text file containing s-expressions into a [[file:elisp/chronometrist.org::#program-data-structures][hash table]], and query that. When the file is changed—whether by the program or the user—they [[file:elisp/chronometrist.org::refresh-file][update the hash table]] and the [[file:elisp/chronometrist.org::#program-frontend-chronometrist-refresh][buffer]]. The s-expression backends also make use of a [[file:elisp/chronometrist.org::#program-pretty-printer][plist pretty-printer]] of their own. @@ -212,6 +221,9 @@ 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. ** chronometrist-details +:PROPERTIES: +:CUSTOM_ID: chronometrist-details +:END: ** common commands :PROPERTIES: @@ -289,6 +301,9 @@ If you want it to be loaded with =literate-elisp-load= on Emacs startup, add the 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. ** How to skip running hooks/attaching tags and key values +:PROPERTIES: +:CUSTOM_ID: how-to-skip-running-hooks/attaching-tags-and-key-values +:END: Use =M-RET= (=chronometrist-toggle-task-no-hooks=) to clock in/out. ** How to open certain files when you start a task @@ -390,6 +405,9 @@ Or use =vertico-multiform= to disable sorting for only specific commands - #+END_SRC * User's reference +:PROPERTIES: +:CUSTOM_ID: users-reference +:END: All variables intended for user customization are listed here. They serve as the public API for this project for the purpose of semantic versioning. Any changes to these which require a user to modify their configuration are considered breaking changes. 1. =chronometrist-file= @@ -417,6 +435,9 @@ Hooks 9. =chronometrist-timer-hook= * Local variables :noexport: +:PROPERTIES: +:CUSTOM_ID: local-variables +:END: # Local Variables: # my-org-src-default-lang: "emacs-lisp" # End: