feat(series): sort and filter the series page

The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).

Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).

Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
This commit is contained in:
John O'Keefe committed 2026-10-27 14:53:45 -04:00
1 parent 79f4d668f1
commit d1f9ad3b25
10 files changed
+1118 -154

No files matched your search

+105 -18
View File
@@ -5266,21 +5266,59 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
const GetDistinctSeries = `-- name: GetDistinctSeries :many
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
MAX(created_at) as last_entry_at,
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
MAX(rp.last_read_at) AS last_read_at
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = $1::uuid
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT $4 OFFSET $3
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
$6::text IS NULL
OR CASE $6::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
ORDER BY
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
CASE WHEN $7::text = 'progress_desc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END DESC NULLS LAST,
CASE WHEN $7::text = 'progress_asc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END ASC NULLS LAST,
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
library_id, series
LIMIT $9 OFFSET $8
`
type GetDistinctSeriesParams struct {
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
Status pgtype.Text `db:"status" json:"status"`
Sort pgtype.Text `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
@@ -5291,20 +5329,35 @@ type GetDistinctSeriesRow struct {
BookCount int64 `db:"book_count" json:"book_count"`
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
FinishedCount int64 `db:"finished_count" json:"finished_count"`
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
}
// 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.
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.ViewerUserID,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.SeriesQuery,
arg.AuthorQuery,
arg.Status,
arg.Sort,
arg.Offset,
arg.Limit,
)
@@ -5321,6 +5374,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
&i.BookCount,
&i.TotalInSeries,
&i.LastEntryAt,
&i.FinishedCount,
&i.LastReadAt,
); err != nil {
return nil, err
}
@@ -5333,22 +5388,54 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
}
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT (library_id, series))::int
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
SELECT COUNT(*)::int FROM (
SELECT library_id, series
FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = $1::uuid
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
$6::text IS NULL
OR CASE $6::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
) groups
`
type GetDistinctSeriesCountParams struct {
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
Status pgtype.Text `db:"status" json:"status"`
}
// 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).
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
arg.ViewerUserID,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.SeriesQuery,
arg.AuthorQuery,
arg.Status,
)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err