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

+39 -15
View File
@@ -13,7 +13,7 @@ import (
// BookInfo is THE canonical wire+template card shape: every surface that
// lists a book card as JSON (collections detail, series, preview,
// dashboard sections, search) or renders a templ card (BookCard in
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
// keys and no others — pinned by docs/contract_pins.md.
//
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
@@ -34,6 +34,15 @@ type BookInfo struct {
// comic vs manga rides the LIBRARY type (format_group only knows
// comic_archive — manga_type is unreliable across sources).
Badge string `json:"badge"`
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
// percentage = the requesting user never opened the book, so cards
// render no progress bar and no completed badge. Adapters that
// don't load viewer progress leave it nil; the search-unified
// adapter is the only populated feed today. LibraryName rides the
// same deal (unified rows carry it as a column; other feeds leave
// it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -79,12 +88,14 @@ func MediaBadgeKind(formatGroup, libraryTypeName string) string {
// adapters below normalize the sqlc row shapes into cardSource.
func buildBookCard(src cardSource) BookInfo {
return BookInfo{
MediaItemID: src.ID.String(),
Title: src.Title,
Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
MediaItemID: src.ID.String(),
Title: src.Title,
Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
ViewerPercentage: src.ViewerProgress,
LibraryName: src.LibraryName,
}
}
@@ -102,6 +113,10 @@ type cardSource struct {
CoverPath pgtype.Text
FormatGroup string
LibraryType string
// Viewer-scoped extras: nil progress = no data loaded for the
// requesting user (see BookInfo.ViewerPercentage).
ViewerProgress *float64
LibraryName string
}
// BookCardsFromMediaItems builds cards from full media rows
@@ -167,18 +182,27 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all.
// needs no LibraryTypeMap query at all — and the viewer's own
// progress (the 2026-10-27 search-progress batch), which no other
// feed loads.
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
var viewer *float64
if r.ViewerPercentage.Valid {
pct := r.ViewerPercentage.Float64
viewer = &pct
}
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName,
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName,
ViewerProgress: viewer,
LibraryName: r.LibraryName,
}))
}
return cards