diff options
| -rw-r--r-- | org-drill.el | 152 | ||||
| -rw-r--r-- | tests/test-org-drill-session-record.el | 407 | ||||
| -rw-r--r-- | working/stats-dashboard/stats-dashboard.org | 419 |
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. |
