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
This commit is contained in:
@@ -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=<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)
|
||||
@@ -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" `<select>` dropdown
|
||||
populated from `spine-shelves`.
|
||||
- Required. No datalist — only existing shelves.
|
||||
- All other fields unchanged.
|
||||
|
||||
## Sample file
|
||||
|
||||
`sample-books.org` is restructured to the new shelf layout with
|
||||
shelves like "Fiction", "Non-Fiction", "Science Fiction".
|
||||
|
||||
## Test changes
|
||||
|
||||
- `spine-books-parses-sample`: loads restructured sample, asserts flat
|
||||
book list with `:shelf` keys.
|
||||
- `spine-books-first-book-has-all-fields`: adds `:shelf` assertion.
|
||||
- `spine-books-missing-properties-are-nil`: adds `:shelf` check.
|
||||
- `spine-index-model-*`: rewritten — tests shelf grouping, shelf
|
||||
filtering, empty-shelf inclusion. Status-based tests removed.
|
||||
- `spine-add-book-*`: tests require `:shelf` arg. Removes status check.
|
||||
Tests that books land under the correct shelf heading.
|
||||
- `spine-edit-*`: keeps note/rating tests. Removes status-change tests.
|
||||
Updates model-assertion tests for new minibuffer shape.
|
||||
- New `spine-shelves` tests: parses shelf names, includes empty shelves.
|
||||
Reference in New Issue
Block a user