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)
+9 -1
View File
@@ -14693,11 +14693,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
lt.name as library_type_name
lt.name as library_type_name,
-- Viewer's own progress (search-result badges): NULL when the
-- requester never opened the book. Reuses the SAME requester param
-- the visibility join binds; UNIQUE(media_item, user) keeps the
-- join row-neutral.
rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
@@ -14963,6 +14969,7 @@ type SearchMediaItemsUnifiedRow struct {
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
LibraryName string `db:"library_name" json:"library_name"`
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
}
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
@@ -15058,6 +15065,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
&i.HashConfidence,
&i.LibraryName,
&i.LibraryTypeName,
&i.ViewerPercentage,
); err != nil {
return nil, err
}
+7 -1
View File
@@ -734,11 +734,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
lt.name as library_type_name
lt.name as library_type_name,
-- Viewer's own progress (search-result badges): NULL when the
-- requester never opened the book. Reuses the SAME requester param
-- the visibility join binds; UNIQUE(media_item, user) keeps the
-- join row-neutral.
rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0