Files
spine/docs/plans/2026-06-22-shelves-implementation.md
T
jbrechtel df1a7aebc9 feat: add book detail view with spine-book-model, httpd/book handler, and title links
- Add spine--format-defs defconst shared between index and book models
- Add spine-book-model with status extraction, format selector, notes, recommendation
- Add templates/book.mustache (Pico CSS, journal-style layout)
- Add httpd/book handler serving the detail page
- Link book titles in index view to /book?id=X
- Add spine-book-model-test.el with 13 tests
2026-06-22 22:15:51 -04:00

37 KiB

Shelves Data Model Implementation Plan

Goal: Restructure spine's data model from TODO-state-based top-level books to shelf-based organization with books nested under shelves.

Architecture: Level-1 Org headlines become shelves (arbitrary organizing concepts). Level-2 headlines are books. spine-books returns books with a :shelf key. New spine-shelves returns all shelf names including empties. UI groups by shelf, nav bar lists shelves. TODO states removed; logbook handles history. Add-book requires picking an existing shelf.

Tech Stack: Emacs Lisp, Org-mode, mustache templates


Task 1: Restructure sample-books.org

Files:

  • Modify: sample-books.org

  • Step 1: Rewrite sample-books.org with shelf hierarchy

Replace the flat headline list with shelves as level-1 headings and books as level-2. Keep all existing properties, logbooks, notes, and IDs. Remove the #+TODO: line. Books retain their TODO keywords for org-mode compatibility but need not — either way the app ignores them.

#+TITLE: Spine

* Fiction
** WANT The Left Hand of Darkness
:PROPERTIES:
:AUTHOR:  Ursula K. Le Guin
:ISBN:    978-0441478125
:COVER:   covers/left-hand.jpg
:FORMAT:  ebook
:REC_BY:  Alex
:REC_NOTE: Le Guin at her most human
:RATING:  
:ADDED:   [2026-04-01]
:LAST_MODIFIED: [2026-04-01]
:ID:      9a2b-lhd
:END:

** WANT Dune
:PROPERTIES:
:AUTHOR:  Frank Herbert
:ISBN:    978-0441172719
:COVER:   covers/dune.jpg
:FORMAT:  hardcover
:REC_BY:  Everyone
:REC_NOTE: 
:RATING:  
:ADDED:   [2026-05-10]
:LAST_MODIFIED: [2026-05-10]
:ID:      7f3c-dun
:END:

** WANT Piranesi                                                               :fiction:
:PROPERTIES:
:AUTHOR:  Susanna Clarke
:ISBN:    
:COVER:   
:FORMAT:  
:REC_BY:  
:REC_NOTE: 
:RATING:  
:ADDED:   [2026-06-01]
:LAST_MODIFIED: [2026-06-01]
:ID:      5e8d-pir
:END:

** READING Use of Weapons                                                      :scifi:literary:
:PROPERTIES:
:AUTHOR:  Iain M. Banks
:ISBN:    978-0316029193
:COVER:   covers/use-of-weapons.jpg
:FORMAT:  audiobook
:REC_BY:  Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you
:RATING:  
:ADDED:   [2026-02-20]
:LAST_MODIFIED: [2026-03-09]
:ID:      8c1e-uow
:END:
::LOGBOOK:
- State "READING" from "WANT" [2026-03-01]
::END:
- [2026-03-02] The two-track structure is doing something I can't name yet.
- [2026-03-07] Zakalwe's competence reads as a wound.
- [2026-03-09] The chapter numbering. Oh.

* Science Fiction
** READ Ancillary Justice                                                    :scifi:
:PROPERTIES:
:AUTHOR:  Ann Leckie
:ISBN:    978-0316246620
:COVER:   covers/ancillary.jpg
:FORMAT:  ebook
:REC_BY:  
:REC_NOTE: 
:RATING:  5
:ADDED:   [2025-11-15]
:LAST_MODIFIED: [2025-11-15]
:ID:      3a1b-aj
:END:
::LOGBOOK:
- State "READ" from "READING" [2026-01-10]
- State "READING" from "WANT" [2025-11-20]
::END:
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
- [2026-01-08] The tea set. Perfect ending.

