aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org1
-rw-r--r--org-drill.el183
-rw-r--r--org-drill.org56
-rw-r--r--tests/test-org-drill-statistics-export.el180
4 files changed, 419 insertions, 1 deletions
diff --git a/README.org b/README.org
index 5ce0229..0f928cf 100644
--- a/README.org
+++ b/README.org
@@ -46,6 +46,7 @@ If you've been hitting any of those, this fork should help.
- Cloze deletion with optional hints (=[hidden||hint]=)
- Multi-file decks via Org's agenda-files mechanism, or per-directory scope
- Session controls: maximum duration, maximum items per session, leech detection
+- Statistics dashboard (=M-x org-drill-statistics=): overview, daily/weekly trends with sparklines, quality histogram, a needs-attention view (leeches, long-overdue, forgotten-new), and a 7-day forecast, with CSV export
- LaTeX preview, inline images, and the rest of the Org rendering machinery just work inside drill sessions
** Installation
diff --git a/org-drill.el b/org-drill.el
index ac7e03b..4a8262c 100644
--- a/org-drill.el
+++ b/org-drill.el
@@ -5571,7 +5571,6 @@ then carries a short note in place of the table."
;; the integration notes for the exact contract each renderer is expected
;; to honor.
-(declare-function org-drill-statistics-export-csv "org-drill")
(declare-function org-drill-statistics--render-overview "org-drill")
(declare-function org-drill-statistics--render-trends "org-drill")
(declare-function org-drill-statistics--render-distribution "org-drill")
@@ -5796,5 +5795,187 @@ no records the filter stays at all-algorithms. Bound to a."
(org-drill-statistics-refresh)
(message "Algorithm: %s" (or next "all"))))
+;;; CSV export for the statistics dashboard (step 3).
+;;
+;; The row builders are pure: they turn the session log and the scope's
+;; drill entries into lists of field strings. `--write-csv' renders those
+;; through `--csv-row' and writes a file. The command threads the active
+;; dashboard filters (scope, range, algorithm) into all three views.
+
+(defun org-drill-statistics--csv-quote (field)
+ "Return FIELD as a CSV field string, quoted when required (RFC 4180).
+FIELD is coerced to a string with `format' when it is not already one.
+It is wrapped in double quotes when it contains a comma, a double quote,
+a carriage return, or a newline, and any embedded double quote is doubled."
+ (let ((s (if (stringp field) field (format "%s" field))))
+ (if (string-match-p "[\",\r\n]" s)
+ (concat "\"" (replace-regexp-in-string "\"" "\"\"" s) "\"")
+ s)))
+
+(defun org-drill-statistics--csv-row (fields)
+ "Join FIELDS into one CSV record line, quoting each field as needed.
+FIELDS is a list; each element passes through `org-drill-statistics--csv-quote'.
+No trailing newline is added."
+ (mapconcat #'org-drill-statistics--csv-quote fields ","))
+
+(defun org-drill-statistics--write-csv (path header rows)
+ "Write HEADER then ROWS as CSV to PATH.
+HEADER is a list of column names. ROWS is a list of field-lists. Each is
+rendered with `org-drill-statistics--csv-row' and terminated by a newline."
+ (with-temp-file path
+ (insert (org-drill-statistics--csv-row header) "\n")
+ (dolist (row rows)
+ (insert (org-drill-statistics--csv-row row) "\n"))))
+
+(defconst org-drill-statistics--sessions-csv-header
+ '("start" "end" "scope" "algorithm" "qualities" "pass_percent"
+ "new" "mature" "failed" "cram")
+ "Column header for sessions.csv, tracking the session-record slots.")
+
+(defun org-drill-statistics--session-row (record)
+ "Return RECORD as a list of CSV field strings for sessions.csv.
+RECORD is an `org-drill-session-record'. Start and end times render as
+local \"YYYY-MM-DD HH:MM:SS\"; the qualities vector renders space-joined."
+ (list
+ (format-time-string
+ "%Y-%m-%d %H:%M:%S"
+ (seconds-to-time (org-drill-session-record-start-time record)))
+ (format-time-string
+ "%Y-%m-%d %H:%M:%S"
+ (seconds-to-time (org-drill-session-record-end-time record)))
+ (format "%s" (org-drill-session-record-scope record))
+ (format "%s" (org-drill-session-record-algorithm record))
+ (mapconcat #'number-to-string
+ (append (or (org-drill-session-record-qualities record) []) nil)
+ " ")
+ (number-to-string (org-drill-session-record-pass-percent record))
+ (number-to-string (org-drill-session-record-new-count record))
+ (number-to-string (org-drill-session-record-mature-count record))
+ (number-to-string (org-drill-session-record-failed-count record))
+ (if (org-drill-session-record-cram-mode record) "t" "nil")))
+
+(defun org-drill-statistics--sessions-rows (log)
+ "Return one CSV field-list per record in LOG, preserving LOG order."
+ (mapcar #'org-drill-statistics--session-row log))
+
+(defconst org-drill-statistics--cards-csv-header
+ '("heading" "last_interval" "repeats_since_fail" "total_repeats"
+ "failure_count" "average_quality" "ease" "last_quality"
+ "last_reviewed" "status")
+ "Column header for cards.csv.")
+
+(defun org-drill-statistics--card-row (session)
+ "Return the drill entry at point as a list of CSV fields for cards.csv.
+SESSION is a transient `org-drill-session' used only to classify status.
+A missing property renders as the empty string."
+ (list
+ (org-get-heading t t t t)
+ (or (org-entry-get (point) "DRILL_LAST_INTERVAL") "")
+ (or (org-entry-get (point) "DRILL_REPEATS_SINCE_FAIL") "")
+ (or (org-entry-get (point) "DRILL_TOTAL_REPEATS") "")
+ (or (org-entry-get (point) "DRILL_FAILURE_COUNT") "")
+ (or (org-entry-get (point) "DRILL_AVERAGE_QUALITY") "")
+ (or (org-entry-get (point) "DRILL_EASE") "")
+ (or (org-entry-get (point) "DRILL_LAST_QUALITY") "")
+ (or (org-entry-get (point) "DRILL_LAST_REVIEWED") "")
+ (format "%s" (or (car (org-drill-entry-status session)) ""))))
+
+(defun org-drill-statistics--cards-rows (&optional scope)
+ "Return one CSV field-list per drill entry in SCOPE for cards.csv.
+SCOPE is a value understood by `org-drill-current-scope'; nil uses the
+current `org-drill-scope'. A fresh non-cram session classifies status."
+ (let ((session (org-drill-session))
+ (rows nil))
+ (org-drill-map-entries
+ (lambda () (push (org-drill-statistics--card-row session) rows))
+ scope)
+ (nreverse rows)))
+
+(defconst org-drill-statistics--daily-csv-header
+ '("date" "reviews" "passes" "fails" "pass_percent" "duration_min")
+ "Column header for daily.csv.")
+
+(defun org-drill-statistics--daily-rows (log &optional days)
+ "Return one CSV field-list per day over the last DAYS for daily.csv.
+LOG is a list of `org-drill-session-record'. DAYS defaults to
+`org-drill-statistics-trend-days'; a non-positive value is clamped to 1.
+Rows are oldest-first, one per day, the final row being today. Each row
+is (DATE REVIEWS PASSES FAILS PASS_PERCENT DURATION_MIN). A pass is a
+quality above `org-drill-failure-quality'. Empty days report zeros."
+ (let* ((days (max 1 (or days org-drill-statistics-trend-days)))
+ (today (org-drill-statistics--today-day))
+ (start-day (- today (1- days)))
+ (reviews (make-vector days 0))
+ (passes (make-vector days 0))
+ (fails (make-vector days 0))
+ (duration (make-vector days 0.0)))
+ (dolist (record log)
+ (let* ((day (org-drill-statistics--record-day record))
+ (idx (- day start-day)))
+ (when (and (>= idx 0) (< idx days))
+ (let ((qs (org-drill-session-record-qualities record)))
+ (when qs
+ (mapc
+ (lambda (q)
+ (aset reviews idx (1+ (aref reviews idx)))
+ (if (> q org-drill-failure-quality)
+ (aset passes idx (1+ (aref passes idx)))
+ (aset fails idx (1+ (aref fails idx)))))
+ qs)))
+ (aset duration idx
+ (+ (aref duration idx)
+ (/ (- (org-drill-session-record-end-time record)
+ (org-drill-session-record-start-time record))
+ 60.0))))))
+ (let ((rows nil))
+ (dotimes (i days)
+ (let* ((g (calendar-gregorian-from-absolute (+ start-day i)))
+ (date (format "%04d-%02d-%02d" (nth 2 g) (nth 0 g) (nth 1 g)))
+ (r (aref reviews i)))
+ (push (list date
+ (number-to-string r)
+ (number-to-string (aref passes i))
+ (number-to-string (aref fails i))
+ (number-to-string
+ (if (> r 0)
+ (round (* 100.0 (/ (float (aref passes i)) r)))
+ 0))
+ (number-to-string (round (aref duration i))))
+ rows)))
+ (nreverse rows))))
+
+;;;###autoload
+(defun org-drill-statistics-export-csv (directory)
+ "Export the statistics views to CSV files in DIRECTORY.
+Writes sessions.csv (one row per session record), cards.csv (one row per
+drill entry in the active scope), and daily.csv (one row per day in the
+active range). When called from the dashboard buffer the active
+scope/range/algorithm filters apply; otherwise the buffer-local defaults
+do. Interactively, prompts for DIRECTORY."
+ (interactive
+ (list (read-directory-name "Export statistics CSV to directory: ")))
+ (let* ((scope org-drill-statistics--scope)
+ (range (or org-drill-statistics--range
+ (caar org-drill-statistics-range-presets)))
+ (algorithm org-drill-statistics--algorithm)
+ (log (org-drill-statistics--filtered-log range algorithm))
+ (days (cdr (assoc range org-drill-statistics-range-presets))))
+ (make-directory directory t)
+ (org-drill-statistics--write-csv
+ (expand-file-name "sessions.csv" directory)
+ org-drill-statistics--sessions-csv-header
+ (org-drill-statistics--sessions-rows log))
+ (org-drill-statistics--write-csv
+ (expand-file-name "cards.csv" directory)
+ org-drill-statistics--cards-csv-header
+ (org-drill-statistics--cards-rows scope))
+ (org-drill-statistics--write-csv
+ (expand-file-name "daily.csv" directory)
+ org-drill-statistics--daily-csv-header
+ (org-drill-statistics--daily-rows log days))
+ (when (called-interactively-p 'interactive)
+ (message "Exported sessions.csv, cards.csv, daily.csv to %s" directory))
+ directory))
+
(provide 'org-drill)
;;; org-drill.el ends here
diff --git a/org-drill.org b/org-drill.org
index 34092d0..a795513 100644
--- a/org-drill.org
+++ b/org-drill.org
@@ -510,6 +510,62 @@ card.
See [[http://www.supermemo.com/help/leech.htm][the SuperMemo website]] for more on leeches.
+* Statistics dashboard
+
+
+=M-x org-drill-statistics= opens a read-only dashboard summarising your drill
+history and the current state of your cards. It reads the session log that
+org-drill records at the end of each completed (non-aborted) session, plus the
+=DRILL_*= properties on the cards in scope. The buffer has five sections:
+
+1. *Overview* — total / new / mature / lapsed card counts, and a one-line recap
+ of your most recent session.
+2. *Trends* — reviews-per-day and pass-rate-per-day sparklines (quadrant blocks
+ =▁▂▃▄▅▆▇█=) over the trend window, plus a table of the last 12 weeks.
+3. *Distribution* — a histogram of recall qualities 0–5 across the log.
+4. *Needs attention* — three tables: leech candidates, long-overdue cards, and
+ "forgotten new" cards (added a while ago but never reviewed). Each row links
+ to the card.
+5. *Forecast* — how many cards are scheduled for each of the next seven days.
+
+A filter line at the top shows the active scope, range, and algorithm. The
+dashboard keys are:
+
+| Key | Action |
+|-------+------------------------------------------------|
+| =q= | bury the dashboard |
+| =g= | refresh |
+| =s= | cycle the scope filter |
+| =r= | cycle the range (last 90d / 30d / 7d / all) |
+| =a= | cycle the algorithm filter |
+| =e= | export the current view to CSV |
+| =RET= | follow the card link at point |
+
+** Exporting to CSV
+
+=e= in the dashboard (or =M-x org-drill-statistics-export-csv=) writes three
+files into a directory you choose, honouring the active scope and range:
+
+- =sessions.csv= — one row per recorded session (start, end, scope, algorithm,
+ the quality vector, pass percent, the card counts).
+- =cards.csv= — one row per drill card in scope, with its scheduling
+ properties and computed status.
+- =daily.csv= — one row per day in the range: reviews, passes, fails, pass
+ percent, and total duration in minutes.
+
+Fields are quoted per RFC 4180, so values containing commas or quotes survive a
+round-trip through a spreadsheet. For TSV, pipe the output through =sed=.
+
+** Dashboard settings
+
+| Variable | Default | Meaning |
+|---------------------------------------------+---------+----------------------------------------|
+| =org-drill-statistics-trend-days= | 90 | days the trend section spans |
+| =org-drill-statistics-forecast-days= | 7 | days the forecast section spans |
+| =org-drill-statistics-attention-row-limit= | 10 | max rows per needs-attention table |
+| =org-drill-statistics-leech-quality-threshold= | 2.5 | average-quality ceiling for a leech |
+
+
* Customisation
diff --git a/tests/test-org-drill-statistics-export.el b/tests/test-org-drill-statistics-export.el
new file mode 100644
index 0000000..849f439
--- /dev/null
+++ b/tests/test-org-drill-statistics-export.el
@@ -0,0 +1,180 @@
+;;; test-org-drill-statistics-export.el --- Tests for stats CSV export -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Step 3 of the statistics dashboard: `org-drill-statistics-export-csv'
+;; writes sessions.csv, cards.csv, and daily.csv. The row builders are pure
+;; and tested with deterministic fixtures here; the command itself is exercised
+;; against a temp directory.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+(require 'cl-lib)
+
+;;;; Fixtures
+
+(defun test-org-drill-stats-export--record (day-offset qualities duration-min
+ &optional algorithm)
+ "Build a session record DAY-OFFSET days before now.
+QUALITIES is a list of ints, DURATION-MIN the session length in minutes,
+ALGORITHM a symbol (default simple8)."
+ (let* ((start (- (float-time) (* day-offset 86400.0)))
+ (end (+ start (* duration-min 60.0)))
+ (qv (vconcat qualities))
+ (passes (cl-count-if (lambda (q) (> q org-drill-failure-quality)) qv)))
+ (make-org-drill-session-record
+ :start-time start :end-time end :scope 'directory
+ :algorithm (or algorithm 'simple8)
+ :qualities qv
+ :pass-percent (if (> (length qv) 0)
+ (round (* 100.0 (/ (float passes) (length qv)))) 0)
+ :new-count 0 :mature-count 0 :failed-count 0 :cram-mode nil)))
+
+;;;; csv-quote
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-plain ()
+ "A field with no special characters is returned unchanged."
+ (should (equal (org-drill-statistics--csv-quote "hello") "hello")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-comma ()
+ "A field containing a comma is wrapped in double quotes."
+ (should (equal (org-drill-statistics--csv-quote "a,b") "\"a,b\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-doubles-quotes ()
+ "Embedded double quotes are doubled and the field is wrapped."
+ (should (equal (org-drill-statistics--csv-quote "say \"hi\"")
+ "\"say \"\"hi\"\"\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-newline ()
+ "A field containing a newline is wrapped in double quotes."
+ (should (equal (org-drill-statistics--csv-quote "a\nb") "\"a\nb\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-coerces-non-string ()
+ "A non-string field is coerced to its printed form."
+ (should (equal (org-drill-statistics--csv-quote 42) "42")))
+
+;;;; csv-row
+
+(ert-deftest test-org-drill-statistics-export-csv-row-joins-and-quotes ()
+ "A row joins fields with commas, quoting only those that need it."
+ (should (equal (org-drill-statistics--csv-row '("a" "b,c" 7))
+ "a,\"b,c\",7")))
+
+;;;; sessions rows
+
+(ert-deftest test-org-drill-statistics-export-session-row-fields ()
+ "A session row carries the counts, the space-joined qualities, and cram."
+ (let* ((rec (test-org-drill-stats-export--record 0 '(5 4 1) 6 'sm5))
+ (row (org-drill-statistics--session-row rec)))
+ ;; qualities column is space-joined
+ (should (member "5 4 1" row))
+ ;; algorithm and cram render as printed symbols
+ (should (member "sm5" row))
+ (should (member "nil" row))
+ ;; pass-percent for 2 of 3 passing is 67
+ (should (member "67" row))))
+
+(ert-deftest test-org-drill-statistics-export-sessions-rows-one-per-record ()
+ "One row per record in the log."
+ (let ((log (list (test-org-drill-stats-export--record 0 '(5) 1)
+ (test-org-drill-stats-export--record 1 '(2) 1))))
+ (should (= (length (org-drill-statistics--sessions-rows log)) 2))))
+
+;;;; daily rows
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-length ()
+ "Daily rows have one entry per day in the window."
+ (let ((log (list (test-org-drill-stats-export--record 0 '(5) 1))))
+ (should (= (length (org-drill-statistics--daily-rows log 7)) 7))))
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-today-aggregates ()
+ "Today's row sums reviews, passes, fails, and duration across its records."
+ ;; failure-quality default 2: 5 and 4 pass, 1 fails.
+ (let* ((log (list (test-org-drill-stats-export--record 0 '(5 4 1) 6)))
+ (rows (org-drill-statistics--daily-rows log 7))
+ (today (car (last rows)))) ; oldest-first, so today is last
+ ;; row shape: (date reviews passes fails pass% duration)
+ (should (equal (nth 1 today) "3")) ; reviews
+ (should (equal (nth 2 today) "2")) ; passes
+ (should (equal (nth 3 today) "1")) ; fails
+ (should (equal (nth 4 today) "67")) ; pass percent
+ (should (equal (nth 5 today) "6")))) ; duration minutes
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-empty-day-zeros ()
+ "A day with no records reports zero reviews and a zero pass percent."
+ (let* ((log (list (test-org-drill-stats-export--record 0 '(5) 1)))
+ (rows (org-drill-statistics--daily-rows log 7))
+ (yesterday (nth (- (length rows) 2) rows)))
+ (should (equal (nth 1 yesterday) "0"))
+ (should (equal (nth 2 yesterday) "0"))))
+
+;;;; cards rows
+
+(ert-deftest test-org-drill-statistics-export-cards-rows-from-scope ()
+ "Cards rows carry the heading, scheduling props, and computed status."
+ (with-temp-buffer
+ (insert "* Capital of France :drill:\n"
+ ":PROPERTIES:\n"
+ ":DRILL_TOTAL_REPEATS: 4\n"
+ ":DRILL_LAST_QUALITY: 5\n"
+ ":END:\n"
+ "Paris\n")
+ (org-mode)
+ (let* ((org-drill-question-tag "drill")
+ (rows (org-drill-statistics--cards-rows 'file)))
+ (should (= (length rows) 1))
+ (let ((row (car rows)))
+ (should (member "Capital of France" row))
+ (should (member "4" row)) ; DRILL_TOTAL_REPEATS
+ (should (member "5" row)))))) ; DRILL_LAST_QUALITY
+
+;;;; export command
+
+(ert-deftest test-org-drill-statistics-export-writes-three-files ()
+ "The export command writes sessions.csv, cards.csv, and daily.csv."
+ (let ((dir (make-temp-file "org-drill-stats-export" t)))
+ (unwind-protect
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (let ((org-drill-question-tag "drill")
+ (org-drill-scope 'file)
+ (org-drill-session-log
+ (list (test-org-drill-stats-export--record 0 '(5 4) 3)
+ (test-org-drill-stats-export--record 1 '(1) 2))))
+ (org-drill-statistics-export-csv dir)))
+ nil)
+ (unwind-protect
+ (dolist (name '("sessions.csv" "cards.csv" "daily.csv"))
+ (let ((path (expand-file-name name dir)))
+ (should (file-exists-p path))
+ ;; non-empty: at least a header line
+ (should (> (nth 7 (file-attributes path)) 0))))
+ (delete-directory dir t))))
+
+(ert-deftest test-org-drill-statistics-export-sessions-file-has-header-and-rows ()
+ "sessions.csv starts with the header and has one line per record."
+ (let ((dir (make-temp-file "org-drill-stats-export" t)))
+ (unwind-protect
+ (progn
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (let ((org-drill-question-tag "drill")
+ (org-drill-scope 'file)
+ (org-drill-session-log
+ (list (test-org-drill-stats-export--record 0 '(5 4) 3)
+ (test-org-drill-stats-export--record 1 '(1) 2))))
+ (org-drill-statistics-export-csv dir)))
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name "sessions.csv" dir))
+ (let ((lines (split-string (buffer-string) "\n" t)))
+ ;; header + 2 data rows
+ (should (= (length lines) 3))
+ (should (string-match-p "pass_percent" (car lines))))))
+ (delete-directory dir t))))
+
+(provide 'test-org-drill-statistics-export)
+
+;;; test-org-drill-statistics-export.el ends here