feat(search): viewer progress + completed badges; dropdown to templ

Search results now show where you are in a book: a completed check at
the 0.999 threshold (same cut as the filter status arms), a partial
progress bar along the cover while mid-read, nothing when you never
opened it. Rendered in BookCard (any grid whose adapter loads viewer
progress) and in the header-search dropdown rows.

The progress rides a LEFT JOIN reading_progress on the SAME user_id
param SearchMediaItemsUnified already binds for library visibility —
one requester param, no new wire inputs, row-neutral join (the table
is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo
gain the keys additively (viewer_percentage null when unknown,
library_name empty on feeds that don't load it); contract_pins.md
carries the changelog entry and the pin tests widen in lockstep.

The header dropdown itself migrates off the innerHTML string renderer
in search.ts (a rendering-ownership violation: a templ twin existed via
the HX-Request branch). The input now hx-gets layout=dropdown and swaps
the new templ SearchResults partial — titles/authors highlighted
server-side (escape-first, safe by construction), rows with the same
progress overlays — while a slim Alpine component owns only
client-local state (min-length gating, open/close, keyboard selection,
htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts
baseline to zero accordingly.

Integration test pins the per-user join (fresh user sees 1.0/0.42/null
on the fixture; the shared admin sees null everywhere — read-only) and
the dropdown partial's overlays/count/row count.
This commit is contained in:
John O'Keefe committed 2026-10-27 15:11:49 -04:00
1 parent d1f9ad3b25
commit 61e5f47ec0
18 files changed
+1140 -616

No files matched your search

+10
View File
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
// ---------------------------------------------------------------------------
// bookInfoKeys is the BookInfo JSON every card surface emits.
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
// viewer data; only the search-unified adapter populates it today)
// and + library_name (same deal). docs/contract_pins.md changelog.
var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
"viewer_percentage", "library_name",
}
func collectionURL(setup *TestServerSetup, id string) string {
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
// (docs/contract_pins.md changelog).
"subtitle", "explicit", "authors", "authors_search",
"publishers", "publishers_search", "narrators_search",
// 2026-10-27 (search-progress batch): + viewer_percentage —
// the requester's reading_progress.percentage (LEFT JOIN on
// the same user_id param the visibility join binds), null
// when they never opened the book (docs/contract_pins.md
// changelog).
"viewer_percentage",
}
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)