Compare commits
7 Commits
b6b0539b82
...
31833bf38e
| Author | SHA1 | Date | |
|---|---|---|---|
| 31833bf38e | |||
| 439f9d3eed | |||
| 4cf11fdd02 | |||
| 8e07d0efec | |||
| 126330c1dc | |||
| d00732a8ca | |||
| fbe49d729b |
@@ -0,0 +1,28 @@
|
|||||||
|
# Spine — Agent Workflow
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
This project does not use pull requests or feature branches. All work is committed
|
||||||
|
directly to `main` and pushed.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Commit early, commit often** — small, focused commits with clear messages.
|
||||||
|
2. **Push after each logical batch** — don't accumulate un-pushed work.
|
||||||
|
3. **Test before push** — run the relevant test suite; don't push known failures.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use conventional commits:
|
||||||
|
- `feat:` for new features
|
||||||
|
- `fix:` for bug fixes
|
||||||
|
- `test:` for test additions/changes
|
||||||
|
- `docs:` for documentation
|
||||||
|
- `refactor:` for code restructuring
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run tests before pushing:
|
||||||
|
```bash
|
||||||
|
emacs --batch -l test/<test-file>.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
# Concise Default View Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Default index shows only READING + 5 most recent WANT books, with filter links to expanded views.
|
||||||
|
|
||||||
|
**Architecture:** `spine-index-model` gains an optional `filter` argument (nil = concise, "all"/"read"/"want" = expanded). The handler reads `filter` from the request query and passes it through. The template gains conditional nav links and summary-group rows. Existing group rendering unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Emacs Lisp (model/handler), Mustache (template)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File overview
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `spine.el` — `spine-index-model` (add filter param, concise logic), `defservlet index` (pass query filter)
|
||||||
|
- `templates/index.mustache` — filter nav bar, summary-group rows
|
||||||
|
- `test/spine-index-model-test.el` — new test file for filtered model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Write failing tests for filtered `spine-index-model`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/spine-index-model-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create test file with setup**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
|
||||||
|
|
||||||
|
(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-index-model-test--books (spine-books)
|
||||||
|
"Books fixture loaded once from sample-books.org.")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write test — concise view excludes READ and ABANDONED groups, has summary**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
|
||||||
|
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should-not (member "read" group-labels))
|
||||||
|
(should-not (member "abandoned" group-labels))
|
||||||
|
(should summaries)
|
||||||
|
(should (= (length summaries) 1)) ; only READ, no ABANDONED in sample data
|
||||||
|
(should (string= (ht-get (car summaries) "label") "read"))
|
||||||
|
(should (= (ht-get (car summaries) "count") 1)) ; Ancillary Justice
|
||||||
|
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write test — concise view includes READING group**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-concise-includes-reading ()
|
||||||
|
"Concise view includes READING group with all reading books."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(reading-group (cl-find "reading" groups
|
||||||
|
:key (lambda (g) (ht-get g "label"))
|
||||||
|
:test #'string=)))
|
||||||
|
(should reading-group)
|
||||||
|
(should (= (ht-get reading-group "count") 1)) ; Use of Weapons
|
||||||
|
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
|
||||||
|
"Use of Weapons"))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write test — concise view limits WANT to 5 most recent**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-concise-want-limited-to-five ()
|
||||||
|
"Concise view limits WANT group to 5 books, sorted by :ADDED: desc."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(want-group (cl-find "on deck" groups
|
||||||
|
:key (lambda (g) (ht-get g "label"))
|
||||||
|
:test #'string=)))
|
||||||
|
(should want-group)
|
||||||
|
(let ((books (ht-get want-group "books")))
|
||||||
|
(should (<= (length books) 5))
|
||||||
|
;; sample-books.org has 3 WANT books — all should appear, sorted newest first
|
||||||
|
(should (= (length books) 3))
|
||||||
|
;; Piranesi added 2026-06-01, Dune 2026-05-10, Left Hand 2026-04-01
|
||||||
|
(should (string= (ht-get (nth 0 books) "title") "Piranesi"))
|
||||||
|
(should (string= (ht-get (nth 1 books) "title") "Dune"))
|
||||||
|
(should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness")))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write test — filter=all returns full model (no summary, all groups)**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-filter-all-shows-all-groups ()
|
||||||
|
"filter=all returns all groups, no summary-groups."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "all"))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should-not summaries)
|
||||||
|
(should (member "on deck" group-labels))
|
||||||
|
(should (member "reading" group-labels))
|
||||||
|
(should (member "read" group-labels))
|
||||||
|
(should (= (length groups) 3)))) ; WANT, READING, READ (no ABANDONED in sample)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write test — filter=read returns only READ group**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-filter-read-returns-only-read ()
|
||||||
|
"filter=read returns only the READ group."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "read"))
|
||||||
|
(groups (ht-get model "groups")))
|
||||||
|
(should (= (length groups) 1))
|
||||||
|
(should (string= (ht-get (car groups) "label") "read"))
|
||||||
|
(should (= (ht-get (car groups) "count") 1))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Write test — filter=want returns only WANT group**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-filter-want-returns-only-want ()
|
||||||
|
"filter=want returns only the WANT group."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "want"))
|
||||||
|
(groups (ht-get model "groups")))
|
||||||
|
(should (= (length groups) 1))
|
||||||
|
(should (string= (ht-get (car groups) "label") "on deck"))
|
||||||
|
(should (= (ht-get (car groups) "count") 3))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Write test — invalid filter falls back to concise**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
|
||||||
|
"Unrecognised filter value behaves like nil (concise view)."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should summaries) ; has summary
|
||||||
|
(should-not (member "read" group-labels)) ; no read group
|
||||||
|
(should (member "reading" group-labels)) ; has reading
|
||||||
|
(should (member "on deck" group-labels)))) ; has want
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Write test — model includes current_filter in context**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(ert-deftest spine-index-model-includes-current-filter ()
|
||||||
|
"Model includes :current_filter key matching the filter argument."
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books) "current_filter") ""))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read"))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want")))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /work/personal-local/spine
|
||||||
|
emacs --batch -l test/spine-index-model-test.el \
|
||||||
|
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests fail because `spine-index-model` doesn't accept a second argument yet.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit test file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add test/spine-index-model-test.el
|
||||||
|
git commit -m "test: add failing tests for filtered spine-index-model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement filtered `spine-index-model`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `spine.el:77-155`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update signature and add filter handling**
|
||||||
|
|
||||||
|
Swap the function at line 77:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(defun spine-index-model (books &optional filter selected-id)
|
||||||
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||||
|
FILTER controls which books are shown:
|
||||||
|
nil — concise: READING + 5 most recent WANT, others as summary links
|
||||||
|
\"all\" — full listing, no truncation
|
||||||
|
\"read\" — only READ books
|
||||||
|
\"want\" — only WANT books
|
||||||
|
SELECTED-ID expands the matching book's detail section."
|
||||||
|
(let* ((status-labels
|
||||||
|
'(("WANT" . "on deck")
|
||||||
|
("READING" . "reading")
|
||||||
|
("READ" . "read")
|
||||||
|
("ABANDONED" . "abandoned")))
|
||||||
|
(format-icons
|
||||||
|
'(("hardcover" . "ti-book")
|
||||||
|
("ebook" . "ti-device-tablet")
|
||||||
|
("audiobook" . "ti-headphones")))
|
||||||
|
(order '("WANT" "READING" "READ" "ABANDONED"))
|
||||||
|
(valid-filters '("all" "read" "want"))
|
||||||
|
(effective-filter (if (member filter valid-filters) filter nil))
|
||||||
|
(grouped (make-hash-table :test 'equal))
|
||||||
|
(total (length books))
|
||||||
|
(reading-count 0))
|
||||||
|
(dolist (book books)
|
||||||
|
(when (equal "READING" (plist-get book :status))
|
||||||
|
(cl-incf reading-count)))
|
||||||
|
(dolist (book books)
|
||||||
|
(let ((status (or (plist-get book :status) "WANT")))
|
||||||
|
(push book (gethash status grouped))))
|
||||||
|
(let ((groups nil)
|
||||||
|
(summary-groups nil))
|
||||||
|
(dolist (status order)
|
||||||
|
(let ((group-books (nreverse (gethash status grouped))))
|
||||||
|
(when group-books
|
||||||
|
(if (and (null effective-filter)
|
||||||
|
(member status '("READ" "ABANDONED")))
|
||||||
|
;; Concise: replace read/abandoned groups with summary links
|
||||||
|
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||||
|
("count" (length group-books))
|
||||||
|
("href" (format "/index?filter=%s"
|
||||||
|
(downcase status))))
|
||||||
|
summary-groups)
|
||||||
|
;; Show the group (possibly filtered to one status)
|
||||||
|
(when (or (null effective-filter)
|
||||||
|
(string= (downcase status) effective-filter)
|
||||||
|
(string= effective-filter "all"))
|
||||||
|
;; Sort WANT by ADDED descending when in concise view
|
||||||
|
(when (and (null effective-filter)
|
||||||
|
(string= status "WANT"))
|
||||||
|
(setq group-books
|
||||||
|
(cl-subseq (sort group-books
|
||||||
|
(lambda (a b)
|
||||||
|
(let ((a-date (plist-get a :added))
|
||||||
|
(b-date (plist-get b :added)))
|
||||||
|
(cond
|
||||||
|
((null a-date) nil)
|
||||||
|
((null b-date) t)
|
||||||
|
(t (string> a-date b-date))))))
|
||||||
|
0 (min (length group-books) 5))))
|
||||||
|
(let ((book-models nil))
|
||||||
|
(dolist (book group-books)
|
||||||
|
(let* ((id (plist-get book :id))
|
||||||
|
(selected (equal id selected-id))
|
||||||
|
(fmt (plist-get book :format))
|
||||||
|
(rating (plist-get book :rating))
|
||||||
|
(notes (plist-get book :notes))
|
||||||
|
(status (or (plist-get book :status) "WANT"))
|
||||||
|
(model
|
||||||
|
(ht
|
||||||
|
("format_icon"
|
||||||
|
(or (cdr (assoc fmt format-icons)) ""))
|
||||||
|
("status_class" (downcase status))
|
||||||
|
("status_label" (downcase status))
|
||||||
|
("title" (plist-get book :title))
|
||||||
|
("author" (or (plist-get book :author) ""))
|
||||||
|
("tags_inline"
|
||||||
|
(let ((tags (plist-get book :tags)))
|
||||||
|
(when tags
|
||||||
|
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||||
|
tags " "))))
|
||||||
|
("rec_via" (plist-get book :rec_by))
|
||||||
|
("rating_display"
|
||||||
|
(when (and rating (> (length rating) 0))
|
||||||
|
(make-string (string-to-number rating) ?\u2605)))
|
||||||
|
("selected" selected)
|
||||||
|
("detail"
|
||||||
|
(when selected
|
||||||
|
(ht
|
||||||
|
("meta"
|
||||||
|
(let ((added (plist-get book :added))
|
||||||
|
(fmt (plist-get book :format)))
|
||||||
|
(cond
|
||||||
|
((and added fmt)
|
||||||
|
(format "started %s · %s" added fmt))
|
||||||
|
(added (format "started %s" added))
|
||||||
|
(fmt (format "format: %s" fmt))
|
||||||
|
(t ""))))
|
||||||
|
("notes"
|
||||||
|
(when notes
|
||||||
|
(cl-loop for (date text) in notes
|
||||||
|
collect (ht
|
||||||
|
("date" (format "[%s]" date))
|
||||||
|
("text" text)))))))))))
|
||||||
|
(push model book-models)))
|
||||||
|
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||||
|
("count" (length group-books))
|
||||||
|
("books" (nreverse book-models)))
|
||||||
|
groups))))))
|
||||||
|
(ht ("app_title" "spine")
|
||||||
|
("total_books" total)
|
||||||
|
("reading_count" reading-count)
|
||||||
|
("current_filter" (or effective-filter ""))
|
||||||
|
("summary_groups" (nreverse summary-groups))
|
||||||
|
("groups" (nreverse groups))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /work/personal-local/spine
|
||||||
|
emacs --batch -l test/spine-index-model-test.el \
|
||||||
|
-l test/spine-books-test.el \
|
||||||
|
-l test/spine-add-book-test.el \
|
||||||
|
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass (new filter tests + existing books + add-book tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add spine.el
|
||||||
|
git commit -m "feat: add filter parameter to spine-index-model for concise default view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update `defservlet index` handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `spine.el:308-313`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Pass query filter to model**
|
||||||
|
|
||||||
|
Swap lines 308-313:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(defservlet index text/html (path query request)
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(filter (cadr (assoc "filter" query))))
|
||||||
|
(if books
|
||||||
|
(insert (spine-render "index.mustache"
|
||||||
|
(spine-index-model books filter (cadr (assoc "id" query)))))
|
||||||
|
(insert (spine-render-empty-state)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run existing tests to confirm handler change doesn't break anything**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /work/personal-local/spine
|
||||||
|
emacs --batch -l test/spine-index-model-test.el \
|
||||||
|
-l test/spine-books-test.el \
|
||||||
|
-l test/spine-add-book-test.el \
|
||||||
|
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add spine.el
|
||||||
|
git commit -m "feat: pass filter query param from index handler to model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update `templates/index.mustache` with filter links and summary groups
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/index.mustache`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add filter nav bar after header, before groups**
|
||||||
|
|
||||||
|
Insert after the closing `</header>` (line 87) and before the `{{#groups}}` section (line 89):
|
||||||
|
|
||||||
|
```mustache
|
||||||
|
{{#current_filter}}
|
||||||
|
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||||
|
<a href="/index">Concise view</a>
|
||||||
|
</nav>
|
||||||
|
{{/current_filter}}
|
||||||
|
{{^current_filter}}
|
||||||
|
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||||
|
<span>
|
||||||
|
<a href="/index?filter=all">All books ({{total_books}})</a>
|
||||||
|
·
|
||||||
|
<a href="/index?filter=want">Want list</a>
|
||||||
|
·
|
||||||
|
<a href="/index?filter=read">Read ({{reading_count}})</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
{{/current_filter}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add summary group rows after groups section**
|
||||||
|
|
||||||
|
Insert after the closing `{{/groups}}` (line 109) and before `<div class="endpad">` (line 110):
|
||||||
|
|
||||||
|
```mustache
|
||||||
|
{{#summary_groups}}
|
||||||
|
<div class="row"><a href="{{href}}">{{label}} · {{count}}</a></div>
|
||||||
|
{{/summary_groups}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/index.mustache
|
||||||
|
git commit -m "feat: add filter nav bar and summary group links to index template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Manual integration test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the server and verify concise default view**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /work/personal-local/spine
|
||||||
|
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
|
||||||
|
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||||
|
--eval "(httpd-start)" \
|
||||||
|
--eval "(message \"Server running on port 8080\")" &
|
||||||
|
sleep 2
|
||||||
|
curl -s http://localhost:8080/index | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the output:
|
||||||
|
- Shows READING group (Use of Weapons)
|
||||||
|
- Shows WANT group (Piranesi, Dune, Left Hand)
|
||||||
|
- Does NOT show READ group (Ancillary Justice) as a full group
|
||||||
|
- Shows a summary link for "read · 1"
|
||||||
|
- Shows "All books (5)" filter nav link
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test filter=all**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/index?filter=all"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: all books shown in their groups, no summary sections, "Concise view" link present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test filter=read**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/index?filter=read"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: only Ancillary Justice shown, "Concise view" link present.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Kill the server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kill %1 2>/dev/null; wait 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests one final time**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /work/personal-local/spine
|
||||||
|
emacs --batch -l test/spine-index-model-test.el \
|
||||||
|
-l test/spine-books-test.el \
|
||||||
|
-l test/spine-add-book-test.el \
|
||||||
|
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Final commit if any fixes needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -am "fix: address integration test findings"
|
||||||
|
```
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Concise Default View — design spec
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The index page shows every book grouped by status. As the library grows, this becomes
|
||||||
|
too much information at a glance. The user needs a focused default showing only what's
|
||||||
|
active right now, with easy paths to browse the rest.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The index view takes an optional `filter` query parameter. With no filter, it shows a
|
||||||
|
concise default: only books in-flight (READING) and the 5 most recently added WANT
|
||||||
|
books. All other books are accessible via filter links. With a filter (`all`, `read`,
|
||||||
|
`want`), it shows the full matching subset.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
### Default / no filter (concise view)
|
||||||
|
|
||||||
|
- **READING group**: all READING books (unchanged from current).
|
||||||
|
- **WANT group**: sorted by `:ADDED:` date descending, limited to 5.
|
||||||
|
- **READ / ABANDONED**: replaced by summary links — e.g. "Read · 3" as a clickable
|
||||||
|
link to `/index?filter=read`.
|
||||||
|
- **Filter nav bar** shown at the top: links to `All books (N)`, `Want list`,
|
||||||
|
`Read (N)`.
|
||||||
|
|
||||||
|
### `?filter=all`
|
||||||
|
|
||||||
|
Full index, all groups, no truncation — identical to today's default.
|
||||||
|
|
||||||
|
### `?filter=read`
|
||||||
|
|
||||||
|
Only the READ group. Single group in the listing.
|
||||||
|
|
||||||
|
### `?filter=want`
|
||||||
|
|
||||||
|
Only the WANT group. Single group in the listing.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- An unrecognised/empty filter value is treated as `nil` (concise default).
|
||||||
|
- A WANT book with no `:ADDED:` property sorts before dated entries (treated as
|
||||||
|
oldest).
|
||||||
|
- Fewer than 5 WANT books → all are shown, no padding or placeholder rows.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Model: `spine-index-model`
|
||||||
|
|
||||||
|
Signature changes from:
|
||||||
|
```
|
||||||
|
spine-index-model (books &optional selected-id)
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```
|
||||||
|
spine-index-model (books &optional filter selected-id)
|
||||||
|
```
|
||||||
|
|
||||||
|
When `filter` is nil, after building the group alist:
|
||||||
|
- Sort the `:want` group entries by `added` descending (ISO date string; nils sort
|
||||||
|
first = oldest).
|
||||||
|
- Truncate to 5 entries.
|
||||||
|
- Replace `:read` and `:abandoned` groups with a `:summary-groups` list. Each
|
||||||
|
entry is a plist matching the existing model convention:
|
||||||
|
```
|
||||||
|
(:label "read" :count 3 :href "/index?filter=read")
|
||||||
|
```
|
||||||
|
The template renders these as clickable `.row` links.
|
||||||
|
When `filter` is non-nil, `:summary-groups` is absent.
|
||||||
|
When `filter` is non-nil, return only the matching group (or all groups for `"all"`).
|
||||||
|
|
||||||
|
### Handler: `defservlet index`
|
||||||
|
|
||||||
|
Read the `filter` key from the request query alist. Pass it to `spine-index-model`.
|
||||||
|
Pass `current_filter` in the template context for conditional rendering.
|
||||||
|
|
||||||
|
### Template: `index.mustache`
|
||||||
|
|
||||||
|
- **Filter bar** (above groups): shown when `current_filter` is nil (links to expanded
|
||||||
|
views) or truthy (link back to concise view).
|
||||||
|
- **Summary groups**: new `{{#summary_groups}}` block renders a simple `.row` link per
|
||||||
|
suppressed group (READ, ABANDONED).
|
||||||
|
- Existing group rendering unchanged — still handles full listing when `filter=all`
|
||||||
|
or single-group views.
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
|
||||||
|
Within the WANT group for the concise view: sort by `added` field descending. The
|
||||||
|
`added` field is already part of each book's model entry. Nil dates sort before dated
|
||||||
|
entries (oldest).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Pagination for large WANT lists (5 is a design choice; if the user wants more, the
|
||||||
|
"All books" link is there).
|
||||||
|
- Remembering the user's last selected filter across requests (stateless is simpler).
|
||||||
|
- Keyboard-driven filter switching (plain links for now).
|
||||||
@@ -74,8 +74,13 @@ Returns the rendered string."
|
|||||||
("existing_authors" (sort authors #'string<))
|
("existing_authors" (sort authors #'string<))
|
||||||
("existing_categories" (sort categories #'string<)))))
|
("existing_categories" (sort categories #'string<)))))
|
||||||
|
|
||||||
(defun spine-index-model (books &optional selected-id)
|
(defun spine-index-model (books &optional filter selected-id)
|
||||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||||
|
FILTER controls which books are shown:
|
||||||
|
nil - concise: READING + 5 most recent WANT, others as summary links
|
||||||
|
all - full listing, no truncation
|
||||||
|
read - only READ books
|
||||||
|
want - only WANT books
|
||||||
SELECTED-ID expands the matching book's detail section."
|
SELECTED-ID expands the matching book's detail section."
|
||||||
(let* ((status-labels
|
(let* ((status-labels
|
||||||
'(("WANT" . "on deck")
|
'(("WANT" . "on deck")
|
||||||
@@ -87,6 +92,8 @@ SELECTED-ID expands the matching book's detail section."
|
|||||||
("ebook" . "ti-device-tablet")
|
("ebook" . "ti-device-tablet")
|
||||||
("audiobook" . "ti-headphones")))
|
("audiobook" . "ti-headphones")))
|
||||||
(order '("WANT" "READING" "READ" "ABANDONED"))
|
(order '("WANT" "READING" "READ" "ABANDONED"))
|
||||||
|
(valid-filters '("all" "read" "want"))
|
||||||
|
(effective-filter (if (member filter valid-filters) filter nil))
|
||||||
(grouped (make-hash-table :test 'equal))
|
(grouped (make-hash-table :test 'equal))
|
||||||
(total (length books))
|
(total (length books))
|
||||||
(reading-count 0))
|
(reading-count 0))
|
||||||
@@ -96,64 +103,88 @@ SELECTED-ID expands the matching book's detail section."
|
|||||||
(dolist (book books)
|
(dolist (book books)
|
||||||
(let ((status (or (plist-get book :status) "WANT")))
|
(let ((status (or (plist-get book :status) "WANT")))
|
||||||
(push book (gethash status grouped))))
|
(push book (gethash status grouped))))
|
||||||
(let ((groups nil))
|
(let ((groups nil)
|
||||||
|
(summary-groups nil))
|
||||||
(dolist (status order)
|
(dolist (status order)
|
||||||
(let ((group-books (nreverse (gethash status grouped))))
|
(let ((group-books (nreverse (gethash status grouped))))
|
||||||
(when group-books
|
(when group-books
|
||||||
(let ((book-models nil))
|
(if (and (null effective-filter)
|
||||||
(dolist (book group-books)
|
(member status '("READ" "ABANDONED")))
|
||||||
(let* ((id (plist-get book :id))
|
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||||
(selected (equal id selected-id))
|
("count" (length group-books))
|
||||||
(fmt (plist-get book :format))
|
("href" (format "/index?filter=%s"
|
||||||
(rating (plist-get book :rating))
|
(downcase status))))
|
||||||
(notes (plist-get book :notes))
|
summary-groups)
|
||||||
(status (or (plist-get book :status) "WANT"))
|
(when (or (null effective-filter)
|
||||||
(model
|
(string= (downcase status) effective-filter)
|
||||||
(ht
|
(string= effective-filter "all"))
|
||||||
("format_icon"
|
(when (and (null effective-filter)
|
||||||
(or (cdr (assoc fmt format-icons)) ""))
|
(string= status "WANT"))
|
||||||
("status_class" (downcase status))
|
(setq group-books
|
||||||
("status_label" (downcase status))
|
(cl-subseq (sort group-books
|
||||||
("title" (plist-get book :title))
|
(lambda (a b)
|
||||||
("author" (or (plist-get book :author) ""))
|
(let ((a-date (plist-get a :added))
|
||||||
("tags_inline"
|
(b-date (plist-get b :added)))
|
||||||
(let ((tags (plist-get book :tags)))
|
(cond
|
||||||
(when tags
|
((null a-date) nil)
|
||||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
((null b-date) t)
|
||||||
tags " "))))
|
(t (string> a-date b-date))))))
|
||||||
("rec_via" (plist-get book :rec_by))
|
0 (min (length group-books) 5))))
|
||||||
("rating_display"
|
(let ((book-models nil))
|
||||||
(when (and rating (> (length rating) 0))
|
(dolist (book group-books)
|
||||||
(make-string (string-to-number rating) ?\u2605)))
|
(let* ((id (plist-get book :id))
|
||||||
("selected" selected)
|
(selected (equal id selected-id))
|
||||||
("detail"
|
(fmt (plist-get book :format))
|
||||||
(when selected
|
(rating (plist-get book :rating))
|
||||||
|
(notes (plist-get book :notes))
|
||||||
|
(status (or (plist-get book :status) "WANT"))
|
||||||
|
(model
|
||||||
(ht
|
(ht
|
||||||
("meta"
|
("format_icon"
|
||||||
(let ((added (plist-get book :added))
|
(or (cdr (assoc fmt format-icons)) ""))
|
||||||
(fmt (plist-get book :format)))
|
("status_class" (downcase status))
|
||||||
(cond
|
("status_label" (downcase status))
|
||||||
((and added fmt)
|
("title" (plist-get book :title))
|
||||||
(format "started %s · %s" added fmt))
|
("author" (or (plist-get book :author) ""))
|
||||||
(added (format "started %s" added))
|
("tags_inline"
|
||||||
(fmt (format "format: %s" fmt))
|
(let ((tags (plist-get book :tags)))
|
||||||
(t ""))))
|
(when tags
|
||||||
("notes"
|
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||||
(when notes
|
tags " "))))
|
||||||
(cl-loop for (date text) in notes
|
("rec_via" (plist-get book :rec_by))
|
||||||
collect (ht
|
("rating_display"
|
||||||
("date" (format "[%s]" date))
|
(when (and rating (> (length rating) 0))
|
||||||
("text" text)))))))))))
|
(make-string (string-to-number rating) ?\u2605)))
|
||||||
(push model book-models)))
|
("selected" selected)
|
||||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
("detail"
|
||||||
("count" (length group-books))
|
(when selected
|
||||||
("books" (nreverse book-models)))
|
(ht
|
||||||
groups)))))
|
("meta"
|
||||||
|
(let ((added (plist-get book :added))
|
||||||
|
(fmt (plist-get book :format)))
|
||||||
|
(cond
|
||||||
|
((and added fmt)
|
||||||
|
(format "started %s \302\267 %s" added fmt))
|
||||||
|
(added (format "started %s" added))
|
||||||
|
(fmt (format "format: %s" fmt))
|
||||||
|
(t ""))))
|
||||||
|
("notes"
|
||||||
|
(when notes
|
||||||
|
(cl-loop for (date text) in notes
|
||||||
|
collect (ht
|
||||||
|
("date" (format "[%s]" date))
|
||||||
|
("text" text)))))))))))
|
||||||
|
(push model book-models)))
|
||||||
|
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||||
|
("count" (length group-books))
|
||||||
|
("books" (nreverse book-models)))
|
||||||
|
groups)))))))
|
||||||
(ht ("app_title" "spine")
|
(ht ("app_title" "spine")
|
||||||
("total_books" total)
|
("total_books" total)
|
||||||
("reading_count" reading-count)
|
("reading_count" reading-count)
|
||||||
|
("current_filter" effective-filter)
|
||||||
|
("summary_groups" (nreverse summary-groups))
|
||||||
("groups" (nreverse groups))))))
|
("groups" (nreverse groups))))))
|
||||||
|
|
||||||
(defun spine-add-book (&rest args)
|
(defun spine-add-book (&rest args)
|
||||||
"Add a new WANT book to `spine-org-file'.
|
"Add a new WANT book to `spine-org-file'.
|
||||||
Keyword arguments: :title :author :category :format :isbn :cover
|
Keyword arguments: :title :author :category :format :isbn :cover
|
||||||
@@ -306,10 +337,11 @@ Set by test harnesses that only need the model functions.")
|
|||||||
;; --- handlers ---------------------------------------------------------
|
;; --- handlers ---------------------------------------------------------
|
||||||
|
|
||||||
(defservlet index text/html (path query request)
|
(defservlet index text/html (path query request)
|
||||||
(let ((books (spine-books)))
|
(let* ((books (spine-books))
|
||||||
|
(filter (cadr (assoc "filter" query))))
|
||||||
(if books
|
(if books
|
||||||
(insert (spine-render "index.mustache"
|
(insert (spine-render "index.mustache"
|
||||||
(spine-index-model books (cadr (assoc "id" query)))))
|
(spine-index-model books filter (cadr (assoc "id" query)))))
|
||||||
(insert (spine-render-empty-state)))))
|
(insert (spine-render-empty-state)))))
|
||||||
|
|
||||||
(defun httpd/add (proc uri-path query request)
|
(defun httpd/add (proc uri-path query request)
|
||||||
|
|||||||
@@ -86,6 +86,23 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{{#current_filter}}
|
||||||
|
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||||
|
<a href="/index">Concise view</a>
|
||||||
|
</nav>
|
||||||
|
{{/current_filter}}
|
||||||
|
{{^current_filter}}
|
||||||
|
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||||
|
<span>
|
||||||
|
<a href="/index?filter=all">All books ({{total_books}})</a>
|
||||||
|
·
|
||||||
|
<a href="/index?filter=want">Want list</a>
|
||||||
|
·
|
||||||
|
<a href="/index?filter=read">Read ({{reading_count}})</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
{{/current_filter}}
|
||||||
|
|
||||||
{{#groups}}
|
{{#groups}}
|
||||||
<div class="group">{{label}} · {{count}}</div>
|
<div class="group">{{label}} · {{count}}</div>
|
||||||
{{#books}}
|
{{#books}}
|
||||||
@@ -107,6 +124,9 @@
|
|||||||
{{/detail}}
|
{{/detail}}
|
||||||
{{/books}}
|
{{/books}}
|
||||||
{{/groups}}
|
{{/groups}}
|
||||||
|
{{#summary_groups}}
|
||||||
|
<div class="row"><a href="{{href}}">{{label}} · {{count}}</a></div>
|
||||||
|
{{/summary_groups}}
|
||||||
<div class="endpad"></div>
|
<div class="endpad"></div>
|
||||||
|
|
||||||
{{#minibuffer}}
|
{{#minibuffer}}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
|
||||||
|
|
||||||
|
(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-index-model-test--books (spine-books)
|
||||||
|
"Books fixture loaded once from sample-books.org.")
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
|
||||||
|
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should-not (member "read" group-labels))
|
||||||
|
(should-not (member "abandoned" group-labels))
|
||||||
|
(should summaries)
|
||||||
|
(should (= (length summaries) 1))
|
||||||
|
(should (string= (ht-get (car summaries) "label") "read"))
|
||||||
|
(should (= (ht-get (car summaries) "count") 1))
|
||||||
|
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-concise-includes-reading ()
|
||||||
|
"Concise view includes READING group with all reading books."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(reading-group (cl-find "reading" groups
|
||||||
|
:key (lambda (g) (ht-get g "label"))
|
||||||
|
:test #'string=)))
|
||||||
|
(should reading-group)
|
||||||
|
(should (= (ht-get reading-group "count") 1))
|
||||||
|
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
|
||||||
|
"Use of Weapons"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-concise-want-limited-to-five ()
|
||||||
|
"Concise view limits WANT group to 5 books, sorted by :ADDED: desc."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(want-group (cl-find "on deck" groups
|
||||||
|
:key (lambda (g) (ht-get g "label"))
|
||||||
|
:test #'string=)))
|
||||||
|
(should want-group)
|
||||||
|
(let ((books (ht-get want-group "books")))
|
||||||
|
(should (<= (length books) 5))
|
||||||
|
(should (= (length books) 3))
|
||||||
|
;; Piranesi (2026-06-01), Dune (2026-05-10), Left Hand (2026-04-01)
|
||||||
|
(should (string= (ht-get (nth 0 books) "title") "Piranesi"))
|
||||||
|
(should (string= (ht-get (nth 1 books) "title") "Dune"))
|
||||||
|
(should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-filter-all-shows-all-groups ()
|
||||||
|
"filter=all returns all groups, no summary-groups."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "all"))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should-not summaries)
|
||||||
|
(should (member "on deck" group-labels))
|
||||||
|
(should (member "reading" group-labels))
|
||||||
|
(should (member "read" group-labels))
|
||||||
|
(should (= (length groups) 3))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-filter-read-returns-only-read ()
|
||||||
|
"filter=read returns only the READ group."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "read"))
|
||||||
|
(groups (ht-get model "groups")))
|
||||||
|
(should (= (length groups) 1))
|
||||||
|
(should (string= (ht-get (car groups) "label") "read"))
|
||||||
|
(should (= (ht-get (car groups) "count") 1))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-filter-want-returns-only-want ()
|
||||||
|
"filter=want returns only the WANT group."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "want"))
|
||||||
|
(groups (ht-get model "groups")))
|
||||||
|
(should (= (length groups) 1))
|
||||||
|
(should (string= (ht-get (car groups) "label") "on deck"))
|
||||||
|
(should (= (ht-get (car groups) "count") 3))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
|
||||||
|
"Unrecognised filter value behaves like nil (concise view)."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
|
||||||
|
(groups (ht-get model "groups"))
|
||||||
|
(summaries (ht-get model "summary_groups"))
|
||||||
|
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
|
(should summaries)
|
||||||
|
(should-not (member "read" group-labels))
|
||||||
|
(should (member "reading" group-labels))
|
||||||
|
(should (member "on deck" group-labels))))
|
||||||
|
|
||||||
|
(ert-deftest spine-index-model-includes-current-filter ()
|
||||||
|
"Model includes current_filter key matching the filter argument."
|
||||||
|
(should (not (ht-get (spine-index-model spine-index-model-test--books) "current_filter")))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read"))
|
||||||
|
(should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want")))
|
||||||
Reference in New Issue
Block a user