Compare commits

...
This repository has been archived on 2022-05-13. You can view files and clone it, but cannot push or open issues or pull requests.

13 Commits

Author SHA1 Message Date
contrapunctus c6ed69d0f6 Update to-list to handle record key-values 2022-02-20 01:24:00 +05:30
contrapunctus a911dbf16e Change "event" to "interval", use "ht" for hash-table functions 2022-02-20 01:09:09 +05:30
contrapunctus 73477552cc Tweak term definition 2022-02-20 01:07:30 +05:30
contrapunctus 4c3f220f43 Rename "events" to "ht" 2022-02-20 00:36:24 +05:30
contrapunctus b43dea5bc5 Merge branch 'doc' into date-properties 2022-02-19 23:28:18 +05:30
contrapunctus 993281ed6d Define new terms 2022-02-16 02:34:08 +05:30
contrapunctus d1107a50e1 Update call site for latest-date-records 2022-02-15 20:41:01 +05:30
contrapunctus bf40c72250 Update documentation for date key-values 2022-02-15 20:32:07 +05:30
contrapunctus 2aef569656 Rename date-properties -> record-properties 2022-02-15 20:31:09 +05:30
contrapunctus 92564720e6 Define accessor for intervals 2022-02-15 20:30:26 +05:30
contrapunctus b0b73bd1c9 Merge branch 'doc' into date-properties 2022-02-13 11:05:57 +05:30
contrapunctus 0eea7f99d5 Merge branch 'dev' into date-properties 2022-02-13 02:41:39 +05:30
contrapunctus 6dc89776b7 Implement date-properties 2022-02-13 02:18:21 +05:30
3 changed files with 243 additions and 166 deletions

View File

