# Concise Default View Implementation Plan **Goal:** Default index shows only READING + 5 most recent WANT books, with filter links to expanded views. **Architecture:** `spine-index-model` gains an optional `filter` argument (nil = concise, "all"/"read"/"want" = expanded). The handler reads `filter` from the request query and passes it through. The template gains conditional nav links and summary-group rows. Existing group rendering unchanged. **Tech Stack:** Emacs Lisp (model/handler), Mustache (template) --- ## File overview **Modify:** - `spine.el` — `spine-index-model` (add filter param, concise logic), `defservlet index` (pass query filter) - `templates/index.mustache` — filter nav bar, summary-group rows - `test/spine-index-model-test.el` — new test file for filtered model --- ### Task 1: Write failing tests for filtered `spine-index-model` **Files:** - Create: `test/spine-index-model-test.el` - [ ] **Step 1: Create test file with setup** ```elisp ;;; spine-index-model-test.el — ERT tests for spine-index-model with filter (require 'ert) (require 'cl-lib) (setq spine-skip-server-start t) (setq spine-org-file (expand-file-name "sample-books.org" (file-name-directory (directory-file-name (file-name-directory load-file-name))))) (load-file (expand-file-name "../spine.el" (file-name-directory load-file-name))) (defconst spine-index-model-test--books (spine-books) "Books fixture loaded once from sample-books.org.") ``` - [ ] **Step 2: Write test — concise view excludes READ and ABANDONED groups, has summary** ```elisp (ert-deftest spine-index-model-concise-hides-read-and-abandoned () "Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups." (let* ((model (spine-index-model spine-index-model-test--books)) (groups (ht-get model "groups")) (summaries (ht-get model "summary_groups")) (group-labels (mapcar (lambda (g) (ht-get g "label")) groups))) (should-not (member "read" group-labels)) (should-not (member "abandoned" group-labels)) (should summaries) (should (= (length summaries) 1)) ; only READ, no ABANDONED in sample data (should (string= (ht-get (car summaries) "label") "read")) (should (= (ht-get (car summaries) "count") 1)) ; Ancillary Justice (should (string= (ht-get (car summaries) "href") "/index?filter=read")))) ``` - [ ] **Step 3: Write test — concise view includes READING group** ```elisp (ert-deftest spine-index-model-concise-includes-reading () "Concise view includes READING group with all reading books." (let* ((model (spine-index-model spine-index-model-test--books)) (groups (ht-get model "groups")) (reading-group (cl-find "reading" groups :key (lambda (g) (ht-get g "label")) :test #'string=))) (should reading-group) (should (= (ht-get reading-group "count") 1)) ; Use of Weapons (should (string= (ht-get (car (ht-get reading-group "books")) "title") "Use of Weapons")))) ``` - [ ] **Step 4: Write test — concise view limits WANT to 5 most recent** ```elisp (ert-deftest spine-index-model-concise-want-limited-to-five () "Concise view limits WANT group to 5 books, sorted by :ADDED: desc." (let* ((model (spine-index-model spine-index-model-test--books)) (groups (ht-get model "groups")) (want-group (cl-find "on deck" groups :key (lambda (g) (ht-get g "label")) :test #'string=))) (should want-group) (let ((books (ht-get want-group "books"))) (should (<= (length books) 5)) ;; sample-books.org has 3 WANT books — all should appear, sorted newest first (should (= (length books) 3)) ;; Piranesi added 2026-06-01, Dune 2026-05-10, Left Hand 2026-04-01 (should (string= (ht-get (nth 0 books) "title") "Piranesi")) (should (string= (ht-get (nth 1 books) "title") "Dune")) (should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness"))))) ``` - [ ] **Step 5: Write test — filter=all returns full model (no summary, all groups)** ```elisp (ert-deftest spine-index-model-filter-all-shows-all-groups () "filter=all returns all groups, no summary-groups." (let* ((model (spine-index-model spine-index-model-test--books "all")) (groups (ht-get model "groups")) (summaries (ht-get model "summary_groups")) (group-labels (mapcar (lambda (g) (ht-get g "label")) groups))) (should-not summaries) (should (member "on deck" group-labels)) (should (member "reading" group-labels)) (should (member "read" group-labels)) (should (= (length groups) 3)))) ; WANT, READING, READ (no ABANDONED in sample) ``` - [ ] **Step 6: Write test — filter=read returns only READ group** ```elisp (ert-deftest spine-index-model-filter-read-returns-only-read () "filter=read returns only the READ group." (let* ((model (spine-index-model spine-index-model-test--books "read")) (groups (ht-get model "groups"))) (should (= (length groups) 1)) (should (string= (ht-get (car groups) "label") "read")) (should (= (ht-get (car groups) "count") 1)))) ``` - [ ] **Step 7: Write test — filter=want returns only WANT group** ```elisp (ert-deftest spine-index-model-filter-want-returns-only-want () "filter=want returns only the WANT group." (let* ((model (spine-index-model spine-index-model-test--books "want")) (groups (ht-get model "groups"))) (should (= (length groups) 1)) (should (string= (ht-get (car groups) "label") "on deck")) (should (= (ht-get (car groups) "count") 3)))) ``` - [ ] **Step 8: Write test — invalid filter falls back to concise** ```elisp (ert-deftest spine-index-model-invalid-filter-falls-back-to-concise () "Unrecognised filter value behaves like nil (concise view)." (let* ((model (spine-index-model spine-index-model-test--books "bogus")) (groups (ht-get model "groups")) (summaries (ht-get model "summary_groups")) (group-labels (mapcar (lambda (g) (ht-get g "label")) groups))) (should summaries) ; has summary (should-not (member "read" group-labels)) ; no read group (should (member "reading" group-labels)) ; has reading (should (member "on deck" group-labels)))) ; has want ``` - [ ] **Step 9: Write test — model includes current_filter in context** ```elisp (ert-deftest spine-index-model-includes-current-filter () "Model includes :current_filter key matching the filter argument." (should (equal (ht-get (spine-index-model spine-index-model-test--books) "current_filter") "")) (should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all")) (should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read")) (should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want"))) ``` - [ ] **Step 10: Run tests to verify they fail** ```bash cd /work/personal-local/spine emacs --batch -l test/spine-index-model-test.el \ --eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1 ``` Expected: all tests fail because `spine-index-model` doesn't accept a second argument yet. - [ ] **Step 11: Commit test file** ```bash git add test/spine-index-model-test.el git commit -m "test: add failing tests for filtered spine-index-model" ``` --- ### Task 2: Implement filtered `spine-index-model` **Files:** - Modify: `spine.el:77-155` - [ ] **Step 1: Update signature and add filter handling** Swap the function at line 77: ```elisp (defun spine-index-model (books &optional filter selected-id) "Build the view model ht for templates/index.mustache from BOOKS. FILTER controls which books are shown: nil — concise: READING + 5 most recent WANT, others as summary links \"all\" — full listing, no truncation \"read\" — only READ books \"want\" — only WANT books SELECTED-ID expands the matching book's detail section." (let* ((status-labels '(("WANT" . "on deck") ("READING" . "reading") ("READ" . "read") ("ABANDONED" . "abandoned"))) (format-icons '(("hardcover" . "ti-book") ("ebook" . "ti-device-tablet") ("audiobook" . "ti-headphones"))) (order '("WANT" "READING" "READ" "ABANDONED")) (valid-filters '("all" "read" "want")) (effective-filter (if (member filter valid-filters) filter nil)) (grouped (make-hash-table :test 'equal)) (total (length books)) (reading-count 0)) (dolist (book books) (when (equal "READING" (plist-get book :status)) (cl-incf reading-count))) (dolist (book books) (let ((status (or (plist-get book :status) "WANT"))) (push book (gethash status grouped)))) (let ((groups nil) (summary-groups nil)) (dolist (status order) (let ((group-books (nreverse (gethash status grouped)))) (when group-books (if (and (null effective-filter) (member status '("READ" "ABANDONED"))) ;; Concise: replace read/abandoned groups with summary links (push (ht ("label" (cdr (assoc status status-labels))) ("count" (length group-books)) ("href" (format "/index?filter=%s" (downcase status)))) summary-groups) ;; Show the group (possibly filtered to one status) (when (or (null effective-filter) (string= (downcase status) effective-filter) (string= effective-filter "all")) ;; Sort WANT by ADDED descending when in concise view (when (and (null effective-filter) (string= status "WANT")) (setq group-books (cl-subseq (sort group-books (lambda (a b) (let ((a-date (plist-get a :added)) (b-date (plist-get b :added))) (cond ((null a-date) nil) ((null b-date) t) (t (string> a-date b-date)))))) 0 (min (length group-books) 5)))) (let ((book-models nil)) (dolist (book group-books) (let* ((id (plist-get book :id)) (selected (equal id selected-id)) (fmt (plist-get book :format)) (rating (plist-get book :rating)) (notes (plist-get book :notes)) (status (or (plist-get book :status) "WANT")) (model (ht ("format_icon" (or (cdr (assoc fmt format-icons)) "")) ("status_class" (downcase status)) ("status_label" (downcase status)) ("title" (plist-get book :title)) ("author" (or (plist-get book :author) "")) ("tags_inline" (let ((tags (plist-get book :tags))) (when tags (mapconcat (lambda (tag) (concat ":" tag ":")) tags " ")))) ("rec_via" (plist-get book :rec_by)) ("rating_display" (when (and rating (> (length rating) 0)) (make-string (string-to-number rating) ?\u2605))) ("selected" selected) ("detail" (when selected (ht ("meta" (let ((added (plist-get book :added)) (fmt (plist-get book :format))) (cond ((and added fmt) (format "started %s · %s" added fmt)) (added (format "started %s" added)) (fmt (format "format: %s" fmt)) (t "")))) ("notes" (when notes (cl-loop for (date text) in notes collect (ht ("date" (format "[%s]" date)) ("text" text))))))))))) (push model book-models))) (push (ht ("label" (cdr (assoc status status-labels))) ("count" (length group-books)) ("books" (nreverse book-models))) groups)))))) (ht ("app_title" "spine") ("total_books" total) ("reading_count" reading-count) ("current_filter" (or effective-filter "")) ("summary_groups" (nreverse summary-groups)) ("groups" (nreverse groups)))))) ``` - [ ] **Step 2: Run tests to verify they pass** ```bash cd /work/personal-local/spine emacs --batch -l test/spine-index-model-test.el \ -l test/spine-books-test.el \ -l test/spine-add-book-test.el \ --eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1 ``` Expected: all tests pass (new filter tests + existing books + add-book tests). - [ ] **Step 3: Commit** ```bash git add spine.el git commit -m "feat: add filter parameter to spine-index-model for concise default view" ``` --- ### Task 3: Update `defservlet index` handler **Files:** - Modify: `spine.el:308-313` - [ ] **Step 1: Pass query filter to model** Swap lines 308-313: ```elisp (defservlet index text/html (path query request) (let* ((books (spine-books)) (filter (cadr (assoc "filter" query)))) (if books (insert (spine-render "index.mustache" (spine-index-model books filter (cadr (assoc "id" query))))) (insert (spine-render-empty-state))))) ``` - [ ] **Step 2: Run existing tests to confirm handler change doesn't break anything** ```bash cd /work/personal-local/spine emacs --batch -l test/spine-index-model-test.el \ -l test/spine-books-test.el \ -l test/spine-add-book-test.el \ --eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1 ``` Expected: all pass. - [ ] **Step 3: Commit** ```bash git add spine.el git commit -m "feat: pass filter query param from index handler to model" ``` --- ### Task 4: Update `templates/index.mustache` with filter links and summary groups **Files:** - Modify: `templates/index.mustache` - [ ] **Step 1: Add filter nav bar after header, before groups** Insert after the closing `` (line 87) and before the `{{#groups}}` section (line 89): ```mustache {{#current_filter}} {{/current_filter}} {{^current_filter}} {{/current_filter}} ``` - [ ] **Step 2: Add summary group rows after groups section** Insert after the closing `{{/groups}}` (line 109) and before `