docs: add data model and index page design spec
This commit is contained in:
@@ -0,0 +1,218 @@
|
|||||||
|
# Spine — data model and index page
|
||||||
|
|
||||||
|
Extends [`spine-spec.md`](../../spine-spec.md) with a concrete Org data structure
|
||||||
|
and defines the first real view: an index page that reads from `spine.org` and
|
||||||
|
displays books grouped by lifecycle status.
|
||||||
|
|
||||||
|
## Data model (`spine.org`)
|
||||||
|
|
||||||
|
Each book is a top-level headline. The TODO keyword is the lifecycle state.
|
||||||
|
Properties hold structured metadata. The headline body holds reading notes
|
||||||
|
as a plain Org list with inactive timestamps.
|
||||||
|
|
||||||
|
### TODO states
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Property | Required | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| `:AUTHOR:` | yes | freeform string |
|
||||||
|
| `:ISBN:` | no | freeform string |
|
||||||
|
| `:COVER:` | no | path relative to `spine.org` |
|
||||||
|
| `:FORMAT:` | no | `hardcover` \| `ebook` \| `audiobook` |
|
||||||
|
| `:REC_BY:` | no | who recommended it |
|
||||||
|
| `:REC_NOTE:` | no | why it's on your radar |
|
||||||
|
| `:RATING:` | no | 1–5, set when moved to `READ` |
|
||||||
|
| `:ADDED:` | no | `[YYYY-MM-DD]` date added to spine |
|
||||||
|
| `:ID:` | yes | stable unique identifier (e.g. `8c1e-uow`) |
|
||||||
|
|
||||||
|
Missing or empty properties → `nil` at read time. The view decides how to
|
||||||
|
render nils.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Org tags on the headline = categories (e.g. `:scifi:literary:`).
|
||||||
|
Tags filter and inherit natively in Org.
|
||||||
|
|
||||||
|
### Body: reading notes
|
||||||
|
|
||||||
|
A plain Org list in the headline body. Each item is prefixed with an inactive
|
||||||
|
timestamp `[YYYY-MM-DD]`. Appending a note = inserting one list item.
|
||||||
|
|
||||||
|
```org
|
||||||
|
- [2026-03-02] The two-track structure is doing something I can't name yet.
|
||||||
|
```
|
||||||
|
|
||||||
|
### LOGBOOK
|
||||||
|
|
||||||
|
State-change history is captured automatically by `org-log-into-drawer`.
|
||||||
|
This gives us the "when read" timeline for free — each state transition gets
|
||||||
|
a timestamped entry.
|
||||||
|
|
||||||
|
```org
|
||||||
|
:LOGBOOK:
|
||||||
|
- State "READING" from "WANT" [2026-02-20]
|
||||||
|
:END:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full example entry
|
||||||
|
|
||||||
|
```org
|
||||||
|
* READING Use of Weapons
|
||||||
|
: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]
|
||||||
|
:ID: 8c1e-uow
|
||||||
|
:END:
|
||||||
|
:LOGBOOK:
|
||||||
|
- State "READING" from "WANT" [2026-02-20]
|
||||||
|
: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.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Model/view split. Data extraction from Org is isolated from HTML rendering.
|
||||||
|
|
||||||
|
```
|
||||||
|
spine.org ──→ [spine-books] ──→ book plists ──→ [spine-render-index] ──→ HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model layer: `spine-books`
|
||||||
|
|
||||||
|
Opens the Org file, walks top-level headlines with `org-element`, returns a
|
||||||
|
list of plists — one per book.
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(spine-books) → list of plists
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-book plist shape:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(:id "8c1e-uow"
|
||||||
|
:title "Use of Weapons"
|
||||||
|
:status "READING"
|
||||||
|
: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…"
|
||||||
|
:rating nil
|
||||||
|
:added "[2026-02-20]"
|
||||||
|
:tags ("scifi" "literary")
|
||||||
|
:notes (("[2026-03-02]" "The two-track structure…")
|
||||||
|
("[2026-03-07]" "Zakalwe's competence…")))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Org file not found or invalid:** `spine-books` returns `nil` and messages a
|
||||||
|
warning. The `condition-case` around the parse catches malformed Org.
|
||||||
|
|
||||||
|
**Reading strategy:** visit `spine.org` in a temp buffer, parse with
|
||||||
|
`org-element-parse-buffer`, walk top-level headlines. Full scan — adequate
|
||||||
|
for ~tens of books. No `org-ql` yet; add it later when live filtering is needed.
|
||||||
|
|
||||||
|
### View layer: `spine-render-index`
|
||||||
|
|
||||||
|
Takes the book list and an optional selected ID, returns an HTML string.
|
||||||
|
No CSS, no JavaScript — structural HTML with CSS class names matching
|
||||||
|
mockup A so styles drop in later.
|
||||||
|
|
||||||
|
**Grouping:** books partitioned by status in this order:
|
||||||
|
`WANT` → `READING` → `READ` → `ABANDONED`.
|
||||||
|
Each group gets a header with count.
|
||||||
|
|
||||||
|
**Collapsed row per book:**
|
||||||
|
- Format glyph: `[H]` hardcover, `[E]` ebook, `[A]` audiobook (nothing if unset)
|
||||||
|
- Title + author
|
||||||
|
- Tags
|
||||||
|
- Recommendation trail (`REC_BY: REC_NOTE`) when present
|
||||||
|
- Status pill
|
||||||
|
|
||||||
|
**Expanded detail** (triggered by `?id=` query param):
|
||||||
|
- Reading log: each note as date + text
|
||||||
|
- Properties: format, ISBN, rating, added date
|
||||||
|
|
||||||
|
**Section structure:**
|
||||||
|
```html
|
||||||
|
<div class="frame">
|
||||||
|
<div class="titlebar">Spine</div>
|
||||||
|
<div class="group">WANT (3)</div>
|
||||||
|
<div class="row">…</div>
|
||||||
|
<div class="row">…</div>
|
||||||
|
<div class="row">…</div>
|
||||||
|
<div class="group">READING (1)</div>
|
||||||
|
<div class="row selected">
|
||||||
|
<div class="detail">
|
||||||
|
<!-- expanded notes for selected book -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty state:** when `spine-books` returns `nil` → page with "No books yet."
|
||||||
|
message.
|
||||||
|
|
||||||
|
### Handler
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(defservlet index text/html ()
|
||||||
|
(let ((books (spine-books)))
|
||||||
|
(if books
|
||||||
|
(insert (spine-render-index books
|
||||||
|
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
|
||||||
|
(insert (spine-render-empty-state)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/` → index, all books collapsed
|
||||||
|
- `/?id=8c1e-uow` → index with that book's detail expanded
|
||||||
|
- Bogus `?id=` → no expansion, normal collapsed view
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(defvar spine-org-file "~/spine/books.org"
|
||||||
|
"Path to the spine.org data file.")
|
||||||
|
```
|
||||||
|
|
||||||
|
Overridable before load.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `spine.org` missing | `spine-books` returns `nil` → empty-state page |
|
||||||
|
| `spine.org` invalid Org | `condition-case` around parse, message error, return `nil` |
|
||||||
|
| Bogus `?id=` param | no expansion, normal collapsed view |
|
||||||
|
| Package/bootstrap failures | surfaced at startup (existing scaffold behavior) |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Write endpoints (add book, add note, set status/rating/format)
|
||||||
|
- CSS styling
|
||||||
|
- JavaScript interactivity
|
||||||
|
- Cover image display
|
||||||
|
- `org-ql` integration
|
||||||
|
- Search or filtering beyond status grouping
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] `spine-books` parses a sample `spine.org` and returns correct plists
|
||||||
|
- [ ] Missing properties → `nil`, not errors
|
||||||
|
- [ ] `/?id=...` expands the matching book's detail
|
||||||
|
- [ ] Empty `spine.org` (no headlines) → empty list, not error
|
||||||
|
- [ ] Missing `spine.org` → empty-state page, no crash
|
||||||
|
- [ ] Groups appear in order: WANT, READING, READ, ABANDONED
|
||||||
|
- [ ] Groups with zero books are omitted from output
|
||||||
Reference in New Issue
Block a user