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

+218
View File
@@ -0,0 +1,218 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSeriesSortAndFilter pins the series page's sort/filter contract
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
// endpoint share one sort registry (services.ResolveSeriesSort) and one
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
// DISTINCT answers per sort key so ordering bugs can't hide:
//
// created author books viewer progress
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
// Probe Apple 01-01 Midway Two 1 none → unstarted
//
// All viewer progress belongs to the FRESH regular user
// (getRegularUserID) and cascades away with the test library — the
// shared admin's reading data is never touched.
func TestSeriesSortAndFilter(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
viewerID := getRegularUserID(t, setup.DB)
type seriesFixture struct {
name string
author string
createdAt string
books []struct {
title string
percentage string
lastReadAt string
}
}
fixtures := []seriesFixture{
{
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
},
},
{
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
},
},
{
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
},
},
}
stripComments := regexp.MustCompile(`<!--.*?-->`)
// Card headings only: the empty-state panels use different h3
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
// must not read as a rendered series.
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
renderedNames := func(query string) []string {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
if code != 200 {
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
}
require.Equal(t, 200, code)
var names []string
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
names = append(names, strings.TrimSpace(m[1]))
}
return names
}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
for _, fx := range fixtures {
for _, b := range fx.books {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
).Scan(&itemID)
require.NoError(t, err)
if b.percentage != "0" && b.percentage != "" {
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, $3::float, $4::timestamptz)`,
itemID, viewerID, b.percentage, b.lastReadAt,
)
require.NoError(t, err)
}
}
}
t.Run("default sort is recently added", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
})
t.Run("every sort key orders the grid", func(t *testing.T) {
cases := []struct {
sort string
want []string
}{
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
}
for _, tc := range cases {
got := renderedNames("&" + tc.sort)
require.Len(t, got, 3, tc.sort)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
}
}
})
t.Run("unknown sort falls back to the default", func(t *testing.T) {
// Unknown keys resolve to "recent" in the registry; the SQL never
// interpolates the value (bound-param CASE cascade), so a hostile
// sort value is inert by construction.
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
})
t.Run("name query filters", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
// ILIKE is case-insensitive and substring-shaped.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
})
t.Run("author query filters on any book's author", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
assert.Empty(t, renderedNames("&author=nobody"))
})
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
})
t.Run("filters compose", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
})
t.Run("cards carry the viewer's read count", func(t *testing.T) {
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
assert.Contains(t, body, "3 of 3 read")
assert.Contains(t, body, "1 of 2 read")
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
})
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var payload struct {
Series []struct {
Name string `json:"name"`
ReadCount int64 `json:"read_count"`
} `json:"series"`
Total int `json:"total"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload.Series, 1)
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
assert.Equal(t, 1, payload.Total)
})
}