From 27385697fbf60e3319a598bb509197f5579c9405 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 31 May 2026 10:46:18 -0500 Subject: feat: add statistics dashboard CSV export and docs org-drill-statistics-export-csv, bound to e in the dashboard and now implemented, writes three files into a chosen directory honoring the active scope and range: sessions.csv (one row per recorded session), cards.csv (one row per drill card in scope with its scheduling properties and computed status), and daily.csv (per-day reviews, passes, fails, pass percent, and duration). Fields are quoted per RFC 4180 by a small csv-quote helper, since csv-mode isn't a dependency. This is the dashboard's last piece, step 3 of the spec. The row builders for the three views are pure and unit-tested with deterministic fixtures, and csv-quote covers the comma, quote, and newline cases. I documented the dashboard and the export in org-drill.org (a new section with the keymap, the CSV columns, and the settings table) and added a feature bullet to the README. I also removed the now-redundant declare-function forward reference for export-csv. It named this file as the source, so once the real defun landed the byte-compiler counted the function twice and warned. --- README.org | 1 + org-drill.el | 183 +++++++++++++++++++++++++++++- org-drill.org | 56 +++++++++ tests/test-org-drill-statistics-export.el | 180 +++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 tests/test-org-drill-statistics-export.el 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 -- cgit v1.2.3