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

34 KiB

chronometrist-key-values

TODO [50%]

  1. Remove calls from chronometrist.org to make this an optional dependency.
  2. key-values and tags should work regardless of what hook they're called from, including chronometrist-before-in-functions
  3. investigate rmc.el (read multiple choice) as an alternative to choice.el

About this file

Definition metadata

Each definition has its own heading. The type of definition is stored in tags -

  1. custom group
  2. [custom|internal] variable
  3. keymap (use variable instead?)
  4. macro
  5. function

    • does not refer to external state
    • primarily used for the return value
  6. reader

    • reads external state without modifying it
    • primarily used for the return value
  7. writer

    • modifies external state, namely a data structure or file
    • primarily used for side-effects
  8. procedure

    • any other impure function
    • usually affects the display
    • primarily used for side-effects
  9. major/minor mode
  10. command

A :hook:variable: is a variable which contains a list of functions; a :hook: tag with any of the function tags means a function meant to be added to a hook.

Further details are stored in properties -

  1. :INPUT: (for functions)
  2. :VALUE: list|hash table|…

    • for functions, this is the return value
  3. :STATE: <external file or data structure read or written to>

Explanation

chronometrist-key-values.org deals with adding additional information to events, in the form of key-values and tags.

Key-values are stored as plist keywords and values. The user can add any keywords except :name, :tags, :start, and :stop. 1 Values can be any readable Lisp values.

Similarly, tags are stored using a :tags (<tag>*) keyword-value pair. The tags themselves (the elements of the list) can be any readable Lisp value.

User input

The entry points are chronometrist-kv-add and chronometrist-tags-add. The user adds these to the desired hooks, and they prompt the user for tags/key-values.

Both have corresponding functions to create a prompt -

chronometrist-kv-add's way of reading key-values from the user is somewhat different from most Emacs prompts - it creates a new buffer, and uses the minibuffer to alternatingly ask for keys and values in a loop. Key-values are inserted into the buffer as the user enters/selects them. The user can break out of this loop with an empty input (the keys to accept an empty input differ between completion systems, so we try to let the user know about them using chronometrist-kv-completion-quit-key). After exiting the loop, they can edit the key-values in the buffer, and use the commands chronometrist-kv-accept to accept the key-values (which uses chronometrist-plist-update to add them to the last plist in chronometrist-file) or chronometrist-kv-reject to discard them.

History

All prompts suggest past user inputs. These are queried from three history hash tables -

Each of these has a corresponding function to clear it and fill it with values -

Library headers and commentary