* Non-Fiction
** WANT The Checklist Manifesto
:PROPERTIES:
:AUTHOR:  Atul Gawande
:ID:      b2c1-tcm
:ADDED:   [2026-06-15]
:LAST_MODIFIED: [2026-06-15]
:END:

Added a "Non-Fiction" shelf (empty example of a shelf that could be pre-created).

  • Step 2: Run the test suite to confirm it fails

Run: emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: failures because spine-books still expects flat level-1 headlines.


Task 2: Add spine-shelves and adapt spine-books

Files:

  • Modify: spine.el

  • Modify: test/spine-books-test.el

  • Step 1: Write failing test for spine-shelves

Add to test/spine-books-test.el:

(ert-deftest spine-shelves-returns-shelf-names ()
  "spine-shelves returns all level-1 headlines as shelf names."
  (let ((shelves (spine-shelves)))
    (should (member "Fiction" shelves))
    (should (member "Science Fiction" shelves))
    (should (member "Non-Fiction" shelves))
    (should (= (length shelves) 3))))

(ert-deftest spine-shelves-includes-empty-shelves ()
  "spine-shelves includes empty shelves (shelves with no books)."
  (let ((shelves (spine-shelves)))
    (should (member "Non-Fiction" shelves))))
  • Step 2: Run test to verify it fails

Run: emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: FAIL — spine-shelves undefined.

  • Step 3: Write spine-shelves implementation

In spine.el, before spine-books, add:

