Compare commits

..

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
23 changed files with 916 additions and 6295 deletions

View File

@ -5,12 +5,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## unreleased
### Added
1. `chronometrist-third`, an extension to add support for the [Third Time](https://www.lesswrong.com/posts/RWu8eZqbwgB9zaerh/third-time-a-better-way-to-work) system.
2. New custom variable `chronometrist-key-value-preset-alist`, to define completion suggestions in advance.
3. New custom variable `chronometrist-key-value-use-database-history`, to control whether database history is used for key-value suggestions.
## [0.10.0] - 2022-02-15
### Changed
1. The value of `chronometrist-file` must now be a file path _without extension._ Please update your configurations.
2. The existing file format used by Chronometrist is now called the `plist` format.
@ -23,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4. New custom variable `chronometrist-task-list`, to add/hide tasks without modifying the database. Setting it also disables generation of the task list from the database, speeding up many operations.
5. New command `chronometrist-discard-active`, to discard the active interval.
6. Debug logging messages - to view them, set `chronometrist-debug-enable`.
7. `chronometrist-third`, an extension to add support for the [Third Time](https://www.lesswrong.com/posts/RWu8eZqbwgB9zaerh/third-time-a-better-way-to-work) system.
### Fixed
1. Code to detect the type of change made to the file has been rewritten, hopefully fixing some uncommon `read` errors and `args out of range` errors.

View File

@ -19,8 +19,7 @@ tangle:
--eval='(org-babel-tangle-file "chronometrist.org")' \
--eval='(org-babel-tangle-file "chronometrist-key-values.org")' \
--eval='(org-babel-tangle-file "chronometrist-spark.org")' \
--eval='(org-babel-tangle-file "chronometrist-third.org")' \
--eval='(org-babel-tangle-file "chronometrist-sqlite.org")' ; \
--eval='(org-babel-tangle-file "chronometrist-third.org")' ; \
cd ..
compile: tangle
@ -30,8 +29,7 @@ compile: tangle
--eval='(byte-compile-file "chronometrist.el")' \
--eval='(byte-compile-file "chronometrist-key-values.el")' \
--eval='(byte-compile-file "chronometrist-spark.el")' \
--eval='(byte-compile-file "chronometrist-third.el")' \
--eval='(byte-compile-file "chronometrist-sqlite.el")' ; \
--eval='(byte-compile-file "chronometrist-third.el")' ; \
cd ..
lint-check-declare: tangle
@ -40,8 +38,7 @@ lint-check-declare: tangle
--eval='(check-declare-file "chronometrist.el")' \
--eval='(check-declare-file "chronometrist-key-values.el")' \
--eval='(check-declare-file "chronometrist-spark.el")' \
--eval='(check-declare-file "chronometrist-third.el")' \
--eval='(check-declare-file "chronometrist-sqlite.el")' ; \
--eval='(check-declare-file "chronometrist-third.el")' ; \
cd ..
lint-checkdoc: tangle
@ -50,8 +47,7 @@ lint-checkdoc: tangle
--eval='(checkdoc-file "chronometrist.el")' \
--eval='(checkdoc-file "chronometrist-key-values.el")' \
--eval='(checkdoc-file "chronometrist-spark.el")' \
--eval='(checkdoc-file "chronometrist-third.el")' \
--eval='(checkdoc-file "chronometrist-sqlite.el")' ; \
--eval='(checkdoc-file "chronometrist-third.el")' ; \
cd ..
lint-package-lint: setup tangle
@ -61,8 +57,7 @@ lint-package-lint: setup tangle
-f 'package-lint-batch-and-exit' chronometrist.el \
-f 'package-lint-batch-and-exit' chronometrist-key-values.el \
-f 'package-lint-batch-and-exit' chronometrist-spark.el \
-f 'package-lint-batch-and-exit' chronometrist-third.el \
-f 'package-lint-batch-and-exit' chronometrist-sqlite.el ; \
-f 'package-lint-batch-and-exit' chronometrist-third.el ; \
cd ..
lint-relint: setup tangle
@ -72,8 +67,7 @@ lint-relint: setup tangle
--eval='(relint-file "chronometrist.el")' \
--eval='(relint-file "chronometrist-key-values.el")' \
--eval='(relint-file "chronometrist-spark.el")' \
--eval='(relint-file "chronometrist-third.el")' \
--eval='(relint-file "chronometrist-sqlite.el")' ; \
--eval='(relint-file "chronometrist-third.el")' ; \
cd ..
lint: lint-check-declare lint-checkdoc lint-package-lint lint-relint

1
README.md Symbolic link
View File

@ -0,0 +1 @@
manual.md

View File

@ -1 +0,0 @@
manual.org

260
TODO.org
View File

@ -8,12 +8,10 @@
* v0.3.0 [100%]
1. [X] +Release or+ bundle plist-pp.el
2. [X] -report - error opening when clocked in
* MELPA, hosting [0%]
1. [ ] merge =chronometrist-goal= into this repository
2. [X] move project to codeberg.org or tildegit.org
3. [X] ...and then, update the MELPA recipes.
* Chronometrist
1. implement point restore for =chronometrist=/=chronometrist-report=/=chronometrist-statistics= in a kill-buffer-hook, so it works whenever the buffer is killed, not just when we call the command.
@ -36,13 +34,11 @@
5. Delete =:stop= time and save
6. Clock out again
7. Accept the already-inserted key-value. Duplicate!
** Features [50%]
1. [ ] Hook enhancement - can we supply the whole plist (including tags and key-values) to the task-start hooks, so users can have even smarter hook functions?
* That would mean ensuring that -kv-read runs before other hooks.
* Actually, it should be trivial to access the last expression in the file, so maybe this is unnecessary.
2. [X] Revisit 'no reason' commands - maybe we should ask for tags and key-values with the regular commands, and skip them with the 'no reason' variants?
*** Key-values [66%]
1. [X] bug - value-history appears in chronological rather than reverse chronological order
2. [X] generate history hash table from chronometrist-file.
@ -74,7 +70,6 @@
15. [X] bug - tag history starts at the beginning (wtf?), value history is empty (wtf?)
* Does not occur on master, only on dev
* Does not occur if you disable lexical binding (introduced in 4e89836)
**** Values
What forms can they take?
1. Integers, floating point numbers - easy to identify via regexp
@ -114,7 +109,6 @@
** Maybe
1. Add a new kind of plist - =(:name "NAME" :time "TIME" ...)=
To record events for which the time interval is not relevant. These won't be shown in =chronometrist= - perhaps in a different buffer.
* Optimization
** DONE Don't update task list from data when user has set their own
:PROPERTIES:
@ -141,7 +135,6 @@ Defer (tag/key/value) history generation from file-change-time to prompt-time, a
+ Reduce memory use by allowing user to restrict number of s-expressions read.
+ Per-task history generation will create problems - e.g. values for a given key for one task won't be suggested for values for the same key in another 🤦
* Tags and keys are already task-sensitive; just don't make values task-sensitive.
** DONE Hash file contents to optimize for common changes
Compare partial hashes of file to know what has changed - only update memory when necessary.
@ -150,24 +143,20 @@ Don't store entire file into memory; instead, split midnight-spanning intervals
+ Will increase load time for each forward/backward command in =chronometrist-report= and =chronometrist-statistics=
+ Will reduce memory used by =chronometrist-events=.
* Further reductions can take place, if we automatically discard cache entries past a certain limit. (perhaps excluding data for the current day, week, or month)
** Split and save midnight-spanning intervals to disk
It will remove the need for an in-memory version of data with split midnight-spanning intervals.
+ Least memory use?
+ Might make the file harder for a user to edit.
** Save timestamps as UNIX epoch time
+ Will (probably) greatly speed up time parsing and interval splitting.
+ Will greatly impede human editing of the file, too. 🤔
* An editing UI could help - pretty sure every timestamp edit I've ever made has been for the last interval, or at most an interval in today's data.
- The editing UI could have commands for next/previous interval; one could also have a command which, in the file, opens the plist at point for editing.
** Use an SQL database instead of a text file
That is, assuming SQL can
1. find the difference between ISO-8601 timestamps
2. compare ISO-8601 timestamps, and
3. do 1 and 2 faster than Elisp.
** Change data structure
Instead of storing each plist as-is, split each into two, one with the =:start= and one with the =:end=. Now we have the elegance of the one-plist-is-a-complete-interval schema in the file, and the ease and speed of detection of midnight spanning intervals in memory.
@ -179,7 +168,6 @@ Instead of storing each plist as-is, split each into two, one with the =:start=
(:stop "<timestamp>")
...)
#+END_SRC
** Change file timestamp format to =("<iso-date>" "<iso-time>")=
** Change file schema
Pros
@ -237,7 +225,6 @@ Cons
+ Anything requiring split intervals will first look in =chronometrist-events=, and if not found, will read from the file and update =chronometrist-events=.
+ When the file changes, use the file byte length and hash strategy described below to know whether to keep the cache.
+ Save cache to a file, so that event splitting is avoided by reading from that.
** (old) ideas to make -refresh-file faster
1. Support multiple files, so we read and process lesser data when one of them changes.
2. Make file writing async
@ -249,14 +236,12 @@ Cons
* Or even the first expression of the current date. That way, we just re-read the intervals for today. Because chronometrist-events uses dates as keys, it's easy to work on the basis of dates.
6. [ ] Don't generate tag/keyword/value history from the entire log, just from the last N days (where N is user-customizable).
7. [ ] Just why are we reading the whole file? ~chronometrist~ should not read more than a day; ~chronometrist-report~ should not read more than a week at a time, and so on. Make a branch which works on this logic, see if it is faster.
* Certain
1. [ ] statistics UI for arbitrary queries
* user provides a predicate
* we show buffer with
+ matched unique tag groups, and sparklines for time spent on each
+ matched key-values, and sparklines for time spent on each
** plist-pp [66%]
1. [X] plist-pp - work recursively for plist/alist values
2. [ ] Fix alignment of alist dots
@ -388,7 +373,7 @@ Cons
4. [ ] Fix heading link to "midnight-spanning intervals" - jumps to the correct heading in HTML export, but jumps to its own self in Org mode.
5. [ ] Figure out some way to hide package prefixes in identifiers in Org mode (without actually affecting the contents, a la nameless-mode)
** UX [33%]
** UX [30%]
1. [X] Optimization - (jonasw) store length and hash of previous file, see if the new file has the same hash until old-length bytes.
* [X] Check for type of change to file
+ [X] Handle last expression being removed
@ -396,13 +381,14 @@ Cons
* [X] BUG - if something was removed from the last expression (thereby decreasing the length of the file), =chronometrist-file-change-type= returns =t= instead of =:last=
* [X] BUG - args out of range error when last plist is removed
2. [X] Optimization - generate history before querying, not when the file changes.
3. [ ] When starting a project with time of "-" (i.e. not worked on today until now), immediately set time to 0 instead of waiting for the first timer refresh
4. [ ] Mouse commands should work only on buttons.
5. [X] Button actions should accept prefix arguments and behave exactly like their keyboard counterparts.
6. [ ] mouse-3 should clock-out without asking for reason.
7. [ ] Some way to ask for the reason just before starting a project. Even when clocking out, the reason is asked /before/ clocking out, which adds time to the project.
8. [ ] Allow calling chronometrist-in/out from anywhere-within-Emacs (a la timeclock) as well as from the chronometrist buffer.
9. [ ] =chronometrist-timer= - if =chronometrist-file= is being edited (buffer exists and modified), don't refresh - this will (hopefully) prevent Emacs from going crazy with errors in trying to parse malformed data.
3. [ ] Don't suggest nil when asking for first project on first run
4. [ ] When starting a project with time of "-" (i.e. not worked on today until now), immediately set time to 0 instead of waiting for the first timer refresh
5. [ ] Mouse commands should work only on buttons.
6. [X] Button actions should accept prefix arguments and behave exactly like their keyboard counterparts.
7. [ ] mouse-3 should clock-out without asking for reason.
8. [ ] Some way to ask for the reason just before starting a project. Even when clocking out, the reason is asked /before/ clocking out, which adds time to the project.
9. [ ] Allow calling chronometrist-in/out from anywhere-within-Emacs (a la timeclock) as well as from the chronometrist buffer.
10. [ ] =chronometrist-timer= - if =chronometrist-file= is being edited (buffer exists and modified), don't refresh - this will (hopefully) prevent Emacs from going crazy with errors in trying to parse malformed data.
* Maybe
** New features
@ -472,7 +458,6 @@ Cons
- untouched project with target defined - red
- target ±5 minutes - green
- target*2 and above - red
* viewing/editing frontends
:PROPERTIES:
:CUSTOM_ID: viewing-editing-frontends
@ -492,6 +477,7 @@ A way to represent s-expressions as Markdown, Org, etc, so the entire plist can
* see [[info:elisp#Swapping Text][swapping text]]
*** A binding in the key-value buffer, which will insert the string at point in a buffer of a certain mode.
* documentation discoverability :doc:
Ensure that the user manual is easily discoverable.
@ -701,13 +687,10 @@ list of tasks, one day, durations and graphs
2. [ ] set day (blank input to reset to today's date)
3. [ ] set duration format
4. [ ] display sparkline for total time
*** report
list of tasks, one week, durations only
*** statistics
list of tasks, one week/month/year [fn:1]
*** details (intervals for a day) [16%]
list of intervals, one day [fn:1]
+ [-] commands [50%]
@ -724,14 +707,12 @@ list of intervals, one day [fn:1]
Bugs
1. [ ] day-crossing events have the wrong duration and time. e.g. ~1 Exercise walking 30 minutes from 23:37 to 00:07~
** New frontends I want
*** task-key-values
list of unique key-values for a task, one day [fn:1], durations and graphs
+ commands [0%]
1. [ ] set task
2. [ ] set range
*** task-graph [0%]
list of days, weeks, or months [fn:1], one task (with optional key-value filter [fn:2]), horizontal graph (and durations/stats?)
+ columns
@ -826,28 +807,11 @@ See docstring of =timeclock-log-data=
:PROPERTIES:
:CUSTOM_ID: new-backends-klog
:END:
** SQLite [0%]
Chronometrist allows the user to add arbitrary key-values. There are a few ways to do this in SQL, but [[https://mariadb.com/kb/en/entity-attribute-value-implementation/][this solution]] seems well-suited to Chronometrist - put commonly-queried keys (name, start, stop) into SQL columns, and put user key-values in a JSON/s-exp TEXT column. The latter can be queried separately. SQLite has support for [[https://www.sqlite.org/json1.html][JSON columns]].
1. [ ] Use =string-remove-prefix= rather than =s-chop-prefix=
2. [-] =to-file=
* [X] insert intervals
* [ ] insert date properties
* [ ] insert events
** SQLite
** Project Hamster
https://github.com/projecthamster/hamster
+ https://github.com/projecthamster/hamster/wiki/Our-datamodel
** [[https://gitlab.com/Harag/cl-naive-store][cl-naive-store]]
:PROPERTIES:
:CREATED: 2022-04-16T21:11:07+0530
:END:
Reimplementing the plist/plist-group backends in Common Lisp, without the buffer visualisation and buffer-movement commands provided by Emacs/Elisp, is not something I'm very enthusiastic about. Even if those turn out to not be major factors, implementing these backends in Elisp taught me that making your own database can be a lot of work. Especially if you just care about your application, and writing a database was not what you set out to do (I severely underestimated the work involved).
Yet, some may prefer the plist-group backend to an SQLite database. I trust that a cl-naive-store backend could have the same capabilities as the plist-group backend, but without the maintenance burden of an /ad hoc/ s-expression store.
* Functions doing similar things :code:
1. =task-time-one-day=
2. =interval= (unused)
@ -879,10 +843,8 @@ Command to delete the interval currently being recorded. (bind to 'k')
* pretty printer
** fix handling of tagged alist group values :bug:
** put each list element on its own line if length of list exceeds fill-column :feature:
* verify command [20%] :feature:
:PROPERTIES:
:CUSTOM_ID: verify-command
:END:
With different checks, for different levels of speed/thoroughness -
1. [X] (plist group) Sequence of plist group dates
2. [ ] Check that every record (except the last) has a =:stop=
@ -963,18 +925,8 @@ The plist backend could theoretically be extended to support it, but I'd rather
:PROPERTIES:
:CREATED: 2022-01-08T23:32:37+0530
:END:
1. [ ] Use active backend as the suggested input backend when migrating
2. [ ] Conserve file local variables prop line for text backends
** make migrate command async [33%]
:PROPERTIES:
:CREATED: 2022-04-01T18:02:03+0530
:END:
=chronometrist-migrate= / =chronometrist-to-file= can take a long time to run, freezing Emacs until completion if run synchronously.
1. [X] basic async export
2. [ ] display message on start and completion
3. [ ] test the INPUT-FILE argument
1. [ ] default suggested input backend should be the active backend
2. [ ] conserve file local variables prop line for text backends
* STARTED Support for the Third Time System [57%] :extension:
:PROPERTIES:
@ -1048,7 +1000,7 @@ Also define =chronometrist--timer-alist=, which associates =symbol= with a timer
[fn:4] Actually, make a macro to define such alists, since the docstrings are likely to be near-identical.
* DONE Predefined key-values for tasks
* Predefined key-values for tasks [100%]
:PROPERTIES:
:CREATED: 2022-02-17T23:34:17+0530
:END:
@ -1060,185 +1012,3 @@ Benefits
'(("Task" <plist> ...)
...)
#+END_SRC
* no-goal-alert - "You have spent on <task>" [0%] :bug:
:PROPERTIES:
:CREATED: 2022-02-25T00:19:49+0530
:END:
=chronometrist-goal-no-goal-alert= uses =chronometrist-task-time-one-day=, which calls =chronometrist-task-records-for-date= with the /current/ date.
1. [ ] Use the data for the latest date recorded in the database, rather than the current date.
* better error reporting
:PROPERTIES:
:CREATED: 2022-02-28T11:35:37+0530
:END:
Similar to [[#verify-command][verify command]], perhaps, but this is really about making error messages thrown by the program more meaningful to the user.
** record does not contain intervals
:PROPERTIES:
:CREATED: 2022-02-28T11:35:47+0530
:END:
* User-defined categories
:PROPERTIES:
:CREATED: 2022-03-31T22:44:34+0530
:END:
A task can be part of any number of categories. A category may also contain other categories. The categories, and the total time spent on each one, could be displayed in the main view, separate from the tasks.
* First run behaviour :ux:
:PROPERTIES:
:CREATED: 2022-04-02T17:28:33+0530
:END:
1. [ ] Look for any existing Chronometrist database in the default location. If one is found, use it to determine the default active backend. Otherwise, offer to import from one of the supported backends.
+ Some backends may not be installed - "see MELPA for more formats"
2. [ ] Don't suggest nil when asking for first project on first run
* STARTED Common Lisp port
:PROPERTIES:
:CREATED: 2022-04-03T09:36:53+0530
:END:
** DONE Replace =add-variable-watcher=
Common Lisp does not have Elisp's =add-variable-watcher=. The Elisp code used that to automatically update the =file= slot of the active backend object when the user changes the value of =chronometrist-file=. The new solution is to remove direct accesses to the =file= slot, define a =backend-file= method which returns the value of =file= if non-nil (it's usually nil), or derives a file path from =*user-data-file*= and the backend's extension.
It seems that the =file= slot is now unnecessary - =backend-file= can check =path= instead of =file=, and use either =path= or =*user-data-file*= to derive the full path.
1. [X] Remove the =file= slot
** DONE Use pathnames instead of namestrings
:PROPERTIES:
:CREATED: 2022-04-03T12:30:24+0530
:END:
** CLIM UI design
*** One-day task-duration table
:PROPERTIES:
:CREATED: 2022-04-10T08:09:18+0530
:END:
columns
1. index (for numeric argument)
2. task name
3. task duration today
4. task activity indicator
* click to clock in/out (hover to show icon)
5. graph of daily task durations
* Click on a point in the graph to view that day in the Details View.
click on row to show taller graph and inline display of either key-value breakdown, or interval breakdown.
commands
1. clock in, clock out, etc
2. toggle details for row
3. toggle details for all rows
4. next/previous day
5. set displayed day
6. cycle graph heights (self/comparative)
* the height of the graphs can be based on the range of each individual graph's own data ('self') or based on the range of all data ('comparative').
settings
1. default graph height
*** One-day category-duration table
:PROPERTIES:
:CREATED: 2022-04-10T08:11:20+0530
:END:
1. category
2. time duration today
commands
1. toggle details for row (show tasks in each category)
2. toggle details for all rows
*** One-day event-occurrence table
:PROPERTIES:
:CREATED: 2022-04-10T08:12:58+0530
:END:
("Useful for recording things like weight, medication, waist circumference, etc.")
1. index (prefix argument)
2. event name
3. time of occurrence
4. graph of past occurrences
commands
1. add event
2. record event
*** 1+ day editable log
:PROPERTIES:
:CREATED: 2022-04-08T18:23:42+0530
:END:
Displays tables of day properties, intervals, and events, grouped by day, in date/week/month/year range (default - today)
can select a day property/interval/event to edit its data
buttons to add new day properties, intervals, and events
When there are no intervals/events/day properties, show placeholder text - "No intervals recorded on this day." / "No events recorded on this day." / "No properties for this day."
Commands
1. filter contents
2. next/previous day (or range)
3. set displayed day (or range)
*** Line chart views
:PROPERTIES:
:CREATED: [2022-04-10 Sun 14:35]
:END:
Line chart with one or more (adjustable) metrics -
* duration for task
* task-property combinations
* numeric property value/function with numeric output
...over a time range (adjustable), with points for days/weeks/months/years (adjustable)
User can switch between single graph vs separate graphs, different types of graphs, etc.
Commands
1. new/open/save/delete view
2. add/remove metric (a line on the graph)
3. increase/decrease/set range, next/previous range
4. increase/decrease/set granularity
*** Default view (overview for a day)
Default view is a tiled composition of the one-day task-duration table, one-day grouped log, and the one-day category-duration table.
*** view/edit database as
:PROPERTIES:
:CREATED: 2022-04-08T19:56:38+0530
:END:
some way to view/edit the database in a different format, e.g. Lisp plists
* Documentation restructuring :doc:
:PROPERTIES:
:CREATED: 2022-04-14T18:04:21+0530
:END:
1. Move documentation from LPs to manual.org
2. Revamp tutorials - https://diataxis.fr/tutorials/#the-language-of-tutorials
3. Make how-to guides more general?
4. Move some how-to guide code into a contrib directory?
** tutorial and how-to topic revamp
tutorials
1. installation (MELPA) -> first run
2. adding the first task
3. clocking in, clocking out
4. customization - open a file when starting a task (move from how-to)
5. customization - warn when exiting with an active task
6. clocking in/out without running hooks
7. restarting or discarding an activity
8. key-value extension - installation -> customization -> testing/results
9. graph extension - installation -> customization -> testing/results
how-to guides
1. how to install (Git, MELPA, Quelpa, etc)
2. how to attach tags and properties to time intervals (cover different commands)
3. how to configure Emacs for Chronometrist development
* Concurrent tasks
:PROPERTIES:
:CREATED: 2022-04-19T22:12:54+0530
:END:
I'm not interested in using this myself, so it must be possible to enable/disable it.
+ Enabling/disabling it would just affect how the clock-in command works. Perhaps data verification, too.
How might it affect the data consumer?
1. Calculation of total time tracked (in any range, e.g. a day) will definitely require a more complex algorithm.

View File

@ -1,9 +0,0 @@
(defsystem chronometrist-sqlite
:version "0.0.1"
:serial t
:license "Unlicense"
:author "contrapunctus <contrapunctus at disroot dot org>"
:description "SQLite backend for Chronometrist"
:defsystem-depends-on ("literate-lisp")
:depends-on (:trivia :clsql-sqlite3)
:components ((:org "chronometrist")))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
(defsystem chronometrist
:version "0.0.1"
:serial t
:license "Unlicense"
:author "contrapunctus <contrapunctus at disroot dot org>"
:description "Friendly and extensible personal time tracker - common library"
:defsystem-depends-on ("literate-lisp")
:depends-on (:trivia)
:components ((:org "chronometrist")))

View File

@ -1,32 +0,0 @@
* Explanation
:PROPERTIES:
:CUSTOM_ID: explanation
:END:
This is a port of Chronometrist to Common Lisp.
Currently, it contains
1. a read-only plist-group backend
2. an incomplete SQLite backend
3. an incomplete CLIM frontend
** Source code overview
:PROPERTIES:
:CUSTOM_ID: source-code-overview
:END:
*** CLIM frontend
:PROPERTIES:
:CUSTOM_ID: clim-frontend
:END:
The CLIM frontend uses CLIM panes for each view of data Chronometrist provides. Each pane has a [[file:chronometrist.org::#display-pane][=display-pane=]] method to display its contents.
Currently, only one CLIM pane has been implemented, called the [[file:chronometrist.org::#task-duration-table-pane][task-duration table]]. By default, it displays a list of tasks, and each of their durations for today.
**** Tables
:PROPERTIES:
:CUSTOM_ID: tables
:END:
The tables in the CLIM frontend are designed with ease of extensibility in mind. Thus -
1. The columns displayed in this table are controlled by [[file:chronometrist.org::#*task-duration-table-spec*][=*task-duration-table-spec*=]], which contains a list of [[file:chronometrist.org::#column-specifier][=column-specifier=]] objects.
2. Each [[file:chronometrist.org::#column-specifier][=column-specifier=]] has two methods specializing on it, a [[file:chronometrist.org::#cell-data][=cell-data=]] method and a [[file:chronometrist.org::#cell-print][=cell-print=]] method. These determine the data contained by the cells of the column, and how that data is printed in the CLIM pane.
The function [[file:chronometrist.org::#task-duration-table-function][=task-duration-table-function=]] uses the [[file:chronometrist.org::#cell-data][=cell-data=]] methods to return the data of the table as a list of lists. This data is used by [[file:chronometrist.org::#display-pane][=display-pane=]] in conjunction with [[file:chronometrist.org::#cell-print][=cell-print=]] methods to display the data in the pane.

View File

@ -1,9 +0,0 @@
(defsystem chronometrist-clim
:version "0.0.1"
:serial t
:license "Unlicense"
:author "contrapunctus <contrapunctus at disroot dot org>"
:description "Friendly and extensible personal time tracker - CLIM GUI"
:defsystem-depends-on ("literate-lisp")
:depends-on (:trivia :mcclim)
:components ((:org "chronometrist")))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@ -192,29 +192,6 @@ used in `chronometrist-before-out-functions'."
"Add key-values to Chronometrist time intervals."
:group 'chronometrist)
(defcustom chronometrist-key-value-use-database-history t
"If non-nil, use database to generate key-value suggestions.
If nil, only `chronometrist-key-value-preset-alist' is used."
:type 'boolean
:group 'chronometrist-key-value)
(defcustom chronometrist-key-value-preset-alist nil
"Alist of key-value suggestions for `chronometrist-key-value' prompts.
Each element must be in the form (\"TASK\" <KEYWORD> <VALUE> ...)"
:type
'(repeat
(cons
(string :tag "Task name")
(repeat :tag "Property preset"
(plist :tag "Property"
;; :key-type 'keyword :value-type 'sexp
))))
:group 'chronometrist-key-values)
(defun chronometrist-key-value-get-presets (task)
"Return presets for TASK from `chronometrist-key-value-preset-alist' as a list of plists."
(alist-get task chronometrist-key-value-preset-alist nil nil #'equal))
(defcustom chronometrist-kv-buffer-name "*Chronometrist-Key-Values*"
"Name of buffer in which key-values are entered."
:group 'chronometrist-key-values
@ -427,35 +404,30 @@ used in `chronometrist-before-out-functions'."
["Change tags and key-values for active/last interval"
chronometrist-key-values-unified-prompt]))
(cl-defun chronometrist-key-values-unified-prompt
(&optional (task (plist-get (chronometrist-latest-record (chronometrist-active-backend)) :name)))
(cl-defun chronometrist-key-values-unified-prompt (&optional (task (plist-get (chronometrist-latest-record (chronometrist-active-backend)) :name)))
"Query user for tags and key-values to be added for TASK.
Return t, to permit use in `chronometrist-before-out-functions'."
(interactive)
(let* ((backend (chronometrist-active-backend))
(presets (--map (format "%S" it)
(chronometrist-key-value-get-presets task)))
(key-values
(when chronometrist-key-value-use-database-history
(cl-loop for plist in (chronometrist-to-list backend)
when (equal (plist-get plist :name) task)
collect
(let ((plist (chronometrist-plist-remove plist :name :start :stop)))
(when plist (format "%S" plist)))
into key-value-plists
finally return
(--> (seq-filter #'identity key-value-plists)
(cl-remove-duplicates it :test #'equal :from-end t)))))
(cl-loop for plist in (chronometrist-to-list backend)
when (equal (plist-get plist :name) task)
collect
(let ((plist (chronometrist-plist-remove plist :name :start :stop)))
(when plist (format "%S" plist)))
into key-value-plists
finally return
(--> (seq-filter #'identity key-value-plists)
(cl-remove-duplicates it :test #'equal :from-end t))))
(latest (chronometrist-latest-record backend)))
(if (and (null presets) (null key-values))
(if (null key-values)
(progn (chronometrist-tags-add) (chronometrist-kv-add))
(let* ((candidates (append presets key-values))
(input (completing-read
(format "Key-values for %s: " task)
candidates nil nil nil 'chronometrist-key-values-unified-prompt-history)))
(chronometrist-replace-last backend
(chronometrist-plist-update latest
(read input))))))
(chronometrist-replace-last
backend
(chronometrist-plist-update
latest
(read (completing-read (format "Key-values for %s: " task)
key-values nil nil nil 'chronometrist-key-values-unified-prompt-history))))))
t)
(provide 'chronometrist-key-values)

View File

@ -330,39 +330,6 @@ used in `chronometrist-before-out-functions'."
"Add key-values to Chronometrist time intervals."
:group 'chronometrist)
#+END_SRC
*** use-database-history :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-key-value-use-database-history t
"If non-nil, use database to generate key-value suggestions.
If nil, only `chronometrist-key-value-preset-alist' is used."
:type 'boolean
:group 'chronometrist-key-value)
#+END_SRC
*** preset-alist :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-key-value-preset-alist nil
"Alist of key-value suggestions for `chronometrist-key-value' prompts.
Each element must be in the form (\"TASK\" <KEYWORD> <VALUE> ...)"
:type
'(repeat
(cons
(string :tag "Task name")
(repeat :tag "Property preset"
(plist :tag "Property"
;; :key-type 'keyword :value-type 'sexp
))))
:group 'chronometrist-key-values)
#+END_SRC
**** get-presets
#+BEGIN_SRC emacs-lisp
(defun chronometrist-key-value-get-presets (task)
"Return presets for TASK from `chronometrist-key-value-preset-alist' as a list of plists."
(alist-get task chronometrist-key-value-preset-alist nil nil #'equal))
#+END_SRC
*** kv-buffer-name :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-kv-buffer-name "*Chronometrist-Key-Values*"
@ -370,7 +337,6 @@ Each element must be in the form (\"TASK\" <KEYWORD> <VALUE> ...)"
:group 'chronometrist-key-values
:type 'string)
#+END_SRC
*** key-history :variable:
:PROPERTIES:
:VALUE: hash table
@ -741,40 +707,34 @@ Return t, to permit use in `chronometrist-before-out-functions'."
:CUSTOM_ID: unified-prompt
:END:
1. [ ] Improve appearance - is there an easy way to syntax highlight the plists?
#+BEGIN_SRC emacs-lisp
(cl-defun chronometrist-key-values-unified-prompt
(&optional (task (plist-get (chronometrist-latest-record (chronometrist-active-backend)) :name)))
(cl-defun chronometrist-key-values-unified-prompt (&optional (task (plist-get (chronometrist-latest-record (chronometrist-active-backend)) :name)))
"Query user for tags and key-values to be added for TASK.
Return t, to permit use in `chronometrist-before-out-functions'."
(interactive)
(let* ((backend (chronometrist-active-backend))
(presets (--map (format "%S" it)
(chronometrist-key-value-get-presets task)))
(key-values
(when chronometrist-key-value-use-database-history
(cl-loop for plist in (chronometrist-to-list backend)
when (equal (plist-get plist :name) task)
collect
(let ((plist (chronometrist-plist-remove plist :name :start :stop)))
(when plist (format "%S" plist)))
into key-value-plists
finally return
(--> (seq-filter #'identity key-value-plists)
(cl-remove-duplicates it :test #'equal :from-end t)))))
(cl-loop for plist in (chronometrist-to-list backend)
when (equal (plist-get plist :name) task)
collect
(let ((plist (chronometrist-plist-remove plist :name :start :stop)))
(when plist (format "%S" plist)))
into key-value-plists
finally return
(--> (seq-filter #'identity key-value-plists)
(cl-remove-duplicates it :test #'equal :from-end t))))
(latest (chronometrist-latest-record backend)))
(if (and (null presets) (null key-values))
(if (null key-values)
(progn (chronometrist-tags-add) (chronometrist-kv-add))
(let* ((candidates (append presets key-values))
(input (completing-read
(format "Key-values for %s: " task)
candidates nil nil nil 'chronometrist-key-values-unified-prompt-history)))
(chronometrist-replace-last backend
(chronometrist-plist-update latest
(read input))))))
(chronometrist-replace-last
backend
(chronometrist-plist-update
latest
(read (completing-read (format "Key-values for %s: " task)
key-values nil nil nil 'chronometrist-key-values-unified-prompt-history))))))
t)
#+END_SRC
#+END_SRC
* Provide
#+BEGIN_SRC emacs-lisp
(provide 'chronometrist-key-values)
@ -783,6 +743,5 @@ Return t, to permit use in `chronometrist-before-out-functions'."
* Local variables :noexport:
# Local Variables:
# my-org-src-default-lang: "emacs-lisp"
# eval: (when (package-installed-p 'literate-elisp) (require 'literate-elisp) (literate-elisp-load (buffer-file-name)))
# End:

View File

@ -1,210 +0,0 @@
;;; chronometrist-sqlite.el --- SQLite backend for Chronometrist -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "24.3") (chronometrist "0.9.0") (emacsql-sqlite "1.0.0"))
;; Version: 0.1.0
;; This is free and unencumbered software released into the public domain.
;;
;; Anyone is free to copy, modify, publish, use, compile, sell, or
;; distribute this software, either in source code form or as a compiled
;; binary, for any purpose, commercial or non-commercial, and by any
;; means.
;;
;; For more information, please refer to <https://unlicense.org>
;;; Commentary:
;;
;; This package provides an SQLite 3 backend for Chronometrist.
;;; Code:
(require 'chronometrist)
(require 'emacsql-sqlite)
(defclass chronometrist-sqlite-backend (chronometrist-backend chronometrist-file-backend-mixin)
((extension :initform "sqlite"
:accessor chronometrist-backend-ext
:custom 'string)
(connection :initform nil
:initarg :connection
:accessor chronometrist-backend-connection)))
(chronometrist-register-backend
:sqlite "Store records in SQLite database."
(make-instance 'chronometrist-sqlite-backend :path chronometrist-file))
(cl-defmethod initialize-instance :after ((backend chronometrist-sqlite-backend)
&rest _initargs)
"Initialize connection for BACKEND based on its file."
(with-slots (file connection) backend
(when (and file (not connection))
(setf connection (emacsql-sqlite file)))))
(cl-defmethod chronometrist-create-file ((backend chronometrist-sqlite-backend) &optional file)
"Create file for BACKEND if it does not already exist.
Return the connection object from `emacsql-sqlite'."
(let* ((file (or file (chronometrist-backend-file backend)))
(db (or (chronometrist-backend-connection backend)
(setf (chronometrist-backend-connection backend)
(emacsql-sqlite file)))))
(cl-loop
for query in
'(;; Properties are user-defined key-values stored as JSON.
[:create-table properties
([(prop-id integer :primary-key)
(properties text :unique :not-null)])]
;; An event is a timestamp with a name and optional properties.
[:create-table event-names
([(name-id integer :primary-key)
(name text :unique :not-null)])]
[:create-table events
([(event-id integer :primary-key)
(name-id integer :not-null :references event-names [name-id])])]
;; An interval is a time range with a name and optional properties.
[:create-table interval-names
([(name-id integer :primary-key)
(name text :unique :not-null)])]
[:create-table intervals
([(interval-id integer :primary-key)
(name-id integer :not-null :references interval-names [name-id])
(start-time integer :not-null)
;; The latest interval may be ongoing, so the stop time may be NULL.
(stop-time integer)
(prop-id integer :references properties [prop-id])]
(:unique [name-id start-time stop-time]))]
;; A date contains one or more events and intervals. It may
;; also contain properties.
[:create-table dates
([(date-id integer :primary-key)
(date integer :unique :not-null)
(prop-id integer :references properties [prop-id])])]
[:create-table date-events
([(date-id integer :not-null :references dates [date-id])
(event-id integer :not-null :references events [event-id])])]
[:create-table date-intervals
([(date-id integer :not-null :references dates [date-id])
(interval-id integer :not-null :references intervals [interval-id])])])
do (emacsql db query)
finally return db)))
(defun chronometrist-iso-to-unix (timestamp)
(truncate (float-time (parse-iso8601-time-string timestamp))))
(cl-defmethod chronometrist-to-file (hash-table (backend chronometrist-sqlite-backend) file)
(with-slots (connection) backend
(delete-file file)
(when connection (emacsql-close connection))
(setf connection nil)
(chronometrist-create-file backend file)
(cl-loop for date in (sort (hash-table-keys hash-table) #'string-lessp) do
;; insert date if it does not exist
(emacsql connection [:insert-or-ignore-into dates [date] :values [$s1]]
(chronometrist-iso-to-unix date))
(cl-loop for plist in (gethash date hash-table) do
(chronometrist-insert backend plist)))))
(defun chronometrist-sqlite-insert-properties (backend plist)
"Insert properties from PLIST to (SQLite) BACKEND.
Properties are key-values excluding :name, :start, and :stop.
Insert nothing if the properties already exist. Return the
prop-id of the inserted or existing property."
(with-slots (connection) backend
(let* ((plist (chronometrist-plist-key-values plist))
(props (if (functionp chronometrist-sqlite-properties-function)
(funcall chronometrist-sqlite-properties-function plist)
plist)))
(emacsql connection
[:insert-or-ignore-into properties [properties] :values [$s1]]
props)
(caar (emacsql connection [:select [prop-id]
:from properties
:where (= properties $s1)]
props)))))
(defun chronometrist-sqlite-properties-to-json (plist)
"Return PLIST as a JSON string."
(json-encode
;; `json-encode' throws an error when it thinks
;; it sees "alists" which have numbers as
;; "keys", so we convert any cons cells and any
;; lists starting with a number to vectors
(-tree-map (lambda (elt)
(cond ((chronometrist-pp-pair-p elt)
(vector (car elt) (cdr elt)))
((consp elt)
(vconcat elt))
(t elt)))
plist)))
(defcustom chronometrist-sqlite-properties-function nil
"Function used to control the encoding of user key-values.
The function must accept a single argument, the plist of key-values.
Any non-function value results in key-values being inserted as
s-expressions in a text column."
:type '(choice function (sexp :tag "Insert as s-expressions")))
(cl-defmethod chronometrist-insert ((backend chronometrist-sqlite-backend) plist)
(-let (((plist-1 plist-2) (chronometrist-split-plist plist))
(db (chronometrist-backend-connection backend)))
(cl-loop for plist in (if (and plist-1 plist-2)
(list plist-1 plist-2)
(list plist))
do
(-let* (((&plist :name name :start start :stop stop) plist)
(date-unix (chronometrist-iso-to-unix (chronometrist-iso-to-date start)))
(start-unix (chronometrist-iso-to-unix start))
(stop-unix (and stop (chronometrist-iso-to-unix stop)))
name-id interval-id prop-id)
;; insert name if it does not exist
(emacsql db [:insert-or-ignore-into interval-names [name]
:values [$s1]]
name)
;; insert interval properties if they do not exist
(setq prop-id (chronometrist-sqlite-insert-properties backend plist))
;; insert interval and associate it with the date
(setq name-id
(caar (emacsql db [:select [name-id]
:from interval-names
:where (= name $s1)]
name)))
(emacsql db [:insert-or-ignore-into intervals
[name-id start-time stop-time prop-id]
:values [$s1 $s2 $s3 $s4]]
name-id start-unix stop-unix prop-id)
(emacsql db [:insert-or-ignore-into dates [date]
:values [$s1]] date-unix)
(setq date-id
(caar (emacsql db [:select [date-id] :from dates
:where (= date $s1)]
date-unix))
interval-id
(caar (emacsql db [:select (funcall max interval-id) :from intervals])))
(emacsql db [:insert-into date-intervals [date-id interval-id]
:values [$s1 $s2]]
date-id interval-id)))))
(cl-defmethod chronometrist-edit-backend ((backend chronometrist-sqlite-backend))
(require 'sql)
(switch-to-buffer
(sql-comint-sqlite 'sqlite (list file))))
;; SELECT * FROM TABLE WHERE ID = (SELECT MAX(ID) FROM TABLE);
;; SELECT * FROM tablename ORDER BY column DESC LIMIT 1;
(cl-defmethod chronometrist-latest-record ((backend chronometrist-sqlite-backend) db)
(emacsql db [:select * :from events :order-by rowid :desc :limit 1]))
(cl-defmethod chronometrist-task-records-for-date ((backend chronometrist-sqlite-backend) task date-ts))
(cl-defmethod chronometrist-active-days ((backend chronometrist-sqlite-backend) task))
(cl-defmethod chronometrist-replace-last ((backend chronometrist-sqlite-backend) plist)
(emacsql db [:delete-from events :where ]))
(provide 'chronometrist-sqlite)
;;; chronometrist-sqlite.el ends here

View File

@ -1,277 +0,0 @@
#+TITLE: chronometrist-sqlite
#+AUTHOR: contrapunctus
#+SUBTITLE: SQLite backend for Chronometrist
#+PROPERTY: header-args :tangle yes :load yes
* Library headers and commentary
#+BEGIN_SRC emacs-lisp
;;; chronometrist-sqlite.el --- SQLite backend for Chronometrist -*- lexical-binding: t; -*-
;; Author: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Maintainer: contrapunctus <xmpp:contrapunctus@jabjab.de>
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "24.3") (chronometrist "0.9.0") (emacsql-sqlite "1.0.0"))
;; Version: 0.1.0
;; This is free and unencumbered software released into the public domain.
;;
;; Anyone is free to copy, modify, publish, use, compile, sell, or
;; distribute this software, either in source code form or as a compiled
;; binary, for any purpose, commercial or non-commercial, and by any
;; means.
;;
;; For more information, please refer to <https://unlicense.org>
#+END_SRC
"Commentary" is displayed when the user clicks on the package's entry in =M-x list-packages=.
#+BEGIN_SRC emacs-lisp
;;; Commentary:
;;
;; This package provides an SQLite 3 backend for Chronometrist.
#+END_SRC
* Dependencies
#+BEGIN_SRC emacs-lisp
;;; Code:
(require 'chronometrist)
(require 'emacsql-sqlite)
#+END_SRC
* Code
** class
#+BEGIN_SRC emacs-lisp
(defclass chronometrist-sqlite-backend (chronometrist-backend chronometrist-file-backend-mixin)
((extension :initform "sqlite"
:accessor chronometrist-backend-ext
:custom 'string)
(connection :initform nil
:initarg :connection
:accessor chronometrist-backend-connection)))
(chronometrist-register-backend
:sqlite "Store records in SQLite database."
(make-instance 'chronometrist-sqlite-backend :path chronometrist-file))
#+END_SRC
** initialize-instance :method:
#+BEGIN_SRC emacs-lisp
(cl-defmethod initialize-instance :after ((backend chronometrist-sqlite-backend)
&rest _initargs)
"Initialize connection for BACKEND based on its file."
(with-slots (file connection) backend
(when (and file (not connection))
(setf connection (emacsql-sqlite file)))))
#+END_SRC
** create-file
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-create-file ((backend chronometrist-sqlite-backend) &optional file)
"Create file for BACKEND if it does not already exist.
Return the connection object from `emacsql-sqlite'."
(let* ((file (or file (chronometrist-backend-file backend)))
(db (or (chronometrist-backend-connection backend)
(setf (chronometrist-backend-connection backend)
(emacsql-sqlite file)))))
(cl-loop
for query in
'(;; Properties are user-defined key-values stored as JSON.
[:create-table properties
([(prop-id integer :primary-key)
(properties text :unique :not-null)])]
;; An event is a timestamp with a name and optional properties.
[:create-table event-names
([(name-id integer :primary-key)
(name text :unique :not-null)])]
[:create-table events
([(event-id integer :primary-key)
(name-id integer :not-null :references event-names [name-id])])]
;; An interval is a time range with a name and optional properties.
[:create-table interval-names
([(name-id integer :primary-key)
(name text :unique :not-null)])]
[:create-table intervals
([(interval-id integer :primary-key)
(name-id integer :not-null :references interval-names [name-id])
(start-time integer :not-null)
;; The latest interval may be ongoing, so the stop time may be NULL.
(stop-time integer)
(prop-id integer :references properties [prop-id])]
(:unique [name-id start-time stop-time]))]
;; A date contains one or more events and intervals. It may
;; also contain properties.
[:create-table dates
([(date-id integer :primary-key)
(date integer :unique :not-null)
(prop-id integer :references properties [prop-id])])]
[:create-table date-events
([(date-id integer :not-null :references dates [date-id])
(event-id integer :not-null :references events [event-id])])]
[:create-table date-intervals
([(date-id integer :not-null :references dates [date-id])
(interval-id integer :not-null :references intervals [interval-id])])])
do (emacsql db query)
finally return db)))
#+END_SRC
** iso-to-unix :function:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-iso-to-unix (timestamp)
(truncate (float-time (parse-iso8601-time-string timestamp))))
#+END_SRC
** to-file :method:
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-to-file (hash-table (backend chronometrist-sqlite-backend) file)
(with-slots (connection) backend
(delete-file file)
(when connection (emacsql-close connection))
(setf connection nil)
(chronometrist-create-file backend file)
(cl-loop for date in (sort (hash-table-keys hash-table) #'string-lessp) do
;; insert date if it does not exist
(emacsql connection [:insert-or-ignore-into dates [date] :values [$s1]]
(chronometrist-iso-to-unix date))
(cl-loop for plist in (gethash date hash-table) do
(chronometrist-insert backend plist)))))
#+END_SRC
** insert-properties :writer:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-sqlite-insert-properties (backend plist)
"Insert properties from PLIST to (SQLite) BACKEND.
Properties are key-values excluding :name, :start, and :stop.
Insert nothing if the properties already exist. Return the
prop-id of the inserted or existing property."
(with-slots (connection) backend
(let* ((plist (chronometrist-plist-key-values plist))
(props (if (functionp chronometrist-sqlite-properties-function)
(funcall chronometrist-sqlite-properties-function plist)
plist)))
(emacsql connection
[:insert-or-ignore-into properties [properties] :values [$s1]]
props)
(caar (emacsql connection [:select [prop-id]
:from properties
:where (= properties $s1)]
props)))))
#+END_SRC
*** properties-to-json :function:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-sqlite-properties-to-json (plist)
"Return PLIST as a JSON string."
(json-encode
;; `json-encode' throws an error when it thinks
;; it sees "alists" which have numbers as
;; "keys", so we convert any cons cells and any
;; lists starting with a number to vectors
(-tree-map (lambda (elt)
(cond ((chronometrist-pp-pair-p elt)
(vector (car elt) (cdr elt)))
((consp elt)
(vconcat elt))
(t elt)))
plist)))
#+END_SRC
*** properties-function :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-sqlite-properties-function nil
"Function used to control the encoding of user key-values.
The function must accept a single argument, the plist of key-values.
Any non-function value results in key-values being inserted as
s-expressions in a text column."
:type '(choice function (sexp :tag "Insert as s-expressions")))
#+END_SRC
** insert
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-insert ((backend chronometrist-sqlite-backend) plist)
(-let (((plist-1 plist-2) (chronometrist-split-plist plist))
(db (chronometrist-backend-connection backend)))
(cl-loop for plist in (if (and plist-1 plist-2)
(list plist-1 plist-2)
(list plist))
do
(-let* (((&plist :name name :start start :stop stop) plist)
(date-unix (chronometrist-iso-to-unix (chronometrist-iso-to-date start)))
(start-unix (chronometrist-iso-to-unix start))
(stop-unix (and stop (chronometrist-iso-to-unix stop)))
name-id interval-id prop-id)
;; insert name if it does not exist
(emacsql db [:insert-or-ignore-into interval-names [name]
:values [$s1]]
name)
;; insert interval properties if they do not exist
(setq prop-id (chronometrist-sqlite-insert-properties backend plist))
;; insert interval and associate it with the date
(setq name-id
(caar (emacsql db [:select [name-id]
:from interval-names
:where (= name $s1)]
name)))
(emacsql db [:insert-or-ignore-into intervals
[name-id start-time stop-time prop-id]
:values [$s1 $s2 $s3 $s4]]
name-id start-unix stop-unix prop-id)
(emacsql db [:insert-or-ignore-into dates [date]
:values [$s1]] date-unix)
(setq date-id
(caar (emacsql db [:select [date-id] :from dates
:where (= date $s1)]
date-unix))
interval-id
(caar (emacsql db [:select (funcall max interval-id) :from intervals])))
(emacsql db [:insert-into date-intervals [date-id interval-id]
:values [$s1 $s2]]
date-id interval-id)))))
#+END_SRC
** open-file
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-edit-backend ((backend chronometrist-sqlite-backend))
(require 'sql)
(switch-to-buffer
(sql-comint-sqlite 'sqlite (list file))))
#+END_SRC
** latest-record
#+BEGIN_SRC emacs-lisp
;; SELECT * FROM TABLE WHERE ID = (SELECT MAX(ID) FROM TABLE);
;; SELECT * FROM tablename ORDER BY column DESC LIMIT 1;
(cl-defmethod chronometrist-latest-record ((backend chronometrist-sqlite-backend) db)
(emacsql db [:select * :from events :order-by rowid :desc :limit 1]))
#+END_SRC
** task-records-for-date
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-task-records-for-date ((backend chronometrist-sqlite-backend) task date-ts))
#+END_SRC
** active-days
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-active-days ((backend chronometrist-sqlite-backend) task))
#+END_SRC
** replace-last
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-replace-last ((backend chronometrist-sqlite-backend) plist)
(emacsql db [:delete-from events :where ]))
#+END_SRC
** Provide
#+BEGIN_SRC emacs-lisp
(provide 'chronometrist-sqlite)
;;; chronometrist-sqlite.el ends here
#+END_SRC
* Local variables :noexport:
# Local Variables:
# eval: (when (or (package-installed-p 'emacsql) (featurep 'emacsql)) (require 'emacsql) (emacsql-fix-vector-indentation))
# eval: (when (or (package-installed-p 'literate-elisp) (featurep 'literate-elisp)) (require 'literate-elisp) (literate-elisp-load (buffer-file-name)))
# End:

View File

@ -42,7 +42,7 @@
;; divisor:1 ends here
;; [[file:chronometrist-third.org::*duration-format][duration-format:1]]
(defcustom chronometrist-third-duration-format "%H, %M and %S%z"
(defcustom chronometrist-third-duration-format "%H, %M, and %S%z"
"Format string for durations, passed to `format-seconds'."
:type 'string)
;; duration-format:1 ends here
@ -131,19 +131,19 @@ and stopped when they clock in."
"Stop alert timers and update break time."
(chronometrist-third-stop-alert-timers)
(unless (zerop chronometrist-third-break-time)
(-let* (((&plist :stop stop) (cl-second (chronometrist-to-list (chronometrist-active-backend))))
(used-break (ts-diff (ts-now) (chronometrist-iso-to-ts stop)))
(used-break-string (format-seconds chronometrist-third-duration-format used-break))
(new-break (- chronometrist-third-break-time used-break))
(old-break chronometrist-third-break-time))
(setq chronometrist-third-break-time (if (> new-break 0) new-break 0))
(-let* (((&plist :stop stop) (cl-second (chronometrist-to-list (chronometrist-active-backend))))
(used-break-duration (ts-diff (ts-now) (chronometrist-iso-to-ts stop)))
(new-break-time (- chronometrist-third-break-time used-break-duration))
(old-break-time chronometrist-third-break-time)
(used-break-duration-string (format-seconds chronometrist-third-duration-format
used-break-duration)))
(setq chronometrist-third-break-time (if (> new-break-time 0) new-break-time 0))
(alert
(if (zerop chronometrist-third-break-time)
(format "You have used up all %s of your break time (%s break)"
(format-seconds chronometrist-third-duration-format old-break)
used-break-string)
old-break-time used-break-duration-string)
(format "You have used %s of your break time (%s left)"
used-break-string
used-break-duration-string
(format-seconds chronometrist-third-duration-format chronometrist-third-break-time)))))))
;; clock-in:1 ends here

View File

@ -56,7 +56,7 @@
** duration-format :custom:variable:
#+BEGIN_SRC emacs-lisp
(defcustom chronometrist-third-duration-format "%H, %M and %S%z"
(defcustom chronometrist-third-duration-format "%H, %M, and %S%z"
"Format string for durations, passed to `format-seconds'."
:type 'string)
#+END_SRC
@ -155,19 +155,19 @@ and stopped when they clock in."
"Stop alert timers and update break time."
(chronometrist-third-stop-alert-timers)
(unless (zerop chronometrist-third-break-time)
(-let* (((&plist :stop stop) (cl-second (chronometrist-to-list (chronometrist-active-backend))))
(used-break (ts-diff (ts-now) (chronometrist-iso-to-ts stop)))
(used-break-string (format-seconds chronometrist-third-duration-format used-break))
(new-break (- chronometrist-third-break-time used-break))
(old-break chronometrist-third-break-time))
(setq chronometrist-third-break-time (if (> new-break 0) new-break 0))
(-let* (((&plist :stop stop) (cl-second (chronometrist-to-list (chronometrist-active-backend))))
(used-break-duration (ts-diff (ts-now) (chronometrist-iso-to-ts stop)))
(new-break-time (- chronometrist-third-break-time used-break-duration))
(old-break-time chronometrist-third-break-time)
(used-break-duration-string (format-seconds chronometrist-third-duration-format
used-break-duration)))
(setq chronometrist-third-break-time (if (> new-break-time 0) new-break-time 0))
(alert
(if (zerop chronometrist-third-break-time)
(format "You have used up all %s of your break time (%s break)"
(format-seconds chronometrist-third-duration-format old-break)
used-break-string)
old-break-time used-break-duration-string)
(format "You have used %s of your break time (%s left)"
used-break-string
used-break-duration-string
(format-seconds chronometrist-third-duration-format chronometrist-third-break-time)))))))
#+END_SRC

View File

@ -5,7 +5,7 @@
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "27.1") (dash "2.16.0") (seq "2.20") (ts "0.2"))
;; Version: 0.10.0
;; Version: 0.9.0
;; This is free and unencumbered software released into the public domain.
;;
@ -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,12 +305,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))))))))
;; split-plist:1 ends here
@ -322,8 +322,8 @@ 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))
@ -331,21 +331,19 @@ If REPLACE is non-nil, replace the last interval with PLIST."
;; [[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 `chronometrist-events'."
"Return an ISO-8601 date string for the latest date present in HASH-TABLE."
(--> (hash-table-keys hash-table)
(sort it #'string-lessp)
(last it)
(cl-first it)))
;; ht-last-date:1 ends here
;; [[file:chronometrist.org::*ht-last][ht-last:1]]
(cl-defun chronometrist-ht-last (&optional (backend (chronometrist-active-backend)))
"Return the last plist from `chronometrist-events'."
(let* ((hash-table (chronometrist-backend-hash-table backend))
(last-date (chronometrist-ht-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)))
;; ht-last:1 ends here
;; [[file:chronometrist.org::#program-data-structures-ht-subset][ht-subset:1]]
@ -370,12 +368,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)))
;; task-time-one-day:1 ends here
@ -390,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]]
@ -414,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')
@ -455,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]]
@ -474,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)
@ -500,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.
@ -528,11 +549,6 @@ SECONDS is less than 60, return a blank string."
minutes minute-string)))))
;; format-duration-long:1 ends here
;; [[file:chronometrist.org::*iso-to-date][iso-to-date:1]]
(defun chronometrist-iso-to-date (timestamp)
(cl-first (split-string timestamp "T")))
;; iso-to-date:1 ends here
;; [[file:chronometrist.org::*normalize-whitespace][normalize-whitespace:1]]
(defun chronometrist-pp-normalize-whitespace ()
"Remove whitespace following point, and insert a space.
@ -755,12 +771,6 @@ Value must be a keyword corresponding to a key in
(cl-second (alist-get chronometrist-active-backend chronometrist-backends-alist)))
;; active-backend:1 ends here
;; [[file:chronometrist.org::*get-backend][get-backend:1]]
(defun chronometrist-get-backend (keyword)
"Return the backend object associated with KEYWORD."
(cl-second (alist-get keyword chronometrist-backends-alist)))
;; get-backend:1 ends here
;; [[file:chronometrist.org::*switch-backend][switch-backend:1]]
(defun chronometrist-switch-backend ()
(interactive)
@ -794,7 +804,8 @@ be replaced."
;; register-backend:1 ends here
;; [[file:chronometrist.org::*read-backend-name][read-backend-name:1]]
(defun chronometrist-read-backend-name (prompt backend-alist &optional predicate return-keyword)
(defun chronometrist-read-backend-name (prompt backend-alist
&optional predicate return-keyword)
"Prompt user for a Chronometrist backend name.
BACKEND-ALIST should be an alist similar to `chronometrist-backends-alist'.
@ -874,16 +885,14 @@ 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
;; [[file:chronometrist.org::*insert][insert:1]]
(cl-defgeneric chronometrist-insert (backend plist)
"Insert PLIST as new record in BACKEND.
Return non-nil if record is inserted successfully.
PLIST may be an interval which crosses days.")
Return non-nil if record is inserted successfully.")
(cl-defmethod chronometrist-insert :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
@ -1000,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]]
@ -1045,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
@ -1118,7 +1131,6 @@ hash table values must be in chronological order.")
;; [[file:chronometrist.org::*on-file-path-change][on-file-path-change:1]]
(cl-defmethod chronometrist-on-file-path-change ((backend chronometrist-file-backend-mixin) _old-path new-path)
"Update path and file slots of BACKEND to use NEW-PATH when `chronometrist-file' is changed."
(with-slots (path extension file) backend
(setf path new-path
file (concat path "." extension))))
@ -1520,7 +1532,7 @@ 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-ht-last backend)))
((&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))))
@ -1531,7 +1543,7 @@ 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-ht-last))
(-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
@ -1629,7 +1641,7 @@ This is meant to be run in `chronometrist-file' when using an s-expression backe
(backend-latest-date (cl-first latest-plist-group))
(date-today (chronometrist-date-iso))
(insert-new-group (not (equal date-today backend-latest-date)))
(start-date (chronometrist-iso-to-date (plist-get plist :start)))
(start-date (cl-first (split-string (plist-get plist :start) "T")))
(new-plist-group-1 (if latest-plist-group
(append latest-plist-group
(list (or plist-1 plist)))
@ -1756,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]]
@ -1790,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]]
@ -1881,63 +1894,50 @@ Return value is either a list in the form
;; remove-prefix:1 ends here
;; [[file:chronometrist.org::*migrate][migrate:1]]
(defun chronometrist-migrate (&optional input-backend-keyword input-file
output-backend-keyword output-file
&rest load-files)
"Convert from one Chronometrist backend to another.
INPUT-BACKEND-KEYWORD and OUTPUT-BACKEND-KEYWORD must be keywords
corresponding to entries in `chronometrist-backends-alist'.
INPUT-FILE and OUTPUT-FILE must be file names. INPUT-FILE must
already exist.
LOAD-FILES must be paths to Elisp files. These files are `load'ed
before conversion. They may be used to point to Chronometrist
source files to be used."
(defun chronometrist-migrate ()
"Convert from one Chronometrist backend to another."
(interactive)
(let* ((input-backend-keyword (or input-backend-keyword
(chronometrist-read-backend-name "Backend to convert: " chronometrist-backends-alist nil t)))
(input-backend (chronometrist-get-backend input-backend-keyword))
(suggested-input-file (chronometrist-backend-file input-backend))
(input-file (or input-file
(read-file-name "File to convert: "
nil suggested-input-file t suggested-input-file)))
(output-backend-keyword (or output-backend-keyword
(chronometrist-read-backend-name "Backend to write: "
chronometrist-backends-alist
(lambda (keyword)
(not (equal (cl-second
(alist-get keyword chronometrist-backends-alist))
input-backend)))
t)))
(output-backend (chronometrist-get-backend output-backend-keyword))
(suggested-output-file (chronometrist-backend-file output-backend))
(output-file (or output-file
(read-file-name "File to write: " nil nil nil suggested-output-file)))
(input-backend-name (chronometrist-remove-prefix (symbol-name (eieio-object-class-name input-backend))))
(output-backend-name (chronometrist-remove-prefix (symbol-name (eieio-object-class-name output-backend))))
(confirm (yes-or-no-p (format "Convert %s (%s) to %s (%s)? "
input-file input-backend-name
output-file output-backend-name)))
(confirm-overwrite (if (and confirm
(file-exists-p output-file)
(not (chronometrist-file-empty-p output-file)))
(yes-or-no-p
(format "Overwrite existing non-empty file %s ?" output-file))
t)))
(if (and confirm confirm-overwrite)
(start-process
"chronometrist-migrate" (generate-new-buffer-name "chronometrist-migrate")
"emacs" "--batch"
"--eval=(package-initialize)"
"--eval=(cl-loop for (pkg . desc) in package-alist
when (string-match-p \"^chronometrist\" (symbol-name pkg))
do (require pkg))"
(format "--eval=(cl-loop for file in (quote %S) do (load file))" load-files)
;; the hash table slot may be empty, so we generate the hash table from scratch
(format "--eval=(chronometrist-to-file (chronometrist-to-hash-table (chronometrist-get-backend %s)) (chronometrist-get-backend %s) %S)"
input-backend-keyword output-backend-keyword output-file))
(let* ((input-backend
(chronometrist-read-backend-name "Backend to convert: "
chronometrist-backends-alist))
(input-file-suggestion (chronometrist-backend-file input-backend))
(input-file (read-file-name "File to convert: " nil
input-file-suggestion t
input-file-suggestion))
(output-backend (chronometrist-read-backend-name
"Backend to write: "
chronometrist-backends-alist
(lambda (keyword)
(not (equal (cl-second
(alist-get keyword chronometrist-backends-alist))
input-backend)))))
(output-file-suggestion (chronometrist-backend-file output-backend))
(output-file (read-file-name "File to write: " nil nil nil
output-file-suggestion))
(input-backend-name (chronometrist-remove-prefix
(symbol-name
(eieio-object-class-name input-backend))))
(output-backend-name (chronometrist-remove-prefix
(symbol-name
(eieio-object-class-name output-backend))))
(confirm (yes-or-no-p
(format "Convert %s (%s) to %s (%s)? "
input-file
input-backend-name
output-file
output-backend-name)))
(confirm-exists
(if (and confirm
(file-exists-p output-file)
(not (chronometrist-file-empty-p output-file)))
(yes-or-no-p
(format "Overwrite existing non-empty file %s ?"
output-file))
t)))
(if (and confirm confirm-exists)
(chronometrist-to-file (chronometrist-backend-hash-table input-backend)
output-backend
output-file)
(message "Conversion aborted."))))
;; migrate:1 ends here
@ -2974,7 +2974,7 @@ It simply operates on the entire hash table TABLE (see
reduced to the desired range using
`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)

View File

@ -48,13 +48,26 @@ 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=.
See also [[#explanation-time-formats][Currently-Used Time Formats]]
** Overview
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]].
Extensions exist for -
1. [[file:chronometrist-key-values.org][attaching arbitrary metadata]] to time intervals, and
2. support for the [[file:chronometrist-third.org][Third Time system]]
3. [[https://tildegit.org/contrapunctus/chronometrist-goal][time goals and alerts]]
** Optimization
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'.
@ -68,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.
@ -80,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
@ -96,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.
@ -105,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
@ -116,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)
@ -140,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
@ -241,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
@ -402,7 +413,7 @@ But I discovered that if I do that, =package-lint= says - =error: Couldn't parse
;; Keywords: calendar
;; Homepage: https://tildegit.org/contrapunctus/chronometrist
;; Package-Requires: ((emacs "27.1") (dash "2.16.0") (seq "2.20") (ts "0.2"))
;; Version: 0.10.0
;; Version: 0.9.0
;; This is free and unencumbered software released into the public domain.
;;
@ -525,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\".
@ -676,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:
@ -738,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 ()
@ -789,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
@ -831,8 +835,8 @@ 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))
@ -841,22 +845,20 @@ If REPLACE is non-nil, replace the last interval with PLIST."
*** ht-last-date :reader:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-ht-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in `chronometrist-events'."
"Return an ISO-8601 date string for the latest date present in HASH-TABLE."
(--> (hash-table-keys hash-table)
(sort it #'string-lessp)
(last it)
(cl-first it)))
#+END_SRC
*** ht-last :reader:
#+BEGIN_SRC emacs-lisp
(cl-defun chronometrist-ht-last (&optional (backend (chronometrist-active-backend)))
"Return the last plist from `chronometrist-events'."
(let* ((hash-table (chronometrist-backend-hash-table backend))
(last-date (chronometrist-ht-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
*** ht-subset :reader:
@ -887,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
@ -909,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:
@ -937,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
@ -997,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
@ -1019,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)
@ -1075,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.
@ -1120,12 +1151,6 @@ SECONDS is less than 60, return a blank string."
(should (equal (chronometrist-format-duration-long 7320) "2 hours, 2 minutes")))
#+END_SRC
*** iso-to-date :function:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-iso-to-date (timestamp)
(cl-first (split-string timestamp "T")))
#+END_SRC
** Plist pretty-printing
:PROPERTIES:
:CUSTOM_ID: program-pretty-printer
@ -1346,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
@ -1400,13 +1424,6 @@ Value must be a keyword corresponding to a key in
(cl-second (alist-get chronometrist-active-backend chronometrist-backends-alist)))
#+END_SRC
**** get-backend :reader:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-get-backend (keyword)
"Return the backend object associated with KEYWORD."
(cl-second (alist-get keyword chronometrist-backends-alist)))
#+END_SRC
**** switch-backend :command:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-switch-backend ()
@ -1443,7 +1460,8 @@ be replaced."
**** read-backend-name :procedure:
#+BEGIN_SRC emacs-lisp
(defun chronometrist-read-backend-name (prompt backend-alist &optional predicate return-keyword)
(defun chronometrist-read-backend-name (prompt backend-alist
&optional predicate return-keyword)
"Prompt user for a Chronometrist backend name.
BACKEND-ALIST should be an alist similar to `chronometrist-backends-alist'.
@ -1535,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
@ -1543,9 +1561,7 @@ Return nil if BACKEND contains no records.")
#+BEGIN_SRC emacs-lisp
(cl-defgeneric chronometrist-insert (backend plist)
"Insert PLIST as new record in BACKEND.
Return non-nil if record is inserted successfully.
PLIST may be an interval which crosses days.")
Return non-nil if record is inserted successfully.")
(cl-defmethod chronometrist-insert :before ((_backend t) plist &key &allow-other-keys)
(unless (cl-typep plist 'chronometrist-plist)
@ -1676,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:
@ -1715,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 ()
@ -1737,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
@ -1815,10 +1838,9 @@ These can be implemented in terms of the minimal protocol above.
(find-file-noselect file)))))
#+END_SRC
**** on-file-path-change :writer:method:
**** on-file-path-change :generic:function:
#+BEGIN_SRC emacs-lisp
(cl-defmethod chronometrist-on-file-path-change ((backend chronometrist-file-backend-mixin) _old-path new-path)
"Update path and file slots of BACKEND to use NEW-PATH when `chronometrist-file' is changed."
(with-slots (path extension file) backend
(setf path new-path
file (concat path "." extension))))
@ -2443,7 +2465,7 @@ 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-ht-last backend)))
((&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))))
@ -2455,7 +2477,7 @@ 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-ht-last))
(-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
@ -2521,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.
@ -2605,7 +2627,7 @@ Situations -
(backend-latest-date (cl-first latest-plist-group))
(date-today (chronometrist-date-iso))
(insert-new-group (not (equal date-today backend-latest-date)))
(start-date (chronometrist-iso-to-date (plist-get plist :start)))
(start-date (cl-first (split-string (plist-get plist :start) "T")))
(new-plist-group-1 (if latest-plist-group
(append latest-plist-group
(list (or plist-1 plist)))
@ -2742,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:
@ -2779,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:
@ -2897,65 +2920,52 @@ We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method
(replace-regexp-in-string "^chronometrist-" "" string))
#+END_SRC
*** migrate :command:
*** migrate
#+BEGIN_SRC emacs-lisp
(defun chronometrist-migrate (&optional input-backend-keyword input-file
output-backend-keyword output-file
&rest load-files)
"Convert from one Chronometrist backend to another.
INPUT-BACKEND-KEYWORD and OUTPUT-BACKEND-KEYWORD must be keywords
corresponding to entries in `chronometrist-backends-alist'.
INPUT-FILE and OUTPUT-FILE must be file names. INPUT-FILE must
already exist.
LOAD-FILES must be paths to Elisp files. These files are `load'ed
before conversion. They may be used to point to Chronometrist
source files to be used."
(defun chronometrist-migrate ()
"Convert from one Chronometrist backend to another."
(interactive)
(let* ((input-backend-keyword (or input-backend-keyword
(chronometrist-read-backend-name "Backend to convert: " chronometrist-backends-alist nil t)))
(input-backend (chronometrist-get-backend input-backend-keyword))
(suggested-input-file (chronometrist-backend-file input-backend))
(input-file (or input-file
(read-file-name "File to convert: "
nil suggested-input-file t suggested-input-file)))
(output-backend-keyword (or output-backend-keyword
(chronometrist-read-backend-name "Backend to write: "
chronometrist-backends-alist
(lambda (keyword)
(not (equal (cl-second
(alist-get keyword chronometrist-backends-alist))
input-backend)))
t)))
(output-backend (chronometrist-get-backend output-backend-keyword))
(suggested-output-file (chronometrist-backend-file output-backend))
(output-file (or output-file
(read-file-name "File to write: " nil nil nil suggested-output-file)))
(input-backend-name (chronometrist-remove-prefix (symbol-name (eieio-object-class-name input-backend))))
(output-backend-name (chronometrist-remove-prefix (symbol-name (eieio-object-class-name output-backend))))
(confirm (yes-or-no-p (format "Convert %s (%s) to %s (%s)? "
input-file input-backend-name
output-file output-backend-name)))
(confirm-overwrite (if (and confirm
(file-exists-p output-file)
(not (chronometrist-file-empty-p output-file)))
(yes-or-no-p
(format "Overwrite existing non-empty file %s ?" output-file))
t)))
(if (and confirm confirm-overwrite)
(start-process
"chronometrist-migrate" (generate-new-buffer-name "chronometrist-migrate")
"emacs" "--batch"
"--eval=(package-initialize)"
"--eval=(cl-loop for (pkg . desc) in package-alist
when (string-match-p \"^chronometrist\" (symbol-name pkg))
do (require pkg))"
(format "--eval=(cl-loop for file in (quote %S) do (load file))" load-files)
;; the hash table slot may be empty, so we generate the hash table from scratch
(format "--eval=(chronometrist-to-file (chronometrist-to-hash-table (chronometrist-get-backend %s)) (chronometrist-get-backend %s) %S)"
input-backend-keyword output-backend-keyword output-file))
(let* ((input-backend
(chronometrist-read-backend-name "Backend to convert: "
chronometrist-backends-alist))
(input-file-suggestion (chronometrist-backend-file input-backend))
(input-file (read-file-name "File to convert: " nil
input-file-suggestion t
input-file-suggestion))
(output-backend (chronometrist-read-backend-name
"Backend to write: "
chronometrist-backends-alist
(lambda (keyword)
(not (equal (cl-second
(alist-get keyword chronometrist-backends-alist))
input-backend)))))
(output-file-suggestion (chronometrist-backend-file output-backend))
(output-file (read-file-name "File to write: " nil nil nil
output-file-suggestion))
(input-backend-name (chronometrist-remove-prefix
(symbol-name
(eieio-object-class-name input-backend))))
(output-backend-name (chronometrist-remove-prefix
(symbol-name
(eieio-object-class-name output-backend))))
(confirm (yes-or-no-p
(format "Convert %s (%s) to %s (%s)? "
input-file
input-backend-name
output-file
output-backend-name)))
(confirm-exists
(if (and confirm
(file-exists-p output-file)
(not (chronometrist-file-empty-p output-file)))
(yes-or-no-p
(format "Overwrite existing non-empty file %s ?"
output-file))
t)))
(if (and confirm confirm-exists)
(chronometrist-to-file (chronometrist-backend-hash-table input-backend)
output-backend
output-file)
(message "Conversion aborted."))))
#+END_SRC
@ -4122,7 +4132,7 @@ It simply operates on the entire hash table TABLE (see
reduced to the desired range using
`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)

428
manual.md Normal file
View File

@ -0,0 +1,428 @@
# Table of Contents
1. [Benefits](#benefits)
2. [Limitations](#limitations)
3. [Comparisons](#comparisons)
1. [timeclock.el](#timeclock.el)
2. [Org time tracking](#org-time-tracking)
4. [Installation](#installation)
1. [from MELPA](#install-from-melpa)
2. [from Git](#install-from-git)
5. [Usage](#usage)
1. [chronometrist](#usage-chronometrist)
2. [chronometrist-report](#usage-chronometrist-report)
3. [chronometrist-statistics](#usage-chronometrist-statistics)
4. [chronometrist-details](#org09eaa12)
5. [common commands](#usage-common-commands)
6. [Time goals/targets](#time-goals)
6. [How-to](#how-to)
1. [How to display a prompt when exiting with an active task](#how-to-prompt-when-exiting-emacs)
2. [How to load the program using literate-elisp](#how-to-literate-elisp)
3. [How to attach tags to time intervals](#how-to-tags)
4. [How to attach key-values to time intervals](#how-to-key-value-pairs)
5. [How to skip running hooks/attaching tags and key values](#orga569ce7)
6. [How to open certain files when you start a task](#how-to-open-files-on-task-start)
7. [How to warn yourself about uncommitted changes](#how-to-warn-uncommitted-changes)
8. [How to display the current time interval in the activity indicator](#how-to-activity-indicator)
9. [How to back up your Chronometrist data](#how-to-backup)
10. [How to configure Vertico for use with Chronometrist](#howto-vertico)
7. [Explanation](#org2a03f3a)
1. [Literate Program](#explanation-literate-program)
8. [User's reference](#org2585c1f)
9. [Contributions and contact](#contributions-contact)
10. [License](#license)
11. [Thanks](#thanks)
<a href="https://liberapay.com/contrapunctus/donate"><img alt="Donate using Liberapay" src="https://img.shields.io/liberapay/receives/contrapunctus.svg?logo=liberapay"></a>
<a href="https://melpa.org/#/chronometrist"><img src="https://melpa.org/packages/chronometrist-badge.svg"></a>
A time tracker in Emacs with a nice interface
Largely modelled after the Android application, [A Time Tracker](https://github.com/netmackan/ATimeTracker)
<a id="benefits"></a>
# Benefits
1. Extremely simple and efficient to use
2. Displays useful information about your time usage
3. Support for both mouse and keyboard
4. Human errors in tracking are easily fixed by editing a plain text file
5. Hooks to let you perform arbitrary actions when starting/stopping tasks
6. Fancy graphs with the `chronometrist-spark` extension
<a id="limitations"></a>
# Limitations
1. No support for concurrent tasks.
<a id="comparisons"></a>
# Comparisons
<a id="timeclock.el"></a>
## timeclock.el
Compared to timeclock.el, Chronometrist
- stores data in an s-expression format rather than a line-based one
- supports attaching tags and arbitrary key-values to time intervals
- has commands to shows useful summaries
- has more hooks
<a id="org-time-tracking"></a>
## Org time tracking
Chronometrist and Org time tracking seem to be equivalent in terms of capabilities, approaching the same ends through different means.
- Chronometrist doesn't have a mode line indicator at the moment. (planned)
- Chronometrist doesn't have Org's sophisticated querying facilities. (an SQLite backend is planned)
- Org does so many things that keybindings seem to necessarily get longer. Chronometrist has far fewer commands than Org, so most of the keybindings are single keys, without modifiers.
- Chronometrist's UI is cleaner, since the storage is separate from the display. It doesn't show tasks as trees like Org, but it uses tags and key-values to achieve that. Additionally, navigating a flat list takes fewer user operations than navigating a tree.
- Chronometrist data is just s-expressions (plists), and may be easier to parse than a complex text format with numerous use-cases.
<a id="installation"></a>
# Installation
<a id="install-from-melpa"></a>
## from MELPA
1. Set up MELPA - <https://melpa.org/#/getting-started>
(Chronometrist uses Semantic Versioning and the developer is accident-prone, so using MELPA Stable is suggested 😏)
2. `M-x package-install RET chronometrist RET`
<a id="install-from-git"></a>
## from Git
You can get `chronometrist` from <https://tildegit.org/contrapunctus/chronometrist> or <https://codeberg.org/contrapunctus/chronometrist>
`chronometrist` requires
- Emacs v25 or higher
- [dash.el](https://github.com/magnars/dash.el)
- [ts.el](https://github.com/alphapapa/ts.el)
Add the "elisp/" subdirectory to your load-path, and `(require 'chronometrist)`.
<a id="usage"></a>
# Usage
<a id="usage-chronometrist"></a>
## chronometrist
Run `M-x chronometrist` to see your projects, the time you spent on them today, which one is active, and the total time clocked today.
Click or hit `RET` (`chronometrist-toggle-task`) on a project to start tracking time for it. If it's already clocked in, it will be clocked out.
You can also hit `<numeric prefix> RET` anywhere in the buffer to toggle the corresponding project, e.g. =C-1 RET= will toggle the project with index 1.
Press `r` to see a weekly report (see `chronometrist-report`)
<a id="usage-chronometrist-report"></a>
## chronometrist-report
Run `M-x chronometrist-report` (or `chronometrist` with a prefix argument of 1, or press `r` in the `chronometrist` buffer) to see a weekly report.
Press `b` to look at past weeks, and `f` for future weeks.
<a id="usage-chronometrist-statistics"></a>
## chronometrist-statistics
Run `M-x chronometrist-statistics` (or `chronometrist` with a prefix argument of 2) to view statistics.
Press `b` to look at past time ranges, and `f` for future ones.
<a id="org09eaa12"></a>
## chronometrist-details
<a id="usage-common-commands"></a>
## common commands
In the buffers created by the previous three commands, you can press `l` (`chronometrist-open-log`) to view/edit your `chronometrist-file`, which by default is `~/.emacs.d/chronometrist.sexp`.
All of these commands will kill their buffer when run again with the buffer visible, so the keys you bind them to behave as a toggle.
All buffers keep themselves updated via an idle timer - no need to frequently press `g` to update.
<a id="time-goals"></a>
## Time goals/targets
If you wish you could define time goals for some tasks, and have Chronometrist notify you when you're approaching the goal, completing it, or exceeding it, check out the extension [chronometrist-goal.el](https://github.com/contrapunctus-1/chronometrist-goal/).
<a id="how-to"></a>
# How-to
See the Customize groups `chronometrist` and `chronometrist-report` for variables intended to be user-customizable.
<a id="how-to-prompt-when-exiting-emacs"></a>
## How to display a prompt when exiting with an active task
Evaluate or add to your init.el the following -
`(add-hook 'kill-emacs-query-functions 'chronometrist-query-stop)`
<a id="how-to-literate-elisp"></a>
## How to load the program using literate-elisp
(add-to-list 'load-path "<directory containing chronometrist.org>")
(require 'literate-elisp) ;; or autoload, use-package, ...
(literate-elisp-load "chronometrist.org")
<a id="how-to-tags"></a>
## How to attach tags to time intervals
1. Add `chronometrist-tags-add` to one or more of these hooks <sup><a id="fnr.1" class="footref" href="#fn.1" role="doc-backlink">1</a></sup> -
(add-to-list 'chronometrist-after-in-functions 'chronometrist-tags-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-tags-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-tags-add)
2. clock in/clock out to trigger the hook.
The prompt suggests past combinations you used for the current task, which you can browse with `M-p=/=M-n`. You can leave it blank by pressing `RET`.
<a id="how-to-key-value-pairs"></a>
## How to attach key-values to time intervals
1. Add `chronometrist-kv-add` to one or more of these hooks <sup><a id="fnr.1.100" class="footref" href="#fn.1" role="doc-backlink">1</a></sup> -
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
To exit the prompt, press the key it indicates for quitting - you can then edit the resulting key-values by hand if required. Press `C-c C-c` to accept the key-values, or `C-c C-k` to cancel.
<a id="orga569ce7"></a>
## How to skip running hooks/attaching tags and key values
Use `M-RET` (`chronometrist-toggle-task-no-hooks`) to clock in/out.
<a id="how-to-open-files-on-task-start"></a>
## How to open certain files when you start a task
An idea from the author's own init -
(defun my-start-project (project)
(pcase project
("Guitar"
(find-file-other-window "~/repertoire.org"))
;; ...
))
(add-hook 'chronometrist-before-in-functions 'my-start-project)
<a id="how-to-warn-uncommitted-changes"></a>
## How to warn yourself about uncommitted changes
Another one, prompting the user if they have uncommitted changes in a git repository (assuming they use [Magit](https://magit.vc/)) -
(autoload 'magit-anything-modified-p "magit")
(defun my-commit-prompt ()
"Prompt user if `default-directory' is a dirty Git repository.
Return t if the user answers yes, if the repository is clean, or
if there is no Git repository.
Return nil (and run `magit-status') if the user answers no."
(cond ((not (magit-anything-modified-p)) t)
((yes-or-no-p
(format "You have uncommitted changes in %S. Really clock out? "
default-directory)) t)
(t (magit-status) nil)))
(add-hook 'chronometrist-before-out-functions 'my-commit-prompt)
<a id="how-to-activity-indicator"></a>
## How to display the current time interval in the activity indicator
(defun my-activity-indicator ()
(--> (chronometrist-latest-record (chronometrist-active-backend))
(plist-put it :stop (chronometrist-format-time-iso8601))
(list it)
(chronometrist-events-to-durations it)
(-reduce #'+ it)
(truncate it)
(chronometrist-format-duration it)))
(setq chronometrist-activity-indicator #'my-activity-indicator)
<a id="how-to-backup"></a>
## How to back up your Chronometrist data
I suggest backing up Chronometrist data on each save using the [async-backup](https://tildegit.org/contrapunctus/async-backup) package.<sup><a id="fnr.2" class="footref" href="#fn.2" role="doc-backlink">2</a></sup> Here's how you can do that.
1. Add the following to your init.
(use-package async-backup)
2. Open your Chronometrist file and add `async-backup` to a buffer-local `after-save-hook`.
M-x chronometrist-open-log
M-x add-file-local-variable-prop-line RET eval RET (add-hook 'after-save-hook #'async-backup nil t) RET
3. Optionally, configure `backup-directory-alist` to set a specific directory for the backups.
<a id="howto-vertico"></a>
## How to configure Vertico for use with Chronometrist
By default, [Vertico](https://github.com/minad/vertico) uses its own sorting function - for some commands (such as `chronometrist-key-values-unified-prompt`) this results in *worse* suggestions, since Chronometrist sorts suggestions in most-recent-first order.
You can either disable Vertico's sorting entirely -
(setq vertico-sort-function nil)
Or use `vertico-multiform` to disable sorting for only specific commands -
(use-package vertico-multiform
:init (vertico-multiform-mode)
:config
(setq vertico-multiform-commands
'((chronometrist-toggle-task (vertico-sort-function . nil))
(chronometrist-toggle-task-no-hooks (vertico-sort-function . nil))
(chronometrist-key-values-unified-prompt (vertico-sort-function . nil)))))
<a id="org2a03f3a"></a>
# Explanation
<a id="explanation-literate-program"></a>
## Literate Program
Chronometrist is a literate program, made using Org - the canonical source is the `chronometrist.org` file, which contains source blocks. These are provided to users after *tangling* (extracting the source into an Emacs Lisp file).
The Org file can also be loaded directly using the [literate-elisp](https://github.com/jingtaozf/literate-elisp) package, so that all source links (e.g. `xref`, `describe-function`) lead to the Org file, within the context of the concerned documentation. See [How to load the program using literate-elisp](#how-to-literate-elisp).
`chronometrist.org` is also included in MELPA installs, although not used directly by default, since doing so would interfere with automatic generation of autoloads.
<a id="org2585c1f"></a>
# User's reference
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`
2. `chronometrist-buffer-name`
3. `chronometrist-report-buffer-name`
4. `chronometrist-details-buffer-name`
5. `chronometrist-sexp-pretty-print-function`
6. `chronometrist-hide-cursor`
7. `chronometrist-update-interval`
8. `chronometrist-activity-indicator`
Buffer schemas
1. `chronometrist-schema`
2. `chronometrist-details-schema`
Hooks
1. `chronometrist-mode-hook`
2. `chronometrist-schema-transformers`
3. `chronometrist-row-transformers`
4. `chronometrist-before-in-functions`
5. `chronometrist-after-in-functions`
6. `chronometrist-before-out-functions`
7. `chronometrist-after-out-functions`
8. `chronometrist-file-change-hook`
9. `chronometrist-timer-hook`
<a id="contributions-contact"></a>
# Contributions and contact
Feedback and MRs are very welcome. 🙂
- <TODO.md> has a long list of tasks
- <elisp/chronometrist.md> contains all developer-oriented documentation
If you have tried using Chronometrist, I'd love to hear your experiences! Get in touch with the author and other Emacs users in the Emacs channel on the Jabber network - [xmpp:emacs@salas.suchat.org?join](https://conversations.im/j/emacs@salas.suchat.org) ([web chat](https://inverse.chat/#converse/room?jid=emacs@salas.suchat.org))
(For help in getting started with Jabber, [click here](https://xmpp.org/getting-started/))
<a id="license"></a>
# License
I dream of a world where all software is liberated - transparent, trustable, and accessible for anyone to use or improve. But I don't want to make demands or threats (e.g. via legal conditions) to get there.
I'd rather make a request - please do everything you can to help that dream come true. Please Unlicense as much software as you can.
Chronometrist is released under your choice of [Unlicense](https://unlicense.org/) or the [WTFPL](http://www.wtfpl.net/).
(See files [UNLICENSE](UNLICENSE) and [WTFPL](WTFPL)).
<a id="thanks"></a>
# Thanks
wasamasa, bpalmer, aidalgol, pjb and the rest of #emacs for their tireless help and support
jwiegley for `timeclock.el`, which we used as a backend in earlier versions
blandest for helping me with the name
fiete and wu-lee for testing and bug reports
# Footnotes
<sup><a id="fn.1" href="#fnr.1">1</a></sup> but not `chronometrist-before-in-functions`
<sup><a id="fn.2" href="#fnr.2">2</a></sup> It is possible to use Emacs' built-in backup system to do it, but since it is synchronous, doing so will greatly slow down saving of the Chronometrist file.

View File

@ -1,61 +1,55 @@
#+TITLE: Chronometrist
#+SUBTITLE: Friendly and powerful personal time tracker/analyzer with Emacs and CLIM frontends
#+TITLE: Chronometrist - an extensible time tracker for Emacs
#+SUBTITLE: Friendly and powerful personal time tracker and analyzer for Emacs
#+DESCRIPTION: User Manual
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="style.css" />
#+BEGIN_EXPORT html
<a href="https://liberapay.com/contrapunctus/donate">
<img alt="Donate using Liberapay" src="https://img.shields.io/liberapay/receives/contrapunctus.svg?logo=liberapay">
</a>
<a href="https://liberapay.com/contrapunctus/donate"><img alt="Donate using Liberapay" src="https://img.shields.io/liberapay/receives/contrapunctus.svg?logo=liberapay"></a>
<a href="https://melpa.org/#/chronometrist">
<img src="https://melpa.org/packages/chronometrist-badge.svg">
</a>
<a href="https://melpa.org/#/chronometrist"><img src="https://melpa.org/packages/chronometrist-badge.svg"></a>
#+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]].
A time tracker in Emacs with a nice interface
#+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).
[[file:doc/2022-02-20 13-26-53.png]]
Largely modelled after the Android application, [[https://github.com/netmackan/ATimeTracker][A Time Tracker]]
** Benefits
* Benefits
:PROPERTIES:
:CUSTOM_ID: benefits
:END:
1. Extremely simple and efficient to use
2. Displays useful information about your time usage (including fancy graphs with the =chronometrist-spark= extension)
2. Displays useful information about your time usage
3. Support for both mouse and keyboard
4. Human errors in tracking can be easily fixed by editing a plain text file
5. Hooks to integrate time tracking into your workflow
4. Human errors in tracking are easily fixed by editing a plain text file
5. Hooks to let you perform arbitrary actions when starting/stopping tasks
6. Fancy graphs with the =chronometrist-spark= extension
** Limitations
* Limitations
:PROPERTIES:
:CUSTOM_ID: limitations
:END:
1. No support for concurrent tasks.
** Comparisons
* Comparisons
:PROPERTIES:
:CUSTOM_ID: comparisons
:END:
*** timeclock.el (Emacs built-in)
** timeclock.el
:PROPERTIES:
:CUSTOM_ID: timeclock.el
:END:
Compared to timeclock.el, Chronometrist
+ stores data in an s-expression format rather than a line-based one
+ supports attaching tags and arbitrary key-values to time intervals
+ has commands to shows useful summaries
+ has more hooks
*** Org time tracking
** Org time tracking
:PROPERTIES:
:CUSTOM_ID: org-time-tracking
:END:
Chronometrist and Org time tracking seem to be equivalent in terms of capabilities, approaching the same ends through different means.
+ Chronometrist doesn't have a mode line indicator at the moment. (planned)
+ Chronometrist doesn't have Org's sophisticated querying facilities. (an SQLite backend is planned)
@ -63,126 +57,25 @@ Chronometrist and Org time tracking seem to be equivalent in terms of capabiliti
+ Chronometrist's UI is cleaner, since the storage is separate from the display. It doesn't show tasks as trees like Org, but it uses tags and key-values to achieve that. Additionally, navigating a flat list takes fewer user operations than navigating a tree.
+ Chronometrist data is just s-expressions (plists), and may be easier to parse than a complex text format with numerous use-cases.
** 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,
3. a greater variety of frontends, such as -
* a command line interface (CLI), for UNIX scripting;
* a terminal user inteface (TUI), for those so inclined;
* a CLIM (Common Lisp Interface Manager) GUI [fn:1],
* Qt and Android interfaces using [[https://gitlab.com/eql/lqml][LQML]],
* web frontends (possibly via [[https://common-lisp.net/project/parenscript/][Parenscript]] or [[https://github.com/rabbibotton/clog][CLOG]]),
* and perhaps even an interface for wearable devices!
The port was also driven by the desire to have access to Common Lisp's better performance, and features such as namespaces, a /de facto/ standard build system, multithreading, SQLite bindings, a more fully-featured implementation of CLOS and MOP, and type annotations, checking, and inference.
The literate sources for the Common Lisp port may be found in [[file:cl/chronometrist.org][cl/chronometrist.org]]. Currently, this port can -
1. import from a plist-group file and export to an SQLite database
#+BEGIN_SRC lisp
(chronometrist:to-file (chronometrist:to-hash-table
(make-instance 'chronometrist.plist-group:plist-group-backend
:file "/path/to/file.plg"))
(make-instance 'chronometrist.sqlite:sqlite-backend)
"/path/to/file.sqlite")
#+END_SRC
2. display a (WIP) CLIM GUI - =(chronometrist.clim:run-chronometrist)=
The Emacs Lisp codebase will probably become an Emacs frontend to a future Common Lisp CLI client.
[fn:1] McCLIM also has an incomplete ncurses backend - when completed, a CLIM frontend could provide a TUI "for free".
** Literate program
:PROPERTIES:
:CUSTOM_ID: explanation-literate-program
:END:
Chronometrist is written as an Org literate program, which makes it easy to obtain different views of the program source, thanks to tree- and source-block folding, tags, properties, and the =org-match= command.
The canonical source file is [[file:elisp/chronometrist.org][elisp/chronometrist.org]], which contains source blocks. These are provided to users after /tangling/ (extracting the source into an Emacs Lisp file). [fn:2]
The Org literate program can also be loaded directly using the [[https://github.com/jingtaozf/literate-elisp][literate-elisp]] package, so that all source links (e.g. =xref=, =describe-function=) lead to the Org file. See [[#how-to-literate-elisp][How to load the program using literate-elisp]].
[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.
There are also some [[file:elisp/chronometrist.org::#program-migration][migration commands]].
Extensions exist for -
1. [[file:elisp/chronometrist-key-values.org][attaching arbitrary metadata]] to time intervals,
2. [[https://tildegit.org/contrapunctus/chronometrist-goal][time goals and alerts]], and
3. support for the [[file:elisp/chronometrist-third.org][Third Time system]]
** Contributions and contact
:PROPERTIES:
:CUSTOM_ID: contributions-contact
:END:
Feedback and MRs are very welcome. 🙂
+ [[file:TODO.org]] has a long list of tasks
+ [[file:elisp/chronometrist.org]] contains all developer-oriented documentation
If you have tried using Chronometrist, I'd love to hear your experiences! Get in touch with the author and other Emacs users in the Emacs channel on the Jabber network - [[https://conversations.im/j/emacs@salas.suchat.org][xmpp:emacs@salas.suchat.org?join]] ([[https://inverse.chat/#converse/room?jid=emacs@salas.suchat.org][web chat]])
(For help in getting started with Jabber, [[https://xmpp.org/getting-started/][click here]])
** License
:PROPERTIES:
:CUSTOM_ID: license
:END:
I'd /like/ for all software to be liberated - transparent, trustable, and accessible for anyone to use, study, or improve.
I'd /like/ anyone using my software to credit me for the work.
I'd /like/ to receive financial support for my efforts, so I can spend all my time doing what I find meaningful.
But I don't want to make demands or threats (e.g. via legal conditions) to accomplish all that, nor restrict my services to only those who can pay.
Thus, Chronometrist is released under your choice of [[https://unlicense.org/][Unlicense]] or the [[http://www.wtfpl.net/][WTFPL]].
(See files [[file:UNLICENSE][UNLICENSE]] and [[file:WTFPL][WTFPL]]).
** Thanks
:PROPERTIES:
:CUSTOM_ID: thanks
:END:
The main buffer and the report buffer are copied from the Android application, [[https://github.com/netmackan/ATimeTracker][A Time Tracker]]
wasamasa, bpalmer, aidalgol, pjb and the rest of #emacs for their tireless help and support
jwiegley for =timeclock.el=, which we used as a backend in earlier versions
blandest for helping me with the name
fiete and wu-lee for testing and bug reports
* Tutorials
:PROPERTIES:
:CUSTOM_ID: usage
:END:
** Installation
* Installation
:PROPERTIES:
:CUSTOM_ID: installation
:END:
*** from MELPA
** from MELPA
:PROPERTIES:
:CUSTOM_ID: install-from-melpa
:END:
1. Set up MELPA - https://melpa.org/#/getting-started
(Chronometrist uses Semantic Versioning and the developer is accident-prone, so using MELPA Stable is suggested 😏)
2. =M-x package-install RET chronometrist RET=
*** from Git
** from Git
:PROPERTIES:
:CUSTOM_ID: install-from-git
:END:
You can get =chronometrist= from https://tildegit.org/contrapunctus/chronometrist or https://codeberg.org/contrapunctus/chronometrist
=chronometrist= requires
@ -190,12 +83,18 @@ You can get =chronometrist= from https://tildegit.org/contrapunctus/chronometris
+ [[https://github.com/magnars/dash.el][dash.el]]
+ [[https://github.com/alphapapa/ts.el][ts.el]]
Add the ="elisp/"= subdirectory to your load-path, and =(require 'chronometrist)=.
Add the "elisp/" subdirectory to your load-path, and =(require 'chronometrist)=.
* Usage
:PROPERTIES:
:CUSTOM_ID: usage
:END:
** chronometrist
:PROPERTIES:
:CUSTOM_ID: usage-chronometrist
:END:
Run =M-x chronometrist= to see your projects, the time you spent on them today, which one is active, and the total time clocked today.
Click or hit =RET= (=chronometrist-toggle-task=) on a project to start tracking time for it. If it's already clocked in, it will be clocked out.
@ -208,6 +107,7 @@ Press =r= to see a weekly report (see =chronometrist-report=)
:PROPERTIES:
:CUSTOM_ID: usage-chronometrist-report
:END:
Run =M-x chronometrist-report= (or =chronometrist= with a prefix argument of 1, or press =r= in the =chronometrist= buffer) to see a weekly report.
Press =b= to look at past weeks, and =f= for future weeks.
@ -216,14 +116,12 @@ Press =b= to look at past weeks, and =f= for future weeks.
:PROPERTIES:
:CUSTOM_ID: usage-chronometrist-statistics
:END:
Run =M-x chronometrist-statistics= (or =chronometrist= with a prefix argument of 2) to view statistics.
Press =b= to look at past time ranges, and =f= for future ones.
** chronometrist-details
:PROPERTIES:
:CUSTOM_ID: chronometrist-details
:END:
** common commands
:PROPERTIES:
@ -242,16 +140,18 @@ All buffers keep themselves updated via an idle timer - no need to frequently pr
If you wish you could define time goals for some tasks, and have Chronometrist notify you when you're approaching the goal, completing it, or exceeding it, check out the extension [[https://github.com/contrapunctus-1/chronometrist-goal/][chronometrist-goal.el]].
* How-to Guides
* How-to
:PROPERTIES:
:CUSTOM_ID: how-to
:END:
See the Customize groups =chronometrist= and =chronometrist-report= for variables intended to be user-customizable.
** How to display a prompt when exiting with an active task
:PROPERTIES:
:CUSTOM_ID: how-to-prompt-when-exiting-emacs
:END:
Evaluate or add to your init.el the following -
=(add-hook 'kill-emacs-query-functions 'chronometrist-query-stop)=
@ -259,9 +159,7 @@ Evaluate or add to your init.el the following -
:PROPERTIES:
:CUSTOM_ID: how-to-literate-elisp
:END:
The literate Org document will automatically =literate-elisp-load= itself when opened, if =literate-elisp= is installed via =package.el=.
If you want it to be loaded with =literate-elisp-load= on Emacs startup, add the following to your init.el -
#+BEGIN_SRC emacs-lisp
(add-to-list 'load-path "<directory containing chronometrist.org>")
@ -273,7 +171,8 @@ If you want it to be loaded with =literate-elisp-load= on Emacs startup, add the
:PROPERTIES:
:CUSTOM_ID: how-to-tags
:END:
1. Add =chronometrist-tags-add= to one or more of these hooks [fn:3] -
1. Add =chronometrist-tags-add= to one or more of these hooks [fn:2] -
#+BEGIN_SRC emacs-lisp
(add-to-list 'chronometrist-after-in-functions 'chronometrist-tags-add)
@ -284,32 +183,31 @@ If you want it to be loaded with =literate-elisp-load= on Emacs startup, add the
The prompt suggests past combinations you used for the current task, which you can browse with =M-p=/=M-n=. You can leave it blank by pressing =RET=.
[fn:3] but not =chronometrist-before-in-functions=
[fn:2] but not =chronometrist-before-in-functions=
** How to attach key-values to time intervals
:PROPERTIES:
:CUSTOM_ID: how-to-key-value-pairs
:END:
1. Add =chronometrist-kv-add= to one or more of these hooks [fn:3] -
1. Add =chronometrist-kv-add= to one or more of these hooks [fn:2] -
#+BEGIN_SRC emacs-lisp
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-in-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-before-out-functions 'chronometrist-kv-add)
(add-to-list 'chronometrist-after-out-functions 'chronometrist-kv-add)
#+END_SRC
To exit the prompt, press the key it indicates for quitting - you can then edit the resulting key-values by hand if required. Press =C-c C-c= to accept the key-values, or =C-c C-k= to cancel.
** 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
:PROPERTIES:
:CUSTOM_ID: how-to-open-files-on-task-start
:END:
An idea from the author's own init -
#+BEGIN_SRC emacs-lisp
@ -327,6 +225,7 @@ An idea from the author's own init -
:PROPERTIES:
:CUSTOM_ID: how-to-warn-uncommitted-changes
:END:
Another one, prompting the user if they have uncommitted changes in a git repository (assuming they use [[https://magit.vc/][Magit]]) -
#+BEGIN_SRC emacs-lisp
@ -351,6 +250,7 @@ Return nil (and run `magit-status') if the user answers no."
:PROPERTIES:
:CUSTOM_ID: how-to-activity-indicator
:END:
#+BEGIN_SRC emacs-lisp
(defun my-activity-indicator ()
(--> (chronometrist-latest-record (chronometrist-active-backend))
@ -368,7 +268,7 @@ Return nil (and run `magit-status') if the user answers no."
:PROPERTIES:
:CUSTOM_ID: how-to-backup
:END:
I suggest backing up Chronometrist data on each save using the [[https://tildegit.org/contrapunctus/async-backup][async-backup]] package.[fn:4] Here's how you can do that.
I suggest backing up Chronometrist data on each save using the [[https://tildegit.org/contrapunctus/async-backup][async-backup]] package.[fn:1] Here's how you can do that.
1. Add the following to your init.
#+BEGIN_SRC emacs-lisp
@ -377,10 +277,9 @@ I suggest backing up Chronometrist data on each save using the [[https://tildegi
2. Open your Chronometrist file and add =async-backup= to a buffer-local =after-save-hook=.
: M-x chronometrist-open-log
: M-x add-file-local-variable-prop-line RET eval RET (add-hook 'after-save-hook #'async-backup nil t) RET
3. Optionally, configure =async-backup-location= to set a specific directory for the backups -
: (setq async-backup-location "/path/to/backup/dir/")
3. Optionally, configure =backup-directory-alist= to set a specific directory for the backups.
[fn:4] It is possible to use Emacs' built-in backup system to do it, but since it is synchronous, doing so will greatly slow down saving of the Chronometrist file.
[fn:1] It is possible to use Emacs' built-in backup system to do it, but since it is synchronous, doing so will greatly slow down saving of the Chronometrist file.
** How to configure Vertico for use with Chronometrist
:PROPERTIES:
@ -404,10 +303,18 @@ Or use =vertico-multiform= to disable sorting for only specific commands -
(chronometrist-key-values-unified-prompt (vertico-sort-function . nil)))))
#+END_SRC
* User's reference
* Explanation
** Literate Program
:PROPERTIES:
:CUSTOM_ID: users-reference
:CUSTOM_ID: explanation-literate-program
:END:
Chronometrist is a literate program, made using Org - the canonical source is the =chronometrist.org= file, which contains source blocks. These are provided to users after /tangling/ (extracting the source into an Emacs Lisp file).
The Org file can also be loaded directly using the [[https://github.com/jingtaozf/literate-elisp][literate-elisp]] package, so that all source links (e.g. =xref=, =describe-function=) lead to the Org file, within the context of the concerned documentation. See [[#how-to-literate-elisp][How to load the program using literate-elisp]].
=chronometrist.org= is also included in MELPA installs, although not used directly by default, since doing so would interfere with automatic generation of autoloads.
* User's reference
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=
@ -434,10 +341,47 @@ Hooks
8. =chronometrist-file-change-hook=
9. =chronometrist-timer-hook=
* Local variables :noexport:
* Contributions and contact
:PROPERTIES:
:CUSTOM_ID: local-variables
:CUSTOM_ID: contributions-contact
:END:
Feedback and MRs are very welcome. 🙂
+ [[file:TODO.org]] has a long list of tasks
+ [[file:elisp/chronometrist.org]] contains all developer-oriented documentation
If you have tried using Chronometrist, I'd love to hear your experiences! Get in touch with the author and other Emacs users in the Emacs channel on the Jabber network - [[https://conversations.im/j/emacs@salas.suchat.org][xmpp:emacs@salas.suchat.org?join]] ([[https://inverse.chat/#converse/room?jid=emacs@salas.suchat.org][web chat]])
(For help in getting started with Jabber, [[https://xmpp.org/getting-started/][click here]])
* License
:PROPERTIES:
:CUSTOM_ID: license
:END:
I dream of a world where all software is liberated - transparent, trustable, and accessible for anyone to use or improve. But I don't want to make demands or threats (e.g. via legal conditions) to get there.
I'd rather make a request - please do everything you can to help that dream come true. Please Unlicense as much software as you can.
Chronometrist is released under your choice of [[https://unlicense.org/][Unlicense]] or the [[http://www.wtfpl.net/][WTFPL]].
(See files [[file:UNLICENSE][UNLICENSE]] and [[file:WTFPL][WTFPL]]).
* Thanks
:PROPERTIES:
:CUSTOM_ID: thanks
:END:
wasamasa, bpalmer, aidalgol, pjb and the rest of #emacs for their tireless help and support
jwiegley for =timeclock.el=, which we used as a backend in earlier versions
blandest for helping me with the name
fiete and wu-lee for testing and bug reports
* Local variables :noexport:
# Local Variables:
# eval: (progn (require 'ox-md) (add-hook 'after-save-hook (lambda () (org-export-to-file 'md "manual.md")) nil t))
# my-org-src-default-lang: "emacs-lisp"
# End:

View File

@ -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