diff --git a/docs/plans/2026-06-22-book-detail-implementation.md b/docs/plans/2026-06-22-book-detail-implementation.md new file mode 100644 index 0000000..57c8c60 --- /dev/null +++ b/docs/plans/2026-06-22-book-detail-implementation.md @@ -0,0 +1,539 @@ +# Book Detail View Implementation Plan + +**Goal:** Add a dedicated `/book?id=X` page showing full book details — header, format selector, reading log, note composer, and recommendation. + +**Architecture:** New `spine-book-model` function builds the view model `ht` for one book plist. New `httpd/book` handler serves the journal-style template. Index view gets title links pointing to the book page. + +**Tech Stack:** Emacs Lisp, simple-httpd, mustache.el, ht.el, Org-mode + +--- + +### Task 1: Extract shared format definitions + +**Files:** +- Modify: `spine.el` + +The format-icons list is currently local inside `spine-index-model`. Extract to a top-level `defconst` so `spine-book-model` can reuse it. + +- [ ] **Step 1: Add `spine--format-defs` defconst before `spine-index-model`** + +In `spine.el`, before `spine-index-model` (around line 66), add: + +```elisp +(defconst spine--format-defs + '(("hardcover" "ti-book" "Hardcover") + ("ebook" "ti-device-tablet" "eBook") + ("audiobook" "ti-headphones" "Audiobook")) + "Alist of (format-key icon label) for book format display.") +``` + +- [ ] **Step 2: Update `spine-index-model` to use `spine--format-defs`** + +Replace the local `format-icons` let-binding in `spine-index-model`: + +``` +OLD (line 70-73): + (let* ((format-icons + '(("hardcover" . "ti-book") + ("ebook" . "ti-device-tablet") + ("audiobook" . "ti-headphones"))) + (grouped (make-hash-table :test 'equal)) +``` + +NEW: +```elisp + (let* ((grouped (make-hash-table :test 'equal)) +``` + +And at the format_icon line (~103), change: +``` +OLD: ("format_icon" (or (cdr (assoc fmt format-icons)) "")) +NEW: ("format_icon" (or (nth 1 (assoc fmt spine--format-defs)) "")) +``` + +- [ ] **Step 3: Add `book_url` to each book model in `spine-index-model`** + +In the book model ht (around line 101-135), add a `book_url` field: + +```elisp +("book_url" (format "/book?id=%s" id)) +``` + +--- + +### Task 2: Write `spine-book-model` function + +**Files:** +- Create: `test/spine-book-model-test.el` +- Modify: `spine.el` + +- [ ] **Step 1: Write failing test file `test/spine-book-model-test.el`** + +```elisp +;;; spine-book-model-test.el — ERT tests for spine-book-model + +(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-book-model-test--uow + (cl-find "8c1e-uow" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Use of Weapons fixture.") + +(defconst spine-book-model-test--aj + (cl-find "3a1b-aj" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Ancillary Justice fixture.") + +(defconst spine-book-model-test--pir + (cl-find "5e8d-pir" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Piranesi fixture.") + +(ert-deftest spine-book-model-title-strips-todo () + "Title has TODO prefix stripped." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "title") "Use of Weapons")))) + +(ert-deftest spine-book-model-status-reading () + "READING book has status_class reading." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "status_class") "reading")) + (should (equal (ht-get model "status_label") "Reading")))) + +(ert-deftest spine-book-model-status-read () + "READ book has status_class read." + (let ((model (spine-book-model spine-book-model-test--aj))) + (should (equal (ht-get model "status_class") "read")) + (should (equal (ht-get model "status_label") "Read")))) + +(ert-deftest spine-book-model-status-want () + "WANT book has status_class want." + (let ((model (spine-book-model spine-book-model-test--pir))) + (should (equal (ht-get model "status_class") "want")) + (should (equal (ht-get model "status_label") "Want")))) + +(ert-deftest spine-book-model-author () + "Author is passed through." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "author") "Iain M. Banks")))) + +(ert-deftest spine-book-model-meta-with-format-and-date () + "Meta includes format and date when both present." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (string-match "started.*audiobook" (ht-get model "meta"))))) + +(ert-deftest spine-book-model-format-active () + "Format list has correct active flag." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (formats (ht-get model "formats"))) + (should (= (length formats) 3)) + (should (equal (ht-get (nth 0 formats) "label") "Hardcover")) + (should-not (ht-get (nth 0 formats) "active")) + (should (equal (ht-get (nth 1 formats) "label") "eBook")) + (should-not (ht-get (nth 1 formats) "active")) + (should (equal (ht-get (nth 2 formats) "label") "Audiobook")) + (should (ht-get (nth 2 formats) "active")))) + +(ert-deftest spine-book-model-notes () + "Notes are mapped correctly." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (notes (ht-get model "notes"))) + (should (= (length notes) 3)) + (should (equal (ht-get (nth 0 notes) "date") "2026-03-02")) + (should (equal (ht-get (nth 0 notes) "text") + "The two-track structure is doing something I can't name yet.")))) + +(ert-deftest spine-book-model-recommendation () + "Recommendation includes initials, by, and note." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (rec (ht-get model "recommendation"))) + (should rec) + (should (equal (ht-get rec "initials") "P")) + (should (equal (ht-get rec "by") "Priya")) + (should (string-match "Player of Games" (ht-get rec "note"))))) + +(ert-deftest spine-book-model-no-recommendation () + "No recommendation when rec_by absent." + (let ((model (spine-book-model spine-book-model-test--aj))) + (should-not (ht-get model "recommendation")))) + +(ert-deftest spine-book-model-no-todo-prefix () + "Book with no TODO prefix shows title as-is and empty status." + (let* ((books (spine-books)) + (tcm (cl-find "b2c1-tcm" books + :key (lambda (b) (plist-get b :id)) + :test #'equal))) + (should tcm) + (let ((model (spine-book-model tcm))) + (should (equal (ht-get model "title") "The Checklist Manifesto")) + (should (equal (ht-get model "status_class") "")) + (should (equal (ht-get model "status_label") ""))))) + +(ert-deftest spine-book-model-no-format-or-date () + "No format and no date → meta is empty." + (let* ((books (spine-books)) + (tcm (cl-find "b2c1-tcm" books + :key (lambda (b) (plist-get b :id)) + :test #'equal))) + (should tcm) + (let ((model (spine-book-model tcm))) + (should (equal (ht-get model "meta") ""))))) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)" +``` + +Expected: FAIL — `spine-book-model` undefined. + +- [ ] **Step 3: Write `spine-book-model` in `spine.el`** + +Add two helper functions and the main model function after `spine-add-form-model` (~line 64), before `spine-index-model`: + +```elisp +(defun spine--format-date (org-date-str) + "Format Org date \"[2026-02-20]\" to \"20 Feb\"." + (when (and org-date-str (> (length org-date-str) 0)) + (let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str))) + (condition-case nil + (format-time-string "%e %b" (date-to-time clean)) + (error org-date-str))))) + +(defun spine--parse-title-status (title) + "Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE. +TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"." + (let ((case-fold-search nil)) + (if (string-match + "\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'" + title) + (let ((kw (match-string 1 title)) + (rest (match-string 2 title))) + (list rest + (downcase kw) + (capitalize (downcase kw)))) + (list title "" "")))) + +(defun spine-book-model (book) + "Build view model `ht` for a single BOOK plist, for templates/book.mustache." + (let* ((id (plist-get book :id)) + (title (or (plist-get book :title) "")) + (author (or (plist-get book :author) "")) + (fmt (plist-get book :format)) + (added (plist-get book :added)) + (notes (plist-get book :notes)) + (rec-by (plist-get book :rec_by)) + (rec-note (plist-get book :rec_note)) + (title-info (spine--parse-title-status title)) + (clean-title (nth 0 title-info)) + (status-class (nth 1 title-info)) + (status-label (nth 2 title-info)) + (meta (cond + ((and added fmt) + (format "started %s · %s" (spine--format-date added) fmt)) + (added (format "started %s" (spine--format-date added))) + (fmt (format "format: %s" fmt)) + (t ""))) + (formats (mapcar + (lambda (fd) + (ht ("icon" (nth 1 fd)) + ("label" (nth 2 fd)) + ("active" (and fmt (equal (car fd) fmt))))) + spine--format-defs)) + (notes-model + (mapcar (lambda (n) + (ht ("date" (car n)) + ("text" (cadr n)))) + notes)) + (recommendation + (when (and rec-by (> (length rec-by) 0)) + (let ((initials + (mapconcat + (lambda (s) + (when (> (length s) 0) + (upcase (substring s 0 1)))) + (split-string rec-by " +" t) ""))) + (ht ("initials" initials) + ("by" rec-by) + ("note" (or rec-note ""))))))) + (ht ("cover_icon" "ti-book") + ("id" id) + ("title" clean-title) + ("author" author) + ("status_class" status-class) + ("status_label" status-label) + ("meta" meta) + ("progress_label" "") + ("formats" formats) + ("notes" notes-model) + ("recommendation" recommendation) + ("back_url" (format "/index?id=%s" id))))) +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)" +``` + +Expected: PASS for all tests. + +- [ ] **Step 5: Commit** + +```bash +git add spine.el test/spine-book-model-test.el +git commit -m "feat: add spine-book-model for book detail view model" +``` + +--- + +### Task 3: Create `templates/book.mustache` + +**Files:** +- Create: `templates/book.mustache` + +- [ ] **Step 1: Write the mustache template** + +Based on `spine-mockup-b-journal.mustache` with Pico CSS. Differences from mockup: +- Back link at top +- No progress_label in composer placeholder +- No recommendation date (not stored in data model) +- Conditional recommendation section + +```html + + + + + + +Spine — {{title}} + + + + + +
+
+

