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

1048 lines
37 KiB
Markdown

# 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.
```org
#+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`:
```elisp
(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:
```elisp
(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.
```elisp
(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**
```bash
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`:
```elisp
(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`:
```elisp
(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`:
```elisp
(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`:
```elisp
(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:
```elisp
(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
```elisp
(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**
```bash
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:
```elisp
(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:
```elisp
(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**
```bash
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:
```elisp
(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`:
```elisp
;;; 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:
```elisp
(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**
```bash
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=`:
```elisp
(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:
```elisp
(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:
```elisp
(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**
```bash
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:
```mustache
{{#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:
```mustache
<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:
```mustache
<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**
```bash
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:
```mustache
<label>Category <input type="text" name="category" list="categories-list" /></label>
<datalist id="categories-list">{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}</datalist>
```
With:
```mustache
<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:
```mustache
<datalist id="authors-list">{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}</datalist>
```
- [ ] **Step 2: Commit**
```bash
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.