Spec #136: Finished belongs to the Series — owner-owned poll gate, Lifecycle bucket dropped (#163)

Closes #136.

Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone.

## What landed

- **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted.
- **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there.
- **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`.
- **#161** — glossary and the stale Reader-count divergence note catch up.
- **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not.

## Verification

`go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge.

Reviewed-on: #163
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #163.
This commit is contained in:
2026-08-22 17:31:52 +07:00
committed by sulthan
parent faa80c41ea
commit 8e4fa6448e
34 changed files with 1225 additions and 241 deletions
+278 -21
View File
@@ -1523,8 +1523,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
body := rr.Body.String()
want := `hx-target="[id='card-asura:solo']"`
if strings.Count(body, want) != 5 {
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
if strings.Count(body, want) != 4 {
t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, delete buttons, chapter form)",
strings.Count(body, want), want)
}
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
@@ -1748,8 +1748,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
}
for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli()
@@ -1758,7 +1756,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
}
}
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
@@ -1768,11 +1765,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
tab string
want, dontWant []string
}{
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
{"all", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"new", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}},
}
for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) {
@@ -1835,12 +1831,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
if !strings.Contains(strip, "ReadingOne") {
t.Fatal("strip dropped the series with an unread chapter")
}
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne"} {
if strings.Contains(strip, unwanted) {
t.Fatalf("strip included %s", unwanted)
}
}
for _, tab := range []string{"new", "fav", "archived", "finished"} {
for _, tab := range []string{"new", "fav", "archived"} {
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
t.Fatalf("tab %s rendered the strip", tab)
}
@@ -1903,7 +1899,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
for _, want := range []string{store.StatusArchived, store.StatusReading} {
if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK {
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
}
@@ -1971,12 +1967,11 @@ func TestCardShowsStatusControls(t *testing.T) {
tab string
want, dontWant []string
}{
// A series being read can be shelved or completed, not restored.
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
// An archived one can come back or be completed.
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
// A finished one can only come back.
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
// A series being read can be shelved, not restored, and there is no
// finish control any more (issue #157).
{"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
// An archived one can come back.
{"archived", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
}
for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) {
@@ -2010,11 +2005,14 @@ func TestAppRendersNewTabs(t *testing.T) {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
for _, want := range []string{`href="/?tab=archived"`} {
if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("app page missing %s", want)
}
}
if strings.Contains(rr.Body.String(), `href="/?tab=finished"`) {
t.Fatal("app page still offers a Finished tab")
}
}
// A mutation has to bring the chrome with it: the strip and the badge live
@@ -2131,7 +2129,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
for _, want := range []string{
"/?lib=novel&amp;tab=fav",
"/?lib=novel&amp;tab=archived",
"/?lib=novel&amp;tab=finished",
} {
if !strings.Contains(body, want) {
t.Fatalf("novel page missing tab link %s", want)
@@ -2310,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, `<option value="finished" selected>`) {
t.Errorf("the finished option is not selected:\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.
@@ -2994,6 +3027,67 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
}
}
// The finished figure rides last in the Hygiene block: seriesFilterOrder
// appends it at the tail, the block renders order[1:], and the count comes
// from the same aggregate the select is numbered from. Asserting on the body
// order catches a render that merely shows the figure somewhere.
func TestOverviewFinishedFigureRidesLast(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: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "https://asurascans.com/comics/retired", cover: "bbb", 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 := overviewBody(t, srv, st)
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
var labels []string
for _, m := range stat.FindAllStringSubmatch(body, -1) {
switch m[1] {
case "Series", "Manga", "Novels", "Readers":
continue // the Library block, not the hygiene figures
}
labels = append(labels, m[1])
}
if len(labels) != 8 {
t.Fatalf("hygiene block renders %d figures, want 8 (seven problems + finished):\n%s", len(labels), body)
}
if labels[len(labels)-1] != "Finished" {
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
}
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
t.Errorf("the finished figure lacks its count and door:\n%s", body)
}
}
// A measured zero finished count stays on the page as a muted digit, never a
// link: following it would land on an empty list, exactly like every other
// zero figure.
func TestOverviewFinishedZeroIsAnUnlinkedDigit(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:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
if !strings.Contains(body, `Finished</span><span class="fig zero">0</span>`) {
t.Errorf("a zero finished figure does not render as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=finished"`) {
t.Errorf("a zero finished figure is still a link:\n%s", body)
}
}
// The waiting figure sums Due over the latest pass per Site — older passes
// for the same Site must not double-count, so the verdict reads the same
// latest-per-Site projection the Lanes page reads.
@@ -3430,6 +3524,169 @@ func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
}
}
// The finish control lives on the Series detail page only: a neighbouring
// Finish in the fifty-row grid is not a harmless read the way Check now is,
// so the list row renders the state as a mark and offers no control to set
// it. On the detail page the press is confirm-gated — the opener does not
// post and the only finish POST sits inside the confirm row, targeting the
// meta fragment it will swap (#158).
func TestAdminSeriesDetailFinishControl(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`id="confirm-finish"`,
"Mark this Series finished?",
`hx-post="/admin/series/asura:solo/finish"`,
`hx-target="#detail-meta"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
// The press travels through the confirm row: the page's only finish POST
// sits after the row's id (inside the row), and the opener that reveals
// it posts nothing.
if opener := strings.Index(body, `id="confirm-finish"`); opener < 0 ||
strings.Index(body, `hx-post="/admin/series/asura:solo/finish"`) < opener {
t.Errorf("finish POST is not inside the confirm row:\n%s", body)
}
if strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`) != 1 {
t.Errorf("finish POST count = %d, want exactly one (the affirmative):\n%s",
strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`), body)
}
// The list row displays nothing to set: no finish control, no finished
// mark on an unfinished Series.
list := adminSeriesPage(t, router, st, "")
for _, banned := range []string{
`/admin/series/asura:solo/finish`,
`/admin/series/asura:solo/unfinish`,
`mark-faint">finished`,
} {
if strings.Contains(list, banned) {
t.Errorf("list row carries %q:\n%s", banned, list)
}
}
}
// The finish route is the owner's one writer: a finish press stamps
// finished_at and answers with the swapped detail-meta fragment carrying the
// "finished <age> ago" mark, and an un-finish press writes zero and answers
// with a fragment that has no finished line. A malformed key is a 400 and an
// unknown one a 404, like the other Series mutations (#158).
func TestSeriesFinishRoute(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:solo", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
for path, want := range map[string]int{
"/admin/series/solo/finish": http.StatusBadRequest,
"/admin/series/ghost:x/finish": http.StatusNotFound,
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != want {
t.Errorf("POST %s: status = %d, want %d", path, rr.Code, want)
}
}
// A finish press stamps and answers with the swapped meta fragment whose
// mark reads "finished just now".
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/finish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST finish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("finish answer is not the meta fragment:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("finish answer lacks the fresh finished mark:\n%s", body)
}
var at int64
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at: %v", err)
}
if at == 0 {
t.Error("finished_at = 0, want the finish stamp written")
}
// The page after the press offers the instant reversal, no confirm.
body = seriesDetailPage(t, router, st, "asura:solo")
if !strings.Contains(body, `hx-post="/admin/series/asura:solo/unfinish"`) {
t.Errorf("finished detail page lacks the un-finish control:\n%s", body)
}
if strings.Contains(body, "Mark this Series finished?") {
t.Errorf("finished detail page still carries the confirm row:\n%s", body)
}
// An un-finish press fires instantly and writes zero.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/unfinish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST unfinish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("unfinish answer is not the meta fragment:\n%s", body)
}
if strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("unfinish answer still carries the finished mark:\n%s", body)
}
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at after un-finish: %v", err)
}
if at != 0 {
t.Errorf("finished_at = %d after un-finish, want 0", at)
}
}
// A finished Series shows its state in the list row as a faint mark, and the
// row offers no control to set or clear it — the reversal lives on the
// detail page, its press, and the mark is the whole of the row's share
// (#158).
func TestAdminSeriesListShowsFinished(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
if err := st.SetSeriesFinished("asura", "solo", time.Now().Add(-2*time.Minute).UnixMilli()); err != nil {
t.Fatalf("SetSeriesFinished: %v", err)
}
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, `<span class="mark mark-faint">finished</span>`) {
t.Errorf("list row lacks the finished mark:\n%s", body)
}
for _, banned := range []string{"solo/finish", "solo/unfinish"} {
if strings.Contains(body, banned) {
t.Errorf("list row offers %q:\n%s", banned, body)
}
}
}
// The series URL repair validates with the poller's own fetch gate and
// answers 400 before anything reaches the store; a URL that passes the gate
// is stored where an Upsert would have ignored it. The request performs no