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

+17 -5
View File
@@ -275,12 +275,24 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
//
// Series-page sort/filter contract (extensible registry in
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
// it filters in WHERE (the group key — counts stay true); AuthorQuery
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
// series. ViewerUserId scopes the per-user progress aggregates (the rp
// join can't multiply rows: reading_progress is UNIQUE per
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
// cascade — never interpolated — and the GROUP BY tiebreaker stays
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied
// on MAX(created_at), and Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
// HAVING so the pagination total matches what the page query returns
// under the same filters (Sort is a page-ORDER concern, not a set
// membership one, so it is absent here by design).
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)