Sparkly Stats — Stats and sparks from daily data (Emacs package)

Below you find the latest version of (1) the package's README and (2) its main source file.

For the git repository and issue tracker, see the project's page on sr.ht.

For more packages, see Software.


README.org

Fonts matter

The monospace font that you choose can make a big difference on how your sparks look. Try them out.

See details about it in Sparkly's README (M-x sparkly-see-readme).

In browsers, the space between lines may look particularly large.

Overview

Give Sparkly Stats a date range and the values for each date and it gives you statistics and sparks.

See the examples in sparkly-stats-report.

Installation

See my page Software for the most up-to-date instructions on how to download and install any of my Emacs packages.

Having downloaded and installed the package and its dependencies, adapt the configurations below to your init.el file.

(use-package sparkly-stats :demand t)

Alternatively, if you don’t have ‘use-package’:

(require 'sparkly-stats)

Summary of callables

Here's an overview of this package's callables:

Function Summary
sparkly-stats-report Given an org table, return stats and sparks.
sparkly-stats-seq->ht Make hash table using starting date BEG and sequence SEQ.
sparkly-stats-string-paste Paste STRINGS side-by-side separated by string SEP.
sparkly-stats-see-readme Open sparkly-stats's README.org file.
sparkly-stats-see-news See the News in sparkly-stats's README.org file.

They're described in more detail below.

Functions

Report

sparkly-stats-report (&optional key-1 value-1 key-2 value-2 ...)

Given an org table, return stats and sparks.

Optional key–value pairs ARGS may be passed.

The following KEYS are available:
:tbl, :num,
:beg, :end,
:frq-args, :rng-args,
:wdf-args, :mdf-args,
:wkf-args, :mof-args

If the value of :tbl is nil, look for an org table at point and
insert the output after it (useful mostly for interactive use).
Otherwise, the value should refer to a key–value collection, and the
function returns the output as a string.

The key–value collection can be any reasonable format supported by xht,
namely: hash table, list of lists, Org table string, JSON string, TSV,
CSV, SSV, key–value lines, alist, or plist.

The keys must be dates.
Accepted formats are ISO 8601 (without time) and Org timestamps.

You can also refer to a named table by passing an org-sbe sexp:

Date Flanges
[2042-04-18 Fri] 5
[2042-04-19 Sat] 5
[2042-04-21 Mon] 13
[2042-04-22 Tue] 2
(sparkly-stats-report :tbl (org-sbe my-flanges))
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25

   ▅
▅▅_█▂
b   e

▁_▁__▂_______▁
0           13

▅
█▂__▅▅_
MTWTFSS

If the value of :beg is:

  • some date, use it for the end of the date range.
  • nil, use the last date given by the table.

If the value of :end is:

  • 'today, make today the end of the date range.
  • some date, use it for the end of the date range.
  • nil, use the last date given by the table.

If a date is passed for :beg and/or :end, it must be a string in
YYYY-MM-DD format (e.g. "2042-04-29").

The KEY :num is the report number. It specifies what to show. If nil,
use the value of customizable variable sparkly-stats-report-default,
which defaults to 2. Otherwise look that number up in the variables
sparkly-stats-report-alist-preset, sparkly-stats-report-alist-user.

And :rng-args, :frq-args, :wdf-args, :mdf-args, :wkf-args, and
:mof-args are all sparkly-vs arguments, each of which a list, which
could be passed to each of the spark blocks of the output to control
their vertical size:

Key Sparks for...
:rng-args date range
:frq-args frequency
:wdf-args day of week frequency
:mdf-args day of month frequency
:wkf-args ISO week frequency
:mof-args month frequency
  ;;;; Common usage
  (sparkly-stats-report :num 1
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-04-18 Fri] |       5 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-22 Tue] |       2 |")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;; Tweak vertical stretching
  (sparkly-stats-report :num 1 :rng-args '(:lns 4) :frq-args '(:fac .5)
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-04-18 Fri] |       5 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-22 Tue] |       2 |")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25



▄▄ █
██_█▅
b   e

