Files
spine/docs/plans/2026-06-22-book-detail-implementation.md
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

19 KiB

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:

(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:

  (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:

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

;;; 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
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:

(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
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"

Expected: PASS for all tests.

  • Step 5: Commit
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
<!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 — {{title}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
  .book { max-width: 560px; margin-inline: auto; }
  .book hgroup { margin-bottom: .4rem; }
  .book hgroup h3 { margin-bottom: .1rem; }
  .book hgroup p { color: var(--pico-muted-color); margin: 0; }
  .cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
           background: var(--pico-primary-background); color: var(--pico-primary-inverse);
           display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
  .pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
  .pill.want { background: #e6f1fb; color: #0c447c; }
  .pill.reading { background: #faeeda; color: #854f0b; }
  .pill.read { background: #e1f5ee; color: #085041; }
  .formats { margin-bottom: 1.1rem; }
  .formats button { --pico-font-size: .85rem; }
  .seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
              text-transform: none; margin-bottom: .6rem; }
  .thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
  .entry { margin-bottom: .8rem; }
  .entry:last-child { margin-bottom: 0; }
  .edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
  .etext { font-size: .9rem; line-height: 1.5; }
  .avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
            background: var(--pico-primary-background); color: var(--pico-primary-inverse);
            display: flex; align-items: center; justify-content: center; font-size: .75rem; }
  .compose, .recform { margin-bottom: 0; }
  @media (prefers-color-scheme: dark) {
    .pill.want { background: #14304a; color: #b5d4f4; }
    .pill.reading { background: #3a2c12; color: #fac775; }
    .pill.read { background: #103a30; color: #9fe1cb; }
  }
</style>
</head>
<body>
<main class="container">
  <article class="book">
    <p><a href="{{back_url}}" style="font-size:.85rem;">&larr; Back to shelf</a></p>

    <div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
      <div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
      <div style="flex: 1; min-width: 0;">
        <hgroup>
          <h3>{{title}}</h3>
          <p>{{author}}</p>
        </hgroup>
        {{#status_class}}
        <span class="pill {{status_class}}">{{status_label}}</span>
        {{/status_class}}
        {{#meta}}
        <span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
        {{/meta}}
      </div>
    </div>

    <div class="formats" role="group">
      {{#formats}}
      <button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
      {{/formats}}
    </div>

    {{#notes}}
    <p class="seclabel">Reading log</p>
    <div class="thread">
      {{#notes}}
      <div class="entry">
        <div class="edate">{{date}}</div>
        <div class="etext">{{text}}</div>
      </div>
      {{/notes}}
    </div>
    {{/notes}}

    <div class="compose" role="group">
      <input type="text" placeholder="Add a note…" />
      <button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
    </div>

    <hr />

    {{#recommendation}}
    <p class="seclabel">On your radar</p>
    <div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
      <div class="avatar">{{initials}}</div>
      <div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong><br />"{{note}}"</div>
    </div>
    {{/recommendation}}
    <div class="recform" role="group">
      <input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
      <input type="text" placeholder="Why it's on your radar…" />
      <button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
    </div>

  </article>
</main>
</body>
</html>

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)

(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
emacs --batch --eval "(progn (setq spine-skip-server-start t) (load-file \"spine.el\"))"

Expected: loads without errors.

  • Step 3: Commit
git add templates/book.mustache spine.el
git commit -m "feat: add httpd/book handler and book detail template"

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:

      <span class="title">{{title}} <span class="muted">· {{author}}</span></span>

Replace with:

      <a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
      <span class="muted">· {{author}}</span>
  • Step 2: Commit
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
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
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.