aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 01:36:08 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 01:36:08 -0500
commite373ba9e6dc1b288946ce57e3f29bf5372126dee (patch)
tree8622f2ffc05d197875b74aad6d18e3436df461e1
parenta666ed263ccd30666f33be43852644e8246ab94e (diff)
downloadorg-drill-e373ba9e6dc1b288946ce57e3f29bf5372126dee.tar.gz
org-drill-e373ba9e6dc1b288946ce57e3f29bf5372126dee.zip
feat: persist a session log for the stats dashboard
I added the persist + recording layer that the future stats dashboard reads, plus the v0 design spec at working/stats-dashboard/stats-dashboard.org that scoped the work. Every completed (non-suspended) drill session now appends one org-drill-session-record to a persisted org-drill-session-log via persist-defvar, mirroring the SM5-matrix pattern that's already in place. A new sibling defgroup org-drill-statistics groups the dashboard's customs. The record carries: start-time, end-time, scope, algorithm, qualities (as a vector), pass-percent, new-count, mature-count, failed-count, cram-mode. Scope and algorithm are captured at session-start time onto two new org-drill-session slots (scope-at-start, algorithm-at-start) so a mid-session defcustom flip doesn't misrepresent what was actually drilled. Both org-drill--prepare-fresh-session and the org-drill-again resume path populate the slots. I extracted pass-percent into org-drill--compute-pass-percent and call it from both org-drill-final-report and org-drill-session-record-from-session, so the user-visible report and the persisted record can't drift on the rounding or the failure-quality threshold. I wrapped recording in condition-case at the call site, not ignore-errors. Any recorder bug or persist-save IO failure gets messaged so silent data loss leaves a forensic trail. Corrupt-load recovery follows the SM5 path's condition-case fallback and adds an org-drill--session-log-quarantine helper that renames the bad file to a .corrupt-YYYY-MM-DDTHHMMSS sibling so the next save doesn't overwrite it. The seconds-granularity suffix prevents a same-day double corruption from clobbering the earlier quarantine. The helper depends on persist--file-location, an internal symbol guarded behind fboundp and documented inline. The spec at working/stats-dashboard/stats-dashboard.org is ratified, with all 10 originally-open decisions resolved in the Ratified Decisions table at the bottom: persist-defvar, warn-and-rename corrupt-load recovery, quadrant-block sparklines, a single buffer-wide filter, CSV with proper quoting, sync dashboard open, defer aborted-session recording, the q/g/e/s/r/a/RET keymap, leech-quality threshold default 2.5, and a sibling org-drill-statistics defcustom group. The remaining work (dashboard renderer, CSV export, docs) lands in follow-up commits. I followed TDD throughout. 24 tests in tests/test-org-drill-session-record.el cover struct construction, the shared pass-percent helper, the builder including scope/algorithm capture and mature-count summing, log appending with newest-first ordering and persist-save call-through, the symbol-bound smoke check, the quarantine rename plus its seconds-granularity contract, and the end-message hook (records on normal completion, skips on suspend, logs to message on recorder error). Full make test-unit green. eask compile clean.
-rw-r--r--org-drill.el152
-rw-r--r--tests/test-org-drill-session-record.el407
-rw-r--r--working/stats-dashboard/stats-dashboard.org419
3 files changed, 970 insertions, 8 deletions
diff --git a/org-drill.el b/org-drill.el
index 0066c3d..d8f32ac 100644
--- a/org-drill.el
+++ b/org-drill.el
@@ -84,6 +84,15 @@
:tag "Org-Drill Leech"
:group 'org-drill)
+(defgroup org-drill-statistics nil
+ "Persistent session log and the statistics dashboard.
+The dashboard is opt-in via \\='M-x org-drill-statistics\\='; the session log
+itself is updated automatically at the end of every completed (non-suspended)
+drill session. See working/stats-dashboard/stats-dashboard.org for the
+design rationale."
+ :tag "Org-Drill Statistics"
+ :group 'org-drill)
+
(defconst org-drill-version "2.7.0"
"Version of the org-drill package.
Keep this in sync with the Version header at the top of this file.")
@@ -557,6 +566,69 @@ pace of learning.")
(defvar org-drill-sm5-optimal-factor-matrix nil
"Persistent matrix of optimal factors (fallback after load failure).")))
+;; Statistics session log. Records one entry per completed drill session
+;; so the stats dashboard (working/stats-dashboard/stats-dashboard.org) has
+;; a temporal axis to render trends against. Same persist-defvar +
+;; condition-case pattern as the SM5 matrix above. See
+;; `org-drill--session-log-quarantine' for the corrupt-file recovery
+;; behavior — the dashboard's spec adds a rename-and-quarantine step on
+;; top of the SM5 path's fresh-start fallback.
+(cl-defstruct org-drill-session-record
+ "One completed drill session, persisted to `org-drill-session-log'.
+Slots match the v0 stats-dashboard spec; see
+working/stats-dashboard/stats-dashboard.org for field semantics."
+ start-time ; float — `float-time' at session start
+ end-time ; float — `float-time' at session end
+ scope ; symbol or list — `org-drill-scope' at start
+ algorithm ; symbol — `org-drill-spaced-repetition-algorithm' at start
+ qualities ; vector of int — every 0-5 quality entered, in order
+ pass-percent ; int — (count qualities > failure-quality) / total * 100
+ new-count ; int — new entries at session end
+ mature-count ; int — young-mature + old-mature entries at session end
+ failed-count ; int — failed entries at session end
+ cram-mode) ; bool — cram-mode value at session start
+
+(defun org-drill--session-log-quarantine ()
+ "Rename a corrupt `org-drill-session-log' persist file out of the way.
+The file is renamed to a `.corrupt-YYYY-MM-DDTHHMMSS' sibling so the
+next save doesn't overwrite a potentially recoverable file. The
+timestamp includes seconds so a repeat corruption on the same day does
+not silently overwrite the earlier quarantine. No-op if the file
+can't be located or doesn't exist.
+
+Locates the on-disk file via `persist--file-location', which is an
+internal symbol in the `persist' package. The `fboundp' gate keeps a
+future persist release that renames it from breaking
+package load — at the cost of becoming a silent no-op on that release,
+which would let the next save overwrite the corrupt file. Worth a
+follow-up if persist changes its API."
+ (when (fboundp 'persist--file-location)
+ (let ((file (ignore-errors
+ (persist--file-location 'org-drill-session-log))))
+ (when (and file (file-exists-p file))
+ (let ((quarantine (format "%s.corrupt-%s"
+ file
+ (format-time-string "%Y-%m-%dT%H%M%S"))))
+ (ignore-errors (rename-file file quarantine t))
+ (lwarn 'org-drill :warning
+ "Corrupt session-log file moved to %s; starting fresh."
+ quarantine))))))
+
+(condition-case err
+ (persist-defvar org-drill-session-log
+ nil
+ "List of `org-drill-session-record' values, newest first.
+Updated at the end of every completed (non-suspended) drill session by
+`org-drill-record-session'. Read by the stats dashboard
+(`org-drill-statistics', when implemented) to render trend panels.")
+ (error
+ (message
+ "org-drill: failed to load persisted session log (%s); using fresh state"
+ err)
+ (ignore-errors (org-drill--session-log-quarantine))
+ (defvar org-drill-session-log nil
+ "Persistent session log (fallback after load failure).")))
+
(defcustom org-drill-sm5-initial-interval
4.0
"In the SM5 algorithm, the initial interval after the first
@@ -732,6 +804,17 @@ or performing cleanup.")
:initform 0.0
:documentation "Time at which the session started"
:type float)
+ (scope-at-start
+ :initform nil
+ :documentation
+ "Value of `org-drill-scope' captured at session start. Stored on the
+stats-dashboard session record verbatim so a mid-session change to the
+defcustom doesn't misrepresent what was actually drilled.")
+ (algorithm-at-start
+ :initform nil
+ :documentation
+ "Value of `org-drill-spaced-repetition-algorithm' captured at session
+start. Same rationale as `scope-at-start'.")
(new-entries :initform nil)
(dormant-entry-count :initform 0)
(due-entry-count :initform 0)
@@ -3125,16 +3208,22 @@ order to make items appear more frequently over time."
(max 1 (+ (oref session dormant-entry-count)
(oref session due-entry-count))))))
+(defun org-drill--compute-pass-percent (qualities)
+ "Return the pass percentage for QUALITIES — count above
+`org-drill-failure-quality' over total, rounded. Empty QUALITIES yields 0
+(via `(max 1 ...)' on the denominator). Single source of truth shared by
+the end-of-session report and the dashboard session record."
+ (round (* 100 (cl-count-if (lambda (qual)
+ (> qual org-drill-failure-quality))
+ qualities))
+ (max 1 (length qualities))))
+
(defun org-drill-final-report (session)
"Display the end-of-session summary for SESSION.
Reports how many items were reviewed, the pass percentage, and the
new/mature/failed counts."
(let* ((qualities (oref session qualities))
- (pass-percent
- (round (* 100 (cl-count-if (lambda (qual)
- (> qual org-drill-failure-quality))
- qualities))
- (max 1 (length qualities))))
+ (pass-percent (org-drill--compute-pass-percent qualities))
(prompt (org-drill--build-final-report-summary
session pass-percent qualities))
(max-mini-window-height 0.6))
@@ -3147,6 +3236,37 @@ new/mature/failed counts."
(read-char-exclusive
(org-drill--build-low-pass-warning session pass-percent)))))
+(defun org-drill-session-record-from-session (session start-time end-time)
+ "Build an `org-drill-session-record' from SESSION, START-TIME, END-TIME.
+
+START-TIME and END-TIME are floats (e.g. from `float-time'). The
+record's pass-percent comes from `org-drill--compute-pass-percent', the
+same helper `org-drill-final-report' uses, so the two stay in lockstep.
+Scope and algorithm are read from the session's start-of-session
+captures (`scope-at-start' / `algorithm-at-start') so a mid-session
+defcustom change doesn't misrepresent the recorded session."
+ (let ((qualities (oref session qualities)))
+ (make-org-drill-session-record
+ :start-time start-time
+ :end-time end-time
+ :scope (oref session scope-at-start)
+ :algorithm (oref session algorithm-at-start)
+ :qualities (apply #'vector qualities)
+ :pass-percent (org-drill--compute-pass-percent qualities)
+ :new-count (length (oref session new-entries))
+ :mature-count (+ (length (oref session young-mature-entries))
+ (length (oref session old-mature-entries)))
+ :failed-count (length (oref session failed-entries))
+ :cram-mode (oref session cram-mode))))
+
+(defun org-drill-record-session (session start-time end-time)
+ "Append a record for SESSION to `org-drill-session-log' and persist.
+START-TIME and END-TIME are floats. The new record lands at the head
+of the log (newest first), then `persist-save' commits the log to disk."
+ (push (org-drill-session-record-from-session session start-time end-time)
+ org-drill-session-log)
+ (persist-save 'org-drill-session-log))
+
(defun org-drill-free-markers (session markers)
"MARKERS is a list of markers, all of which will be freed (set to
point nowhere). Alternatively, MARKERS can be \\='t\\=', in which case
@@ -3364,7 +3484,9 @@ CRAM, if non-nil, starts the session in cram mode."
(oref session failed-entries) nil
(oref session again-entries) nil
(oref session undo-stack) nil
- (oref session start-time) (float-time (current-time))))
+ (oref session start-time) (float-time (current-time))
+ (oref session scope-at-start) org-drill-scope
+ (oref session algorithm-at-start) org-drill-spaced-repetition-algorithm))
(defun org-drill--collect-entries (session scope drill-match)
"Scan buffers in SCOPE for drill entries matching DRILL-MATCH.
@@ -3399,7 +3521,8 @@ sorts the overdue queue."
"Display the appropriate end-of-session message and side-effects.
If SESSION was suspended (end-pos is set), shows the resume hint.
Otherwise runs `org-drill-final-report', persists the SM5 matrix,
-and optionally saves buffers."
+records the session for the stats dashboard, and optionally saves
+buffers."
(cond
((oref session end-pos)
(when (markerp (oref session end-pos))
@@ -3411,6 +3534,17 @@ and optionally saves buffers."
(org-drill-final-report session)
(when (eql 'sm5 org-drill-spaced-repetition-algorithm)
(persist-save 'org-drill-sm5-optimal-factor-matrix))
+ ;; Recording is protective of the end-of-session flow: a recorder
+ ;; bug or a persist-save failure must not block the final-report
+ ;; dismissal or the SM5 save above. Trap the error and surface it
+ ;; via `message' so silent data loss leaves a forensic trail.
+ (condition-case err
+ (org-drill-record-session session
+ (oref session start-time)
+ (float-time (current-time)))
+ (error
+ (message "org-drill: failed to record session for the stats log (%s)"
+ err)))
(when org-drill-save-buffers-after-drill-sessions-p
(save-some-buffers))
(message "Drill session finished!")
@@ -3537,7 +3671,9 @@ scan will be performed."
(org-drill-free-markers session (oref session done-entries))
(if (markerp (oref session current-item))
(set-marker (oref session current-item) nil))
- (setf (oref session start-time) (float-time (current-time)))
+ (setf (oref session start-time) (float-time (current-time))
+ (oref session scope-at-start) org-drill-scope
+ (oref session algorithm-at-start) org-drill-spaced-repetition-algorithm)
(setf (oref session done-entries) nil
(oref session current-item) nil)
(org-drill scope drill-match t))
diff --git a/tests/test-org-drill-session-record.el b/tests/test-org-drill-session-record.el
new file mode 100644
index 0000000..561b84c
--- /dev/null
+++ b/tests/test-org-drill-session-record.el
@@ -0,0 +1,407 @@
+;;; test-org-drill-session-record.el --- Tests for the stats session log -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the persist + recording layer that powers the stats
+;; dashboard (see working/stats-dashboard/stats-dashboard.org).
+;;
+;; The contract:
+;;
+;; - Every completed (non-suspended) drill session contributes one
+;; `org-drill-session-record' to `org-drill-session-log'.
+;; - The log persists via `persist-defvar' between Emacs runs.
+;; - A corrupt persist file is renamed to a dated `.corrupt-...'
+;; sibling and the log starts fresh, matching the SM5-matrix
+;; recovery pattern in `test-org-drill-persist-recovery.el'.
+;; - Suspended sessions (end-pos set) do NOT record — the abort path
+;; discards, mirroring the `org-drill-on-timeout-action'
+;; `discard-current' semantics.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'persist)
+(require 'org-drill)
+
+;;;; Helpers
+
+(defun test-session-record--marker-at (pos)
+ "Return a marker pointing at POS (an integer)."
+ (let ((m (make-marker)))
+ (set-marker m pos)
+ m))
+
+(defmacro test-session-record--with-empty-log (&rest body)
+ "Run BODY with a fresh, empty `org-drill-session-log' and a stub
+`persist-save' so tests never touch the real persist file."
+ (declare (indent 0))
+ `(let ((org-drill-session-log nil))
+ (cl-letf (((symbol-function 'persist-save) #'ignore))
+ ,@body)))
+
+(cl-defun test-session-record--populated-session (&key qualities new mature failed
+ cram-mode
+ (scope-at-start 'file)
+ (algorithm-at-start 'simple8))
+ "Return an `org-drill-session' with QUALITIES (a list of 0-5 integers)
+and the requested entry counts. NEW / MATURE / FAILED are integers.
+SCOPE-AT-START and ALGORITHM-AT-START populate the session slots that
+`org-drill--prepare-fresh-session' would set in production; pass nil to
+exercise the not-prepared path."
+ (let ((session (org-drill-session)))
+ (oset session qualities qualities)
+ (oset session new-entries
+ (cl-loop for i from 1 to (or new 0)
+ collect (test-session-record--marker-at i)))
+ ;; Mature = young + old; we put all on `old-mature-entries' for
+ ;; the count assertion — sum is what the record stores.
+ (oset session old-mature-entries
+ (cl-loop for i from 1 to (or mature 0)
+ collect (test-session-record--marker-at i)))
+ (oset session failed-entries
+ (cl-loop for i from 1 to (or failed 0)
+ collect (test-session-record--marker-at i)))
+ (oset session cram-mode cram-mode)
+ (oset session scope-at-start scope-at-start)
+ (oset session algorithm-at-start algorithm-at-start)
+ session))
+
+;;;; A. Struct construction (Normal)
+
+(ert-deftest test-session-record-struct-construction-all-slots ()
+ "`make-org-drill-session-record' accepts every documented slot and
+the accessors read them back unchanged."
+ (let ((rec (make-org-drill-session-record
+ :start-time 1700000000.0
+ :end-time 1700001800.0
+ :scope 'file
+ :algorithm 'simple8
+ :qualities [5 4 3 2 1 0]
+ :pass-percent 50
+ :new-count 4
+ :mature-count 10
+ :failed-count 2
+ :cram-mode nil)))
+ (should (= 1700000000.0 (org-drill-session-record-start-time rec)))
+ (should (= 1700001800.0 (org-drill-session-record-end-time rec)))
+ (should (eq 'file (org-drill-session-record-scope rec)))
+ (should (eq 'simple8 (org-drill-session-record-algorithm rec)))
+ (should (equal [5 4 3 2 1 0] (org-drill-session-record-qualities rec)))
+ (should (= 50 (org-drill-session-record-pass-percent rec)))
+ (should (= 4 (org-drill-session-record-new-count rec)))
+ (should (= 10 (org-drill-session-record-mature-count rec)))
+ (should (= 2 (org-drill-session-record-failed-count rec)))
+ (should-not (org-drill-session-record-cram-mode rec))))
+
+;;;; B. Pass-percent (Normal / Boundary / Error)
+
+(ert-deftest test-compute-pass-percent-shared-helper-handles-empty ()
+ "`org-drill--compute-pass-percent' is the single source of truth shared
+by `org-drill-final-report' and the dashboard record. Empty qualities
+must yield 0 (no div-by-zero). Pinned here so future drift between
+the two consumers gets caught."
+ (let ((org-drill-failure-quality 2))
+ (should (= 0 (org-drill--compute-pass-percent nil)))
+ (should (= 0 (org-drill--compute-pass-percent '())))))
+
+(ert-deftest test-compute-pass-percent-shared-helper-rounds-mixed ()
+ "Helper rounds (count above failure-quality) / total * 100."
+ (let ((org-drill-failure-quality 2))
+ (should (= 60 (org-drill--compute-pass-percent '(5 4 3 2 1))))
+ (should (= 100 (org-drill--compute-pass-percent '(5 5 5))))
+ (should (= 0 (org-drill--compute-pass-percent '(0 1 2))))))
+
+
+
+(ert-deftest test-session-record-pass-percent-mixed-qualities ()
+ "Pass percent rounds (count of qualities > failure-quality) / total.
+
+With the default failure-quality of 2 and qualities (5 4 3 2 1), 3 of
+5 are above threshold => 60%."
+ (test-session-record--with-empty-log
+ (let* ((org-drill-failure-quality 2)
+ (session (test-session-record--populated-session
+ :qualities '(5 4 3 2 1)))
+ (rec (org-drill-session-record-from-session
+ session 0.0 1.0)))
+ (should (= 60 (org-drill-session-record-pass-percent rec))))))
+
+(ert-deftest test-session-record-pass-percent-all-pass ()
+ "Every quality above failure-quality => 100%."
+ (test-session-record--with-empty-log
+ (let* ((org-drill-failure-quality 2)
+ (session (test-session-record--populated-session
+ :qualities '(5 5 5 4 3)))
+ (rec (org-drill-session-record-from-session
+ session 0.0 1.0)))
+ (should (= 100 (org-drill-session-record-pass-percent rec))))))
+
+(ert-deftest test-session-record-pass-percent-all-fail ()
+ "Every quality at-or-below failure-quality => 0%."
+ (test-session-record--with-empty-log
+ (let* ((org-drill-failure-quality 2)
+ (session (test-session-record--populated-session
+ :qualities '(2 1 0 2 1)))
+ (rec (org-drill-session-record-from-session
+ session 0.0 1.0)))
+ (should (= 0 (org-drill-session-record-pass-percent rec))))))
+
+(ert-deftest test-session-record-pass-percent-empty-qualities-is-zero ()
+ "An empty qualities list must not divide by zero — returns 0%."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session :qualities nil))
+ (rec (org-drill-session-record-from-session
+ session 0.0 1.0)))
+ (should (= 0 (org-drill-session-record-pass-percent rec))))))
+
+;;;; C. Builder from session (Normal / Boundary)
+
+(ert-deftest test-session-record-from-session-copies-counts ()
+ "The builder reads new / mature / failed counts directly off the session."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session
+ :qualities '(4 4 4)
+ :new 7
+ :mature 12
+ :failed 1))
+ (rec (org-drill-session-record-from-session
+ session 100.0 200.0)))
+ (should (= 7 (org-drill-session-record-new-count rec)))
+ (should (= 12 (org-drill-session-record-mature-count rec)))
+ (should (= 1 (org-drill-session-record-failed-count rec))))))
+
+(ert-deftest test-session-record-mature-count-sums-young-and-old ()
+ "Mature count = young-mature-entries + old-mature-entries."
+ (test-session-record--with-empty-log
+ (let ((session (org-drill-session)))
+ (oset session qualities '(3))
+ (oset session young-mature-entries
+ (list (test-session-record--marker-at 1)
+ (test-session-record--marker-at 2)))
+ (oset session old-mature-entries
+ (list (test-session-record--marker-at 3)
+ (test-session-record--marker-at 4)
+ (test-session-record--marker-at 5)))
+ (let ((rec (org-drill-session-record-from-session session 0.0 1.0)))
+ (should (= 5 (org-drill-session-record-mature-count rec)))))))
+
+(ert-deftest test-session-record-records-cram-mode-flag ()
+ "The session's cram-mode value lands on the record."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session
+ :qualities '(4) :cram-mode t))
+ (rec (org-drill-session-record-from-session session 0.0 1.0)))
+ (should (org-drill-session-record-cram-mode rec)))))
+
+(ert-deftest test-session-record-stores-algorithm-captured-at-start ()
+ "The record carries the algorithm captured at session start
+(via the session's `algorithm-at-start' slot), not the global at
+record-build time. This protects against a mid-session defcustom flip
+misrepresenting what was actually drilled."
+ (test-session-record--with-empty-log
+ (let* ((org-drill-spaced-repetition-algorithm 'simple8) ; mid-session value
+ (session (test-session-record--populated-session
+ :qualities '(4)
+ :algorithm-at-start 'sm5)) ; captured at start
+ (rec (org-drill-session-record-from-session session 0.0 1.0)))
+ (should (eq 'sm5 (org-drill-session-record-algorithm rec))))))
+
+(ert-deftest test-session-record-stores-scope-captured-at-start ()
+ "The record carries the scope captured at session start, not the
+global at record-build time. Same rationale as the algorithm capture."
+ (test-session-record--with-empty-log
+ (let* ((org-drill-scope 'directory) ; mid-session value
+ (session (test-session-record--populated-session
+ :qualities '(3)
+ :scope-at-start 'file)) ; captured at start
+ (rec (org-drill-session-record-from-session session 0.0 1.0)))
+ (should (eq 'file (org-drill-session-record-scope rec))))))
+
+(ert-deftest test-session-record-stores-timestamps ()
+ "Start and end timestamps land on the record verbatim."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session :qualities '(3)))
+ (rec (org-drill-session-record-from-session
+ session 1234567890.0 1234571490.0)))
+ (should (= 1234567890.0 (org-drill-session-record-start-time rec)))
+ (should (= 1234571490.0 (org-drill-session-record-end-time rec))))))
+
+(ert-deftest test-session-record-qualities-stored-as-vector ()
+ "Qualities are stored as a vector (the spec's chosen shape), not a list."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session
+ :qualities '(5 4 3 2 1)))
+ (rec (org-drill-session-record-from-session session 0.0 1.0)))
+ (should (vectorp (org-drill-session-record-qualities rec)))
+ (should (equal [5 4 3 2 1]
+ (org-drill-session-record-qualities rec))))))
+
+;;;; D. Log append (Normal / Boundary)
+
+(ert-deftest test-record-session-prepends-newest-first ()
+ "`org-drill-record-session' adds the new record at the head of the log."
+ (test-session-record--with-empty-log
+ (let ((session (test-session-record--populated-session :qualities '(4))))
+ (org-drill-record-session session 0.0 1.0)
+ (org-drill-record-session session 10.0 11.0)
+ (should (= 2 (length org-drill-session-log)))
+ ;; Most-recent at head.
+ (should (= 10.0 (org-drill-session-record-start-time
+ (car org-drill-session-log)))))))
+
+(ert-deftest test-record-session-handles-empty-log ()
+ "First record on an empty log produces a single-element list."
+ (test-session-record--with-empty-log
+ (let ((session (test-session-record--populated-session :qualities '(4))))
+ (org-drill-record-session session 0.0 1.0)
+ (should (= 1 (length org-drill-session-log)))
+ (should (org-drill-session-record-p (car org-drill-session-log))))))
+
+(ert-deftest test-record-session-calls-persist-save ()
+ "`org-drill-record-session' calls `persist-save' on the log symbol."
+ (let ((org-drill-session-log nil)
+ (saved nil))
+ (cl-letf (((symbol-function 'persist-save)
+ (lambda (sym) (setq saved sym))))
+ (let ((session (test-session-record--populated-session :qualities '(4))))
+ (org-drill-record-session session 0.0 1.0))
+ (should (eq 'org-drill-session-log saved)))))
+
+;;;; E. Persist round-trip smoke check
+
+(ert-deftest test-session-log-symbol-is-bound ()
+ "After org-drill loads, `org-drill-session-log' is bound (either to the
+loaded value or, on persist failure, to the fallback nil — same recovery
+pattern as `org-drill-sm5-optimal-factor-matrix')."
+ (should (boundp 'org-drill-session-log)))
+
+;;;; F. Corrupt-load recovery (Error)
+
+(ert-deftest test-session-log-quarantine-renames-corrupt-file ()
+ "`org-drill--session-log-quarantine' renames the live persist file to a
+timestamped `.corrupt-...' sibling so the next save doesn't overwrite it.
+The suffix uses seconds granularity (YYYY-MM-DDTHHMMSS) so a same-day
+re-quarantine doesn't clobber the earlier one."
+ (let* ((tmp (make-temp-file "org-drill-session-log-test"))
+ (renamed-to nil))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "garbage"))
+ (cl-letf (((symbol-function 'persist--file-location)
+ (lambda (_sym) tmp))
+ ;; Capture the destination path so the cleanup branch
+ ;; can remove it regardless of the exact timestamp.
+ ((symbol-function 'rename-file)
+ (lambda (from to &rest _)
+ (setq renamed-to to)
+ ;; Honor the call so the side effect actually happens.
+ (copy-file from to t)
+ (delete-file from))))
+ (org-drill--session-log-quarantine))
+ (should-not (file-exists-p tmp))
+ (should renamed-to)
+ (should (file-exists-p renamed-to))
+ ;; Match the seconds-granularity suffix shape.
+ (should (string-match-p
+ "\\.corrupt-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{6\\}\\'"
+ renamed-to)))
+ (ignore-errors (delete-file tmp))
+ (when renamed-to (ignore-errors (delete-file renamed-to))))))
+
+(ert-deftest test-session-log-quarantine-uses-seconds-in-suffix ()
+ "The quarantine uses `format-time-string' with a seconds-granularity
+format, so a same-day second corruption gets a distinct suffix. This
+test pins the format string itself so a regression to date-only would
+fail loudly."
+ (let* ((tmp (make-temp-file "org-drill-session-log-test"))
+ (format-arg nil)
+ (renamed-to nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'persist--file-location)
+ (lambda (_sym) tmp))
+ ((symbol-function 'format-time-string)
+ (lambda (fmt &rest _)
+ (setq format-arg fmt)
+ "STAMP"))
+ ((symbol-function 'rename-file)
+ (lambda (_from to &rest _)
+ (setq renamed-to to))))
+ (with-temp-file tmp (insert "garbage"))
+ (org-drill--session-log-quarantine)
+ (should format-arg)
+ ;; Format string must include hours/minutes/seconds, not just date.
+ (should (string-match-p "%H" format-arg))
+ (should (string-match-p "%M" format-arg))
+ (should (string-match-p "%S" format-arg))
+ (should (string-suffix-p ".corrupt-STAMP" renamed-to)))
+ (ignore-errors (delete-file tmp)))))
+
+(ert-deftest test-session-log-quarantine-no-file-is-noop ()
+ "Quarantine on a missing persist file is a quiet no-op (no error)."
+ (let ((tmp (concat (make-temp-name "org-drill-no-such-file-") ".never")))
+ (should-not (file-exists-p tmp))
+ (cl-letf (((symbol-function 'persist--file-location)
+ (lambda (_sym) tmp)))
+ ;; Must not raise.
+ (org-drill--session-log-quarantine))))
+
+;;;; G. Hook integration
+
+(ert-deftest test-show-end-message-records-on-normal-completion ()
+ "A non-suspended session triggers `org-drill-record-session'."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session :qualities '(4 3 5)))
+ (recorded nil))
+ (oset session end-pos nil)
+ (cl-letf (((symbol-function 'org-drill-final-report) #'ignore)
+ ((symbol-function 'save-some-buffers) #'ignore)
+ ((symbol-function 'sit-for) #'ignore)
+ ((symbol-function 'message) #'ignore)
+ ((symbol-function 'org-drill-record-session)
+ (lambda (&rest _) (setq recorded t))))
+ (org-drill--show-end-message session))
+ (should recorded))))
+
+(ert-deftest test-show-end-message-logs-when-recorder-errors ()
+ "A recorder failure (struct shape, persist-save IO, etc.) must surface
+via `message' rather than silently disappearing — no silent data loss."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session :qualities '(4)))
+ (logged nil))
+ (oset session end-pos nil)
+ (cl-letf (((symbol-function 'org-drill-final-report) #'ignore)
+ ((symbol-function 'save-some-buffers) #'ignore)
+ ((symbol-function 'sit-for) #'ignore)
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ ;; Accumulate (newest first). The flow after the recorder
+ ;; error also calls `(message "Drill session finished!")'
+ ;; and `(message nil)', so a single-binding capture would
+ ;; lose the failure message. Nil FMT is the minibuffer-
+ ;; clear call and is ignored.
+ (when fmt (push (apply #'format fmt args) logged))))
+ ((symbol-function 'org-drill-record-session)
+ (lambda (&rest _) (error "boom"))))
+ (org-drill--show-end-message session))
+ (should logged)
+ (should (cl-some (lambda (m) (string-match-p "failed to record session" m))
+ logged)))))
+
+(ert-deftest test-show-end-message-skips-record-on-suspend ()
+ "A suspended session (end-pos set) must NOT record — discard semantics."
+ (test-session-record--with-empty-log
+ (let* ((session (test-session-record--populated-session :qualities '(4)))
+ (recorded nil))
+ (oset session end-pos (test-session-record--marker-at 1))
+ (cl-letf (((symbol-function 'org-reveal) #'ignore)
+ ((symbol-function 'org-fold-show-entry) #'ignore)
+ ((symbol-function 'org-drill-goto-entry) #'ignore)
+ ((symbol-function 'org-drill--show-resume-hint) #'ignore)
+ ((symbol-function 'org-drill-record-session)
+ (lambda (&rest _) (setq recorded t))))
+ (org-drill--show-end-message session))
+ (should-not recorded))))
+
+(provide 'test-org-drill-session-record)
+
+;;; test-org-drill-session-record.el ends here
diff --git a/working/stats-dashboard/stats-dashboard.org b/working/stats-dashboard/stats-dashboard.org
new file mode 100644
index 0000000..8b195cb
--- /dev/null
+++ b/working/stats-dashboard/stats-dashboard.org
@@ -0,0 +1,419 @@
+#+TITLE: Comprehensive Statistics Dashboard for org-drill — v0 design spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-27
+
+* Status
+
+*Ratified 2026-05-27* — all 10 open decisions accepted as recommended.
+The companion todo entry is =todo.org=:20 [#A] "Comprehensive
+Statistics Dashboard". No implementation has started; the spec is now
+the implementation gate.
+
+See =Ratified decisions= at the bottom of this file for the locked
+choice on each question; inline =Decided:= blocks repeat the choice
+next to the section it affects.
+
+* Context and motivation
+
+org-drill's end-of-session report is =org-drill-final-report=
+(=org-drill.el=:3128) — a minibuffer prompt with the pass percentage,
+qualities histogram, and the new/mature/failed counts for the *single
+just-completed session*. Nothing about that report persists. Once the
+user presses a key to dismiss it, the only retained learning data is
+what each card carries in its own =DRILL_*= properties.
+
+The currently-retained per-card data is real and usable:
+
+| Property | Source |
+|----------+--------|
+| =DRILL_LAST_INTERVAL= | scheduler output, updated every review |
+| =DRILL_REPEATS_SINCE_FAIL= | review counter resetting on a lapse |
+| =DRILL_TOTAL_REPEATS= | lifetime review count for the card |
+| =DRILL_FAILURE_COUNT= | lifetime lapses |
+| =DRILL_AVERAGE_QUALITY= | running mean of the 0–5 quality scale |
+| =DRILL_EASE= | SM-family easiness factor |
+| =DRILL_LAST_QUALITY= | quality at the most recent review |
+| =DRILL_LAST_REVIEWED= | inactive timestamp of the most recent review |
+| =DATE_ADDED= | inactive timestamp of card creation |
+
+What's missing is the *temporal axis*: there's no record that the user
+reviewed N cards on a given day, or that the pass percentage trended
+upward over the last month, or that a particular card has been failed
+three times in the last two weeks. Those questions need a per-review
+or per-session event log that survives Emacs restarts.
+
+This spec sketches a dashboard that combines (a) per-card aggregates
+read live from existing properties (no new storage) with (b) a new
+persisted session/review history (org-persist, separate file) that
+gives the time-series view.
+
+* Goals
+
+- New command =org-drill-statistics= opens an interactive dashboard
+ buffer summarizing review history across all known drill files.
+- Per-card aggregates read live from the existing =DRILL_*= properties
+ — no migration, no new per-card properties.
+- A new persisted session log records, at the end of every session,
+ what landed (date, files scoped, cards reviewed, qualities histogram,
+ duration, pass percentage). Survives Emacs restarts.
+- Dashboard renders trends, distributions, and "needs attention" lists
+ by reading the session log + the per-card properties.
+- CSV export for users who want to bring the data into a spreadsheet
+ or a notebook.
+- No-op for users who don't open the dashboard — collection cost on
+ session end is bounded (single =persist-save= write), and the
+ dashboard command is lazy-loaded.
+
+* Non-goals (v1)
+
+- *No graphical charting.* The dashboard renders in plain org-mode
+ text — tables, unicode-block sparklines, and minibuffer summaries.
+ A Vega/SVG/PNG output stays out of v1; users who want charts use
+ CSV export.
+- *No per-review event log.* The persisted record is per-session, not
+ per-card-review. A full per-review log is more expensive to write,
+ more expensive to read, and crosses the line into "we are now a
+ learning-analytics platform." Per-card lifetime aggregates already
+ cover most card-level questions.
+- *No multi-machine sync.* The persist file is local; users who want
+ cross-machine continuity sync it themselves (org-roam-style,
+ Syncthing, whatever). Defining a sync protocol is out of scope.
+- *No retroactive backfill.* Sessions before this feature lands have
+ no history; the dashboard starts from the first session after the
+ upgrade. Per-card properties carry their own retroactive baseline.
+- *No forecasting.* "How many cards will be due tomorrow" is one of
+ the dashboard's panels (cheap — sum over SCHEDULED dates), but
+ predictive forecasting of retention or workload is FSRS territory
+ and stays there.
+
+* Data model
+
+** Live (no new storage)
+
+Read at dashboard-open time, no persistence:
+
+- Per-card aggregates by walking =org-map-entries= over the user's
+ configured =org-drill-scope= or an explicit scope argument. For each
+ drill entry, the existing accessors (=org-drill-entry-total-repeats=,
+ =-failure-count=, =-average-quality=, =-last-reviewed=, =-last-interval=,
+ =-ease=, =-last-quality=, =-days-since-creation=) yield the per-card
+ view without touching disk beyond reading the file.
+- Card status counts (new / young-mature / old-mature / overdue /
+ lapsed / due-tomorrow) by reusing =org-drill-entry-status= over the
+ scope. This is what the session opener already does in =org-drill=
+ for the initial counts.
+
+** Persisted (new storage)
+
+A single new persisted variable, the session log:
+
+#+begin_src elisp
+(persist-defvar org-drill-session-log nil
+ "List of completed-session records, newest first.
+
+Each entry is an `org-drill-session-record' struct.")
+#+end_src
+
+=org-drill-session-record= is a =cl-defstruct=:
+
+| Slot | Type | Meaning |
+|------+------+---------|
+| =start-time= | float | =float-time= at session start |
+| =end-time= | float | =float-time= at session end |
+| =scope= | symbol or list | =org-drill-scope= value at session start |
+| =algorithm= | symbol | =org-drill-spaced-repetition-algorithm= at start |
+| =qualities= | vector of int | every quality 0–5 entered, in order |
+| =pass-percent= | int | qualities > =org-drill-failure-quality=, / total |
+| =new-count= | int | size of =(oref session new-entries)= at end |
+| =mature-count= | int | =young-mature= + =old-mature= entries at end |
+| =failed-count= | int | =failed-entries= at end |
+| =cram-mode= | bool | =cram-mode= at session start |
+
+A single record is small (a few hundred bytes). At one session per
+day, the log holds a year of history in well under 100 KB. At three
+sessions per day for ten years, still under 4 MB. No pruning needed
+for v1.
+
+=Decided:= persistence shape = =persist-defvar=. Integrates with the
+=persist= dependency already in =Package-Requires=, lives at
+=~/.emacs.d/persist/org-drill-session-log= by default, mirrors the
+=org-drill-sm5-optimal-factor-matrix= precedent including its
+=condition-case= wrapper for corrupt-load recovery. Plain elisp file
+and org-mode log file declined — the former is what =persist-defvar=
+already is under the hood, the latter trades structured I/O for
+parse-and-rewrite failure modes.
+
+=Decided:= corrupted-load recovery = log a single warning, start with
+a fresh empty log, rename the corrupt file to =.corrupt-YYYY-MM-DD=
+before next save so it isn't overwritten. Mirrors the SM5-matrix
+path verbatim. History loss is bounded; the precedent makes this
+consistent.
+
+** Out-of-scope storage
+
+Explicit non-storage to keep scope tight:
+
+- No per-review event log (a row per card-rating).
+- No per-card review history (a list of past timestamps + qualities
+ per card). =DRILL_AVERAGE_QUALITY= and =DRILL_LAST_REVIEWED= cover
+ the dashboard's needs; full per-card history is what FSRS would
+ need, and FSRS owns that question.
+- No per-deck (per-file) aggregate cache. Walking org files on
+ dashboard open is fast enough for the file counts org-drill users
+ actually have; if a user has 10 000 files this becomes an issue —
+ cross that bridge if it shows up.
+
+* UI shell
+
+The dashboard is a single command =org-drill-statistics= that opens a
+read-only org-mode buffer named =*Org Drill Statistics*= with the
+sections below. =Decided:= keymap = =q= bury, =g= refresh, =e=
+export-csv, =s= scope, =r= range, =a= algorithm-filter, =RET= follow
+the card link at point. None conflict with read-only org-mode
+bindings.
+
+** Section 1 — Overview
+
+A four-column summary table:
+
+#+begin_example
+| Total cards | New | Mature | Lapsed |
+|-------------+-----+--------+--------|
+| 412 | 18 | 367 | 27 |
+#+end_example
+
+Plus a one-line "last session" recap reading the most recent record
+from =org-drill-session-log= (date, duration, cards reviewed, pass %).
+
+** Section 2 — Trends
+
+Two unicode-block sparklines:
+
+- Reviews per day (last 90 days). X axis = day, Y axis = card count.
+- Pass rate per day (last 90 days). Same X axis, Y axis = 0..100.
+
+Range and bar count are defcustoms. Default 90 days = roughly a
+quarter, fits on one line at 1 cell/day.
+
+Below the sparklines, a small table of weekly aggregates for the last
+12 weeks (reviews, pass %, average duration).
+
+=Decided:= sparkline character set = quadrant blocks (▁▂▃▄▅▆▇█),
+8 levels. Emacs renders them fine by default; users on a font without
+them are rare enough to handle by documentation rather than a runtime
+fallback.
+
+** Section 3 — Distribution
+
+Quality histogram across all sessions in the log (or scoped — see the
+range filter below). A horizontal bar per quality 0..5 with the
+absolute count and the percentage.
+
+** Section 4 — Needs attention
+
+Three tables:
+
+- *Leech candidates* — cards with =DRILL_FAILURE_COUNT= ≥
+ =org-drill-leech-failure-threshold= and =DRILL_AVERAGE_QUALITY= below
+ =org-drill-statistics-leech-quality-threshold= (=Decided:= default
+ *2.5*, below the Hard boundary of 3). Link to card.
+- *Long-overdue* — cards with =DRILL_LAST_REVIEWED= ≥
+ =org-drill-lapse-threshold-days= ago. Sorted most-overdue first.
+- *Forgotten new* — cards with =DATE_ADDED= ≥ 14 days ago but
+ =DRILL_TOTAL_REPEATS= = 0 (or absent). Useful for catching cards
+ that never got into rotation.
+
+Each table caps at =org-drill-stats-attention-row-limit= rows
+(defcustom, default 10) with a "+N more" footer.
+
+** Section 5 — Forecast
+
+A one-line table for the next 7 days:
+
+#+begin_example
+| Today | +1 | +2 | +3 | +4 | +5 | +6 |
+|-------+----+----+----+----+----+----+
+| 34 | 18 | 22 | 9 | 41 | 12 | 6 |
+#+end_example
+
+Computed from SCHEDULED timestamps across the scope. No prediction —
+just count of cards already scheduled for each day.
+
+** Range filter
+
+A line at the top of the buffer carrying the active filters:
+
+#+begin_example
+Scope: file (~/notes/drill.org) Range: last 90d Algorithm: simple8
+#+end_example
+
+Filters are interactive at the buffer header (=s= cycles scope, =r=
+cycles range, =a= filters algorithm). Defaults: =org-drill-scope=, "last
+90d", "all algorithms".
+
+=Decided:= single buffer-wide filter for v1. Per-section filters
+multiply the UI surface and most users want the same window across
+sections.
+
+* Export
+
+=M-x org-drill-statistics-export-csv= writes one CSV per requested
+view to a user-chosen directory:
+
+- =sessions.csv= — one row per session record in the log. Columns
+ match the struct slots.
+- =cards.csv= — one row per drill entry in the active scope, with
+ every =DRILL_*= property plus the computed status.
+- =daily.csv= — one row per day in the active range, with reviews,
+ passes, fails, pass-percent, duration-minutes.
+
+=Decided:= column delimiter = =,= (CSV) with proper quoting via
+=csv-mode='s writer if available, else a hand-rolled =csv-quote=
+helper. Users who want TSV can run a one-line sed pipe.
+
+* Performance
+
+The expensive paths and their bounds:
+
+| Path | Cost | Mitigation |
+|------+------+------------|
+| Session log save | one =persist-save= per session | already wrapped in =condition-case=; cost is a few KB write |
+| Scope walk on dashboard open | =org-map-entries= over scope | same cost as a session open; cached for the dashboard's lifetime, refreshes on =g= |
+| CSV export | one walk + one file write | one-off; user-triggered |
+| Sparkline rendering | bucket the in-memory log by day | log is bounded; bucket-by-day is linear in log length |
+
+The dashboard does *not* run at session open or close — only when the
+user invokes =M-x org-drill-statistics=. Session-end pays one =persist-save=
+write. Idle Emacs pays nothing.
+
+=Decided:= sync dashboard open for v1. =org-map-entries= over a
+typical scope is well under a second. Async refresh
+(=run-with-idle-timer=, status line in the buffer) is a follow-on
+ticket if anyone reports >2 s on a large scope.
+
+* Integration points
+
+** New code
+
+- =org-drill-session-record= struct (=cl-defstruct=).
+- =org-drill-session-log= persistent variable.
+- =org-drill-record-session= — called once from the end-of-session
+ finalizer (=org-drill-finalize-session= or wherever
+ =org-drill-final-report= currently sits) when the session was not
+ aborted. Appends a record, =persist-save='s the log.
+- =org-drill-statistics= — interactive command, opens the dashboard.
+- =org-drill-statistics-mode= — minor-mode-like keymap on top of an
+ org-mode buffer (=q g e s r a=).
+- =org-drill-statistics--render-*= — one helper per section (overview,
+ trends, distribution, attention, forecast).
+- =org-drill-statistics-export-csv= — interactive command.
+
+** Existing code touched
+
+- =org-drill-final-report= grows a single call to =org-drill-record-session=
+ before the read-char-exclusive dismisses the report (so the record
+ lands even if the user dismisses immediately).
+- A defcustom group =org-drill-statistics= added as a sibling group
+ next to =org-drill-session= (=Decided:= sibling, not nested — the
+ dashboard isn't session-state and a separate group keeps Customize's
+ tree readable).
+
+** Aborted-session handling
+
+A session ended via =C-g= or quit doesn't reach =org-drill-final-report=.
+=Decided:= record nothing for these — an =unwind-protect= path to
+salvage partial sessions is deferred. The "abort discards" semantics
+matches =org-drill-on-timeout-action= =discard-current= already, and a
+partial record's pass percentage misrepresents what the user
+experienced.
+
+* Defcustoms (proposed list)
+
+#+begin_src elisp
+(defcustom org-drill-statistics-trend-days 90
+ "Number of days the trends section spans.")
+
+(defcustom org-drill-statistics-forecast-days 7
+ "Number of days ahead the forecast section spans.")
+
+(defcustom org-drill-statistics-attention-row-limit 10
+ "Maximum rows in each `Needs attention' table.")
+
+(defcustom org-drill-statistics-leech-quality-threshold 2.5
+ "Cards with `DRILL_AVERAGE_QUALITY' below this and at least
+`org-drill-leech-failure-threshold' failures appear in `Leech candidates'.")
+
+(defcustom org-drill-statistics-export-directory
+ (expand-file-name "org-drill-stats/" user-emacs-directory)
+ "Default directory for CSV exports.")
+#+end_src
+
+* Test strategy
+
+Three test files, matching the existing file-per-area convention:
+
+1. =tests/test-org-drill-session-record.el= — struct construction,
+ round-trip through =persist-save=/=persist-load=, log appending,
+ newest-first ordering, corrupted-load recovery.
+2. =tests/test-org-drill-statistics-aggregates.el= — given a fixture
+ session log + a fixture org file with known =DRILL_*= properties,
+ each of the dashboard's section-render helpers produces the
+ expected table. Aggregation math (pass %, weekly buckets,
+ sparkline buckets) is tested here with deterministic input.
+3. =tests/test-org-drill-statistics-integration.el= — end-to-end:
+ simulate three completed sessions via =org-drill-record-session=
+ against a fixture file, open the dashboard, assert the buffer
+ contents and the export-CSV output.
+
+Normal / Boundary / Error per public function on the helpers.
+Boundary cases worth pinning: empty log (no sessions yet), single
+session, range filter that selects zero days, scope that contains
+zero drill entries, the day-bucket histogram on the day-boundary
+edge (a session that crosses midnight).
+
+* Effort estimate
+
+Multi-day, plausibly spanning sessions:
+
+- Session record + persist round-trip + recording hook: 0.5 day.
+- Dashboard renderer (5 sections) + minor-mode keymap + range filter:
+ 1.5 days. Each section is a small helper; the time goes to layout
+ polish and the sparkline math.
+- CSV export: 0.5 day.
+- Test coverage at parity (the three files above, ~40 tests): 1 day.
+- Documentation (manual entry, README option list, defcustom
+ docstrings, this spec ratified): 0.5 day.
+
+Realistic: a session for the persist + recording layer with full
+tests, a session for the dashboard renderer, a follow-up session for
+the export + docs + polish.
+
+* Ratified decisions (2026-05-27)
+
+All 10 open questions resolved as recommended. Implementation can
+proceed against this spec.
+
+| # | Question | Resolution |
+|---+----------+------------|
+| 1 | Persistence shape | =persist-defvar=, mirroring the SM5 matrix |
+| 2 | Corrupted-load recovery | warn, fresh-start, rename to =.corrupt-YYYY-MM-DD= |
+| 3 | Sparkline character set | quadrant blocks (▁▂▃▄▅▆▇█) |
+| 4 | Filter scope | single buffer-wide filter |
+| 5 | CSV delimiter | =,= with proper quoting |
+| 6 | Dashboard-open mode | sync |
+| 7 | Aborted-session recording | record nothing; =unwind-protect= deferred |
+| 8 | Dashboard keymap | =q g e s r a RET= |
+| 9 | Leech-quality threshold default | 2.5 |
+| 10 | Defcustom group placement | sibling group =org-drill-statistics= |
+
+* References
+
+- =org-drill.el= around line 729 — the =org-drill-session= EIEIO class.
+ Source of the per-session in-memory state that becomes a record.
+- =org-drill.el= around line 3128 — =org-drill-final-report=. Hook
+ site for =org-drill-record-session=.
+- =org-drill.el= around line 540 — the existing =persist-defvar= use
+ for the SM5 matrix. Template for the session-log persist + the
+ =condition-case= wrapper for corrupt-load recovery.
+- =working/fsrs-spec/fsrs-spec.org= — sister v0 spec, same DECIDE-marker
+ convention.