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

+96 -3
View File
@@ -16,6 +16,69 @@ type SeriesInfo struct {
TotalInSeries int
CoverPaths []string
LastEntryAt string
// Viewer-scoped progress aggregates (requesting user): books
// finished (percentage >= 0.999) and the series' most recent read.
ReadCount int64
LastReadAt string
}
// SeriesPageOptions carries the series page's sort/filter state. Zero
// values mean defaults (recent, no filters). Extensible by design:
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
// queries.sql (GetDistinctSeries) + one <option> in templates/
// series.templ; adding a filter = a param on this struct threaded into
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
// stale URL or hand-rolled API call can never reach the SQL with an
// unhandled key.
type SeriesPageOptions struct {
Sort string // registry key, resolved by ResolveSeriesSort
Query string // series-name substring
Author string // matches when ANY book's author matches
Status string // "" | unstarted | reading | finished
ViewerUserID pgtype.UUID
}
// seriesSortKeys is the registry of sort= values the series page
// understands.
var seriesSortKeys = map[string]bool{
"recent": true, // default: newest series first
"recent_asc": true,
"name_asc": true,
"name_desc": true,
"author_asc": true,
"author_desc": true,
"books_asc": true,
"books_desc": true,
"progress_asc": true,
"progress_desc": true,
"last_read_asc": true,
"last_read_desc": true,
}
// seriesStatusKeys is the registry of status= values. The three states
// are mutually exclusive and exhaustive: a series is finished when
// every owned book is, reading when any book sits between 0 and the
// finish threshold, unstarted when no book has progress > 0.
var seriesStatusKeys = map[string]bool{
"unstarted": true,
"reading": true,
"finished": true,
}
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
// the single source of truth backing both the SSR page and the JSON
// API — the SQL CASE cascade must contain an arm for every key here.
func ResolveSeriesSort(sort string) string {
if seriesSortKeys[sort] {
return sort
}
return "recent"
}
// nargText makes an optional text param: empty string → SQL NULL so
// the query's IS NULL guard skips the filter.
func nargText(v string) pgtype.Text {
return pgtype.Text{String: v, Valid: v != ""}
}
type SeriesService struct {
@@ -31,18 +94,34 @@ func NewSeriesService(db *database.Queries) *SeriesService {
// Pass libraryID for a single-library page, or leave it invalid and pass
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
sort := nargText(ResolveSeriesSort(opts.Sort))
status := nargText("")
if seriesStatusKeys[opts.Status] {
status = nargText(opts.Status)
}
countParams := database.GetDistinctSeriesCountParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
})
SeriesQuery: nargText(opts.Query),
AuthorQuery: nargText(opts.Author),
Status: status,
}
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
if err != nil {
return nil, 0, err
}
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
SeriesQuery: countParams.SeriesQuery,
AuthorQuery: countParams.AuthorQuery,
Status: status,
Sort: sort,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
@@ -76,6 +155,18 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
}
}
lastRead := ""
if row.LastReadAt != nil {
switch v := row.LastReadAt.(type) {
case pgtype.Timestamptz:
if v.Valid {
lastRead = v.Time.String()
}
case string:
lastRead = v
}
}
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
totalInSeries = int(row.BookCount)
}
@@ -87,6 +178,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
TotalInSeries: totalInSeries,
CoverPaths: coverPaths,
LastEntryAt: lastEntry,
ReadCount: row.FinishedCount,
LastReadAt: lastRead,
})
}