{{title}}
{{author}}
{{#status_class}} {{status_label}} {{/status_class}} {{#meta}} {{meta}} {{/meta}}Reading log
{{#recommendation}}
On your radar
"{{note}}"
# 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
{{author}}
{{#status_class}} {{status_label}} {{/status_class}} {{#meta}} {{meta}} {{/meta}}Reading log
On your radar