(defun spine-shelves ()
  "Return a list of shelf names (strings), one per level-1 headline.
Includes empty shelves (shelves with no books)."
  (if (not (file-exists-p spine-org-file))
      (progn
        (message "spine-shelves: file not found: %s" spine-org-file)
        nil)
    (condition-case err
        (with-temp-buffer
          (insert-file-contents spine-org-file)
          (org-mode)
          (let ((shelves nil))
            (org-element-map (org-element-parse-buffer 'headline) 'headline
              (lambda (hl)
                (when (= (org-element-property :level hl) 1)
                  (push (org-element-property :raw-value hl) shelves))))
            (nreverse shelves)))
      (error
       (message "spine-shelves: failed to parse %s: %s"
                spine-org-file (error-message-string err))
       nil))))
  • Step 4: Run test to verify it passes

Run: emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS for the two spine-shelves tests.

  • Step 5: Update spine-books to walk nested books under shelves

Replace the current spine-books body. New logic: walk level-1 headlines as shelves. For each, walk level-2 children as books. Each book plist gets a :shelf key. No :status key.

(defun spine-books ()
  "Return a list of plists, one per book in `spine-org-file'.
Books are level-2 headlines nested under level-1 shelf headlines.
Returns nil if the file is missing or cannot be parsed."
  (if (not (file-exists-p spine-org-file))
      (progn
        (message "spine-books: file not found: %s" spine-org-file)
        nil)
    (condition-case err
        (with-temp-buffer
          (insert-file-contents spine-org-file)
          (org-mode)
          (let ((books nil))
            (org-element-map (org-element-parse-buffer 'headline) 'headline
              (lambda (hl)
                (let ((level (org-element-property :level hl)))
                  (when (= level 2)
                    (let* ((pos (org-element-property :begin hl))
                           (parent (org-element-property :parent hl))
                           (shelf (and parent
                                       (= (org-element-property :level parent) 1)
                                       (org-element-property :raw-value parent))))
                      (when shelf
                        (push (list :id       (spine--prop pos "ID")
                                    :title    (org-element-property :raw-value hl)
                                    :author   (spine--prop pos "AUTHOR")
                                    :isbn     (spine--prop pos "ISBN")
                                    :cover    (spine--prop pos "COVER")
                                    :format   (spine--prop pos "FORMAT")
                                    :rec_by   (spine--prop pos "REC_BY")
                                    :rec_note (spine--prop pos "REC_NOTE")
                                    :rating   (spine--prop pos "RATING")
                                    :added    (spine--prop pos "ADDED")
                                    :last_modified (spine--prop pos "LAST_MODIFIED")
                                    :tags     (org-element-property :tags hl)
                                    :notes    (spine--extract-notes
                                               (org-element-property :begin hl)
                                               (org-element-property :end hl))
                                    :shelf    shelf)
                              books)))))))
            (nreverse books)))
      (error
       (message "spine-books: failed to parse %s: %s"
                spine-org-file (error-message-string err))
       nil))))

Note: org-element-property :raw-value for plain text headings (no links within). Safer than :title which returns a string or list depending on content.

  • Step 6: Update spine-books tests in spine-books-test.el

Replace the status assertions and add :shelf assertions:

In spine-books-parses-sample:

  • Change (= (length books) 5)(= (length books) 6) (new book added to Non-Fiction)

In spine-books-first-book-has-all-fields:

  • Replace :status "READING" check with :shelf "Fiction" check
  • Keep all other field assertions

In spine-books-missing-properties-are-nil:

  • Add (should (equal (plist-get pir :shelf) "Fiction"))
  • Remove :status "WANT" check

In spine-books-read-book-has-rating:

  • Replace :status "READ" check with :shelf "Science Fiction" check

  • Keep rating and notes assertions

  • Step 7: Run tests to verify both pass

Run: emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS for all spine-books tests.

  • Step 8: Commit
git add sample-books.org spine.el test/spine-books-test.el
git commit -m "feat: add spine-shelves, adapt spine-books for shelf nesting"

Task 3: Adapt spine-add-book for shelves

Files:

  • Modify: spine.el

  • Modify: test/spine-add-book-test.el

  • Step 1: Update spine-add-book-test.el tests

Remove status checks. Add :shelf argument to all spine-add-book calls. Update assertions.

Replace spine-add-book-creates-headline:

(ert-deftest spine-add-book-creates-headline ()
  "spine-add-book creates a new book under the given shelf."
  (spine-add-book-test--cleanup)
  (unwind-protect
      (progn
        ;; Create the file first with a shelf
        (with-temp-file spine-org-file
          (insert "* Fiction\n\n"))
        (spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
        (let ((books (spine-books)))
          (should books)
          (should (= (length books) 1))
          (let ((book (car books)))
            (should (equal (plist-get book :title) "Test Book"))
            (should (equal (plist-get book :author) "Author Name"))
            (should (equal (plist-get book :shelf) "Fiction"))
            (should (plist-get book :id)))))
    (spine-add-book-test--cleanup)))

Update spine-add-book-sets-all-fields:

(ert-deftest spine-add-book-sets-all-fields ()
  "spine-add-book sets all provided fields."
  (spine-add-book-test--cleanup)
  (unwind-protect
      (progn
        (with-temp-file spine-org-file
          (insert "* Fiction\n\n"))
        (spine-add-book
         :title "Full Test"
         :author "Jane Doe"
         :format "hardcover"
         :isbn "978-1234567890"
         :cover "covers/test.jpg"
         :rec_by "Friend"
         :rec_note "You'll love this"
         :date_added "2026-06-21"
         :initial_note "Looking forward to this"
         :shelf "Fiction")
        (let* ((books (spine-books))
               (book (car books)))
          (should (equal (plist-get book :title) "Full Test"))
          (should (equal (plist-get book :author) "Jane Doe"))
          (should (equal (plist-get book :format) "hardcover"))
          (should (equal (plist-get book :isbn) "978-1234567890"))
          (should (equal (plist-get book :cover) "covers/test.jpg"))
          (should (equal (plist-get book :rec_by) "Friend"))
          (should (equal (plist-get book :rec_note) "You'll love this"))
          (should (equal (plist-get book :added) "[2026-06-21]"))
          (should (equal (plist-get book :notes)
                         '(("2026-06-21" "Looking forward to this"))))))
    (spine-add-book-test--cleanup)))

Update spine-add-book-omits-empty-fields:

(ert-deftest spine-add-book-omits-empty-fields ()
  "spine-add-book does not set empty/omitted fields."
  (spine-add-book-test--cleanup)
  (unwind-protect
      (progn
        (with-temp-file spine-org-file
          (insert "* Fiction\n\n"))
        (spine-add-book :title "Minimal Book" :shelf "Fiction")
        (let* ((books (spine-books))
               (book (car books)))
          (should (equal (plist-get book :title) "Minimal Book"))
          (should (equal (plist-get book :shelf) "Fiction"))
          (should-not (plist-get book :author))
          (should-not (plist-get book :format))
          (should-not (plist-get book :isbn))
          (should-not (plist-get book :tags))))
    (spine-add-book-test--cleanup)))

Update spine-add-book-requires-title — no change needed (still signals error).

Update spine-add-book-generates-unique-ids:

(ert-deftest spine-add-book-generates-unique-ids ()
  "spine-add-book generates different IDs for each book."
  (spine-add-book-test--cleanup)
  (unwind-protect
      (progn
        (with-temp-file spine-org-file
          (insert "* Fiction\n\n"))
        (spine-add-book :title "Book One" :shelf "Fiction")
        (spine-add-book :title "Book Two" :shelf "Fiction")
        (let* ((books (spine-books))
               (id1 (plist-get (nth 0 books) :id))
               (id2 (plist-get (nth 1 books) :id)))
          (should (not (equal id1 id2)))))
    (spine-add-book-test--cleanup)))

Add a test for missing shelf:

(ert-deftest spine-add-book-requires-shelf ()
  "spine-add-book signals error when shelf is missing."
  (spine-add-book-test--cleanup)
  (unwind-protect
      (should-error (spine-add-book :title "No Shelf"))
    (spine-add-book-test--cleanup)))
  • Step 2: Run test to verify failures

Run: emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: FAIL — spine-add-book doesn't accept :shelf yet.

  • Step 3: Update spine-add-book implementation

Replace the function body. Key changes:

  • Accept :shelf keyword arg (required)
  • Remove TODO state setting
  • Remove user tag/category setting (tags can be added back later if needed)
  • Insert under the matching shelf headline instead of at file end
  • Remove #+TODO: file initialization
(defun spine-add-book (&rest args)
  "Add a new book to shelf `:shelf' in `spine-org-file'.
Keyword arguments: :title :author :shelf :format :isbn :cover
                   :rec_by :rec_note :date_added :initial_note
:shelf is required. Signals an error if the shelf doesn't exist."
  (let ((title (plist-get args :title))
        (shelf (plist-get args :shelf))
        (author (plist-get args :author))
        (format (plist-get args :format))
        (isbn (plist-get args :isbn))
        (cover (plist-get args :cover))
        (rec-by (plist-get args :rec_by))
        (rec-note (plist-get args :rec_note))
        (date-added (plist-get args :date_added))
        (initial-note (plist-get args :initial_note)))
    (unless title
      (error "spine-add-book: title is required"))
    (unless shelf
      (error "spine-add-book: :shelf is required"))
    (let ((org-file (expand-file-name spine-org-file)))
      (unless (file-exists-p org-file)
        (error "spine-add-book: file %s does not exist; create shelves first"
               org-file))
      (with-current-buffer (find-file-noselect org-file)
        (unwind-protect
            (progn
              ;; Find the shelf headline
              (goto-char (point-min))
              (let ((shelf-found nil))
                (while (and (not shelf-found)
                            (re-search-forward
                             (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
                             nil t))
                  (when (= (org-current-level) 1)
                    (setq shelf-found t)))
                (unless shelf-found
                  (error "spine-add-book: shelf \"%s\" not found" shelf)))
              ;; Go to end of shelf section
              (org-end-of-subtree)
              ;; Insert new book headline
              (org-insert-heading nil t t)
              (insert title)
              ;; Set properties (only non-empty)
              (when (and author (> (length author) 0))
                (org-set-property "AUTHOR" author))
              (when (and format (> (length format) 0))
                (org-set-property "FORMAT" format))
              (when (and isbn (> (length isbn) 0))
                (org-set-property "ISBN" isbn))
              (when (and cover (> (length cover) 0))
                (org-set-property "COVER" cover))
              (when (and rec-by (> (length rec-by) 0))
                (org-set-property "REC_BY" rec-by))
              (when (and rec-note (> (length rec-note) 0))
                (org-set-property "REC_NOTE" rec-note))
              (when (and date-added (> (length date-added) 0))
                (org-set-property "ADDED" (format "[%s]" date-added)))
              ;; Set ID
              (org-set-property "ID"
                (format "%04x-%s"
                        (random 65535)
                        (substring (sha1 (concat title (number-to-string (random)))) 0 4)))
              ;; Set last modified
              (org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
              ;; Add initial note
              (when (and initial-note (> (length initial-note) 0))
                (let ((date (if (and date-added (> (length date-added) 0))
                                date-added
                              (format-time-string "%Y-%m-%d"))))
                  (goto-char (org-entry-end-position))
                  (insert (format "\n- [%s] %s\n" date initial-note))))
              (save-buffer)
              t)
          (kill-buffer))))))
  • Step 4: Run test to verify it passes

Run: emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS.

  • Step 5: Commit
git add spine.el test/spine-add-book-test.el
git commit -m "feat: adapt spine-add-book for shelf-based nesting"

Task 4: Remove spine-set-status and update edit tests

Files:

  • Modify: spine.el

  • Modify: test/spine-edit-test.el

  • Step 1: Remove spine-set-status function

Delete the entire spine-set-status function (lines 296-319 approximately). The HTTP handler httpd/edit will be updated in a later task.

  • Step 2: Update spine-edit-test.el

Remove tests that use spine-set-status:

  • spine-set-status-want-to-reading — delete
  • spine-set-status-reading-to-read — delete
  • spine-set-status-missing-id — delete
  • spine-edit-model-minibuffer-reading — rewrite for new minibuffer (add note / set rating only)
  • spine-edit-model-minibuffer-nil-when-no-selection — keep as spine-edit-model-no-actions-when-no-selection

Update spine-edit-test--setup to create a shelf structure:

(defun spine-edit-test--setup ()
  "Create a test Org file with one shelf and one book."
  (with-temp-file spine-org-file
    (insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR:  Test Author\n:ID:      test-0001\n:END:\n"))
  (spine-books)  ; force file load, verify it works
  t)

Keep note and rating tests (spine-add-note-appends, spine-add-note-default-date, spine-set-rating-sets-rating) — they should still work with the new setup.

Update minibuffer tests:

(ert-deftest spine-edit-model-actions-include-note-and-rating ()
  "Selected book shows Add note and Set rating actions."
  (spine-edit-test--cleanup)
  (unwind-protect
      (progn
        (spine-edit-test--setup)
        (let* ((books (spine-books))
               (book (car books))
               (id (plist-get book :id))
               (model (spine-index-model books nil id))
               (minibuffer (ht-get model "minibuffer")))
          (should minibuffer)
          (let ((actions (ht-get minibuffer "actions")))
            (should (= (length actions) 2))
            (should (equal (ht-get (nth 0 actions) "label") "Add note"))
            (should (equal (ht-get (nth 1 actions) "label") "Set rating")))))
    (spine-edit-test--cleanup)))

(ert-deftest spine-edit-model-no-actions-when-no-selection ()
  "No minibuffer actions when no book is selected."
  (spine-edit-test--cleanup)
  (unwind-protect
      (progn
        (spine-edit-test--setup)
        (let* ((books (spine-books))
               (model (spine-index-model books nil nil)))
          (should-not (ht-get model "minibuffer"))))
    (spine-edit-test--cleanup)))
  • Step 3: Run edit tests

Run: emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS for remaining tests.

  • Step 4: Commit
git add spine.el test/spine-edit-test.el
git commit -m "feat: remove spine-set-status, update edit tests"

Task 5: Update spine-add-form-model

Files:

  • Modify: spine.el

  • Step 1: Replace category/authors logic with shelf list

Update spine-add-form-model to use spine-shelves for the shelf dropdown:

(defun spine-add-form-model (books)
  "Build view model for templates/add.mustache from current BOOKS."
  (ht ("app_title" "spine")
      ("shelves" (or (spine-shelves) '()))))

This replaces the existing implementation that computed existing_authors and existing_categories.

  • Step 2: Run books test to verify no regression

Run: emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS.


Task 6: Rewrite spine-index-model for shelf grouping

Files:

  • Modify: spine.el

  • Modify: test/spine-index-model-test.el

  • Step 1: Rewrite index-model tests

Replace all tests in spine-index-model-test.el:

;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping

(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.")

(ert-deftest spine-index-model-groups-by-shelf ()
  "Index model groups books by :shelf."
  (let* ((model (spine-index-model spine-index-model-test--books))
         (groups (ht-get model "groups")))
    (should groups)
    (should (= (length groups) 3))  ;; Fiction, Science Fiction, Non-Fiction
    (let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
      (should (member "Fiction" labels))
      (should (member "Science Fiction" labels))
      (should (member "Non-Fiction" labels)))))  ;; even empty shelves appear

(ert-deftest spine-index-model-shelf-counts ()
  "Each shelf group has the correct number of books."
  (let* ((model (spine-index-model spine-index-model-test--books))
         (groups (ht-get model "groups")))
    (dolist (g groups)
      (let ((label (ht-get g "label"))
            (count (ht-get g "count")))
        (cond
         ((equal label "Fiction")
          (should (= count 4))
          (should (= (length (ht-get g "books")) 4)))
         ((equal label "Science Fiction")
          (should (= count 1))
          (should (= (length (ht-get g "books")) 1)))
         ((equal label "Non-Fiction")
          (should (= count 1))
          (should (= (length (ht-get g "books")) 1))))))))

(ert-deftest spine-index-model-filters-by-shelf ()
  "filter=shelf-name shows only that shelf's books."
  (let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
         (groups (ht-get model "groups")))
    (should (= (length groups) 1))
    (should (equal (ht-get (car groups) "label") "Fiction"))))

(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
  "Shelf nav includes all shelves, including empties."
  (let* ((model (spine-index-model spine-index-model-test--books))
         (shelf-nav (ht-get model "shelf_nav")))
    (should (= (length shelf-nav) 3))
    (should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))

(ert-deftest spine-index-model-current-shelf ()
  "current_shelf is set when filter is active."
  (let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
    (should (equal (ht-get model "current_shelf") "Science Fiction"))))

(ert-deftest spine-index-model-no-current-shelf-without-filter ()
  "current_shelf is nil when no filter."
  (let* ((model (spine-index-model spine-index-model-test--books)))
    (should-not (ht-get model "current_shelf"))))

(ert-deftest spine-index-model-total-and-shelf-count ()
  "Titlebar shows correct totals."
  (let* ((model (spine-index-model spine-index-model-test--books)))
    (should (= (ht-get model "total_books") 6))
    (should (= (ht-get model "shelf_count") 3))))
  • Step 2: Run test to verify failures

Run: emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: FAIL — spine-index-model still groups by status.

  • Step 3: Rewrite spine-index-model

Replace the entire function:

(defun spine-index-model (books &optional shelf-filter selected-id)
  "Build the view model ht for templates/index.mustache from BOOKS.
SHELF-Filter limits to one shelf by name (string). nil = all shelves.
SELECTED-ID expands the matching book's detail section."
  (let* ((format-icons
          '(("hardcover"  . "ti-book")
            ("ebook"     . "ti-device-tablet")
            ("audiobook" . "ti-headphones")))
         (grouped (make-hash-table :test 'equal))
         (all-shelves (or (spine-shelves) '()))
         (total (length books)))
    ;; Group books by shelf
    (dolist (book books)
      (let ((shelf (or (plist-get book :shelf) "Uncategorized")))
        (push book (gethash shelf grouped))))
    (let ((groups nil)
          (shelf-nav nil))
      ;; Build shelf nav from all shelves (including empty)
      (dolist (shelf all-shelves)
        (push (ht ("name" shelf)
                  ("href" (format "/index?shelf=%s" (url-encode-url shelf)))
                  ("current" (equal shelf shelf-filter)))
              shelf-nav))
      (setq shelf-nav (nreverse shelf-nav))
      ;; Build shelf groups (only those with books, or empty if filtered)
      (dolist (shelf all-shelves)
        (let ((shelf-books (nreverse (gethash shelf grouped))))
          (when (or (null shelf-filter) (equal shelf shelf-filter))
            (let ((book-models nil))
              (dolist (book shelf-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))
                       (model
                        (ht
                         ("format_icon"
                          (or (cdr (assoc fmt format-icons)) ""))
                         ("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" shelf)
                        ("count" (length shelf-books))
                        ("books" (nreverse book-models)))
                    groups)))))
      (ht ("app_title" "spine")
          ("total_books" total)
          ("shelf_count" (length all-shelves))
          ("current_shelf" shelf-filter)
          ("shelf_nav" shelf-nav)
          ("groups" (nreverse groups))
          ("minibuffer"
           (when selected-id
             (let* ((books-by-id (make-hash-table :test 'equal)))
               (dolist (b books)
                 (puthash (plist-get b :id) b books-by-id))
               (let ((book (gethash selected-id books-by-id)))
                 (when book
                   (ht ("actions"
                        (list
                         (ht ("command" "note") ("label" "Add note")
                             ("href" (format "/edit?id=%s&action=note" selected-id)))
                         (ht ("command" "rating") ("label" "Set rating")
                             ("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
         ))))
  • Step 4: Run test to verify it passes

Run: emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS for all index-model tests.

  • Step 5: Commit
git add spine.el test/spine-index-model-test.el
git commit -m "feat: rewrite spine-index-model for shelf grouping"

Task 7: Update HTTP handlers

Files:

  • Modify: spine.el

  • Step 1: Update defservlet index

Update the index handler to support ?shelf= filter instead of ?filter=:

(defservlet index text/html (path query request)
  (let* ((books (spine-books))
         (shelf-filter (cadr (assoc "shelf" query))))
    (if books
        (insert (spine-render "index.mustache"
                  (spine-index-model books shelf-filter (cadr (assoc "id" query)))))
      (insert (spine-render-empty-state)))))
  • Step 2: Update httpd/add

Update to pass :shelf from the form:

(defun httpd/add (proc uri-path query request)
  "Handle /add: GET shows form, POST creates a book."
  (let ((method (caar request)))
    (if (equal method "POST")
        (progn
          (spine-add-book
           :title (cadr (assoc "title" query))
           :author (cadr (assoc "author" query))
           :shelf (cadr (assoc "shelf" query))
           :format (cadr (assoc "format" query))
           :isbn (cadr (assoc "isbn" query))
           :cover (cadr (assoc "cover" query))
           :rec_by (cadr (assoc "rec_by" query))
           :rec_note (cadr (assoc "rec_note" query))
           :date_added (cadr (assoc "date_added" query))
           :initial_note (cadr (assoc "initial_note" query)))
          (httpd-redirect proc "/index" 303))
      (httpd-with-buffer proc "text/html"
        (let* ((books (spine-books))
               (model (if books
                          (spine-add-form-model books)
                        (ht ("app_title" "spine" "shelves" '())))))
          (insert (spine-render "add.mustache" model)))))))
  • Step 3: Update httpd/edit

Remove status action cases. Only note and rating remain:

(defun httpd/edit (proc uri-path query request)
  "Handle /edit: GET shows prompt page, POST processes actions.
Query params:
  id     - book ID
  action - note | rating
  text   - note text (for note action)
  date   - optional date string (for note action)
  value  - rating value (for rating action)"
  (let ((method (caar request))
        (id (cadr (assoc "id" query)))
        (action (cadr (assoc "action" query)))
        (value (cadr (assoc "value" query)))
        (text (cadr (assoc "text" query)))
        (date (cadr (assoc "date" query))))
    (if (equal method "POST")
        (progn
          (cond
           ((equal action "note")
            (spine-add-note id text date))
           ((equal action "rating")
            (spine-set-rating id value)))
          (httpd-redirect proc (format "/index?id=%s" id) 303))
      ;; GET: show prompt page for actions needing input
      (if (and id (equal action "note"))
          (let* ((books (spine-books))
                 (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
                 (title (if book (plist-get book :title) "Unknown")))
            (httpd-with-buffer proc "text/html"
              (insert (format
"<!DOCTYPE html>
<html lang=\"en\">
<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
<meta name=\"color-scheme\" content=\"light dark\"/>
<title>Spine — note</title>
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
</head>
<body>
<main class=\"container\">
<article style=\"max-width:560px;margin-inline:auto;\">
<header><strong>spine · add note</strong></header>
<p><em>%s</em></p>
<form method=\"post\" action=\"/edit\">
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
<input type=\"hidden\" name=\"action\" value=\"note\"/>
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
<button type=\"submit\">Add note</button>
</form>
</article>
</main>
</body>
</html>"
                       title id (format-time-string "%Y-%m-%d"))))))
        ;; Unknown action or missing id: redirect to index
        (httpd-redirect proc "/index" 302)))))
  • Step 4: Run all tests to check for regressions

Run: emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: PASS for all.

  • Step 5: Commit
git add spine.el
git commit -m "feat: update HTTP handlers for shelf model"

Task 8: Update index.mustache template

Files:

  • Modify: templates/index.mustache

  • Step 1: Replace filter bar with shelf nav

Replace:

    {{#current_filter}}
    <div class="filter-bar">
      <a href="/index">Concise view</a>
    </div>
    {{/current_filter}}
    {{^current_filter}}
    <div class="filter-bar">
      <a href="/index?filter=all">All books ({{total_books}})</a>
      &nbsp;·&nbsp;
      <a href="/index?filter=want">Want list</a>
      &nbsp;·&nbsp;
      <a href="/index?filter=read">Read ({{reading_count}})</a>
    </div>
    {{/current_filter}}

With:

    <div class="filter-bar">
      <a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
      {{#shelf_nav}}
      &nbsp;·&nbsp;
      <a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
      {{/shelf_nav}}
    </div>
  • Step 2: Update title bar

Replace {{total_books}} books · {{reading_count}} reading with {{total_books}} books · {{shelf_count}} shelves

  • Step 3: Remove status pill from book rows

In the row div, remove:

      <span class="pill {{status_class}}">{{status_label}}</span>
  • Step 4: Remove summary_groups section

Remove the entire summary_groups block.

  • Step 5: Run a quick start test to verify no template errors

Run: emacs --batch --eval "(setq spine-skip-server-start t)" -l spine.el Expected: loads without error.

  • Step 6: Commit
git add templates/index.mustache
git commit -m "feat: adapt index template for shelf nav and grouping"

Task 9: Update add.mustache template

Files:

  • Modify: templates/add.mustache

  • Step 1: Replace category field with shelf dropdown

Replace:

        <label>Category <input type="text" name="category" list="categories-list" /></label>
        <datalist id="categories-list">{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}</datalist>

With:

        <label>Shelf * <select name="shelf" required>
          <option value="">— select —</option>
          {{#shelves}}<option value="{{.}}">{{.}}</option>{{/shelves}}
        </select></label>

Remove the authors-list datalist too — we removed it from the model:

        <datalist id="authors-list">{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}</datalist>
  • Step 2: Commit
git add templates/add.mustache
git commit -m "feat: adapt add template with shelf selector"

Task 10: Final integration check

Files:

  • All

  • Step 1: Run the full test suite

Run: emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)" Expected: All PASS.

  • Step 2: Spot-check the full flow loads

Run: emacs --batch --eval "(setq spine-skip-server-start nil)" -l spine.el --eval "(sleep-for 1)" --eval "(httpd-stop)" Expected: Spine starts and stops without error.