▄_▄__█_______▄
0           13
"

  ;;;; Larger example, with latest dates first
  (sparkly-stats-report :num 1
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253

      ▂  ▂  ▂ ▂
      █  █  █ █
      █▃ █  █ █
      ██ █  █ █
   ▅  ██▆█  █▆█
▅▅_█▂_████__███▅
beg          end

▄_▁__▃_______▁▂____________▁______________▄
0                                        42
"

  ;;;; Same, but restricting the dates range
  ;;;;; Only the beginning
  (sparkly-stats-report :num 1 :beg "2042-04-28"
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-28 Mon]--[2042-05-03 Sat] (6 days)
 mode    median    average    sum
 (0 42)  9.5       17.17      103

  ▂ ▂
  █ █
  █ █
  █ █
  █▆█
__███▅
b    e

▂____▁________▁___________________________▂
0                                        42
"
  ;;;;; Only the end
  (sparkly-stats-report :num 1 :end "2042-04-29"
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-04-29 Tue] (12 days)
 mode    median    average    sum
 0       5         12.5       150

      ▂  ▂
      █  █
      █▃ █
      ██ █
   ▅  ██▆█
▅▅_█▂_████__
beg      end

▄_▁__▂_______▁▁____________▁______________▂
0                                        42
"

  ;;;;; Both the beginning and the end
  (sparkly-stats-report :num 1 :beg "2042-04-22" :end "2042-04-29"
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-22 Tue]--[2042-04-29 Tue] (8 days)
 mode    median    average    sum
 0       8         15.88      127

  ▂  ▂
  █  █
  █▃ █
  ██ █
  ██▆█
▂_████__
beg  end

▃_▁___________▁____________▁______________▂
0                                        42
"

  ;;;;; Same, but with dates that are outside the table's range
  (sparkly-stats-report :num 1 :beg "2042-04-01" :end "2042-05-31"
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-01 Tue]--[2042-05-31 Sat] (61 days)
 mode    median    average    sum
 0       0         4.15       253

                       ▂  ▂  ▂ ▂
                       █  █  █ █
                       █▃ █  █ █
                       ██ █  █ █
                    ▅  ██▆█  █▆█
_________________▅▅_█▂_████__███▅____________________________
beg                                                       end







█_▁__▃_______▁▂____________▁______________▄
0                                        42
"

  ;;;;; Range of one month, shown wrapped in the middle
  ;;    (requires sparkly 0.3.0)
  (sparkly-stats-report :num 1 :beg "2042-04-16" :end "2042-05-15"
                        :rng-args '(:wrp 15)
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-16 Wed]--[2042-05-15 Thu] (30 days)
 mode    median    average    sum
 0       0         8.43       253

        ▂  ▂  ▂
        █  █  █
        █▃ █  █
        ██ █  █
     ▅  ██▆█  █
__▅▅_█▂_████__█




▆█
██▅____________



█_▁__▃_______▁▂____________▁______________▄
0                                        42
"

  ;;;;; Range of one month, using alternative bars
  ;;    (requires sparkly 0.3.0)
  (let ((sparkly-bars "_░▒▓█"))
    (sparkly-stats-report :num 1 :beg "2042-04-16" :end "2042-05-15"
                          :tbl "| Date             | Flanges |
                                |------------------+---------|
                                | [2042-05-03 Sat] |       5 |
                                | [2042-05-02 Fri] |      42 |
                                | [2042-05-01 Thu] |      14 |
                                | [2042-04-30 Wed] |      42 |
                                | [2042-04-27 Sun] |      42 |
                                | [2042-04-26 Sat] |      14 |
                                | [2042-04-25 Fri] |      27 |
                                | [2042-04-24 Thu] |      42 |
                                | [2042-04-22 Tue] |       2 |
                                | [2042-04-21 Mon] |      13 |
                                | [2042-04-19 Sat] |       5 |
                                | [2042-04-18 Fri] |       5 |"))
  => "\
[2042-04-16 Wed]--[2042-05-15 Thu] (30 days)
 mode    median    average    sum
 0       0         8.43       253

        ▒  ▒  ▒ ▒
        █  █  █ █
        █  █  █ █
        █  █  █ █
        █▓ █  █ █
        ██ █  █ █
        ██ █  █ █
     ░  ██▒█  █▒█
     █  ████  ███
  ░░ █  ████  ███░
__██_█▒_████__████____________
beg                        end





█_░__▓_______░▒____________░______________█
0                                        42
"


  ;;;; Different input formats — :tbl is flexible!
  ;;;;; Other date formats are accepted
  (sparkly-stats-report :num 1
                        :tbl "| Date                   | Flanges |
                              |------------------------+---------|
                              | 2042-04-18             |       5 |
                              | [2042-04-19 Sat]       |       5 |
                              | <2042-04-21 Mon>       |      13 |
                              | [2042-04-22 Tue 21:42] |       2 |")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;; Other key–value structures are accepted
  ;;;;;; List of Lists
  (sparkly-stats-report
   :num 1 :tbl '(("Date" "Flanges")
                 ("2042-04-18"   5)
                 ("2042-04-19"   5)
                 ("2042-04-21"  13)
                 ("2042-04-22"   2)))
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; TSV
  (sparkly-stats-report
   :num 1 :tbl "\
Date    Flanges
2042-04-18  5
2042-04-19  5
2042-04-21  13
2042-04-22  2")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; CSV
  (sparkly-stats-report
   :num 1 :tbl "\
Date,Flanges
2042-04-18,5
2042-04-19,5
2042-04-21,13
2042-04-22,2")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; 2D Hash Table
  (sparkly-stats-report
   :num 1 :tbl (h* "2042-04-18" (h* "Date" "2042-04-18" "Flanges" "5")
                   "2042-04-19" (h* "Date" "2042-04-19" "Flanges" "5")
                   "2042-04-21" (h* "Date" "2042-04-21" "Flanges" "13")
                   "2042-04-22" (h* "Date" "2042-04-22" "Flanges" "2")))
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; 1D Hash Table
  (sparkly-stats-report
   :num 1 :tbl (h* "2042-04-18"  5 "2042-04-19" 5
                   "2042-04-21" 13 "2042-04-22" 2))
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; Association List
  (sparkly-stats-report
   :num 1 :tbl '(("2042-04-18" .  5) ("2042-04-19" . 5)
                 ("2042-04-21" . 13) ("2042-04-22" . 2)))
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; Property List
  (sparkly-stats-report
   :num 1 :tbl '("2042-04-18"  5 "2042-04-19" 5
                 "2042-04-21" 13 "2042-04-22" 2))
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;;;; Key–Value Lines
  (sparkly-stats-report
   :num 1  ; none of these need to be sorted, by the way:
   :tbl "\
2042-04-21 = 13
2042-04-19 =  5
2042-04-18 =  5
2042-04-22 =  2")
  => "\
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13
"

  ;;;; Reports
  ;;;;; Exclude sparks — report #0
  (sparkly-stats-report :num 0
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-03-23 Sun] |       3 |
                              | [2042-03-23 Sun] |       3 |
                              | [2042-03-21 Fri] |       6 |
                              | [2042-03-20 Thu] |       6 |
                              | [2042-03-19 Wed] |       3 |
                              | [2042-03-18 Tue] |       3 |
                              | [2042-03-11 Tue] |       6 |
                              | [2042-03-10 Mon] |       3 |
                              | [2042-03-09 Sun] |       6 |
                              | [2042-03-08 Sat] |      12 |
                              | [2042-03-07 Fri] |       6 |
                              | [2042-03-06 Thu] |       6 |
                              | [2042-03-05 Wed] |       6 |
                              | [2042-03-04 Tue] |       6 |
                              | [2042-03-03 Mon] |       8 |
                              | [2042-03-02 Sun] |       2 |
                              | [2042-03-01 Sat] |       7 |")
  => "\
[2042-03-01 Sat]--[2042-03-23 Sun] (23 days)
 mode    median    average    sum
 6       3         3.87       89
"

  (sparkly-stats-report :num 0
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253
"

  ;;;;; Include weekday frequencies — report #2
  (sparkly-stats-report :num 2
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-03-23 Sun] |       3 |
                              | [2042-03-23 Sun] |       3 |
                              | [2042-03-21 Fri] |       6 |
                              | [2042-03-20 Thu] |       6 |
                              | [2042-03-19 Wed] |       3 |
                              | [2042-03-18 Tue] |       3 |
                              | [2042-03-11 Tue] |       6 |
                              | [2042-03-10 Mon] |       3 |
                              | [2042-03-09 Sun] |       6 |
                              | [2042-03-08 Sat] |      12 |
                              | [2042-03-07 Fri] |       6 |
                              | [2042-03-06 Thu] |       6 |
                              | [2042-03-05 Wed] |       6 |
                              | [2042-03-04 Tue] |       6 |
                              | [2042-03-03 Mon] |       8 |
                              | [2042-03-02 Sun] |       2 |
                              | [2042-03-01 Sat] |       7 |")
  => "\
[2042-03-01 Sat]--[2042-03-23 Sun] (23 days)
 mode    median    average    sum
 6       3         3.87       89


▇▂█▆▆▆▆█▆▃▆______▃▃▆▆_▃
beg                 end

▇_▁▄__█▁▁___▁
0          12


▃▇▁▄▄█▃
███████
MTWTFSS
"

  (sparkly-stats-report :num 2
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253

      ▂  ▂  ▂ ▂
      █  █  █ █
      █▃ █  █ █
      ██ █  █ █
   ▅  ██▆█  █▆█
▅▅_█▂_████__███▅
beg          end

▄_▁__▃_______▁▂____________▁______________▄
0                                        42




   ██
  ▂██ ▂
  ███ █
  ███ █
  █████
▅ █████
█▂█████
MTWTFSS
"

  ;;;;; Include weekday and monthly frequencies — report #3
  ;; (we also tweak the vertical sizes of these two)
  (sparkly-stats-report :num 3
                        :wdf-args '(:fac .05)
                        :mof-args '(:fac .05)
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253

      ▂  ▂  ▂ ▂
      █  █  █ █
      █▃ █  █ █
      ██ █  █ █
   ▅  ██▆█  █▆█
▅▅_█▂_████__███▅
beg          end

▄_▁__▃_______▁▂____________▁______________▄
0                                        42


  ▁▆█ ▁
  ███▂█
▅▁█████
MTWTFSS








   ██
   ██
___██_______
JFMAMJJASOND
"

  ;;;;; Include all frequencies, fit some side by side — report #6
  ;; (we also tweak all vertical sizes)
  (sparkly-stats-report :num 6
                        :wkf-args '(:fac .05) :mof-args '(:fac .05)
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-03 Sat] |       5 |
                              | [2042-05-02 Fri] |      42 |
                              | [2042-05-01 Thu] |      14 |
                              | [2042-04-30 Wed] |      42 |
                              | [2042-04-27 Sun] |      42 |
                              | [2042-04-26 Sat] |      14 |
                              | [2042-04-25 Fri] |      27 |
                              | [2042-04-24 Thu] |      42 |
                              | [2042-04-22 Tue] |       2 |
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253

      ▂  ▂  ▂ ▂
      █  █  █ █
      █▃ █  █ █
      ██ █  █ █
   ▅  ██▆█  █▆█
▅▅_█▂_████__███▅
beg          end

▄_▁__▃_______▁▂____________▁______________▄
0                                        42





   █                   ▂                     ▂  ▂  ▂
   █                   █                     █  █  █
   █                   █                     █▃ █  █
   ██                  █                     ██ █  █
   ██                 ▆█                  ▅  ██▆█  █
___██_______          ██▅______________▅▅_█▂_████__█_         ▂
JFMAMJJASOND          d01                         d31         █

                █                                            ██
                █▁                                          ▂██ ▂
                ██                                          ███ █
                ██                                          ███ █
                ██                                          █████
                ██                                        ▅ █████
_______________▄██___________________________________     █▂█████
W01                                               W53     MTWTFSS
"

  ;;;;; Same, but building the input with sparkly-stats-seq->ht
  (let* ((beg "2042-04-18")
         (seq '(5 5 0 13 2 0 42 27 14 42 0 0 42 14 42 5))
         (htb (sparkly-stats-seq->ht beg seq)))
    (sparkly-stats-report :tbl htb :num 6
                          :wkf-args '(:fac .05)
                          :mof-args '(:fac .05)))
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 (0 42)  9         15.81      253

      ▂  ▂  ▂ ▂
      █  █  █ █
      █▃ █  █ █
      ██ █  █ █
   ▅  ██▆█  █▆█
▅▅_█▂_████__███▅
beg          end

▄_▁__▃_______▁▂____________▁______________▄
0                                        42





   █                   ▂                     ▂  ▂  ▂
   █                   █                     █  █  █
   █                   █                     █▃ █  █
   ██                  █                     ██ █  █
   ██                 ▆█                  ▅  ██▆█  █
___██_______          ██▅______________▅▅_█▂_████__█_         ▂
JFMAMJJASOND          d01                         d31         █

                █                                            ██
                █▁                                          ▂██ ▂
                ██                                          ███ █
                ██                                          ███ █
                ██                                          █████
                ██                                        ▅ █████
_______________▄██___________________________________     █▂█████
W01                                               W53     MTWTFSS
"

  ;;;; Coercion to zero
  ;; Non-numeric values are coerced to zero
  (sparkly-stats-report :wkf-args '(:fac .05)
                        :mof-args '(:fac .05)
                        :num 1 :tbl "\
| Date             | Flanges |
|------------------+---------|
| [2042-05-03 Sat] |       ? |
| [2042-05-02 Fri] |      42 |
| [2042-05-01 Thu] |      14 |
| [2042-04-30 Wed] |     nil |
| [2042-04-27 Sun] |     N/A |
| [2042-04-26 Sat] |      14 |
| [2042-04-25 Fri] |      27 |
| [2042-04-24 Thu] |     foo |
| [2042-04-22 Tue] |       t |
| [2042-04-21 Mon] |         |
| [2042-04-19 Sat] |       5 |
| [2042-04-18 Fri] |       5 |
")
  => "\
[2042-04-18 Fri]--[2042-05-03 Sat] (16 days)
 mode    median    average    sum
 0       0         6.69       107



       ▃      █
       █      █
       █▆    ▆█
▅▅_____██____██_
beg          end


█____▂________▂____________▁______________▁
0                                        42
"

  ;;;; Corner cases
  ;;;;; Just one day
  (sparkly-stats-report :num 0
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-01 Thu] |      14 |")
  => "\
[2042-05-01 Thu] (1 day)
 mode    median    average    sum
 14      14        14.0       14
"

  (sparkly-stats-report :num 6
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-05-01 Thu] |      14 |")
  => "\
[2042-05-01 Thu] (1 day)
 mode    median    average    sum
 14      14        14.0       14



beg


14

    ▆                 ▆
____█_______          █______________________________
JFMAMJJASOND          d01                         d31

                 ▆                                           ▆
_________________█___________________________________     ___█___
W01                                               W53     MTWTFSS
"

  ;;;; Errors
  ;;;;; Dates, if passed, must be strings: ISO 8601 or org timestamps
  (sparkly-stats-report :beg '2042-04-15
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  !!> wrong-type-argument

  (sparkly-stats-report :beg "20420415"
                        :tbl "| Date             | Flanges |
                              |------------------+---------|
                              | [2042-04-21 Mon] |      13 |
                              | [2042-04-19 Sat] |       5 |
                              | [2042-04-18 Fri] |       5 |")
  !!> error

  ;;;;; With no :tbl passed — and no org table at point
  (sparkly-stats-report) !!> error

Make input

sparkly-stats-seq->ht (beg seq)

Make hash table using starting date BEG and sequence SEQ.

BEG is a date string in ISO 8601 or org timestamp format.

SEQ is a list or vector. Each of its values must be either a number or a
string representing a number, or else it'll be coerced to 0.

The resulting hash table can then be passed as input to the :tbl key
of sparkly-stats-report.

(sparkly-stats-seq->ht "2042-11-01" '(2 7 18 5 3 0 1))
H=> (h* "2042-11-01" 2
        "2042-11-02" 7
        "2042-11-03" 18
        "2042-11-04" 5
        "2042-11-05" 3
        "2042-11-06" 0
        "2042-11-07" 1)

(sparkly-stats-seq->ht "[2042-11-01 Sat]" ["2" 7 "18" 5 "3" 0 1])
H=> (h* "2042-11-01" 2
        "2042-11-02" 7
        "2042-11-03" 18
        "2042-11-04" 5
        "2042-11-05" 3
        "2042-11-06" 0
        "2042-11-07" 1)

(sparkly-stats-seq->ht "[2042-11-01 Sat]" '("2" t "N/A" nil "" :zero 1))
H=> (h* "2042-11-01" 2
        "2042-11-02" 0
        "2042-11-03" 0
        "2042-11-04" 0
        "2042-11-05" 0
        "2042-11-06" 0
        "2042-11-07" 1)

(sparkly-stats-seq->ht "2042-11-01" "Not a list or a vector") !!> error

Support

String operations

sparkly-stats-string-paste (sep &rest strings)

Paste STRINGS side-by-side separated by string SEP.

Unlike the UNIX paste command, the strings are aligned by their
respective last (rather than first) lines.

  ;;;; with two strings
  (sparkly-stats-string-paste " = " "a\nb\nc" "d\ne\nf")
  => "\
a = d
b = e
c = f"
  (sparkly-stats-string-paste "   " "a\nb\nc" "d\ne\nf")
  => "\
a   d
b   e
c   f"
  (sparkly-stats-string-paste "   " "a\nb\nc" "d\ne")
  => "\
a
b   d
c   e"
  (sparkly-stats-string-paste "   " "a\nb" "c\nd\ne")
  => "\
    c
a   d
b   e"
  (sparkly-stats-string-paste "   " " a\n b\nc" "d\ne")
  => "\
 a
 b   d
c    e"
  (sparkly-stats-string-paste "   " "a\nb" " c\n d\ne")
  => "\
     c
a    d
b   e"

  ;;;; with three or more strings
  (sparkly-stats-string-paste ":" "a\n z" "b\n c\nd" "  e\n f  ")
  => "\
  :b
a : c:  e
 z:d : f  "
  (sparkly-stats-string-paste "  ←→  " "a\n z" "b\n c\nd" "  e\n f  ")
  => "\
    ←→  b
a   ←→   c  ←→    e
 z  ←→  d   ←→   f  "
  (sparkly-stats-string-paste " " "3\n2\n1" "4\n3\n2\n1" "2\n1")
  => "\
  4
3 3
2 2 2
1 1 1"
  (sparkly-stats-string-paste "  " "2\n1" "X\n2\n1" "2\n1" "Y\n2\n1" "2\n1")
  => "\
   X     Y
2  2  2  2  2
1  1  1  1  1"

See README

sparkly-stats-see-readme (&optional heading narrow)

Open sparkly-stats's README.org file.

Search for the file in sparkly-stats.el's directory.

If found, open it read-only.

If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.

If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found.

sparkly-stats-see-news ()

See the News in sparkly-stats's README.org file.

Contributing

See my page Software for information about how to contribute to any of my Emacs packages.

News

0.5.1

Sparkly Stats News

This release has a small fix related to the wrapping of date range sparks.

If you have Democratize, run M-x democratize-library to update the available examples.

Fixes

A trailing newline is no longer added when wrapping date range sparks

0.5.0

Sparkly Stats News

This release brings a new function and more flexibility for input values.

If you have Democratize, run M-x democratize-library to update the available examples.

New features

New functions
sparkly-stats-seq->ht

You can now pass input data as a combination of a start date plus a list or vector of values.

Enhancements

More flexible input formats
Invalid values are now coerced to zero

Up to here, if your input key–value data had some value that was neither a number nor a string representing a number, Sparkly Stats would throw an error.

Now any value that doesn't quack as a number is simply coerced to zero: empty strings, "N/A", nils, etc.

Improved docstring of sparkly-stats-report

0.4.0

Sparkly Stats News

This release brings several new features and fixes, such as:

  • three new reports
  • user-customizable reports
  • new function: place sparks side by side
  • more flexibility in accepted input format
    • when using org-sbe, hlines in the table no longer matter
    • plain ISO 8601 date is now also accepted
    • other org timestamp formats are now accepted
    • key–value formats other than org table are now accepted
  • and a few bug fixes

If you have Democratize, run M-x democratize-library to update the available examples.

New features

New reports
Report #4

Same as Report #3, plus day of month frequency sparks.

Report #5

Same as Report #4, plus ISO week frequency sparks.

Report #6

Same as Report #5, but arranging some frequency sparks side by side.

New customizable variables
sparkly-stats-report-alist-user

You can now pass an arbitrary lambda to build reports just as you wish.

So you can now add titles and comments, invert the order of sparks, change spacing — as you wish.

This variable comes with an example.

New functions
sparkly-stats-string-paste

Allows you to display sparks side by side, which you can them use to build customizable reports.

Enhancements

More flexible input formats
Other key–value structures are accepted as input as well

Instead of an org table, you can directly pass a list of lists, TSV, CSV, hash table, alist, or plist, for example.

Table keys can now be regular ISO 8601 dates

Up to here, they had to be Org timestamps.

Table keys can be different Org timestamp formats

Up to here, they had to be date-only and inactive.
Now they can be active and/or include the time.

org-sbe now works when table has zero or multiple hlines

Up to here, data tables should have exactly one hline, just after its header, or org-sbe wouldn't be able to properly process it.
Now hlines should no longer matter.

One-day reports now show only that date, instead of a range

Fixes

Adjusted minimum xht version in Package-Requires

Needed to be at least 2.0 for h-type to return :lines.

Count of days in range has been fixed

An off-by-one error in the count of days in a range was happening when there was change in daylight saving times inside a date range.

Fixed after using a simpler and better function.

Renamed customizable variables

The customizable variable sparkly-report-default was not properly named: it has now been renamed to sparkly-stats-report-default.

Since this mistake was caught before publicly pushing version 0.3.0, marking the previous variable as obsolete seems pointless: just skip directly to this newer version.

0.3.0

Sparkly Stats News

This release brings sparks for weekday frequencies, sparks for month frequencies, and three new report options.

If you have Democratize, run M-x democratize-library to update the available examples.

New features

New reports
Report #0

Same as Report #1, but without sparks: just stats numbers.

Report #2

Same as Report #1, plus weekday frequency sparks.

Report #3

Same as Report #2, plus month frequency sparks.

New customizable variables
sparkly-report-default

The default report number, if :num is not passed, is now 2. If you prefer the original, simply change it to 1.

Note that this variable was not properly named, as it doesn't use sparkly-stats as prefix.
This has been fixed in version 0.4.0, to which you're advised to skip.

0.2.0

Sparkly Stats News

You can now pick date ranges for your report.

New features

You can now restrict the beginning of the date range with :beg "YYYY-MM-DD"
You can now restrict the end of the date range with :end "YYYY-MM-DD"

0.1.4

Fixes

Fixed unnecessarily tight spacing in sparkly-stats-report

Added one empty line before and one empty line after the first block of sparks (date range).

0.1.3 (untagged)

Fixes

Previously implicit package dependencies are now explicit

0.1.2 (untagged)

Fixes

Fixed detection of singular/plural of number of elapsed days

0.1.1

Fixes

A needed require to xht was missing, and has been fixed
Docstring fixes

0.1.0

Release

See also

Other packages

You can integrate sparkly-stats' examples into Helpful or Help buffers with another package of mine called Democratize, which can also integrate examples from xht, sparkly, dash, s, f, and native Emacs libraries.

License

This project follows the REUSE Specification (FAQ), which in turn is built upon SPDX.

Therefore, license and copyright information can be found in:

  • each file's comment header, or
  • an adjacent file of the same name with the additional extension .license, or
  • the .reuse/dep5 file

The full text of the licenses can be found in the LICENSES subdirectory.


sparkly-stats.el

Structure

;;; sparkly-stats.el --- Stats and sparks from daily data -*- lexical-binding: t -*-
;;; Commentary:
;;;; For all the details, please do see the README
;;; Code:
;;;; Libraries
;;;; Symbols from other packages
;;;; Package metadata
;;;; Customizable variables
;;;; Other variables
;;;; Functions
;;;;; Report
;;;;; Make input
;;;;; Support
;;;;;; Parse input
;;;;;; Make output
;;;;;; Make parts of output
;;;;;;; Make header
;;;;;;; Make values
;;;;;;; Make labels
;;;;;;; Make sparks
;;;;;; Statistics
;;;;;; Date operations
;;;;;; String operations
;;;;; See README
;;;; Wrapping up
;;; sparkly-stats.el ends here

Contents

;;; sparkly-stats.el --- Stats and sparks from daily data -*- lexical-binding: t -*-

;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>

;;---------------------------------------------------------------------------
;; Author:   flandrew
;; Created:  2023-11-12
;; Updated:  2025-11-02
;; Keywords: extensions
;; Homepage: <https://flandrew.srht.site/listful/software.html>
;;---------------------------------------------------------------------------
;; Package-Version:  0.5.1
;; Package-Requires: ((emacs "25.1")(sparkly "0.2")(xht "2.0")(dash "2.15")(s "1.12"))
;;---------------------------------------------------------------------------

;; SPDX-License-Identifier: GPL-3.0-or-later

;; This file is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published
;; by the Free Software Foundation, either version 3 of the License,
;; or (at your option) any later version.
;;
;; This file is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with this file. If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; With Sparkly Stats you can create statistics and sparks from daily data.
;;
;;;; For all the details, please do see the README
;;
;; Open it easily with:
;;   (find-file-read-only "README.org")   <--- C-x C-e here¹
;;
;; or from any buffer:
;;   M-x sparkly-stats-see-readme
;;
;; or read it online:
;;   <https://flandrew.srht.site/listful/sw-emacs-sparkly-stats.html>
;;
;; ¹ or the key that ‘eval-last-sexp’ is bound to, if not C-x C-e.
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;



;;; Code:
;;;; Libraries

(require 's)
(require 'xht)       ; <--also by the author of this package
(require 'dash)
(require 'sparkly)   ; <--also by the author of this package
(require 'lisp-mnt)  ; lm-summary’, ‘lm-homepage’, ‘lm-version’, ‘lm-header
(require 'time-date) ; date-to-time’, ‘time-to-days’, ‘days-between


;;;; Symbols from other packages

;; Silence "not known to be defined" compiler warnings
(declare-function org-at-table-p  "ext:org-table" (&optional table-type))
(declare-function org-table-begin "ext:org-table" (&optional table-type))
(declare-function org-table-end   "ext:org-table" (&optional table-type))

(defvar org-ts-regexp-both)


;;;; Package metadata

(defvar sparkly-stats--name "Sparkly Stats")

(defvar sparkly-stats--dot-el
  (format "%s.el" (file-name-sans-extension (eval-and-compile
                                              (or load-file-name
                                                  buffer-file-name)))))
(defvar sparkly-stats--readme-org
  (expand-file-name "README.org" (file-name-directory sparkly-stats--dot-el)))

(defvar sparkly-stats--summary  (lm-summary   sparkly-stats--dot-el))
(defvar sparkly-stats--homepage (lm-homepage  sparkly-stats--dot-el))
(defvar sparkly-stats--version  (lm-with-file sparkly-stats--dot-el
                                  (or (lm-header "package-version")
                                      (lm-version))))


;;;; Customizable variables

(defgroup sparkly-stats nil
  (format "%s." sparkly-stats--summary)
  :group 'extensions
  :link  '(emacs-library-link :tag "Lisp file" "sparkly-stats.el")
  :link  `(file-link :tag "README.org" ,sparkly-stats--readme-org)
  :link  `(url-link  :tag "Homepage"   ,sparkly-stats--homepage))

(defcustom sparkly-stats-report-default 2
  "Default report number to use.
When no `:num' is passed to `sparkly-stats-report', use this."
  :package-version '(sparkly-stats "0.4.0")
  :type '(choice (const 0) (const 1) (const 2) (const 3)
                 (const 4) (const 5) (const 6)))

(defcustom sparkly-stats-report-alist-user
  '((199 . (format "%s\n %s\n %-8s%-10s%-11s%s\n\nDOW:\n%s"
                   hdr lbl mod med avg sum wdf)))
  "User-defined reports.

Preset reports are listed under `sparkly-stats-report-alist-preset'.
If none of these are exactly to your liking, create yours here.

This format example (199) shows sparks for only weekday frequencies.

The keys must be natural numbers greater than or equal to 100.

The values are forms that may use any of these variables:

  hdr = header       mod = mode     avg = average
  lbl = label        med = median   sum = sum

plus variables for sparks of:

  rng = data range   wdf = weekday frequency   mof = month frequency
  frq = frequency    wkf = ISO week frequency  mdf = day of month frequency"
  :package-version '(sparkly-stats "0.4.0")
  :type '(alist :key-type (restricted-sexp :tag "Number" :match-alternatives
                                           ((lambda (n) (and (natnump n)
                                                             (>= n 100)))))
                :value-type (sexp :tag "Form")))


;;;; Other variables

(defvar sparkly-stats-report-alist-preset
  (let ((ten "          ") (five "     ")
        (f1 "%s\n %s\n %-8s%-10s%-11s%s")
        (f2 "\n\n%s"))
    `((0 . (format ,f1
                   hdr lbl mod med avg sum))
      (1 . (format ,(concat f1 f2 f2)
                   hdr lbl mod med avg sum rng frq))
      (2 . (format ,(concat f1 f2 f2 f2)
                   hdr lbl mod med avg sum rng frq wdf))
      (3 . (format ,(concat f1 f2 f2 f2 f2)
                   hdr lbl mod med avg sum rng frq wdf mof))
      (4 . (format ,(concat f1 f2 f2 f2 f2 f2)
                   hdr lbl mod med avg sum rng frq wdf mof mdf))
      (5 . (format ,(concat f1 f2 f2 f2 f2 f2 f2)
                   hdr lbl mod med avg sum rng frq wdf mof mdf wkf))
      (6 . (format ,(concat f1 f2 f2 f2)
                   hdr lbl mod med avg sum rng frq
                   (--> (sparkly-stats-string-paste ,ten mof mdf)
                        (format "%s\n\n%s" it wkf)
                        (sparkly-stats-string-paste ,five it wdf))))))
  "Preset reports.
- Report 0: header and basic stats
- Report 1: report 0 plus date range sparks and frequency sparks
- Report 2: report 1 plus weekday frequency sparks
- Report 3: report 2 plus month frequency sparks
- Report 4: report 3 plus day of month frequency sparks
- Report 5: report 4 plus ISO week frequency sparks
- Report 6: report 5 but arranging frequencies side by side")

(defvar sparkly-stats--date-iso-rx "^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}$")


;;;; Functions
;;;;; Report

;;;###autoload
(defun sparkly-stats-report (&rest args)
  "Given an org table, return stats and sparks.

Optional key–value pairs ARGS may be passed.

The following KEYS are available:
`:tbl', `:num',
`:beg', `:end',
`:frq-args', `:rng-args',
`:wdf-args', `:mdf-args',
`:wkf-args', `:mof-args'

If the value of `:tbl' is nil, look for an org table at point and
insert the output after it (useful mostly for interactive use).
Otherwise, the value should refer to a key–value collection, and the
function returns the output as a string.

The key–value collection can be any reasonable format supported by xht,
namely: hash table, list of lists, Org table string, JSON string, TSV,
CSV, SSV, key–value lines, alist, or plist.

The keys must be dates.
Accepted formats are ISO 8601 (without time) and Org timestamps.

You can also refer to a named table by passing an `org-sbe' sexp:

#+name: my-flanges
| Date             | Flanges |
|------------------+---------|
| [2042-04-18 Fri] |       5 |
| [2042-04-19 Sat] |       5 |
| [2042-04-21 Mon] |      13 |
| [2042-04-22 Tue] |       2 |

#+begin_src emacs-lisp
\(sparkly-stats-report :tbl (org-sbe my-flanges))
#+end_src

#+begin_example
[2042-04-18 Fri]--[2042-04-22 Tue] (5 days)
 mode    median    average    sum
 5       5         5.0        25


▅▅_█▂
b   e

▁_▁__▂_______▁
0           13


█▂__▅▅_
MTWTFSS
#+end_example

If the value of `:beg' is:
- some date, use it for the end of the date range.
- nil, use the last date given by the table.

If the value of `:end' is:
- \\='today, make today the end of the date range.
- some date, use it for the end of the date range.
- nil, use the last date given by the table.

If a date is passed for `:beg' and/or `:end', it must be a string in
YYYY-MM-DD format (e.g. \"2042-04-29\").

The KEY `:num' is the report number. It specifies what to show. If nil,
use the value of customizable variable `sparkly-stats-report-default',
which defaults to 2. Otherwise look that number up in the variables
`sparkly-stats-report-alist-preset', `sparkly-stats-report-alist-user'.

And `:rng-args', `:frq-args', `:wdf-args', `:mdf-args', `:wkf-args', and
`:mof-args' are all `sparkly-vs' arguments, each of which a list, which
could be passed to each of the spark blocks of the output to control
their vertical size:

| Key         | Sparks for...          |
|-------------+------------------------|
| `:rng-args' | date range             |
| `:frq-args' | frequency              |
| `:wdf-args' | day of week frequency  |
| `:mdf-args' | day of month frequency |
| `:wkf-args' | ISO week frequency     |
| `:mof-args' | month frequency        |

\(fn &optional KEY-1 VALUE-1 KEY-2 VALUE-2 ...)"
  (interactive)
  (-let* (((&plist :tbl :num :rng-args :frq-args)           args)
          ((&plist :wdf-args :mdf-args :wkf-args :mof-args) args)
          ((&plist :beg :end :htbl) (apply #'sparkly-stats--parse-input args))
          (output (--> (sparkly-stats--make-output
                        beg end htbl num
                        :rng-args rng-args :frq-args frq-args
                        :wdf-args wdf-args :mdf-args mdf-args
                        :wkf-args wkf-args :mof-args mof-args)
                       ;; If no ORGTBL, we're at point.
                       ;; So we add leading colons "by hand".
                       (if tbl it (s-replace "\n" "\n: " (format "\n%s" it)))
                       (format "%s\n" it))))
    (if tbl output (save-excursion
                     (-let [(_b . e) (sparkly-stats--orgtbl-get-boundaries)]
                       (goto-char e)
                       (sparkly--smart-insert output))))))


;;;;; Make input

(defun sparkly-stats-seq->ht (beg seq)
  "Make hash table using starting date BEG and sequence SEQ.
BEG is a date string in ISO 8601 or org timestamp format.

SEQ is a list or vector. Each of its values must be either a number or a
string representing a number, or else it'll be coerced to 0.

The resulting hash table can then be passed as input to the `:tbl' key
of ‘sparkly-stats-report’."
  (setq beg (sparkly-stats--date-as-iso beg))
  ;; Yes, we could do this in a single pass instead of two.
  ;; No, it doesn't seem worth it in this case: much more code, less readable,
  ;; with performance gains imperceptible to the user unless SEQ is enormous.
  (h--hmap (sparkly-stats--date-add-days beg key)
           (sparkly-stats--as-number-safe value)
           (cond ((listp   seq) (h<-list   seq))
                 ((vectorp seq) (h<-vector seq))
                 (:otherwise    (error "SEQ must be a list or vector")))))


;;;;; Support
;;;;;; Parse input

(defun sparkly-stats--parse-input (&rest args)
  "Try to parse input. Return (BEG END HTBL).
For ARGS, see `sparkly-stats-report'."
  (-let [(&plist :tbl :beg :end) args]
    (let* ((htbl  (sparkly-stats--input-as-1d-htbl tbl))
           (dates (->> htbl h-keys (-sort #'string<)))
           (beg   (pcase beg
                    ('nil   (-> dates      car sparkly-stats--date-as-iso))
                    (_      (-> beg            sparkly-stats--date-as-iso))))
           (end   (pcase end
                    ('today (format-time-string "%F"))
                    ('nil   (-> dates last car sparkly-stats--date-as-iso))
                    (_      (-> end            sparkly-stats--date-as-iso)))))
      (list :beg beg :end end :htbl htbl))))

(defun sparkly-stats--input-as-1d-htbl (&optional obj)
  "Try to read key–value object OBJ; return it as 1D hash table.
Org tables can be detected at point, passed as string, or through
`org-sbe'. Other key–value objects may also be passed. For example:
alists, plists, and hash tables."
  (let ((input (or obj (sparkly-stats--orgtbl-get-string))))
    (pcase (h-type input)
      ;; Will happen when using org-sbe.
      ;; Depending on the presence of hlines, headers may be stripped,
      ;; so we make sure one is there for conversion to 1D.
      (:lines (-as-> (->> input h-read-list (remq 'hline) (cons '(k v))) lol
                     (if (eq (h-type lol) :lol)
                         (-> lol  h<-lol  h-2d->1d
                             sparkly-stats--remap-1d-htbl)
                       (error "Could not read it as list of lists"))))
      ;; Will happen when using orgtbl string, or with orgtbl at point.
      ;; Also when input is passed as some other key–value object.
      (_      (-as-> (h<-it input) htbl
                     (pcase (h-dim htbl)
                       (1 htbl)
                       (2 (h-2d->1d htbl))
                       (_ (error "Could not parse input")))
                     (sparkly-stats--remap-1d-htbl htbl))))))

(defun sparkly-stats--remap-1d-htbl (htbl)
  "Ensure HTBL only has valid keys and values.
Keys will be org timestamps (ISO 8601 will be converted).
Values will be numbers: numbers remain as is, strings representing
numbers are converted, anything else is coerced to 0."
  (unless (h-1d? htbl)
    (error "%s is not 1D" htbl))
  (h--hmap (cond ((string-match-p org-ts-regexp-both key)
                  ;; Canonicalize timestamp
                  (-> (sparkly-stats--date-org-to-iso key)
                      (sparkly-stats--date-iso-to-org)))
                 ((string-match-p sparkly-stats--date-iso-rx key)
                  (sparkly-stats--date-iso-to-org key)))
           (sparkly-stats--as-number-safe value)
           htbl))

(defun sparkly-stats--as-number-safe (&optional obj)
  "Convert OBJ to number, coercing to 0 when not possible."
  (condition-case nil (h-as-number obj) (error 0)))

(defun sparkly-stats--orgtbl-get-boundaries ()
  "Cons cell with boundaries of org table at point."
  (require 'org-table)
  (unless (org-at-table-p)
    (error "No org table at point"))
  (cons (org-table-begin) (org-table-end)))

(defun sparkly-stats--orgtbl-get-string ()
  "String of org table at point."
  (require 'org-table)
  (buffer-substring-no-properties (org-table-begin) (org-table-end)))


;;;;;; Make output

(defun sparkly-stats--make-output (beg end htbl &optional num &rest args)
  "Make output from BEG, END, HTBL, NUM and ARGS.
For ARGS, see `sparkly-stats-report'."
  (unless num (setq num sparkly-stats-report-default))
  (let* ((ops `(,@sparkly-stats-report-alist-preset
                ,@sparkly-stats-report-alist-user))
         (sexp (alist-get num ops)))
    (unless sexp (error "Report #%s was not found" num))
    (-let [(&hash :hdr :lbl :mod :med :avg :sum :rng :frq :wdf :mdf :wkf :mof)
           (apply #'sparkly-stats--make-output-ht beg end htbl args)]
      (funcall `(lambda  (hdr lbl mod med avg sum rng frq wdf mdf wkf mof)
                  (ignore hdr lbl mod med avg sum rng frq wdf mdf wkf mof)
                  ,sexp)  hdr lbl mod med avg sum rng frq wdf mdf wkf mof))))

(defun sparkly-stats--make-output-ht (beg end htbl &rest args)
  "Output hash table given BEG, END, HTBL, and ARGS.
This hash table has all values needed for generating reports.
For ARGS, see `sparkly-stats-report'."
  (-let* ((lbl "mode    median    average    sum")
          (beg (sparkly-stats--date-as-org beg))
          (end (sparkly-stats--date-as-org end))
          (len (sparkly-stats--date-range-length beg end))
          (hdr (sparkly-stats--make-header beg end len))
          (rnv (sparkly-stats--make-values 'rn beg end htbl))
          (wdv (sparkly-stats--make-values 'wd beg end htbl))
          (mdv (sparkly-stats--make-values 'md beg end htbl))
          (wkv (sparkly-stats--make-values 'wk beg end htbl))
          (mov (sparkly-stats--make-values 'mo beg end htbl))
          (stt (sparkly-stats--make-stats rnv))
          ((&plist :mod :med :avg :sum) stt)
          ((&plist :rng-args :wdf-args :wkf-args) args)
          ((&plist :frq-args :mdf-args :mof-args) args)
          (rng (sparkly-stats--make-sparks 'rn rnv rng-args))
          (frq (sparkly-stats--make-sparks 'fr rnv frq-args))
          (wdf (sparkly-stats--make-sparks 'wd wdv wdf-args))
          (mdf (sparkly-stats--make-sparks 'md mdv mdf-args))
          (wkf (sparkly-stats--make-sparks 'wk wkv wkf-args))
          (mof (sparkly-stats--make-sparks 'mo mov mof-args)))
    (h* :hdr hdr :lbl lbl :mod mod :med med :avg avg :sum sum
        :rng rng :frq frq :wdf wdf :mdf mdf :wkf wkf :mof mof)))


;;;;;; Make parts of output
;;;;;;; Make header

(defun sparkly-stats--make-header (beg end len)
  "Make header from BEG, END, and LEN."
  (if (> len 1)
      (format "%s--%s (%s days)" beg end len)
    (format "%s (1 day)" beg)))


;;;;;;; Make values

(defun sparkly-stats--make-values (type beg end htbl)
  "Return list of VALUES given TYPE, BEG, END, and HTBL."
  (let ((full (sparkly-stats--make-fullht beg end htbl)))
    (if (eq type 'rn)
        (h-values full)
      (-let* (((fmt lbl)
               (pcase type
                 ('wd `("%a" ("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun")))
                 ('md `("%d" ,(sparkly-stats--make-numstr-list 31)))
                 ('wk `("%V" ,(sparkly-stats--make-numstr-list 53)))
                 ('mo `("%m" ,(sparkly-stats--make-numstr-list 12)))))
              (rslt (h-zip-lists lbl (make-list (length lbl) 0))))
        (h--each full
          (h-count-put! rslt (h* (sparkly-stats--date-fmt fmt key) value)))
        (h-values rslt)))))

(defun sparkly-stats--make-numstr-list (n)
  "Make list of number strings from 1 to N."
  (--map (format "%02d" it) (number-sequence 1 n)))

(defun sparkly-stats--make-fullht (beg end htbl)
  "Return complete hash table given BEG, END, and HTBL.
Complete means that it uses BEG and END for range, and all days
in-between have values (which might be zero)."
  (let* ((len  (sparkly-stats--date-range-length beg end))
         (full (h-new len))
         date vals)
    (dotimes (it len)
      (setq date (sparkly-stats--date-add-days beg it)
            vals (sparkly-stats--as-number-safe
                  (h-get htbl (sparkly-stats--date-as-org date))))
      (h-put! full date vals))
    full))


;;;;;;; Make labels

(defun sparkly-stats--make-labels (type values args)
  "Make sparks labels given TYPE and a list of VALUES.
ARGS is a list of `sparks-vs' arguments."
  (pcase type
    ;; Date range
    ('rn (let ((len (length values))
               (wrp (plist-get args :wrp)))
           (cond ((and wrp (< wrp len)) "")
                 ((= len 1) "beg")
                 (:otherwise
                  (let* ((rep (- len (if (<= len 7) 2 6)))
                         (spc (s-repeat rep " "))
                         (lbl (if (<= len 7) "b e" "beg end")))
                    (s-replace " " spc lbl))))))
    ;; Frequency
    ('fr (let* ((min (-min values))
                (max (-max values)))
           (if (= min max)
               (format "%s" min)
             (let* ((digmin (sparkly-stats--num-digits min))
                    (digmax (sparkly-stats--num-digits max))
                    (repeat (- max min -1 digmin digmax))
                    (spaces (s-repeat repeat " ")))
               (format "%s%s%s" min spaces max)))))
    ;; Other frequencies
    ('wd "MTWTFSS")
    ('md (format "%s%s%s" "d01" (s-repeat 25 " ") "d31"))
    ('wk (format "%s%s%s" "W01" (s-repeat 47 " ") "W53"))
    ('mo "JFMAMJJASOND")))


;;;;;;; Make sparks

(defun sparkly-stats--make-sparks (type values args)
  "Make labeled sparks given TYPE and a list of VALUES.
ARGS is a list of `sparks-vs' arguments."
  (let* ((vls (if (eq type 'fr) (sparkly-stats--rng->frq values) values))
         (spk (apply #'sparkly-vs vls args))
         (lbl (sparkly-stats--make-labels type values args)))
    (if (s-blank? lbl) spk (format "%s\n%s" spk lbl))))

(defun sparkly-stats--rng->frq (values)
  "From date range VALUES to frequency values."
  (let ((cnt-ht (h-count<-list values))
        (counts (number-sequence (-min values) (-max values))))
    (--map (h-get cnt-ht it 0) counts)))


;;;;;; Statistics

(defun sparkly-stats--make-stats (values)
  "Return plist of stats given list of VALUES."
  (list  :sum (-sum values)  :mod (sparkly-stats--mode    values)
         :min (-min values)  :med (sparkly-stats--median  values)
         :max (-max values)  :avg (sparkly-stats--average values)))

(defun sparkly-stats--average (list &optional precision)
  "Return the average of LIST of numbers.
PRECISION is an integer representing the number of decimal digits
to round to. When nil, default to no more than 2."
  (sparkly-stats--roundf (/ (apply #'+ list) (length list) 1.0)
                         (or precision 2)))

(defun sparkly-stats--median (list)
  "Return the median of LIST of numbers."
  (let ((len (length list))
        (lis (-sort #'< list)))
    (if (= 0 (% len 2))
        (let* ((1st (elt lis     (/ len 2)))
               (2nd (elt lis (1- (/ len 2))))
               (sum (+ 1st 2nd))
               (avg (/ sum 2.0)))
          (if (and (integerp 1st)
                   (integerp 2nd)
                   (= 0 (% sum 2)))
              (round avg)
            (sparkly-stats--roundf avg 2)))
      (elt lis (/ (1- len) 2)))))

(defun sparkly-stats--mode (list)
  "Return the mode(s) of LIST.
If multimodal, return a list of modes. Otherwise, the mode."
  (let* ((hcount (h-count<-list list))
         (maxval (->> hcount  h-values  -max))
         (modes  (->> hcount  (h--sel (= value maxval))  h-keys)))
    (if (cdr modes) (-sort #'< modes) (car modes))))

(defun sparkly-stats--roundf (number precision)
  "Round a NUMBER to a maximum PRECISION of decimal digits.
If NUMBER is an integer, return it as float with one decimal digit.
If a float, round it to no more decimal digits than PRECISION."
  (let ((pow10 (expt 10 precision)))
    (-> number  (* pow10)  fround  (/ pow10))))

(defun sparkly-stats--num-digits (int)
  "Return the number of digits of integer INT."
  (if (= 0 int) 1 (-> int  abs  (log 10)  floor  1+)))


;;;;;; Date operations

(defun sparkly-stats--date-range-length (beg end)
  "How many days are there from ISO dates BEG to END?
Notice that it's 1+ the difference: the number of elements in the
sequence going from BEG to END."
  (1+ (abs (days-between beg end))))

(defun sparkly-stats--date-add-days (beg days)
  "Add DAYS to ISO date BEG."
  (format-time-string
   "%F" (-> beg  date-to-day  (+ days)  (- (time-to-days 0))  days-to-time)))

(defun sparkly-stats--date-as-org (date)
  "Return date in org timestamp format."
  (cond ((string-match-p org-ts-regexp-both date) date)
        ((string-match-p sparkly-stats--date-iso-rx date)
         (sparkly-stats--date-iso-to-org date))
        (:otherwise (error "Invalid date format: %s" date))))

(defun sparkly-stats--date-as-iso (date)
  "Return date in ISO 8601 format."
  (cond ((string-match-p sparkly-stats--date-iso-rx date) date)
        ((string-match-p org-ts-regexp-both date)
         (sparkly-stats--date-org-to-iso date))
        (:otherwise (error "Invalid date format: %s" date))))

(defun sparkly-stats--date-org-to-iso (date)
  "Convert org timestamp DATE to ISO 8601."
  (substring date 1 11))

(defun sparkly-stats--date-iso-to-org (date)
  "Convert ISO 8601 DATE to org timestamp format."
  (format "[%s %s]" date (sparkly-stats--date-fmt "%a" date)))

(defun sparkly-stats--date-fmt (fmt date)
  "Convert ISO 8601 DATE to another format"
  (format-time-string fmt (date-to-time date)))


;;;;;; String operations

(defun sparkly-stats-string-paste (sep &rest strings)
  "Paste STRINGS side-by-side separated by string SEP.
Unlike the UNIX paste command, the strings are aligned by their
respective last (rather than first) lines."
  (--reduce-r (sparkly-stats--string-paste-2 sep it acc) strings))

(defun sparkly-stats--string-paste-2 (sep s1 s2)
  "Helper for `sparkly-stats-string-paste', which see."
  (let* ((ls1 (s-lines s1))
         (ls2 (s-lines s2))
         (ln1 (length ls1))
         (ln2 (length ls2))
         (dlt (- ln1 ln2))
         (ls1 `(,@(make-list (if (> dlt 0) 0 (- dlt)) "") ,@ls1))
         (ls2 `(,@(make-list (if (> dlt 0) dlt 0)     "") ,@ls2))
         (len (-map #'length ls1))
         (mxw (-max len))
         st1 st2 spc)
    (s-chomp
     (with-output-to-string
       (dotimes (i (max ln1 ln2))
         (setq st1 (nth i ls1)
               st2 (nth i ls2)
               spc (if (< i dlt) "" (concat
                                     (make-vector (- mxw (nth i len)) ?\s)
                                     sep)))
         (princ (format "%s%s%s\n" st1 spc st2)))))))


;;;;; See README

;;;###autoload
(defun sparkly-stats-see-readme (&optional heading narrow)
  "Open sparkly-stats's README.org file.
Search for the file in sparkly-stats.el's directory.

If found, open it read-only.

If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.

If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found."
  (interactive)
  (let ((readme sparkly-stats--readme-org))
    (if (file-exists-p readme)
        (let ((pr (make-progress-reporter
                   (format "Opening %s ... "
                           (abbreviate-file-name readme)))))
          (find-file-read-only readme)
          (when heading
            (sparkly-stats--goto-org-heading heading narrow))
          (progress-reporter-done pr))
      (message "Couldn't find %s's README.org" sparkly-stats--name))))

;;;###autoload
(defun sparkly-stats-see-news ()
  "See the News in sparkly-stats's README.org file."
  (interactive)
  (sparkly-stats-see-readme "News" 'narrow)
  (sparkly-stats--display-org-subtree))

(defun sparkly-stats--display-org-subtree ()
  "Selectively display org subtree."
  (let ((cmds '( outline-hide-subtree outline-show-children
                 outline-next-heading outline-show-branches)))
    (and (equal (mapcar #'fboundp cmds) '(t t t t))
         (mapc #'funcall cmds))))

(defun sparkly-stats--goto-org-heading (heading &optional narrow)
  "Navigate to org HEADING and optionally NARROW to it."
  (let* ((hrx (format "^[*]+ %s" heading))
         (pos (save-match-data
                (save-excursion
                  (save-restriction
                    (widen)  (goto-char (point-max))
                    (re-search-backward hrx nil t 1))))))
    (when pos
      (widen)  (goto-char pos)
      (if (and narrow (fboundp 'org-narrow-to-subtree))
          (org-narrow-to-subtree)
        (recenter-top-bottom 1))
      (when (fboundp 'outline-show-subtree)
        (outline-show-subtree))
      (when (fboundp 'org-flag-drawer)
        (save-excursion
          (forward-line 1)
          (org-flag-drawer t))))))


;;;; Wrapping up

(provide 'sparkly-stats)

;; Local Variables:
;; coding:                     utf-8
;; indent-tabs-mode:           nil
;; sentence-end-double-space:  nil
;; outline-regexp:             ";;;;* "
;; End:

;;; sparkly-stats.el ends here
📆 2025-01-10