← Back to shelf

+ +
+
+
+
+

{{title}}

+

{{author}}

+
+ {{#status_class}} + {{status_label}} + {{/status_class}} + {{#meta}} + {{meta}} + {{/meta}} +
+
+ +
+ {{#formats}} + + {{/formats}} +
+ + {{#notes}} +

Reading log

+
+ {{#notes}} +
+
{{date}}
+
{{text}}
+
+ {{/notes}} +
+ {{/notes}} + +
+ + +
+ +
+ + {{#recommendation}} +

On your radar

+
+
{{initials}}
+
{{by}}
"{{note}}"
+
+ {{/recommendation}} +
+ + + +
+ +
+
+ + +``` + +Don't write the template yet — this step is informational. The actual template is created in the next step with `write`. + +- [ ] **Step 2: Create the template file** + +Create `templates/book.mustache` with the content above. + +--- + +### Task 4: Add `httpd/book` handler + +**Files:** +- Modify: `spine.el` + +- [ ] **Step 1: Add the handler function after `httpd/edit` (around line 491)** + +```elisp +(defun httpd/book (proc uri-path query request) + "Show the journal-style detail page for a single book." + (let* ((id (cadr (assoc "id" query))) + (books (spine-books)) + (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))) + (if book + (httpd-with-buffer proc "text/html" + (insert (spine-render "book.mustache" (spine-book-model book)))) + (httpd-redirect proc "/index" 302)))) +``` + +- [ ] **Step 2: Quick smoke test — load spine.el and check no errors** + +```bash +emacs --batch --eval "(progn (setq spine-skip-server-start t) (load-file \"spine.el\"))" +``` + +Expected: loads without errors. + +- [ ] **Step 3: Commit** + +```bash +git add templates/book.mustache spine.el +git commit -m "feat: add httpd/book handler and book detail template" +``` + +--- + +### Task 5: Add title links in index view + +**Files:** +- Modify: `spine.el` (already done in Task 1 Step 3 — `book_url` added) +- Modify: `templates/index.mustache` + +- [ ] **Step 1: Update `templates/index.mustache` to make title a link** + +Find line ~81 in `templates/index.mustache`: +``` + {{title}} · {{author}} +``` + +Replace with: +``` + {{title}} + · {{author}} +``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/index.mustache +git commit -m "feat: link book titles in index to detail page" +``` + +--- + +### Task 6: Run full test suite + +- [ ] **Step 1: Run all tests** + +```bash +emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)" +emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)" +emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)" +emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)" +emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)" +``` + +Expected: all PASS. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "test: verify book detail view and all existing tests pass" +``` + +--- + +### Task 7: Self-review + +- [ ] **Step 1: Verify spec coverage** + +Check each spec requirement maps to a task: +- Model fields (cover_icon, title, author, status_class, status_label, meta, formats, notes, recommendation) → Task 2 +- Status extraction (strip TODO prefix) → Task 2 Step 3 +- Format list with active flag → Task 2 Step 3 +- Recommendation with initials → Task 2 Step 3 +- Handler redirect on missing book → Task 4 +- Template with back link → Task 3 +- No recommendation → handled by mustache `{{#recommendation}}` section +- Title links in index → Task 5 +- Empty reading log → mustache `{{#notes}}` section handles empty + +- [ ] **Step 2: Placeholder scan** + +Verify no "TBD", "TODO", "implement later" in plan. + +- [ ] **Step 3: Type consistency check** + +Verify field names (cover_icon, status_class, etc.) match between model function and template. diff --git a/docs/plans/2026-06-22-shelves-implementation.md b/docs/plans/2026-06-22-shelves-implementation.md new file mode 100644 index 0000000..7470b45 --- /dev/null +++ b/docs/plans/2026-06-22-shelves-implementation.md @@ -0,0 +1,1047 @@ +# 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 +" + + + +Spine — note + + + +
+
+
spine · add note
+

%s

+
+ + + + + +
+
+
+ +" + 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}} +
+ Concise view +
+ {{/current_filter}} + {{^current_filter}} +
+ All books ({{total_books}}) +  ·  + Want list +  ·  + Read ({{reading_count}}) +
+ {{/current_filter}} +``` + +With: +```mustache +
+ All + {{#shelf_nav}} +  ·  + {{name}} + {{/shelf_nav}} +
+``` + +- [ ] **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 + {{status_label}} +``` + +- [ ] **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 + + {{#existing_categories}} +``` + +With: +```mustache + +``` + +Remove the `authors-list` datalist too — we removed it from the model: +```mustache + {{#existing_authors}} +``` + +- [ ] **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. diff --git a/docs/specs/2026-06-22-book-detail-view-design.md b/docs/specs/2026-06-22-book-detail-view-design.md new file mode 100644 index 0000000..348c552 --- /dev/null +++ b/docs/specs/2026-06-22-book-detail-view-design.md @@ -0,0 +1,109 @@ +# Book Detail View Design + +**Date:** 2026-06-22 +**Status:** Draft + +## Summary + +Add a dedicated book detail page (`/book?id=X`) following the journal-style mockup (Direction B). The page shows full book metadata, format badges, reading log, note composer, and recommendation section. Uses Pico CSS and Tabler icons, matching the add-book template style. + +## Route + +- **`GET /book?id=`** — renders the full detail page +- No POST handling (editing flows go through existing `/edit`) +- Book not found → redirect to `/index` + +## Model: `spine-book-model` + +Builds an `ht` (hashtable) view model from one book plist. + +### Fields + +| Field | Type | Source | Notes | +|---|---|---|---| +| `cover_icon` | string | hardcoded `"ti-book"` | Placeholder cover icon | +| `title` | string | `:title` minus TODO prefix | Strip leading `WANT `, `READING `, `READ ` | +| `author` | string | `:author` | Empty string when nil | +| `status_class` | string | TODO prefix → lowercase | `"want"` / `"reading"` / `"read"` / `""` | +| `status_label` | string | TODO prefix → capitalized | `"Want"` / `"Reading"` / `"Read"` / `""` | +| `meta` | string | format + added date | e.g. `"started 20 Feb · audiobook"`. Omits date if not present, format if not present | +| `progress_label` | string | — | Empty string; reserved for future progress tracking | +| `formats` | array | all 3 known formats | `[{icon, label, active}]`. Active matches `:format` property | +| `notes` | array | `:notes` | `[{date, text}]`. Empty array when no notes | +| `recommendation` | object or nil | `:rec_by` + `:rec_note` | `{initials, by, note}`. nil when no rec_by | + +### Status extraction + +Book headlines have TODO keyword prefixes (`WANT`, `READING`, `READ`) as part of their title text. The model function: + +1. Strips the prefix from the title for display +2. Maps recognized prefixes to status classes + +### Format list + +Hardcoded list of three known formats: +- `hardcover` → `ti-book` → "Hardcover" +- `ebook` → `ti-device-tablet` → "eBook" +- `audiobook` → `ti-headphones` → "Audiobook" + +The book's `:format` property determines which is `active: true`; the others are `active: false`. + +### Recommendation + +Generated from `:rec_by` (name) and `:rec_note` (text). No date stored currently. + +`initials` derived from the name: uppercase first letter of each space-separated part. e.g. "Priya" → "P", "John Doe" → "JD". + +## Template: `templates/book.mustache` + +Based on `spine-mockup-b-journal.mustache` with adaptations: +- Pico CSS framework +- Tabler icons for icons +- Progress-related text omitted from the composer placeholder (says "Add a note…" without percentage) +- Back link at top to return to index with this book selected (`/index?id=X`) +- No recommendation date field (not stored in data model) +- Conditional rendering of recommendation section + +## Handler: `httpd/book` + +Registered as `httpd/book` matching the existing add/edit handler pattern: + +```elisp +(defun httpd/book (proc uri-path query request) + "Show the journal-style detail page for a single book." + (let* ((id (cadr (assoc "id" query))) + (books (spine-books)) + (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))) + (if book + (insert (spine-render "book.mustache" (spine-book-model book))) + (httpd-redirect proc "/index" 302)))) +``` +| Action | File | Description | +|---|---|---| +| New | `templates/book.mustache` | Pico CSS + Tabler detail page | +| Modify | `spine.el` | Add `spine-book-model`, `httpd/book` handler, title link in index model | +| New | `test/spine-book-model-test.el` | Tests for `spine-book-model` | +| Modify | `test/spine-index-model-test.el` | Tests for title link in index model | + +## Edge cases + +- **No TODO prefix on headline** → title shown as-is, status empty +- **Unknown TODO prefix** (not WANT/READING/READ) → title shown with prefix, status empty +- **Book not found** → redirect to `/index` +- **No format property** → no format active in the selector +- **No author** → empty string +- **No notes** → empty reading log section +- **No recommendation** → section not rendered (optional block) + +## Testing + +Test file `test/spine-book-model-test.el` covers: +- Title stripped of TODO prefix +- Status class derived correctly for WANT/READING/READ +- Meta string from format + added date +- Format list with correct active flag +- Notes mapping +- Recommendation with initials +- No recommendation when rec_by absent +- Book with no TODO prefix (handles gracefully) +- Book with no format or date (meta is empty) diff --git a/docs/specs/2026-06-22-shelves-data-model-design.md b/docs/specs/2026-06-22-shelves-data-model-design.md new file mode 100644 index 0000000..6f4950a --- /dev/null +++ b/docs/specs/2026-06-22-shelves-data-model-design.md @@ -0,0 +1,133 @@ +# Shelves data model + +Replace the TODO-state-based book model with shelf-based organization. +Shelves are top-level Org headings; books are nested underneath them. + +## Motivation + +Books-as-top-level-headings with TODO states (WANT/READING/READ/ABANDONED) +made the file's structure reflect lifecycle status rather than organizing +concepts. The user wants arbitrary grouping ("shelves") as the primary +organizing axis. TODO states are removed — logbook entries (already present) +record when reading happened. + +## Org data model + +```org +#+TITLE: Spine + +* Fiction +** Piranesi +:PROPERTIES: +:AUTHOR: Susanna Clarke +:FORMAT: ebook +:REC_BY: Alex +:REC_NOTE: Le Guin at her most human +:RATING: +:ADDED: [2026-04-01] +:LAST_MODIFIED: [2026-04-01] +:ID: 5e8d-pir +:END: +::LOGBOOK: +- State "READ" from "WANT" [2026-01-10] +::END: +- [2025-11-22] The pronoun game is a genuinely fresh take on identity. + +* Non-Fiction +** The Checklist Manifesto +... +``` + +- Shelves are level-1 headlines (`*`) +- Books are level-2 headlines (`**`) +- Books keep their TODO keyword syntax for `org-mode` compatibility, + but the app ignores it entirely +- The `#+TODO:` line is removed — no TODO states +- Empty shelves (a level-1 headline with no children) are valid and + appear in the UI +- LOGBOOK drawers remain for historical state-change records but are + not surfaced by the UI + +## Key functions + +### `spine-shelves` (new) + +Returns an ordered list of shelf names (strings), one per level-1 headline +that is not a COMMENT or archive target. Empty shelves are included. + +### `spine-books` (modified) + +Walks level-1 headlines as shelves. For each shelf, walks level-2 children +as books. Returns flat plist list; each book plist gains a `:shelf` key +with the parent headline text. No status/TODO filtering. + +Places that previously checked Todo state (e.g. `spine-set-status`) are +removed — the only remaining mutable fields are notes, rating, format. + +### `spine-index-model` (modified) + +Groups books by `:shelf` instead of `:status`. Accepts an optional +`shelf` filter (instead of `read`/`want`/`all`) to show one shelf. +Includes shelf nav items for all shelves (even empty ones, from +`spine-shelves`). + +Removes: +- Summary groups (READ/ABANDONED links) +- Concise view truncation +- Status-related book model fields (status_class, status_label) + +### `spine-add-book` (modified) + +Takes required `:shelf` argument. Inserts the new book heading under +the matching level-1 shelf headline instead of at file end. If the +shelf doesn't exist, signals an error (shelves are created manually). + +Removes: +- `org-todo "WANT"` call +- `#+TODO:` file initialization +- Category field logic (replaced by shelf) + +### Functions removed + +- `spine-set-status` — no status to change. Logbook records history. +- All HTTP endpoints and UI actions that invoke it. + +## UI changes + +### Index view (`index.mustache`) + +- **Titlebar**: `N books · M shelves` +- **Filter bar**: replaced with shelf nav — links for each shelf name + plus "All" (default). Active shelf highlighted. +- **Groups**: one section per shelf. Each shelf shows its books in the + existing row format, minus the status pill. +- **Empty shelves**: shown as a group with the shelf name and "(empty)" + subtext. No expandable books. +- **Minibuffer** (when a book is selected): simplified to two actions — + **Add note**, **Set rating**. Same for every book. + +### Add form (`add.mustache`) + +- "Category" free-text field replaced with "Shelf" ` + + + +
+ + {{#recommendation}} +

On your radar

+
+
{{initials}}
+
{{by}}
"{{note}}"
+
+ {{/recommendation}} +
+ + + +
+ + + + + diff --git a/templates/index.mustache b/templates/index.mustache index 93b045a..feddca3 100644 --- a/templates/index.mustache +++ b/templates/index.mustache @@ -78,7 +78,8 @@ {{#books}}
- {{title}} · {{author}} + {{title}} + · {{author}} {{#tags_inline}}{{tags_inline}}{{/tags_inline}} {{#rec_via}}via {{rec_via}}{{/rec_via}} {{#rating_display}}{{rating_display}}{{/rating_display}} diff --git a/test/spine-book-model-test.el b/test/spine-book-model-test.el new file mode 100644 index 0000000..8bedb99 --- /dev/null +++ b/test/spine-book-model-test.el @@ -0,0 +1,125 @@ +;;; spine-book-model-test.el — ERT tests for spine-book-model + +(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-book-model-test--uow + (cl-find "8c1e-uow" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Use of Weapons fixture.") + +(defconst spine-book-model-test--aj + (cl-find "3a1b-aj" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Ancillary Justice fixture.") + +(defconst spine-book-model-test--pir + (cl-find "5e8d-pir" (spine-books) + :key (lambda (b) (plist-get b :id)) + :test #'equal) + "Piranesi fixture.") + +(ert-deftest spine-book-model-title-strips-todo () + "Title has TODO prefix stripped." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "title") "Use of Weapons")))) + +(ert-deftest spine-book-model-status-reading () + "READING book has status_class reading." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "status_class") "reading")) + (should (equal (ht-get model "status_label") "Reading")))) + +(ert-deftest spine-book-model-status-read () + "READ book has status_class read." + (let ((model (spine-book-model spine-book-model-test--aj))) + (should (equal (ht-get model "status_class") "read")) + (should (equal (ht-get model "status_label") "Read")))) + +(ert-deftest spine-book-model-status-want () + "WANT book has status_class want." + (let ((model (spine-book-model spine-book-model-test--pir))) + (should (equal (ht-get model "status_class") "want")) + (should (equal (ht-get model "status_label") "Want")))) + +(ert-deftest spine-book-model-author () + "Author is passed through." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "author") "Iain M. Banks")))) + +(ert-deftest spine-book-model-meta-with-format-and-date () + "Meta includes format and date when both present." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (string-match "started.*audiobook" (ht-get model "meta"))))) + +(ert-deftest spine-book-model-format-active () + "Format list has correct active flag." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (formats (ht-get model "formats"))) + (should (= (length formats) 3)) + (should (equal (ht-get (nth 0 formats) "label") "Hardcover")) + (should-not (ht-get (nth 0 formats) "active")) + (should (equal (ht-get (nth 1 formats) "label") "eBook")) + (should-not (ht-get (nth 1 formats) "active")) + (should (equal (ht-get (nth 2 formats) "label") "Audiobook")) + (should (ht-get (nth 2 formats) "active")))) + +(ert-deftest spine-book-model-notes () + "Notes are mapped correctly." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (notes (ht-get model "notes"))) + (should (= (length notes) 3)) + (should (equal (ht-get (nth 0 notes) "date") "2026-03-02")) + (should (equal (ht-get (nth 0 notes) "text") + "The two-track structure is doing something I can't name yet.")))) + +(ert-deftest spine-book-model-recommendation () + "Recommendation includes initials, by, and note." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (rec (ht-get model "recommendation"))) + (should rec) + (should (equal (ht-get rec "initials") "P")) + (should (equal (ht-get rec "by") "Priya")) + (should (string-match "Player of Games" (ht-get rec "note"))))) + +(ert-deftest spine-book-model-no-recommendation () + "No recommendation when rec_by absent." + (let ((model (spine-book-model spine-book-model-test--aj))) + (should-not (ht-get model "recommendation")))) + +(ert-deftest spine-book-model-no-todo-prefix () + "Book with no TODO prefix shows title as-is and empty status." + (let* ((books (spine-books)) + (tcm (cl-find "b2c1-tcm" books + :key (lambda (b) (plist-get b :id)) + :test #'equal))) + (should tcm) + (let ((model (spine-book-model tcm))) + (should (equal (ht-get model "title") "The Checklist Manifesto")) + (should (equal (ht-get model "status_class") "")) + (should (equal (ht-get model "status_label") ""))))) + +(ert-deftest spine-book-model-has-date-no-format () + "Has date but no format → meta shows started date only." + (let* ((books (spine-books)) + (tcm (cl-find "b2c1-tcm" books + :key (lambda (b) (plist-get b :id)) + :test #'equal))) + (should tcm) + (let ((model (spine-book-model tcm))) + (should (equal (ht-get model "meta") "started 15 Jun"))))) + +(ert-deftest spine-book-model-back-url () + "back_url links to index with this book's id." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "back_url") "/index?id=8c1e-uow"))))