@ -217,10 +217,10 @@ Return new position of point."
;; [[file:chronometrist.org::*current-task][current-task:1]]
(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."
(let ((last-event (chronometrist-latest-record backend)))
(if (plist-member last-event :stop)
(let ((last-interval (chronometrist-latest-record backend)))
(if (plist-member last-interval :stop)
nil
(plist-get last-event :name))))
(plist-get last-interval :name))))
;; current-task:1 ends here
;; [[file:chronometrist.org::*install-directory][install-directory:1]]
@ -305,50 +305,49 @@ Return value is a ts struct (see `ts.el')."
((&plist :start start-2 :stop stop-2) (cl-second split-time))
;; `plist-put' modifies lists in-place. The resulting bugs
;; left me puzzled for a while.
(event-1 (cl-copy-list plist))
(event-2 (cl-copy-list plist)))
(list (-> event-1
(interval-1 (cl-copy-list plist))
(interval-2 (cl-copy-list plist)))
(list (-> interval-1
(plist-put :start start-1)
(plist-put :stop stop-1))
(-> event-2
(-> interval-2
(plist-put :start start-2)
(plist-put :stop stop-2))))))))
;; split-plist:1 ends here
;; [[file:chronometrist.org::*events-update][events-update:1]]
(defun chronometrist-events-update (plist hash-table &optional replace)
;; [[file:chronometrist.org::*ht-update][ht-update:1]]
(defun chronometrist-ht-update (plist hash-table &optional replace)
"Return HASH-TABLE with PLIST added as the latest interval.
If REPLACE is non-nil, replace the last interval with PLIST."
(let* ((date (->> (plist-get plist :start)
(chronometrist-iso-to-ts )
(ts-format "%F" )))
(events-today (gethash date hash-table)))
(--> (if replace (-drop-last 1 events-today) events-today)
(intervals-today (gethash date hash-table)))
(--> (if replace (-drop-last 1 intervals-today) intervals-today)
(append it (list plist))
(puthash date it hash-table))
hash-table))
;; events-update:1 ends here
;; ht-update:1 ends here
;; [[file:chronometrist.org::*last-date][last-date:1]]
(defun chronometrist-events-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in `chronometrist-events'."
;; [[file:chronometrist.org::*ht-last-date][ht-last-date:1]]
(defun chronometrist-ht-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in HASH-TABLE."
(--> (hash-table-keys hash-table)
(last it)
(car it)))
;; last-date:1 ends here
(cl-first it)))
;; ht-last-date:1 ends here
;; [[file:chronometrist.org::*events-last][events-last:1]]
(cl-defun chronometrist-events-last (&optional (backend (chronometrist-active-backend)))
"Return the last plist from `chronometrist-events'."
(let* ((hash-table (chronometrist-backend-hash-table backend))
(last-date (chronometrist-events-last-date hash-table)))
(--> (gethash last-date hash-table)
(last it)
(car it))))
;; events-last:1 ends here
;; [[file:chronometrist.org::*ht-last][ht-last:1]]
(cl-defun chronometrist-ht-last (&optional hash-table)
"Return the last plist from HASH-TABLE."
(--> (chronometrist-ht-last-date hash-table)
(gethash it hash-table)
(last it)
(cl-first it)))
;; ht-last:1 ends here
;; [[file:chronometrist.org::#program-data-structures-events-subset][events-subset:1]]
(defun chronometrist-events-subset (start end hash-table)
;; [[file:chronometrist.org::#program-data-structures-ht-subset][ht-subset:1]]
(defun chronometrist-ht-subset (start end hash-table)
"Return a subset of HASH-TABLE.
The subset will contain values between dates START and END (both
inclusive).
@ -363,18 +362,18 @@ treated as though their time is 00:00:00."
(puthash key value subset)))
hash-table)
subset))
;; events-subset:1 ends here
;; ht-subset:1 ends here
;; [[file:chronometrist.org::*task-time-one-day][task-time-one-day:1]]
(cl-defun chronometrist-task-time-one-day (task &optional (date (chronometrist-date-ts)) (backend (chronometrist-active-backend)))
"Return total time spent on TASK today or on DATE, an ISO-8601 date.
The return value is seconds, as an integer."
(let ((task-events (chronometrist-task-records-for-date backend task date)))
(if task-events
(->> (chronometrist-plists-to-durations task-events)
(let ((task-intervals (chronometrist-task-records-for-date backend task date)))
(if task-intervals
(->> (chronometrist-plists-to-durations task-intervals)
(-reduce #'+)
(truncate))
;; no events for this task on DATE, i.e. no time spent
;; no intervals for this task on DATE, i.e. no time spent
0)))
;; task-time-one-day:1 ends here
@ -389,16 +388,16 @@ Return value is seconds as an integer."
;; active-time-on:1 ends here
;; [[file:chronometrist.org::*count-active-days][count-active-days:1]]
(cl-defun chronometrist-statistics-count-active-days (task table)
(cl-defun chronometrist-count-active-days (task hash-table)
"Return the number of days the user spent any time on TASK.
TABLE must be a hash table - if not supplied, `chronometrist-events' is used.
HASH-TABLE must be a hash table as returned by `chronometrist-to-hash-table'.
This will not return correct results if TABLE contains records
which span midnights."
(cl-loop for events being the hash-values of table
This will not return correct results if HASH-TABLE contains
records which span midnights."
(cl-loop for intervals being the hash-values of hash-table
count (seq-find (lambda (event)
(equal task (plist-get event :name)))
events)))
intervals)))
;; count-active-days:1 ends here
;; [[file:chronometrist.org::*task-list][task-list:1]]
@ -413,6 +412,32 @@ active backend."
:group 'chronometrist)
;; task-list:1 ends here
;; [[file:chronometrist.org::*record-properties][record-properties:1]]
(defun chronometrist-record-properties (plist-group)
"Return properties for DATE-RECORDS, if any.
PLIST-GROUP must be a tagged list as returned by
`chronometrist-latest-record'."
(cl-loop with valuep
for elt in plist-group
when (keywordp elt)
collect (progn (setq valuep t) elt) into plist
else when valuep
collect (progn (setq valuep nil) elt) into plist
else when (and (not (keywordp elt)) (not valuep))
do (cl-return plist)))
;; record-properties:1 ends here
;; [[file:chronometrist.org::*record-intervals][record-intervals:1]]
(defun chronometrist-record-intervals (record)
"Return intervals (as a list of plists) from RECORD.
RECORD must be a tagged plist as returned by
`chronometrist-latest-record'."
(cl-loop for elt being the elements of record
using (index i)
when (chronometrist-plist-p elt)
return (seq-drop record i)))
;; record-intervals:1 ends here
;; [[file:chronometrist.org::*iso-to-ts][iso-to-ts:1]]
(defun chronometrist-iso-to-ts (timestamp)
"Convert TIMESTAMP to a TS struct. (see `ts.el')
@ -454,9 +479,6 @@ TS should be a ts struct (see `ts.el')."
Optional argument UNIX-TIME should be a time value (see
`current-time') accepted by `format-time-string'."
(format-time-string "%FT%T%z" unix-time))
;; Note - this assumes that an event never crosses >1 day. This seems
;; sufficient for all conceivable cases.
;; format-time-iso8601:1 ends here
;; [[file:chronometrist.org::*split-time][split-time:1]]
@ -473,13 +495,13 @@ Return a list in the form
(:start <day start time on second day>
:stop STOP-TIME))"
;; FIXME - time zones are ignored; may cause issues with
;; time-zone-spanning events
;; time-zone-spanning intervals
;; The time on which the first provided day starts (according to `chronometrist-day-start-time')
(let* ((stop-ts (chronometrist-iso-to-ts stop-time))
(first-day-start (chronometrist-apply-time day-start-time start-time))
(next-day-start (ts-adjust 'hour 24 first-day-start)))
;; Does the event stop time exceed the next day start time?
;; Does the interval stop time exceed the next day start time?
(when (ts< next-day-start stop-ts)
(let ((split-time (ts-format "%FT%T%z" next-day-start)))
(list `(:start ,start-time :stop ,split-time)
@ -499,8 +521,8 @@ SECONDS must be a positive integer."
;; [[file:chronometrist.org::*interval][interval:1]]
(defun chronometrist-interval (plist)
"Return the period of time covered by EVENT as a time value.
EVENT should be a plist (see `chronometrist-file')."
"Return the period of time covered by PLIST as a time value.
PLIST should be a plist (see `chronometrist-file')."
(let* ((start-ts (chronometrist-iso-to-ts (plist-get plist :start)))
(stop-iso (plist-get plist :stop))
;; Add a stop time if it does not exist.
@ -863,7 +885,7 @@ Return path of new file if successfully created, and nil if it already exists.")
;; [[file:chronometrist.org::*latest-date-records][latest-date-records:1]]
(cl-defgeneric chronometrist-latest-date-records (backend)
"Return intervals of latest day in BACKEND as a tagged list (\"DATE\" PLIST*).
"Return intervals of latest day in BACKEND as a tagged list (\"DATE\" [<KEYWORD> <VALUE>]* PLIST+).
Return nil if BACKEND contains no records.")
;; latest-date-records:1 ends here
@ -987,13 +1009,16 @@ OLD-PATH and NEW-PATH are the old and new values of
(cl-defgeneric chronometrist-to-hash-table (backend)
"Return data in BACKEND as a hash table in chronological order.
Hash table keys are ISO-8601 date strings. Hash table values are
lists of records, represented by plists. Both hash table keys and
hash table values must be in chronological order.")
lists of intervals (plists), optionally preceded by keyword-value
pairs. Both hash table keys and hash table values must be in
chronological order. Intervals in hash table values must not span
across days.")
;; to-hash-table:1 ends here
;; [[file:chronometrist.org::*to-list][to-list:1]]
(cl-defgeneric chronometrist-to-list (backend)
"Return all records in BACKEND as a list of plists.")
"Return all intervals in BACKEND as a list of plists.
The resulting list does not contain record key-values.")
;; to-list:1 ends here
;; [[file:chronometrist.org::*memory-layer-empty-p][memory-layer-empty-p:1]]
@ -1032,7 +1057,8 @@ hash table values must be in chronological order.")
:documentation "Full path to backend file, with extension.")
(hash-table :initform (chronometrist-make-hash-table)
:initarg :hash-table
:accessor chronometrist-backend-hash-table)
:accessor chronometrist-backend-hash-table
:documentation "Hash table as returned by `chronometrist-to-hash-table'.")
(file-watch :initform nil
:initarg :file-watch
:accessor chronometrist-backend-file-watch
@ -1387,7 +1413,7 @@ STREAM (which is the value of `current-buffer')."
(chronometrist-backend-run-assertions backend)
(with-slots (hash-table) backend
(when-let*
((latest-date (chronometrist-events-last-date hash-table))
((latest-date (chronometrist-ht-last-date hash-table))
(records (gethash latest-date hash-table)))
(cons latest-date records))))
;; latest-date-records:1 ends here
@ -1496,7 +1522,7 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
`chronometrist-plist-backend' file."
(with-slots (hash-table) backend
(-let [(new-plist &as &plist :name new-task) (chronometrist-latest-record backend)]
(setf hash-table (chronometrist-events-update new-plist hash-table))
(setf hash-table (chronometrist-ht-update new-plist hash-table))
(chronometrist-add-to-task-list new-task backend))))
;; on-add:1 ends here
@ -1506,8 +1532,8 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
`chronometrist-plist-backend' file is modified."
(with-slots (hash-table) backend
(-let (((new-plist &as &plist :name new-task) (chronometrist-latest-record backend))
((&plist :name old-task) (chronometrist-events-last backend)))
(setf hash-table (chronometrist-events-update new-plist hash-table t))
((&plist :name old-task) (chronometrist-ht-last hash-table)))
(setf hash-table (chronometrist-ht-update new-plist hash-table t))
(chronometrist-remove-from-task-list old-task backend)
(chronometrist-add-to-task-list new-task backend))))
;; on-modify:1 ends here
@ -1517,8 +1543,8 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
"Function run when the newest plist in a
`chronometrist-plist-backend' file is deleted."
(with-slots (hash-table) backend
(-let (((&plist :name old-task) (chronometrist-events-last))
(date (chronometrist-events-last-date hash-table)))
(-let (((&plist :name old-task) (chronometrist-ht-last hash-table))
(date (chronometrist-ht-last-date hash-table)))
;; `chronometrist-remove-from-task-list' checks the hash table to determine
;; if `chronometrist-task-list' is to be updated. Thus, the hash table must
;; not be updated until the task list is.
@ -1742,8 +1768,8 @@ Return value is either a list in the form
;; [[file:chronometrist.org::*to-list][to-list:1]]
(cl-defmethod chronometrist-to-list ((backend chronometrist-plist-group-backend))
(chronometrist-backend-run-assertions backend)
(chronometrist-loop-sexp-file for expr in (chronometrist-backend-file backend)
append (reverse (cl-rest expr))))
(chronometrist-loop-sexp-file for record in (chronometrist-backend-file backend)
append (chronometrist-record-intervals record)))
;; to-list:1 ends here
;; [[file:chronometrist.org::*to-hash-table][to-hash-table:1]]
@ -1776,9 +1802,10 @@ Return value is either a list in the form
"Function run when a new plist-group is added at the end of a
`chronometrist-plist-group-backend' file."
(with-slots (hash-table) backend
(-let [(date plist) (chronometrist-latest-date-records backend)]
(puthash date plist hash-table)
(chronometrist-add-to-task-list (plist-get plist :name) backend))))
(-let* (((record &as date . rest) (chronometrist-latest-date-records backend))
((interval) (chronometrist-record-intervals record)))
(puthash date rest hash-table)
(chronometrist-add-to-task-list (plist-get interval :name) backend))))
;; on-add:1 ends here
;; [[file:chronometrist.org::*on-modify][on-modify:1]]
@ -1787,7 +1814,7 @@ Return value is either a list in the form
`chronometrist-plist-group-backend' file is modified."
(with-slots (hash-table) backend
(-let* (((date . plists) (chronometrist-latest-date-records backend))
(old-date (chronometrist-events-last-date hash-table))
(old-date (chronometrist-ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
(puthash date plists hash-table)
(cl-loop for plist in old-plists
@ -1801,7 +1828,7 @@ Return value is either a list in the form
"Function run when the newest plist-group in a
`chronometrist-plist-group-backend' file is deleted."
(with-slots (hash-table) backend
(-let* ((old-date (chronometrist-events-last-date hash-table))
(-let* ((old-date (chronometrist-ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
(cl-loop for plist in old-plists
do (chronometrist-remove-from-task-list (plist-get plist :name) backend))
@ -2945,9 +2972,9 @@ displayed. They must be ts structs (see `ts.el').")
It simply operates on the entire hash table TABLE (see
`chronometrist-to-hash-table' for table format), so ensure that TABLE is
reduced to the desired range using
`chronometrist-events-subset'."
`chronometrist-ht-subset'."
(cl-loop for task in (chronometrist-task-list) collect
(let* ((active-days (chronometrist-statistics-count-active-days task table))
(let* ((active-days (chronometrist-count-active-days task table))
(active-percent (cl-case (plist-get chronometrist-statistics--ui-state :mode)
('week (* 100 (/ active-days 7.0)))))
(active-percent (if (zerop active-days)
@ -2974,12 +3001,12 @@ reduced to the desired range using
('week
(let* ((start (plist-get chronometrist-statistics--ui-state :start))
(end (plist-get chronometrist-statistics--ui-state :end))
(ht (chronometrist-events-subset start end hash-table)))
(ht (chronometrist-ht-subset start end hash-table)))
(chronometrist-statistics-rows-internal ht)))
(t ;; `chronometrist-statistics--ui-state' is nil, show current week's data
(let* ((start (chronometrist-previous-week-start (chronometrist-date-ts)))
(end (ts-adjust 'day 7 start))
(ht (chronometrist-events-subset start end hash-table)))
(ht (chronometrist-ht-subset start end hash-table)))
(setq chronometrist-statistics--ui-state `(:mode week :start ,start :end ,end))
(chronometrist-statistics-rows-internal ht))))))
;; rows:1 ends here

View File

@ -48,8 +48,11 @@ Quite recently, after around two years of Chronometrist developement and use, I
:PROPERTIES:
:DESCRIPTION: Explanation of some terms used later
:END:
Chronometrist records /time intervals/ (earlier called "events") as plists. Each plist contains at least a =:name "<name>"=, a =:start "<iso-timestamp>"=, and (except in case of an ongoing task) a =:stop "<iso-timestamp>"=.
Chronometrist deals with two entities -
+ intervals (earlier called "events") :: a continuous range of time spent on a task. These are currently represented as plists. Each plist contains at least a =:name "<name>"=, a =:start "<iso-timestamp>"=, and (except in case of an ongoing task) a =:stop "<iso-timestamp>"=. Optionally, it may contain other keys.
+ records :: these associate a date with time intervals (and possibly key-values). These are currently represented as lists in the form =("<ISO-8601 date>" [<keyword> <value>]* <interval>+)=.
Some other terms
+ row :: a row of a table in a =tabulated-list-mode= buffer; an element of =tabulated-list-entries=.
+ schema :: the column descriptor of a table in a =tabulated-list-mode= buffer; the value of =tabulated-list-format=.
@ -78,7 +81,7 @@ One of the earliest 'optimizations' of great importance turned out to simply be
*** 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= 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 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 intervals]] is the real performance killer.
After the optimization...
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.
@ -90,14 +93,14 @@ After the optimization...
There are still some operations which [[* refresh-file][=chronometrist-refresh-file=]] runs unconditionally - which is to say there is scope for further optimization, if or when required.
[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.
[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 interval splitting to it.
*** Determine type of change made to file
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 =chronometrist-events=, instead of doing a full parse again (=chronometrist-events-populate=). The increase in responsiveness has been significant.
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 the hash table, instead of doing a full parse again (=chronometrist-to-hash-table=). The increase in responsiveness has been significant.
When =chronometrist-refresh-file= is run by the file system watcher, it uses =chronometrist-file-hash= to assign indices and a hash to =chronometrist--file-state=. The next time the file changes, =chronometrist-file-change-type= compares this state to the current state of the file to determine the type of change made.
Challenges -
**** Challenges
1. Correctly detecting the type of change
2. Updating =chronometrist-task-list= and the Chronometrist buffer, when a new task is added or the last interval for a task is removed (v0.6.4)
3. Handling changes made to an active interval after midnight
@ -106,7 +109,7 @@ Challenges -
* =:modify= - normally, replace in table; for spanning intervals, split and replace
* =:remove= - normally, remove from table; for spanning intervals, split and remove
Effects on the task list
**** Effects on the task list
1. When a plist is added, the =:name= might be new, in which case we need to add it to the task list.
2. When the last plist is modified, the =:name= may have changed -
1. the =:name= might be new and require addition to the task list.
@ -115,10 +118,10 @@ Effects on the task list
** Midnight-spanning intervals
:PROPERTIES:
:DESCRIPTION: Events starting on one day and ending on another
:DESCRIPTION: Intervals starting on one day and ending on another
:CUSTOM_ID: explanation-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=.
A unique problem in working with Chronometrist, one I had never foreseen, was intervals 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=.
These mess up data consumption in all sorts of unforeseen ways, especially interval calculations and acquiring intervals for a specific date. In case of two of the most common operations throughout the program -
1. finding the intervals recorded on a given date
@ -126,21 +129,21 @@ These mess up data consumption in all sorts of unforeseen ways, especially inter
There are a few different approaches of dealing with them. (Currently, Chronometrist uses #3.)
*** Check the code of the first event of the day (timeclock format)
*** Check the code of the first interval 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.
:DESCRIPTION: When the code of the first interval in the day is "o", it's a midnight-spanning interval.
:END:
+ Advantage - very simple to detect
+ Disadvantage - "in" and "out" events must be represented separately
+ Disadvantage - "in" and "out" intervals must be represented separately
*** Split them at the file level
+ 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./
+ Advantage - operation is performed only once for each such interval + simpler data-consuming code + reduced post-parsing load.
+ What happens when the user changes their day-start-time? The split-up intervals are now split wrongly, and the second interval may get split /again./
Possible solutions -
1. Add function to check if, for two events A and B, the =:stop= of A is the same as the =:start= of B, and that all their other tags are identical. Then we can re-split them according to the new day-start-time.
1. Add function to check if, for two intervals A and B, the =:stop= of A is the same as the =:start= of B, and that all their other tags are identical. Then we can re-split them according to the new day-start-time.
+ Implemented as [[#program-data-structures-plists-split-p][plist-split-p]]
2. Add a =:split= tag to split events. It can denote that the next event was originally a part of this one.
2. Add a =:split= tag to split intervals. It can denote that the next interval was originally a part of this one.
3. Re-check and update the file when the day-start-time changes.
- Possible with ~add-variable-watcher~ or ~:custom-set~ in Customize (thanks bpalmer)
@ -150,7 +153,7 @@ This strategy is implemented in the [[#program-backend-plist-group][plist-group]
Handled by ~chronometrist-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)
*** Split them at the data-consumer level (e.g. when calculating time for one day/getting intervals for one day)
+ Advantage - reduced repetitive post-parsing load.
** Point restore behaviour
@ -251,18 +254,16 @@ Further details are stored in properties -
:END:
*** ts
ts.el struct
=ts.el= struct
* Used by nearly all internal functions
*** iso-timestamp
="YYYY-MM-DDTHH:MM:SSZ"=
* Used in the s-expression file format
* Read by chronometrist-sexp-events-populate
* Used in the plists in the chronometrist-events hash table values
* Used for intervals
*** iso-date
="YYYY-MM-DD"=
* Used as hash table keys in chronometrist-events - can't use ts structs for keys, you'd have to make a hash table predicate which uses ts=
* Used as hash table keys - can't use =ts= structs for keys, you'd have to make a hash table predicate which uses =ts==
*** seconds
integer seconds as duration
@ -535,7 +536,6 @@ If FIRSTONLY is non-nil, return only the first keybinding found."
#+END_SRC
*** day-start-time :custom:variable:
=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\".
@ -686,10 +686,10 @@ Return new position of point."
#+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."
(let ((last-event (chronometrist-latest-record backend)))
(if (plist-member last-event :stop)
(let ((last-interval (chronometrist-latest-record backend)))
(if (plist-member last-interval :stop)
nil
(plist-get last-event :name))))
(plist-get last-interval :name))))
#+END_SRC
*** install-directory :variable:
@ -748,12 +748,6 @@ FORMAT-STRING and ARGS are passed to `format'."
:PROPERTIES:
:CUSTOM_ID: program-data-structures
:END:
Reading directly from the file could be difficult, especially when your most common query is "get all intervals recorded on <date>" [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.
The data from =chronometrist-events= is used by most (all?) interval-consuming functions, but is never written to the user's file itself.
[fn:4] it might be the case that the [[#program-backend][file format]] is not suited to our most frequent operation...
*** reset :command:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-reset ()
@ -799,12 +793,12 @@ Return value is a ts struct (see `ts.el')."
((&plist :start start-2 :stop stop-2) (cl-second split-time))
;; `plist-put' modifies lists in-place. The resulting bugs
;; left me puzzled for a while.
(event-1 (cl-copy-list plist))
(event-2 (cl-copy-list plist)))
(list (-> event-1
(interval-1 (cl-copy-list plist))
(interval-2 (cl-copy-list plist)))
(list (-> interval-1
(plist-put :start start-1)
(plist-put :stop stop-1))
(-> event-2
(-> interval-2
(plist-put :start start-2)
(plist-put :stop stop-2))))))))
#+END_SRC
@ -833,48 +827,47 @@ Return value is a ts struct (see `ts.el')."
:stop "2021-02-13T00:03:46+0530")))))
#+END_SRC
*** events-update :writer:
*** ht-update :writer:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-events-update (plist hash-table &optional replace)
(defun chronometrist-ht-update (plist hash-table &optional replace)
"Return HASH-TABLE with PLIST added as the latest interval.
If REPLACE is non-nil, replace the last interval with PLIST."
(let* ((date (->> (plist-get plist :start)
(chronometrist-iso-to-ts )
(ts-format "%F" )))
(events-today (gethash date hash-table)))
(--> (if replace (-drop-last 1 events-today) events-today)
(intervals-today (gethash date hash-table)))
(--> (if replace (-drop-last 1 intervals-today) intervals-today)
(append it (list plist))
(puthash date it hash-table))
hash-table))
#+END_SRC
*** last-date :reader:
*** ht-last-date :reader:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-events-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in `chronometrist-events'."
(defun chronometrist-ht-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in HASH-TABLE."
(--> (hash-table-keys hash-table)
(last it)
(car it)))
(cl-first it)))
#+END_SRC
*** events-last :reader:
*** ht-last :reader:
#+BEGIN_SRC emacs-lisp
(cl-defun chronometrist-events-last (&optional (backend (chronometrist-active-backend)))
"Return the last plist from `chronometrist-events'."
(let* ((hash-table (chronometrist-backend-hash-table backend))
(last-date (chronometrist-events-last-date hash-table)))
(--> (gethash last-date hash-table)
(last it)
(car it))))
(cl-defun chronometrist-ht-last (&optional hash-table)
"Return the last plist from HASH-TABLE."
(--> (chronometrist-ht-last-date hash-table)
(gethash it hash-table)
(last it)
(cl-first it)))
#+END_SRC
*** events-subset :reader:
*** ht-subset :reader:
:PROPERTIES:
:VALUE: hash table
:CUSTOM_ID: program-data-structures-events-subset
:CUSTOM_ID: program-data-structures-ht-subset
:END:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-events-subset (start end hash-table)
(defun chronometrist-ht-subset (start end hash-table)
"Return a subset of HASH-TABLE.
The subset will contain values between dates START and END (both
inclusive).
@ -896,12 +889,12 @@ treated as though their time is 00:00:00."
(cl-defun chronometrist-task-time-one-day (task &optional (date (chronometrist-date-ts)) (backend (chronometrist-active-backend)))
"Return total time spent on TASK today or on DATE, an ISO-8601 date.
The return value is seconds, as an integer."
(let ((task-events (chronometrist-task-records-for-date backend task date)))
(if task-events
(->> (chronometrist-plists-to-durations task-events)
(let ((task-intervals (chronometrist-task-records-for-date backend task date)))
(if task-intervals
(->> (chronometrist-plists-to-durations task-intervals)
(-reduce #'+)
(truncate))
;; no events for this task on DATE, i.e. no time spent
;; no intervals for this task on DATE, i.e. no time spent
0)))
#+END_SRC
@ -918,16 +911,16 @@ Return value is seconds as an integer."
*** count-active-days :function:
#+BEGIN_SRC emacs-lisp
(cl-defun chronometrist-statistics-count-active-days (task table)
(cl-defun chronometrist-count-active-days (task hash-table)
"Return the number of days the user spent any time on TASK.
TABLE must be a hash table - if not supplied, `chronometrist-events' is used.
HASH-TABLE must be a hash table as returned by `chronometrist-to-hash-table'.
This will not return correct results if TABLE contains records
which span midnights."
(cl-loop for events being the hash-values of table
This will not return correct results if HASH-TABLE contains
records which span midnights."
(cl-loop for intervals being the hash-values of hash-table
count (seq-find (lambda (event)
(equal task (plist-get event :name)))
events)))
intervals)))
#+END_SRC
*** task-list :variable:
@ -946,6 +939,36 @@ active backend."
:group 'chronometrist)
#+END_SRC
*** record-properties :function:
[[file:tests/chronometrist-tests.org::#date-properties][tests]]
#+BEGIN_SRC elisp
(defun chronometrist-record-properties (plist-group)
"Return properties for DATE-RECORDS, if any.
PLIST-GROUP must be a tagged list as returned by
`chronometrist-latest-record'."
(cl-loop with valuep
for elt in plist-group
when (keywordp elt)
collect (progn (setq valuep t) elt) into plist
else when valuep
collect (progn (setq valuep nil) elt) into plist
else when (and (not (keywordp elt)) (not valuep))
do (cl-return plist)))
#+END_SRC
*** record-intervals :function:
#+BEGIN_SRC elisp
(defun chronometrist-record-intervals (record)
"Return intervals (as a list of plists) from RECORD.
RECORD must be a tagged plist as returned by
`chronometrist-latest-record'."
(cl-loop for elt being the elements of record
using (index i)
when (chronometrist-plist-p elt)
return (seq-drop record i)))
#+END_SRC
** Time functions
*** iso-to-ts :function:
#+BEGIN_SRC emacs-lisp
@ -1006,12 +1029,11 @@ TS should be a ts struct (see `ts.el')."
Optional argument UNIX-TIME should be a time value (see
`current-time') accepted by `format-time-string'."
(format-time-string "%FT%T%z" unix-time))
;; Note - this assumes that an event never crosses >1 day. This seems
;; sufficient for all conceivable cases.
#+END_SRC
*** FIXME split-time :reader:
Note - this assumes that an event never crosses >1 day. This seems sufficient for all conceivable cases.
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.)
#+BEGIN_SRC emacs-lisp
@ -1028,13 +1050,13 @@ Return a list in the form
(:start <day start time on second day>
:stop STOP-TIME))"
;; FIXME - time zones are ignored; may cause issues with
;; time-zone-spanning events
;; time-zone-spanning intervals
;; The time on which the first provided day starts (according to `chronometrist-day-start-time')
(let* ((stop-ts (chronometrist-iso-to-ts stop-time))
(first-day-start (chronometrist-apply-time day-start-time start-time))
(next-day-start (ts-adjust 'hour 24 first-day-start)))
;; Does the event stop time exceed the next day start time?
;; Does the interval stop time exceed the next day start time?
(when (ts< next-day-start stop-ts)
(let ((split-time (ts-format "%FT%T%z" next-day-start)))
(list `(:start ,start-time :stop ,split-time)
@ -1084,8 +1106,8 @@ SECONDS must be a positive integer."
*** interval :function:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-interval (plist)
"Return the period of time covered by EVENT as a time value.
EVENT should be a plist (see `chronometrist-file')."
"Return the period of time covered by PLIST as a time value.
PLIST should be a plist (see `chronometrist-file')."
(let* ((start-ts (chronometrist-iso-to-ts (plist-get plist :start)))
(stop-iso (plist-get plist :stop))
;; Add a stop time if it does not exist.
@ -1349,7 +1371,6 @@ IN-SUBLIST, if non-nil, means point is inside an inner list."
:PROPERTIES:
:CUSTOM_ID: program-backend
:END:
*** chronometrist-file :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-file
@ -1532,7 +1553,7 @@ Return path of new file if successfully created, and nil if it already exists.")
***** latest-date-records :generic:function:
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-latest-date-records (backend)
"Return intervals of latest day in BACKEND as a tagged list (\"DATE\" PLIST*).
"Return intervals of latest day in BACKEND as a tagged list (\"DATE\" [<KEYWORD> <VALUE>]* PLIST+).
Return nil if BACKEND contains no records.")
#+END_SRC
@ -1671,14 +1692,17 @@ OLD-PATH and NEW-PATH are the old and new values of
(cl-defgeneric chronometrist-to-hash-table (backend)
"Return data in BACKEND as a hash table in chronological order.
Hash table keys are ISO-8601 date strings. Hash table values are
lists of records, represented by plists. Both hash table keys and
hash table values must be in chronological order.")
lists of intervals (plists), optionally preceded by keyword-value
pairs. Both hash table keys and hash table values must be in
chronological order. Intervals in hash table values must not span
across days.")
#+END_SRC
***** to-list :generic:function:
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-to-list (backend)
"Return all records in BACKEND as a list of plists.")
"Return all intervals in BACKEND as a list of plists.
The resulting list does not contain record key-values.")
#+END_SRC
***** memory-layer-empty-p :generic:function:
@ -1710,6 +1734,9 @@ These can be implemented in terms of the minimal protocol above.
:PROPERTIES:
:CUSTOM_ID: file-backend-mixin
:END:
Reading directly from the file could be difficult, especially when your most common query is "get all intervals recorded on <date>" - and so, each backend maintains a hash table, 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.
The data from the backend hash table is used by most (all?) interval-consuming functions, but is never written to the user's file itself.
#+BEGIN_SRC emacs-lisp
(defclass chronometrist-file-backend-mixin ()
@ -1732,7 +1759,8 @@ These can be implemented in terms of the minimal protocol above.
:documentation "Full path to backend file, with extension.")
(hash-table :initform (chronometrist-make-hash-table)
:initarg :hash-table
:accessor chronometrist-backend-hash-table)
:accessor chronometrist-backend-hash-table
:documentation "Hash table as returned by `chronometrist-to-hash-table'.")
(file-watch :initform nil
:initarg :file-watch
:accessor chronometrist-backend-file-watch
@ -2310,7 +2338,7 @@ In this backend, it's easier to implement this in terms of [[#program-backend-pl
(chronometrist-backend-run-assertions backend)
(with-slots (hash-table) backend
(when-let*
((latest-date (chronometrist-events-last-date hash-table))
((latest-date (chronometrist-ht-last-date hash-table))
(records (gethash latest-date hash-table)))
(cons latest-date records))))
#+END_SRC
@ -2426,7 +2454,7 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
`chronometrist-plist-backend' file."
(with-slots (hash-table) backend
(-let [(new-plist &as &plist :name new-task) (chronometrist-latest-record backend)]
(setf hash-table (chronometrist-events-update new-plist hash-table))
(setf hash-table (chronometrist-ht-update new-plist hash-table))
(chronometrist-add-to-task-list new-task backend))))
#+END_SRC
@ -2437,8 +2465,8 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
`chronometrist-plist-backend' file is modified."
(with-slots (hash-table) backend
(-let (((new-plist &as &plist :name new-task) (chronometrist-latest-record backend))
((&plist :name old-task) (chronometrist-events-last backend)))
(setf hash-table (chronometrist-events-update new-plist hash-table t))
((&plist :name old-task) (chronometrist-ht-last hash-table)))
(setf hash-table (chronometrist-ht-update new-plist hash-table t))
(chronometrist-remove-from-task-list old-task backend)
(chronometrist-add-to-task-list new-task backend))))
#+END_SRC
@ -2449,8 +2477,8 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
"Function run when the newest plist in a
`chronometrist-plist-backend' file is deleted."
(with-slots (hash-table) backend
(-let (((&plist :name old-task) (chronometrist-events-last))
(date (chronometrist-events-last-date hash-table)))
(-let (((&plist :name old-task) (chronometrist-ht-last hash-table))
(date (chronometrist-ht-last-date hash-table)))
;; `chronometrist-remove-from-task-list' checks the hash table to determine
;; if `chronometrist-task-list' is to be updated. Thus, the hash table must
;; not be updated until the task list is.
@ -2515,11 +2543,11 @@ This is largely like the plist backend, but plists are grouped by date by wrappi
#+BEGIN_SRC emacs-lisp :tangle no :load no
("<ISO-8601 date>"
[:keyword <value>]* ;; key-values for the date itself
(:name "Task Name"
[:keyword <value>]*
[:keyword <value>]* ;; key-values for a plist
:start "<ISO-8601 time>"
:stop "<ISO-8601 time>")
...)
:stop "<ISO-8601 time>")+)
#+END_SRC
This makes it easy and computationally cheap to perform our most common query - getting the plists on a given day. [[#explanation-midnight-spanning-intervals][Midnight-spanning intervals]] are split in the file itself. The downside is that the user, if editing it by hand, must take care to split the intervals.
@ -2736,8 +2764,8 @@ Return value is either a list in the form
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-to-list ((backend chronometrist-plist-group-backend))
(chronometrist-backend-run-assertions backend)
(chronometrist-loop-sexp-file for expr in (chronometrist-backend-file backend)
append (reverse (cl-rest expr))))
(chronometrist-loop-sexp-file for record in (chronometrist-backend-file backend)
append (chronometrist-record-intervals record)))
#+END_SRC
**** to-hash-table :reader:method:
@ -2773,9 +2801,10 @@ Return value is either a list in the form
"Function run when a new plist-group is added at the end of a
`chronometrist-plist-group-backend' file."
(with-slots (hash-table) backend
(-let [(date plist) (chronometrist-latest-date-records backend)]
(puthash date plist hash-table)
(chronometrist-add-to-task-list (plist-get plist :name) backend))))
(-let* (((record &as date . rest) (chronometrist-latest-date-records backend))
((interval) (chronometrist-record-intervals record)))
(puthash date rest hash-table)
(chronometrist-add-to-task-list (plist-get interval :name) backend))))
#+END_SRC
**** on-modify :writer:method:
@ -2785,7 +2814,7 @@ Return value is either a list in the form
`chronometrist-plist-group-backend' file is modified."
(with-slots (hash-table) backend
(-let* (((date . plists) (chronometrist-latest-date-records backend))
(old-date (chronometrist-events-last-date hash-table))
(old-date (chronometrist-ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
(puthash date plists hash-table)
(cl-loop for plist in old-plists
@ -2800,7 +2829,7 @@ Return value is either a list in the form
"Function run when the newest plist-group in a
`chronometrist-plist-group-backend' file is deleted."
(with-slots (hash-table) backend
(-let* ((old-date (chronometrist-events-last-date hash-table))
(-let* ((old-date (chronometrist-ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
(cl-loop for plist in old-plists
do (chronometrist-remove-from-task-list (plist-get plist :name) backend))
@ -4101,9 +4130,9 @@ displayed. They must be ts structs (see `ts.el').")
It simply operates on the entire hash table TABLE (see
`chronometrist-to-hash-table' for table format), so ensure that TABLE is
reduced to the desired range using
`chronometrist-events-subset'."
`chronometrist-ht-subset'."
(cl-loop for task in (chronometrist-task-list) collect
(let* ((active-days (chronometrist-statistics-count-active-days task table))
(let* ((active-days (chronometrist-count-active-days task table))
(active-percent (cl-case (plist-get chronometrist-statistics--ui-state :mode)
('week (* 100 (/ active-days 7.0)))))
(active-percent (if (zerop active-days)
@ -4131,12 +4160,12 @@ reduced to the desired range using
('week
(let* ((start (plist-get chronometrist-statistics--ui-state :start))
(end (plist-get chronometrist-statistics--ui-state :end))
(ht (chronometrist-events-subset start end hash-table)))
(ht (chronometrist-ht-subset start end hash-table)))
(chronometrist-statistics-rows-internal ht)))
(t ;; `chronometrist-statistics--ui-state' is nil, show current week's data
(let* ((start (chronometrist-previous-week-start (chronometrist-date-ts)))
(end (ts-adjust 'day 7 start))
(ht (chronometrist-events-subset start end hash-table)))
(ht (chronometrist-ht-subset start end hash-table)))
(setq chronometrist-statistics--ui-state `(:mode week :start ,start :end ,end))
(chronometrist-statistics-rows-internal ht))))))
#+END_SRC
@ -4560,7 +4589,7 @@ range.")
#+END_SRC
**** intervals-for-range :reader:
This is basically like [[#program-data-structures-events-subset][chronometrist-events-subset]], but returns a list instead of a hash table. Might replace one with the other in the future.
This is basically like [[#program-data-structures-ht-subset][chronometrist-ht-subset]], but returns a list instead of a hash table. Might replace one with the other in the future.
#+BEGIN_SRC emacs-lisp
(defun chronometrist-details-intervals-for-range (range table)

View File

@ -233,6 +233,27 @@ BACKEND-VAR is bound to each backend in
(should (seq-every-p #'stringp task-list))))
#+END_SRC
*** date-properties
:PROPERTIES:
:CUSTOM_ID: date-properties
:END:
#+BEGIN_SRC emacs-lisp
(ert-deftest chronometrist-date-properties ()
(should
(not (chronometrist-date-properties
'((:name "Programming" :start "2022-02-12T06:54:01+0530" :stop "2022-02-12T07:41:33+0530")
(:name "OSM" :start "2022-02-12T21:43:50+0530" :stop "2022-02-12T22:14:38+0530")
(:name "Programming" :start "2022-02-12T23:44:37+0530" :stop "2022-02-13T00:00:00+0530")))))
(should
(equal
(chronometrist-date-properties
'(:foo 1 :bar "2" :baz (frob)
(:name "Programming" :start "2022-02-12T06:54:01+0530" :stop "2022-02-12T07:41:33+0530")
(:name "OSM" :start "2022-02-12T21:43:50+0530" :stop "2022-02-12T22:14:38+0530")
(:name "Programming" :start "2022-02-12T23:44:37+0530" :stop "2022-02-13T00:00:00+0530")))
'(:foo 1 :bar "2" :baz (frob)))))
#+END_SRC
** time functions
*** format-duration-long :pure:
#+BEGIN_SRC emacs-lisp