;;; chronometrist-key-values.el --- add key-values to Chronometrist data -*- 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: ((chronometrist "0.7.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" is displayed when the user clicks on the package's entry in M-x list-packages.

;;; Commentary:
;;
;; This package lets users attach tags and key-values to their tracked time, similar to tags and properties in Org mode.
;;
;; To use, add one or more of these functions to any chronometrist hook except `chronometrist-before-in-functions'.
;; * `chronometrist-tags-add'
;; * `chronometrist-kv-add'
;; * `chronometrist-key-values-unified-prompt'

Dependencies

;;; Code:
(require 'chronometrist)

Code

Common

history-prep   writer

(defun chronometrist-history-prep (key history-table)
  "Prepare history of KEY in HISTORY-TABLE for use in prompts.
Each value in hash table TABLE must be a list.  Each value will be reversed and will have duplicate elements removed."
  (--> (gethash key history-table)
       (cl-remove-duplicates it :test #'equal :from-end t)
       (puthash key it history-table)))

keyword-to-string   function

(defun chronometrist-keyword-to-string (keyword)
  "Return KEYWORD as a string, with the leading \":\" removed."
  (replace-regexp-in-string "^:?" "" (symbol-name keyword)))

maybe-string-to-symbol   function

(defun chronometrist-maybe-string-to-symbol (list)
  "For each string in LIST, if it has no spaces, convert it to a symbol."
  (cl-loop for string in list
    if (string-match-p "[[:space:]]" string)
    collect string
    else collect (intern string)))

maybe-symbol-to-string   function

(defun chronometrist-maybe-symbol-to-string (list)
  "Convert each symbol in LIST to a string."
  (--map (if (symbolp it)
             (symbol-name it)
           it)
         list))

plist-update   function

(defun chronometrist-plist-update (old-plist new-plist)
  "Add tags and keyword-values from NEW-PLIST to OLD-PLIST.
OLD-PLIST and NEW-PLIST should be a property lists.

Keywords reserved by Chronometrist - :name, :start, and :stop -
will not be updated. Keywords in OLD-PLIST with new values in
NEW-PLIST will be updated. Tags in OLD-PLIST will be preserved
alongside new tags from NEW-PLIST."
  (-let* (((&plist :name  old-name  :tags old-tags
                   :start old-start :stop old-stop) old-plist)
          ;; Anything that's left will be the user's key-values.
          (old-kvs   (chronometrist-plist-key-values old-plist))
          ;; Prevent the user from adding reserved key-values.
          (plist     (chronometrist-plist-key-values new-plist))
          (new-tags  (-> (append old-tags (plist-get new-plist :tags))
                         (cl-remove-duplicates :test #'equal)))
          ;; In case there is an overlap in key-values, we use
          ;; plist-put to replace old ones with new ones.
          (new-kvs   (cl-copy-list old-plist))
          (new-kvs   (if plist
                         (-> (cl-loop for (key val) on plist by #'cddr
                               do (plist-put new-kvs key val)
                               finally return new-kvs)
                             (chronometrist-plist-key-values))
                       old-kvs)))
    (append `(:name ,old-name)
            (when new-tags `(:tags ,new-tags))
            new-kvs
            `(:start ,old-start)
            (when old-stop `(:stop  ,old-stop)))))

Tags

tags-history   variable

(defvar chronometrist-tags-history (make-hash-table :test #'equal)
  "Hash table of tasks and past tag combinations.
Each value is a list of tag combinations, in reverse
chronological order. Each combination is a list containing tags
as symbol and/or strings.")

tags-history-populate   writer

(defun chronometrist-tags-history-populate (task history-table backend)
  "Store tag history for TASK in HISTORY-TABLE from FILE.
Return the new value inserted into HISTORY-TABLE.

HISTORY-TABLE must be a hash table. (see `chronometrist-tags-history')"
  (puthash task nil history-table)
  (cl-loop for plist in (chronometrist-to-list backend) do
    (let ((new-tag-list  (plist-get plist :tags))
          (old-tag-lists (gethash task history-table)))
      (and (equal task (plist-get plist :name))
           new-tag-list
           (puthash task
                    (if old-tag-lists
                        (append old-tag-lists (list new-tag-list))
                      (list new-tag-list))
                    history-table))))
  (chronometrist-history-prep task history-table))
tests
(ert-deftest chronometrist-tags-history ()
  (progn
    (clrhash chronometrist-tags-history)
    (cl-loop for task in '("Guitar" "Programming") do
      (chronometrist-tags-history-populate task chronometrist-tags-history "test.sexp")))
  (should
   (= (hash-table-count chronometrist-tags-history) 2))
  (should
   (cl-loop for task being the hash-keys of chronometrist-tags-history
     always (stringp task)))
  (should
   (equal (gethash "Guitar" chronometrist-tags-history)
          '((classical solo)
            (classical warm-up))))
  (should
   (equal (gethash "Programming" chronometrist-tags-history)
          '((reading) (bug-hunting)))))

-tag-suggestions   variable

(defvar chronometrist--tag-suggestions nil
  "Suggestions for tags.
Used as history by `chronometrist-tags-prompt'.")

tags-history-add   writer

(defun chronometrist-tags-history-add (plist)
  "Add tags from PLIST to `chronometrist-tags-history'."
  (let* ((table    chronometrist-tags-history)
         (name     (plist-get plist :name))
         (tags     (plist-get plist :tags))
         (old-tags (gethash name table)))
    (when tags
      (--> (cons tags old-tags)
           (puthash name it table)))))

tags-history-combination-strings   reader

(defun chronometrist-tags-history-combination-strings (task)
  "Return list of past tag combinations for TASK.
Each combination is a string, with tags separated by commas.

This is used to provide history for `completing-read-multiple' in
`chronometrist-tags-prompt'."
  (->> (gethash task chronometrist-tags-history)
       (mapcar (lambda (list)
                 (->> list
                      (mapcar (lambda (elt)
                                (if (stringp elt)
                                    elt
                                  (symbol-name elt))))
                      (-interpose ",")
                      (apply #'concat))))))

tags-history-individual-strings   reader

(defun chronometrist-tags-history-individual-strings (task)
  "Return list of tags for TASK, with each tag being a single string.
This is used to provide completion for individual tags, in
`completing-read-multiple' in `chronometrist-tags-prompt'."
  (--> (gethash task chronometrist-tags-history)
    (-flatten it)
    (cl-remove-duplicates it :test #'equal)
    (cl-loop for elt in it
      collect (if (stringp elt)
                  elt
                (symbol-name elt)))))

tags-prompt   reader

(defun chronometrist-tags-prompt (task &optional initial-input)
  "Read one or more tags from the user and return them as a list of strings.
TASK should be a string.
INITIAL-INPUT is as used in `completing-read'."
  (setq chronometrist--tag-suggestions (chronometrist-tags-history-combination-strings task))
  (completing-read-multiple (concat "Tags for " task " (optional): ")
                            (chronometrist-tags-history-individual-strings task)
                            nil
                            'confirm
                            initial-input
                            'chronometrist--tag-suggestions))

tags-add   hook writer

(defun chronometrist-tags-add (&rest _args)
  "Read tags from the user; add them to the last entry in `chronometrist-file'.
_ARGS are ignored. This function always returns t, so it can be
used in `chronometrist-before-out-functions'."
  (interactive)
  (let* ((backend   (chronometrist-active-backend))
         (last-expr (chronometrist-latest-record backend))
         (last-name (plist-get last-expr :name))
         (_history  (chronometrist-tags-history-populate last-name chronometrist-tags-history backend))
         (last-tags (plist-get last-expr :tags))
         (input     (->> (chronometrist-maybe-symbol-to-string last-tags)
                         (-interpose ",")
                         (apply #'concat)
                         (chronometrist-tags-prompt last-name)
                         (chronometrist-maybe-string-to-symbol))))
    (when input
      (--> (append last-tags input)
           (reverse it)
           (cl-remove-duplicates it :test #'equal)
           (reverse it)
           (list :tags it)
           (chronometrist-plist-update
            (chronometrist-latest-record backend) it)
           (chronometrist-replace-last backend it)))
    t))

Key-Values

key-values   custom group

(defgroup chronometrist-key-values nil
  "Add key-values to Chronometrist time intervals."
  :group 'chronometrist)

use-database-history   custom variable

(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)

preset-alist   custom variable

(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)
get-presets
(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))

kv-buffer-name   custom variable

(defcustom chronometrist-kv-buffer-name "*Chronometrist-Key-Values*"
  "Name of buffer in which key-values are entered."
  :group 'chronometrist-key-values
  :type 'string)

key-history   variable

(defvar chronometrist-key-history
  (make-hash-table :test #'equal)
  "Hash table to store previously-used user-keys.
Each hash key is the name of a task. Each hash value is a list
containing keywords used with that task, in reverse chronological
order. The keywords are stored as strings and their leading \":\"
is removed.")

key-history-populate   writer

(defun chronometrist-key-history-populate (task history-table backend)
  "Store key history for TASK in HISTORY-TABLE from FILE.
Return the new value inserted into HISTORY-TABLE.

HISTORY-TABLE must be a hash table (see `chronometrist-key-history')."
  (puthash task nil history-table)
  (cl-loop for plist in backend do
    (catch 'quit
      (let* ((name     (plist-get plist :name))
             (_check   (unless (equal name task) (throw 'quit nil)))
             (keys     (--> (chronometrist-plist-key-values plist)
                            (seq-filter #'keywordp it)
                            (cl-loop for key in it collect
                              (chronometrist-keyword-to-string key))))
             (_check   (unless keys (throw 'quit nil)))
             (old-keys (gethash name history-table)))
        (puthash name
                 (if old-keys (append old-keys keys) keys)
                 history-table))))
  (chronometrist-history-prep task history-table))
tests
(ert-deftest chronometrist-key-history ()
  (progn
    (clrhash chronometrist-key-history)
    (cl-loop for task in '("Programming" "Arrangement/new edition") do
      (chronometrist-key-history-populate task chronometrist-key-history "test.sexp")))
  (should (= (hash-table-count chronometrist-key-history) 2))
  (should (= (length (gethash "Programming" chronometrist-key-history)) 3))
  (should (= (length (gethash "Arrangement/new edition" chronometrist-key-history)) 2)))

value-history   variable

(defvar chronometrist-value-history
  (make-hash-table :test #'equal)
  "Hash table to store previously-used values for user-keys.
The hash table keys are user-key names (as strings), and the
values are lists containing values (as strings).")

value-history-populate   writer

We don't want values to be task-sensitive, so this does not have a KEY parameter similar to TASK for chronometrist-tags-history-populate or chronometrist-key-history-populate.

(defun chronometrist-value-history-populate (history-table backend)
  "Store value history in HISTORY-TABLE from FILE.
HISTORY-TABLE must be a hash table. (see `chronometrist-value-history')"
  (clrhash history-table)
  ;; Note - while keys are Lisp keywords, values may be any Lisp
  ;; object, including lists
  (cl-loop for plist in (chronometrist-to-list backend) do
    ;; We call them user-key-values because we filter out Chronometrist's
    ;; reserved key-values
    (let ((user-key-values (chronometrist-plist-key-values plist)))
      (cl-loop for (key value) on user-key-values by #'cddr do
        (let* ((key-string (chronometrist-keyword-to-string key))
               (old-values (gethash key-string history-table))
               (value      (if (not (stringp value)) ;; why?
                               (list (format "%S" value))
                             (list value))))
          (puthash key-string
                   (if old-values (append old-values value) value)
                   history-table)))))
  (maphash (lambda (key _values)
             (chronometrist-history-prep key history-table))
           history-table))
tests
(ert-deftest chronometrist-value-history ()
  (progn
    (clrhash chronometrist-value-history)
    (chronometrist-value-history-populate chronometrist-value-history "test.sexp"))
  (should (= (hash-table-count chronometrist-value-history) 5))
  (should
   (cl-loop for task being the hash-keys of chronometrist-value-history
     always (stringp task))))

-value-suggestions   variable

(defvar chronometrist--value-suggestions nil
  "Suggestions for values.
Used as history by `chronometrist-value-prompt'.")

kv-read-mode-map   keymap

(defvar chronometrist-kv-read-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c C-c") #'chronometrist-kv-accept)
    (define-key map (kbd "C-c C-k") #'chronometrist-kv-reject)
    map)
  "Keymap used by `chronometrist-kv-read-mode'.")

kv-read-mode   major mode

(define-derived-mode chronometrist-kv-read-mode emacs-lisp-mode "Key-Values"
  "Mode used by `chronometrist' to read key values from the user."
  (->> ";; Use \\[chronometrist-kv-accept] to accept, or \\[chronometrist-kv-reject] to cancel\n"
       (substitute-command-keys)
       (insert)))

kv-completion-quit-key   reader

(defun chronometrist-kv-completion-quit-key ()
  "Return appropriate keybinding (as a string) to quit from `completing-read'.
It currently supports ido, ido-ubiquitous, ivy, and helm."
  (substitute-command-keys
   (cond ((or (bound-and-true-p ido-mode)
              (bound-and-true-p ido-ubiquitous-mode))
          "\\<ido-completion-map>\\[ido-select-text]")
         ((bound-and-true-p ivy-mode)
          "\\<ivy-minibuffer-map>\\[ivy-immediate-done]")
         ((bound-and-true-p helm-mode)
          "\\<helm-comp-read-map>\\[helm-cr-empty-string]")
         (t "leave blank"))))

key-prompt   reader

(defun chronometrist-key-prompt (used-keys)
  "Prompt the user to enter keys.
USED-KEYS are keys they have already added since the invocation
of `chronometrist-kv-add'."
  (let ((key-suggestions (--> (chronometrist-latest-record (chronometrist-active-backend))
                           (plist-get it :name)
                           (gethash it chronometrist-key-history))))
    (completing-read (format "Key (%s to quit): "
                             (chronometrist-kv-completion-quit-key))
                     ;; don't suggest keys which have already been used
                     (cl-loop for used-key in used-keys do
                       (setq key-suggestions
                             (seq-remove (lambda (key)
                                           (equal key used-key))
                                         key-suggestions))
                       finally return key-suggestions)
                     nil nil nil 'key-suggestions)))

value-prompt   writer

(defun chronometrist-value-prompt (key)
  "Prompt the user to enter values.
KEY should be a string for the just-entered key."
  (setq chronometrist--value-suggestions (gethash key chronometrist-value-history))
  (completing-read (format "Value (%s to quit): "
                           (chronometrist-kv-completion-quit-key))
                   chronometrist--value-suggestions nil nil nil
                   'chronometrist--value-suggestions))

value-insert   writer

(defun chronometrist-value-insert (value)
  "Insert VALUE into the key-value entry buffer."
  (insert " ")
  (cond ((or
          ;; list or vector
          (and (string-match-p (rx (and bos (or "(" "\"" "["))) value)
               (string-match-p (rx (and (or ")" "\"" "]") eos)) value))
          ;; int or float
          (string-match-p "^[0-9]*\\.?[0-9]*$" value))
         (insert value))
        (t (insert "\"" value "\"")))
  (insert "\n"))

kv-add   hook writer

(defun chronometrist-kv-add (&rest _args)
  "Read key-values from user, adding them to a temporary buffer for review.
In the resulting buffer, users can run `chronometrist-kv-accept'
to add them to the last s-expression in `chronometrist-file', or
`chronometrist-kv-reject' to cancel.

_ARGS are ignored. This function always returns t, so it can be
used in `chronometrist-before-out-functions'."
  (interactive)
  (let* ((buffer      (get-buffer-create chronometrist-kv-buffer-name))
         (first-key-p t)
         (backend     (chronometrist-active-backend))
         (last-sexp   (chronometrist-latest-record backend))
         (last-name   (plist-get last-sexp :name))
         (last-kvs    (chronometrist-plist-key-values last-sexp))
         (used-keys   (--map (chronometrist-keyword-to-string it)
                             (seq-filter #'keywordp last-kvs))))
    (chronometrist-key-history-populate last-name chronometrist-key-history backend)
    (chronometrist-value-history-populate chronometrist-value-history backend)
    (switch-to-buffer buffer)
    (with-current-buffer buffer
      (erase-buffer)
      (chronometrist-kv-read-mode)
      (if (and (chronometrist-current-task (chronometrist-active-backend)) last-kvs)
          (progn
            (funcall chronometrist-sexp-pretty-print-function last-kvs buffer)
            (down-list -1)
            (insert "\n "))
        (insert "()")
        (down-list -1))
      (catch 'empty-input
        (let (input key value)
          (while t
            (setq key (chronometrist-key-prompt used-keys)
                  input key
                  used-keys (append used-keys
                                    (list key)))
            (if (string-empty-p input)
                (throw 'empty-input nil)
              (unless first-key-p
                (insert " "))
              (insert ":" key)
              (setq first-key-p nil))
            (setq value (chronometrist-value-prompt key)
                  input value)
            (if (string-empty-p input)
                (throw 'empty-input nil)
              (chronometrist-value-insert value)))))
      (chronometrist-sexp-reindent-buffer))
    t))

kv-accept   command

(defun chronometrist-kv-accept ()
  "Accept the plist in `chronometrist-kv-buffer-name' and add it to `chronometrist-file'."
  (interactive)
  (let* ((backend (chronometrist-active-backend))
         (latest  (chronometrist-latest-record backend))
         user-kv-expr)
    (with-current-buffer (get-buffer chronometrist-kv-buffer-name)
      (goto-char (point-min))
      (setq user-kv-expr (ignore-errors (read (current-buffer))))
      (kill-buffer chronometrist-kv-buffer-name))
    (if user-kv-expr
        (chronometrist-replace-last backend (chronometrist-plist-update latest user-kv-expr))
      (chronometrist-refresh))))

kv-reject   command

(defun chronometrist-kv-reject ()
  "Reject the property list in `chronometrist-kv-buffer-name'."
  (interactive)
  (kill-buffer chronometrist-kv-buffer-name)
  (chronometrist-refresh))

chronometrist-key-value-menu   menu

(easy-menu-define chronometrist-key-value-menu chronometrist-mode-map
  "Key value menu for Chronometrist mode."
  '("Key-Values"
    ["Change tags for active/last interval" chronometrist-tags-add]
    ["Change key-values for active/last interval" chronometrist-kv-add]
    ["Change tags and key-values for active/last interval"
     chronometrist-key-values-unified-prompt]))

WIP Single-key prompts [0%]

This was initially implemented using Hydra. But, at the moment of reckoning, it turned out that Hydra does not pause Emacs until the user provides an input, and is thus unsuited for use in a hook. Thus, we created a new library called choice.el which functions similarly to Hydra (associations of keys, Lisp forms, and hints are passed to a macro which emits a prompt function) and used that.

Then I discovered that there's rmc.el which does about the same thing.

  1. Rewrite these using rmc.el

Types of prompts planned (#1 and #2 are meant to be mixed and matched)

  1. (tag|key-value)-combination-choice - select combinations of (tags|key-values)

    • commands

      • 0-9 - use combination (and exit)
      • C-u 0-9 - edit combination (then exit)
      • s - skip (exit)
      • (b - back [to previous prompt])
    • tag-combination-prompt
    • key-value-combination-prompt
  2. (tag|key|value)-multiselect-choice - select individual (tags|keys|values)

    • commands

      • 0-9 - select (toggles; save in var; doesn't exit)
      • u - use selection (and exit)
      • e - edit selection (then exit)
      • n - new tag/key/value
      • s - skip (exit)
      • (b - back [to previous prompt])

    Great for values; makes it easy to add multiple values, too, especially for users who don't know Lisp.

  3. unified-choice - select tag-key-value combinations, all in one prompt

    • commands

      • 0-9 - use combination (and exit)
      • C-u 0-9 - edit combination (then exit)
      • s - skip (exit)
    • basic implementation
    • make it more aesthetically pleasing in case of long suggestion strings

defchoice   function

(defun chronometrist-defchoice (name type list)
  "Construct and evaluate a `defchoice' form.
  NAME should be a string - `defchoice' will be called with chronometrist-NAME.

  TYPE should be a :key-values or :tags.

  LIST should be a list, with all elements being either a plists,
  or lists of symbols."
  (cl-loop with backend = (chronometrist-active-backend)
    with num = 0
    with last = (chronometrist-latest-record backend)
    for elt in (-take 7 list)
    do (incf num)
    if (= num 10) do (setq num 0)
    collect
    (list (format "%s" num)
          `(chronometrist-replace-last
            backend
            (chronometrist-plist-update last
                            ',(cl-case type
                                (:tags (list :tags elt))
                                (:key-values elt))))
          (format "%s" elt)) into numeric-commands
    finally do (eval
                `(defchoice ,(intern (format "chronometrist-%s" name))
                   ,@numeric-commands
                   ("s" nil "skip")))))

tag-choice   function

(defun chronometrist-tag-choice (task)
  "Query user for tags to be added to TASK.
  Return t, to permit use in `chronometrist-before-out-functions'."
  (let ((table chronometrist-tags-history))
    (chronometrist-tags-history-populate task table (chronometrist-active-backend))
    (if (hash-table-empty-p table)
        (chronometrist-tags-add)
      (chronometrist-defchoice "tag" :tag (gethash task table))
      (chronometrist-tag-choice-prompt "Which tags?"))
    t))

WIP chronometrist-key-choice   hook writer

(defun chronometrist-key-choice (task)
  "Query user for keys to be added to TASK.
Return t, to permit use in `chronometrist-before-out-functions'."
  (let ((table chronometrist-key-history))
    (chronometrist-key-history-populate task table (chronometrist-active-backend))
    (if (hash-table-empty-p table)
        (chronometrist-kv-add)
      (chronometrist-defchoice :key task table)
      (chronometrist-key-choice-prompt "Which keys?"))
    t))

WIP chronometrist-kv-prompt-helper   function

(defun chronometrist-kv-prompt-helper (mode task)
  (let ((table (case mode
                 (:tag chronometrist-tags-history)
                 (:key chronometrist-key-history)
                 (:value chronometrist-value-history)))
        ())))

WIP unified-prompt   hook writer

  1. Improve appearance - is there an easy way to syntax highlight the plists?
(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)))))
         (latest (chronometrist-latest-record backend)))
    (if (and (null presets) (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))))))
  t)

Provide

(provide 'chronometrist-key-values)
;;; chronometrist-key-values.el ends here

1

To remove this restriction, I had briefly considered making a keyword called :user, whose value would be another plist containing all user-defined keyword-values. But in practice, this hasn't been a big enough issue yet to justify the work.