# 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.