#142: series list page with eight hygiene filters, site/library narrowing, paging

This commit is contained in:
2026-08-21 19:40:45 +07:00
parent 503fb49d0a
commit e8a3c5f826
8 changed files with 832 additions and 18 deletions
+1 -9
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"log" "log"
"net/url" "net/url"
"sort"
"sync" "sync"
"time" "time"
@@ -173,14 +172,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher {
// laneNames returns every registry Site in the deterministic order both Run // laneNames returns every registry Site in the deterministic order both Run
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who // and runOnce iterate: sorted, so lane behaviour and its tests agree on who
// runs first. // runs first.
func laneNames() []string { func laneNames() []string { return SiteNames() }
names := make([]string, 0, len(sites))
for name := range sites {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (p *Poller) Run(ctx context.Context) { func (p *Poller) Run(ctx context.Context) {
names := laneNames() names := laneNames()
+12
View File
@@ -511,6 +511,18 @@ var sites = map[string]site{
}, },
} }
// SiteNames returns every registry Site, sorted. The admin Series list's Site
// select needs the full registry, not just the Sites that have rows, and
// laneNames() is the poller's copy of the same list — both read this.
func SiteNames() []string {
names := make([]string, 0, len(sites))
for name := range sites {
names = append(names, name)
}
sort.Strings(names)
return names
}
// browserBackedSites is derived from the registry: the Sites whose pages are // browserBackedSites is derived from the registry: the Sites whose pages are
// read through the browser sidecar. Sorted so callers that range it (the // read through the browser sidecar. Sorted so callers that range it (the
// browser fetcher's dispatch) see a stable order instead of map-iteration // browser fetcher's dispatch) see a stable order instead of map-iteration
+6 -6
View File
@@ -18,6 +18,11 @@ type LaneReporter interface {
LaneStatus() latest.Status LaneStatus() latest.Status
} }
// ownerWindow is the staleness boundary the Series list's "not checked in
// 12h" filter compares against, and the same twelve hours the landing page's
// figures share. Declared once; later admin tickets read it.
const ownerWindow = 12 * time.Hour
// adminView is the shared shell data for an administrative page and the roster // adminView is the shared shell data for an administrative page and the roster
// fragment returned after a Reader action. // fragment returned after a Reader action.
type adminView struct { type adminView struct {
@@ -27,6 +32,7 @@ type adminView struct {
// the Readers they may act on. // the Readers they may act on.
OwnerID int64 OwnerID int64
Lanes lanesView Lanes lanesView
SeriesList seriesListView
} }
// lanesView is the Lane status block: one row per Site that has run, plus the // lanesView is the Lane status block: one row per Site that has run, plus the
@@ -142,12 +148,6 @@ func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()}) h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
} }
// adminSeries renders the Series shell. Its data arrives in a later admin
// ticket.
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "series"})
}
func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) { func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
h.render(w, http.StatusOK, "admin", view) h.render(w, http.StatusOK, "admin", view)
} }
+308
View File
@@ -0,0 +1,308 @@
package web
import (
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// seriesPageSize matches the store's row-read page length: the pager's range
// must agree with the LIMIT the store applies or the "of N" figure describes
// the wrong page. The store does not export it (#140).
const seriesPageSize = 50
// seriesFilterLabels names every hygiene filter for the Series list select,
// keyed by the wire constant the URL carries. The render order is
// seriesFilterOrder; the labels are read by later admin tickets too, so the
// map and the constants cannot drift apart.
var seriesFilterLabels = map[string]string{
store.SeriesFilterAll: "All series",
store.SeriesFilterNoURL: "No series URL",
store.SeriesFilterNoChapter: "Never read a chapter",
store.SeriesFilterNoReaders: "No Readers",
store.SeriesFilterNeverChecked: "Never checked",
store.SeriesFilterStale: "Not checked in 12h",
store.SeriesFilterNoCover: "No cover",
store.SeriesFilterReaderReport: "Latest from a Reader",
}
// seriesFilterOrder is the select's render order: All first, then the
// permanent repairs, then the fixable ones (issue #140).
var seriesFilterOrder = []string{
store.SeriesFilterAll,
store.SeriesFilterNoURL,
store.SeriesFilterNoChapter,
store.SeriesFilterNoReaders,
store.SeriesFilterNeverChecked,
store.SeriesFilterStale,
store.SeriesFilterNoCover,
store.SeriesFilterReaderReport,
}
// seriesListView is the Series list page's data. The template renders strings
// and flags, and every judgement about what a value means is made here.
type seriesListView struct {
Filters []seriesFilterOption
Sites []string
Site string // "" = every Site
Kind string // "" = both libraries
FilterLabel string
Rows []seriesRowView
Total int
// KindBoth / KindManga / KindNovel are the Library segment links, and
// PrevHref / NextHref the pager's, all carrying the active filter, Site
// and Kind so narrowing never drops state.
KindBoth string
KindManga string
KindNovel string
PrevHref string
NextHref string
Range string
}
// seriesFilterOption is one entry of the Show select: its wire value, its
// rendered label with the library-wide count, and whether it is the active
// filter.
type seriesFilterOption struct {
Name string
Label string
Count int
Selected bool
}
// seriesRowView is one Series row formatted for the template. Band carries
// the alternating row tint by class rather than nth-of-type, so the confirm
// rows later tickets add are row siblings without breaking the alternation.
// Attention tints the title patina: a row with any hygiene chip needs one.
type seriesRowView struct {
Key string
Title string
Site string
Ch string // chapter number; "—" until first captured
Age string // checked age; "never" until first check
Readers int
Notes []string // chips, capped at two
More int // chips past the cap, rendered as a +N tail
Band bool
Attention bool
}
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
// Library and page all live in the query string, so the list's state is an
// address rather than a click path.
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
view, err := h.seriesListView(r)
if err != nil {
log.Printf("admin series: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
}
// seriesListView assembles one Series list view from the request's query
// string. An unknown filter value is the absent All case, never an error: the
// select's options are not the only way this URL can be reached.
func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
q := r.URL.Query()
filter := q.Get("filter")
if _, ok := seriesFilterLabels[filter]; !ok {
filter = store.SeriesFilterAll
}
site := q.Get("site")
kind := q.Get("kind")
if kind != store.KindManga && kind != store.KindNovel {
kind = ""
}
page := 1
if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 {
page = p
}
sf := store.SeriesFilter{
Site: site,
Kind: kind,
Name: filter,
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
Page: page,
}
data, err := h.store.SeriesPage(sf)
if err != nil {
return seriesListView{}, err
}
// A page past the end is not an empty list: the store's window count runs
// over the rows the result set carries, so an overflow page reports zero
// rows and zero total, and the list re-reads at page 1 to know the truth.
if len(data.Rows) == 0 && page > 1 {
page = 1
sf.Page = 1
data, err = h.store.SeriesPage(sf)
if err != nil {
return seriesListView{}, err
}
}
view := seriesListView{
Site: site,
Kind: kind,
FilterLabel: seriesFilterLabels[filter],
Rows: make([]seriesRowView, 0, len(data.Rows)),
Total: data.Total,
Sites: latest.SiteNames(),
}
now := time.Now()
for i, a := range data.Rows {
view.Rows = append(view.Rows, seriesRow(a, i, now))
}
view.Filters, err = h.seriesFilterOptions(filter)
if err != nil {
return seriesListView{}, err
}
view.KindBoth = seriesListHref(filter, site, "", 0)
view.KindManga = seriesListHref(filter, site, store.KindManga, 0)
view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0)
if page > 1 {
view.PrevHref = seriesListHref(filter, site, kind, page-1)
}
if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last {
view.NextHref = seriesListHref(filter, site, kind, page+1)
}
view.Range = pagerRange(data.Total, len(data.Rows), page)
return view, nil
}
// seriesFilterOptions renders every hygiene filter with its library-wide
// count, one SeriesShapes pass per filter summed in Go — the shipped surface
// offers eight grouped passes, not a single stats query (#140). The counts
// are library-wide because the select sits next to the Site narrowing and
// must not shift as the owner narrows the list itself.
func (h *Handler) seriesFilterOptions(selected string) ([]seriesFilterOption, error) {
out := make([]seriesFilterOption, 0, len(seriesFilterOrder))
for _, name := range seriesFilterOrder {
shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name})
if err != nil {
return nil, err
}
count := 0
for _, sh := range shapes {
count += sh.Total
}
out = append(out, seriesFilterOption{
Name: name,
Label: seriesFilterLabels[name],
Count: count,
Selected: name == selected,
})
}
return out, nil
}
// seriesRow formats one store row for the template. The note chips are the
// row's own hygiene facts, capped at two with a +N tail; attention marks a
// row that carries any.
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
row := seriesRowView{
Key: a.Key(),
Title: a.Title,
Site: a.Site,
Readers: a.ReaderCount,
Band: i%2 == 1,
}
if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
} else {
row.Ch = "—"
}
row.Age = checkedAge(now, a.LatestCheckedAt)
notes := seriesNotes(a, now)
if n := len(notes); n > 2 {
row.Notes, row.More = notes[:2], n-2
} else {
row.Notes = notes
}
row.Attention = len(notes) > 0
return row
}
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
// cover, orphan, stale, reader sighting.
func seriesNotes(a store.AdminSeries, now time.Time) []string {
notes := []string{}
if a.SeriesURL == "" {
notes = append(notes, "no URL")
}
if a.CoverAddress == "" {
notes = append(notes, "no cover")
}
if a.ReaderCount == 0 {
notes = append(notes, "orphan")
}
if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() {
notes = append(notes, "stale")
}
if a.RaisedByReader {
notes = append(notes, "reader sighting")
}
return notes
}
// checkedAge formats how long ago a Series was last checked, at the
// granularity the list reads at — minutes, hours, days. Zero means never.
func checkedAge(now time.Time, ts int64) string {
if ts == 0 {
return "never"
}
d := now.Sub(time.UnixMilli(ts))
switch {
case d < time.Hour:
m := int(d / time.Minute)
if m < 1 {
m = 1
}
return fmt.Sprintf("%dm ago", m)
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d/time.Hour))
default:
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
}
// pagerRange is the pager's "1–50 of 120" line. Zero rows reads as "0 of 0":
// the empty state carries the page then, and a window number for a list that
// rendered nothing would be a number nothing points at.
func pagerRange(total, rows, page int) string {
if rows == 0 {
return "0 of 0"
}
from := (page-1)*seriesPageSize + 1
return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total)
}
// seriesListHref is one Series list address carrying the filter, Site, Kind
// and page. The All filter and page 1 are the absent cases and stay out of
// the URL, so the default address is the shortest one.
func seriesListHref(filter, site, kind string, page int) string {
q := url.Values{}
if filter != "" && filter != store.SeriesFilterAll {
q.Set("filter", filter)
}
if site != "" {
q.Set("site", site)
}
if kind != "" {
q.Set("kind", kind)
}
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
if len(q) == 0 {
return "/admin/series"
}
return "/admin/series?" + q.Encode()
}
+59
View File
@@ -240,12 +240,20 @@
color: var(--patina); color: var(--patina);
} }
.admin-sheet .tbl .trow.attention .c-title {
color: var(--patina);
}
.admin-sheet .tbl .c-ch, .admin-sheet .tbl .c-ch,
.admin-sheet .tbl .c-rd { .admin-sheet .tbl .c-rd {
text-align: right; text-align: right;
padding-right: 26px; padding-right: 26px;
} }
.admin-sheet .tbl .trow .c-note .mark {
margin-right: 8px;
}
.admin-sheet .tbl .c-act { .admin-sheet .tbl .c-act {
text-align: right; text-align: right;
} }
@@ -368,6 +376,42 @@
row-gap: 4px; row-gap: 4px;
} }
.admin-sheet .tbl.series .thead > *:first-child {
padding-left: 20px;
}
.admin-sheet .tbl.series .thead > *:last-child {
padding-right: 20px;
}
/* The site cell's colour is a class, never an inline style: site is
client-supplied and unvalidated, and a hostile value reaching a CSS
context would render ZgotmplZ, while an unknown class degrades to the
unstyled cell. */
.admin-sheet .tbl .c-site.site-asura {
color: var(--asura);
}
.admin-sheet .tbl .c-site.site-demonic {
color: var(--demonic);
}
.admin-sheet .tbl .c-site.site-comix {
color: var(--comix);
}
.admin-sheet .tbl .c-site.site-kagane {
color: var(--kagane);
}
.admin-sheet .tbl .c-site.site-novelfull {
color: var(--novelfull);
}
.admin-sheet .tbl .c-site.site-lightnovelworld {
color: var(--lightnovelworld);
}
.admin-sheet .tbl.series .trow { .admin-sheet .tbl.series .trow {
display: grid; display: grid;
grid-column: 1 / -1; grid-column: 1 / -1;
@@ -434,6 +478,12 @@
border-bottom-color: var(--patina); border-bottom-color: var(--patina);
} }
.admin-sheet .fsel select option,
.admin-sheet .pausebar select option {
background: var(--ink);
color: var(--paper);
}
.admin-sheet .segrow { .admin-sheet .segrow {
display: inline-flex; display: inline-flex;
gap: 2px; gap: 2px;
@@ -480,6 +530,14 @@
text-transform: uppercase; text-transform: uppercase;
} }
.admin-sheet .pager .pg {
color: var(--paper-dim);
}
.admin-sheet .pager .pg:hover {
color: var(--paper);
}
.admin-sheet .pager .pg.disabled { .admin-sheet .pager .pg.disabled {
color: var(--faint); color: var(--faint);
pointer-events: none; pointer-events: none;
@@ -497,6 +555,7 @@
.admin-sheet .empty p { .admin-sheet .empty p {
margin: 6px 0 0; margin: 6px 0 0;
font: 14px/1.5 var(--font-body);
color: var(--mute); color: var(--mute);
} }
+1 -1
View File
@@ -39,7 +39,7 @@
{{else if eq .Page "readers"}} {{else if eq .Page "readers"}}
{{template "readers" .}} {{template "readers" .}}
{{else if eq .Page "series"}} {{else if eq .Page "series"}}
<h2 class="sec">Series</h2> {{template "series-list" .SeriesList}}
{{else}} {{else}}
<h2 class="sec">Overview</h2> <h2 class="sec">Overview</h2>
{{end}} {{end}}
@@ -0,0 +1,46 @@
{{/* The Series list: every Series across every Reader's library, filtered by
one hygiene rule and narrowed by Site and Library. Filter, Site, Library
and page all live in the query string, so the list's state is an address
that can be bookmarked: the two selects submit the GET form, and the
Library segment links and the pager preserve the filter and Site. */}}
{{define "series-list"}}
<form class="filterbar" method="get" action="/admin/series">
<input type="hidden" name="kind" value="{{.Kind}}">
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
</select></label>
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()">
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
</select></label>
<span class="segrow">
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
</span>
</form>
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
{{if .Rows}}
<div class="tbl series">
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
{{range .Rows}}
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a></span>
<span class="c-site site-{{.Site}}">{{.Site}}</span>
<span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
<span class="c-act"></span>
</div>
{{end}}
</div>
<div class="pager">
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
<span>{{.Range}}</span>
{{if .NextHref}}<a class="pg" href="{{.NextHref}}">next ›</a>{{else}}<span class="pg disabled">next ›</span>{{end}}
</div>
{{else}}
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
{{end}}
{{end}}
+397
View File
@@ -1840,3 +1840,400 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
t.Fatal("novel tab=new should render the novel All list") t.Fatal("novel tab=new should render the novel All list")
} }
} }
// seriesRowSeed is one series row (and optionally its bookmarks) for the
// Series list tests. Seeded with direct SQL because the store's own surface
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
// same reason the store's admin tests seed this way.
type seriesRowSeed struct {
key string
kind string
url string
cover string // cover_address
checkedAt int64
latestNum *float64
bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser
}
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
// fresh readers) with the exact admin-relevant facts a test needs.
func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed) {
t.Helper()
site, seriesID, ok := strings.Cut(seed.key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", seed.key)
}
if seed.kind == "" {
seed.kind = store.KindManga
}
var latestChapter any = ""
if seed.latestNum != nil {
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
}
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
latest_checked_at, latest_chapter, latest_chapter_num)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
t.Fatalf("seed series %q: %v", seed.key, err)
}
for i := range seed.bookmarks {
var readerID int64 = st.OwnerID()
if i > 0 {
readerID = seedReader(t, st)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id,
last_chapter, last_chapter_num, last_chapter_url,
favorite, status, updated_at)
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
readerID, site, seriesID, seed.checkedAt); err != nil {
t.Fatalf("seed bookmark %q: %v", seed.key, err)
}
}
if seed.raisedBy {
if _, err := db.Exec(
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
st.OwnerID(), site, seriesID); err != nil {
t.Fatalf("seed raised-by %q: %v", seed.key, err)
}
}
}
// seedReader mints a fresh Reader for a second bookmark, so a series can carry
// a Reader count above one.
func seedReader(t *testing.T, st *store.Store) int64 {
t.Helper()
discordID := "seed-" + strconv.FormatInt(time.Now().UnixNano(), 10)
id, err := st.EnsureReader(discordID, [32]byte{})
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
return id
}
// adminSeriesPage drives one Series list request as the owner and returns the
// rendered body, failing the test on anything but a 200.
func adminSeriesPage(t *testing.T, srv http.Handler, st *store.Store, query string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series"+query, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series%s status = %d, want 200", query, rr.Code)
}
return rr.Body.String()
}
// The filter select reaches the store as the wire constant and the heading
// states the same total the rows render: ?filter=no_cover renders only the
// no-cover row, its option label carries its library-wide count, and an
// unknown filter value is the absent All case, never an error.
func TestSeriesListFilterWiring(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: floatPtr(3), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("no_cover list misses its row:\n%s", body)
}
if strings.Contains(body, "Title of asura:healthy") {
t.Errorf("no_cover list renders a covered row:\n%s", body)
}
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
t.Errorf("no_cover heading lacks the filtered count and name:\n%s", body)
}
if !strings.Contains(body, "No cover (1)") {
t.Errorf("the filter option label lacks its count:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the no_cover option is not selected:\n%s", body)
}
// An unknown filter value is the absent All case: both rows, no error.
body = adminSeriesPage(t, srv, st, "?filter=bogus")
if !strings.Contains(body, "Title of asura:healthy") || !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("unknown filter does not render All series:\n%s", body)
}
if !strings.Contains(body, `<option value="all" selected>`) {
t.Errorf("the all option is not selected for an unknown filter:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
// kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL.
func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
stale := time.Now().Add(-24 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:want", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
if !strings.Contains(body, "Title of kagane:want") {
t.Errorf("stale+kagane+manga misses its row:\n%s", body)
}
for _, unwanted := range []string{"Title of kagane:novel", "Title of kagane:fresh", "Title of asura:stale"} {
if strings.Contains(body, unwanted) {
t.Errorf("stale+kagane+manga renders %q:\n%s", unwanted, body)
}
}
if !strings.Contains(body, "1 series") {
t.Errorf("heading lacks the narrowed count:\n%s", body)
}
// The narrowing survives in the URL: every emitted link carries the
// filter and Site, and the kind hidden input keeps it on select submits.
for _, want := range []string{
`href="/admin/series?filter=stale&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=manga&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=novel&amp;site=kagane"`,
`<input type="hidden" name="kind" value="manga">`,
} {
if !strings.Contains(body, want) {
t.Errorf("narrowing is lost from the URL; missing %q:\n%s", want, body)
}
}
if !strings.Contains(body, `<option value="stale" selected>`) {
t.Errorf("the stale filter is not kept selected:\n%s", body)
}
if !strings.Contains(body, `<option value="kagane" selected>`) {
t.Errorf("the kagane site is not kept selected:\n%s", body)
}
}
// More than one page of rows: page 1 and page 2 share no key, the pager range
// comes from the window total (not a second query), and a page past the end
// re-reads at page 1 rather than rendering an empty table. All 55 rows share
// a zero check stamp, so only the (site, series_id) tie-break keeps the page
// boundary stable.
func TestSeriesListPagingIsStable(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address,
latest_checked_at)
SELECT 'asura', 'bulk-' || g, 'Bulk ' || g, 'https://asurascans.com/comics/bulk-' || g, 'c', 0
FROM generate_series(1, 55) AS g`); err != nil {
t.Fatalf("bulk seed series: %v", err)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
SELECT $1, 'asura', 'bulk-' || g, 1000
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
t.Fatalf("bulk seed bookmarks: %v", err)
}
srv := newRouter(st, testConfig(), nil)
pageKeys := func(body string) map[string]bool {
out := map[string]bool{}
// parts[0] is the prelude before the first detail link; every later
// chunk starts with a key, so only those count.
for _, chunk := range strings.Split(body, `href="/admin/series/`)[1:] {
if i := strings.Index(chunk, `"`); i > 0 {
out[chunk[:i]] = true
}
}
return out
}
p1 := adminSeriesPage(t, srv, st, "")
p2 := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(p1, "1–50 of 55") {
t.Errorf("page 1 pager range wrong:\n%s", p1)
}
if !strings.Contains(p2, "51–55 of 55") {
t.Errorf("page 2 pager range wrong:\n%s", p2)
}
if !strings.Contains(p1, `href="/admin/series?page=2"`) {
t.Errorf("page 1 lacks a next link:\n%s", p1)
}
k1, k2 := pageKeys(p1), pageKeys(p2)
if len(k1) != 50 || len(k2) != 5 {
t.Fatalf("pages hold %d and %d rows, want 50 and 5", len(k1), len(k2))
}
for k := range k1 {
if k2[k] {
t.Errorf("row %q repeats across pages", k)
}
}
if len(k1)+len(k2) != 55 {
t.Errorf("%d distinct rows across pages, want 55 (a row vanished)", len(k1)+len(k2))
}
// A page past the end re-reads at page 1: the pager states the first
// page's range and the rows render rather than an empty table.
pOver := adminSeriesPage(t, srv, st, "?page=99")
if !strings.Contains(pOver, "1–50 of 55") || len(pageKeys(pOver)) != 50 {
t.Errorf("a page past the end does not re-read at page 1:\n%s", pOver)
}
}
// Page 2 of a one-page result re-reads at page 1: the store's window count
// only runs over the rows the result set carries, so an overflow page must
// not be rendered as an empty list.
func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(body, "1–3 of 3") {
t.Errorf("page 2 of a one-page result does not re-read at page 1:\n%s", body)
}
for _, want := range []string{"Title of asura:a", "Title of asura:b", "Title of asura:c"} {
if !strings.Contains(body, want) {
t.Errorf("page 2 of a one-page result dropped %q:\n%s", want, body)
}
}
if strings.Contains(body, `class="empty"`) {
t.Errorf("page 2 of a one-page result renders the empty state:\n%s", body)
}
}
// A filter matching nothing renders the named empty state, still 200, and
// keeps the filter selected: an empty hygiene list reads as good news rather
// than a broken page.
func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "No series") {
t.Errorf("a matching-nothing filter renders no named empty state:\n%s", body)
}
if !strings.Contains(body, "No cover") {
t.Errorf("the empty state does not name the filter:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the empty filter is not kept selected:\n%s", body)
}
if strings.Contains(body, `class="tbl series"`) {
t.Errorf("an empty list still renders the table:\n%s", body)
}
}
// A hostile title is escaped, not executed: titles come from the database and
// from third-party pages, so they are attacker-controlled.
func TestSeriesListEscapesHostileTitles(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address, latest_checked_at)
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
t.Fatalf("seed hostile title: %v", err)
}
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "")
if strings.Contains(body, "<script>alert") {
t.Errorf("hostile title rendered unescaped:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("hostile title is not escaped:\n%s", body)
}
}
// Rows are the two-line form, banded by class (never nth-of-type), the site
// cell colours by class rather than inline style, chips cap at two plus a +N
// tail, and no ember token appears anywhere on the page.
func TestSeriesListRowShape(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// An orphan with no URL and no cover: three chips, capped to two plus a
// tail. The two clean rows sit on either side for the banding.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "")
// Order is least-recently-checked first, then (site, series_id): broken
// (never checked), mid (stale), fine (fresh). The middle row carries the
// band class; the marked row the attention class.
if !(strings.Index(body, "Title of asura:broken") < strings.Index(body, "Title of asura:mid") &&
strings.Index(body, "Title of asura:mid") < strings.Index(body, "Title of asura:fine")) {
t.Errorf("rows are not in checked order:\n%s", body)
}
if strings.Count(body, `class="trow"`) != 1 {
t.Errorf("expected exactly one unmarked, unbanded row:\n%s", body)
}
if strings.Count(body, `class="trow attention"`) != 1 {
t.Errorf("expected exactly one attention row:\n%s", body)
}
if strings.Count(body, `class="trow attention band"`) != 1 {
t.Errorf("expected exactly one attention band row:\n%s", body)
}
// The orphan's chips cap at two plus the +N tail.
if !strings.Contains(body, `<span class="mark">no URL</span><span class="mark">no cover</span><span class="mark mark-faint">+1</span>`) {
t.Errorf("chips do not cap at two plus a tail:\n%s", body)
}
// The site cell is a class, never the design's inline style.
if strings.Contains(body, `style="color:var(--`) {
t.Errorf("a site cell carries an inline style:\n%s", body)
}
if !strings.Contains(body, `class="c-site site-asura"`) {
t.Errorf("the site cell lacks its site class:\n%s", body)
}
// The action cell is present and empty for the sibling ticket, and no
// Remove control or confirm row renders in this batch.
if !strings.Contains(body, `<span class="c-act"></span>`) {
t.Errorf("the action cell is not present and empty:\n%s", body)
}
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
}
// No ember: the new-chapter signal stays off the admin surface. Scoped to
// the page content — the shell's brand mark legitimately wears the ember
// flame on every page, admin or not.
mainStart := strings.Index(body, `<main class="page admin-page">`)
mainEnd := strings.Index(body, `</main>`)
if mainStart < 0 || mainEnd < 0 || mainStart > mainEnd {
t.Fatalf("no page content region to check:\n%s", body)
}
if strings.Contains(body[mainStart:mainEnd], "--ember") {
t.Errorf("the page content carries an ember token:\n%s", body)
}
}