diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index 039cc75..2880e84 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -7,11 +7,13 @@ import ( "strings" ) -// Series filter names (issue #140), ordered permanent-then-fixable — the -// repairs nothing will ever undo first, the ones a Poll can make right after. -// A name is the repair a row needs, not the SQL that finds it; the values are -// the wire form the Series list URL carries (#142). "all" is the absent and -// unknown case: every Series. +// Series filter names (issue #140): the eight hygiene names are ordered +// permanent-then-fixable — the repairs nothing will ever undo first, the +// ones a Poll can make right after. SeriesFilterFinished is not part of +// that ordering: a finished Series is a deliberate state, not a repair, so +// it sits last, informational. A name is the repair a row needs, not the +// SQL that finds it; the values are the wire form the Series list URL +// carries (#142). "all" is the absent and unknown case: every Series. const ( SeriesFilterAll = "all" SeriesFilterNoURL = "no_series_url" @@ -21,6 +23,7 @@ const ( SeriesFilterStale = "stale" SeriesFilterNoCover = "no_cover" SeriesFilterReaderReport = "reader_report" + SeriesFilterFinished = "finished" ) // SeriesFilter is one named hygiene predicate over the whole library. Site @@ -115,10 +118,19 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID } // The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch- // gate case is invisible to SQL, needs the Site registry in Go, and belongs to // a later repair), never-read-a-chapter and never-checked as disjoint halves -// (non-zero versus zero check stamp), stale, no cover, and Reader-report. +// (non-zero versus zero check stamp), stale, no cover, finished (the +// retirement stamp, read directly), and Reader-report. // no_readers is the one HAVING predicate: it is the orphan test, an aggregate // over the LEFT JOIN, where a bare WHERE has no row to test. // +// The clock-versus-outcome split decides which predicates exclude finished +// Series (`s.finished_at = 0` in each of the four): the clock-driven one — +// never-checked, stale, no-chapter, no-cover — keep ticking after the last +// Poll, so they would report a retired row as a problem no Poll is coming to +// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report — +// read stored facts that simply stop arriving, so a finished Series needing a +// genuine repair still shows up under them. +// // stale is the checked-but-old half of the stamp partition — because the // verdict line wants "not checked in twelve hours" as one figure, and a never // checked Series is already counted on its own "waiting"/never-checked @@ -135,16 +147,18 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) { case SeriesFilterNoURL: clauses = append(clauses, `s.series_url = ''`) case SeriesFilterNoChapter: - clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`) + clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`) case SeriesFilterNeverChecked: - clauses = append(clauses, `s.latest_checked_at = 0`) + clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`) case SeriesFilterStale: - clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)) + clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`) args = append(args, f.Cutoff) case SeriesFilterNoCover: - clauses = append(clauses, `s.cover_address = ''`) + clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`) case SeriesFilterReaderReport: clauses = append(clauses, `s.latest_raised_by IS NOT NULL`) + case SeriesFilterFinished: + clauses = append(clauses, `s.finished_at > 0`) case SeriesFilterNoReaders: having = `HAVING COUNT(b.reader_id) = 0` default: diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go index da9efb3..5f4077f 100644 --- a/backend/internal/store/admin_test.go +++ b/backend/internal/store/admin_test.go @@ -128,6 +128,85 @@ func TestAdminSeriesFilters(t *testing.T) { } } +// A finished Series is the owner's deliberate state, not a problem a Poll +// will fix: the four clock-driven hygiene predicates exclude it (their +// stamps stop advancing at the last Poll, so without the guard a retired row +// is reported forever), the three outcome-driven ones still include it, and +// the finished filter returns exactly the retired rows. +func TestAdminFinishedSeriesFilters(t *testing.T) { + s := newTestStore(t) + // Each fin-* row is shaped to trip exactly one predicate if its guard + // fails: checked-but-old for stale, a zero stamp for never-checked, a + // stamp with no chapter for no-chapter, an empty cover for no-cover, and + // the unguarded three shaped to trip their own. A healthy, unfinished + // neighbour keeps the exclusion checks honest: a filter that regressed to + // matching nothing would pass a bare "no finished rows" assertion. + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-stale", url: "u", cover: "c", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-neverchecked", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nocover", url: "u", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nourl", url: "", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-orphan", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 0}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-report", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, raisedBy: true}) + seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + finished := []string{ + "asura:fin-stale", "asura:fin-neverchecked", "asura:fin-nochapter", + "asura:fin-nocover", "asura:fin-nourl", "asura:fin-orphan", "asura:fin-report", + } + for _, key := range finished { + site, id, _ := strings.Cut(key, ":") + if err := s.SetSeriesFinished(site, id, 1000); err != nil { + t.Fatalf("finish %s: %v", key, err) + } + } + + cases := []struct { + name string + f SeriesFilter + want []string + }{ + {"stale excludes finished", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, nil}, + {"never checked excludes finished", SeriesFilter{Name: SeriesFilterNeverChecked}, nil}, + {"no chapter excludes finished", SeriesFilter{Name: SeriesFilterNoChapter}, nil}, + {"no cover excludes finished", SeriesFilter{Name: SeriesFilterNoCover}, nil}, + {"no url includes finished", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:fin-nourl"}}, + {"no readers includes finished", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:fin-orphan"}}, + {"reader report includes finished", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:fin-report"}}, + {"finished returns the retired rows", SeriesFilter{Name: SeriesFilterFinished}, finished}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pageKeys(t, s, tc.f) + want := map[string]bool{} + for _, k := range tc.want { + want[k] = true + } + if len(got) != len(want) { + t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want) + } + for k := range want { + if !got[k] { + t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got) + } + } + }) + } + + // The aggregate's finished total counts every retired row — the same + // predicate the Overview's finished figure is summed from. + shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterFinished}) + if err != nil { + t.Fatalf("SeriesShapes(finished): %v", err) + } + sum := 0 + for _, sh := range shapes { + sum += sh.Total + } + if sum != len(finished) { + t.Fatalf("finished aggregate = %d, want %d", sum, len(finished)) + } +} + // "Never read a chapter" and "never checked" are disjoint by construction: // the first requires a non-zero check stamp, the second a zero one. Over a // mix that should satisfy both, no row may be counted twice. diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go index 1851a17..2d2f9ba 100644 --- a/backend/internal/web/admin_overview.go +++ b/backend/internal/web/admin_overview.go @@ -59,7 +59,7 @@ type siteRow struct { } // overviewView assembles the landing page from the store's read model: one -// SeriesShapes pass per filter summed in Go (the shipped surface offers eight +// SeriesShapes pass per filter summed in Go (the shipped surface offers nine // grouped passes, not a stats query — #140), the pass log's latest pass per // Site, and the roster. A failure in any read is a 500 with a logged reason, // never a page of silent zeroes. @@ -93,8 +93,10 @@ func (h *Handler) overviewView() (overviewView, error) { view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked] view.Verdict, view.HasCounts = overviewVerdict(passes, now) - // The seven problem filters, in seriesFilterOrder's permanent-then-fixable - // order; the All filter's count belongs to the Library block, not to a + // The hygiene figures, in seriesFilterOrder's tail: the seven problem + // filters in permanent-then-fixable order, then the finished figure last — + // informational, not a problem, and last because seriesFilterOrder appends + // it there. The All filter's count belongs to the Library block, not to a // "hygiene" figure. hygiene := make([]fig, 0, len(seriesFilterOrder)-1) for _, name := range seriesFilterOrder[1:] { diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index fd473bf..20298ad 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -20,10 +20,10 @@ import ( // 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. +// seriesFilterLabels names every Series filter for the 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", @@ -33,10 +33,13 @@ var seriesFilterLabels = map[string]string{ store.SeriesFilterStale: "Not checked in 12h", store.SeriesFilterNoCover: "No cover", store.SeriesFilterReaderReport: "Latest from a Reader", + store.SeriesFilterFinished: "Finished", } // seriesFilterOrder is the select's render order: All first, then the -// permanent repairs, then the fixable ones (issue #140). +// permanent repairs, then the fixable ones (issue #140). Finished rides the +// tail, last — deliberate, not a repair — and the Overview's stats block +// renders the same tail, which is what sits the finished figure last there. var seriesFilterOrder = []string{ store.SeriesFilterAll, store.SeriesFilterNoURL, @@ -46,6 +49,7 @@ var seriesFilterOrder = []string{ store.SeriesFilterStale, store.SeriesFilterNoCover, store.SeriesFilterReaderReport, + store.SeriesFilterFinished, } // seriesListView is the Series list page's data. The template renders strings @@ -611,9 +615,9 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) { 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 +// seriesFilterOptions renders every filter with its library-wide count, one +// SeriesShapes pass per filter summed in Go — the shipped surface offers nine +// 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. Cutoff travels with // the stale filter, or its count would always be zero. diff --git a/backend/web_test.go b/backend/web_test.go index 41aa76e..bdfe09c 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -2139,7 +2139,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) { } } - func TestMangaPageKeepsUpdatedTab(t *testing.T) { cfg := testConfig() srv, st := newWebTestServer(t, cfg) @@ -2308,6 +2307,42 @@ func TestSeriesListFilterWiring(t *testing.T) { } } +// The finished filter is a first-class option: the select carries it with +// its label and library-wide count, and entering it lists exactly the +// retired rows — picking it up from order-plus-label like every other +// filter, with no per-filter branch in the handler. +func TestSeriesListFinishedFilterOption(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: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil { + t.Fatalf("finish asura:retired: %v", err) + } + srv := newRouter(st, testConfig()) + + body := adminSeriesPage(t, srv, st, "?filter=finished") + if !strings.Contains(body, "Title of asura:retired") { + t.Errorf("finished list misses its row:\n%s", body) + } + if strings.Contains(body, "Title of asura:healthy") { + t.Errorf("finished list renders an unfinished row:\n%s", body) + } + if !strings.Contains(body, "1 series") || !strings.Contains(body, "Finished") { + t.Errorf("finished heading lacks the count and label:\n%s", body) + } + if !strings.Contains(body, "Finished (1)") { + t.Errorf("the finished option lacks its count:\n%s", body) + } + if !strings.Contains(body, `