diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index ece6477..039cc75 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -42,7 +42,7 @@ type SeriesFilter struct { // and only the anonymous boolean in raisedByReaderAnswer crosses it. const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at, - s.latest_corrected_at` + s.latest_corrected_at, s.finished_at` // raisedByReaderAnswer answers "did a Reader's report set this number" without // naming which Reader. Kept apart from adminSeriesColumns so the column list — @@ -80,7 +80,9 @@ type AdminSeries struct { LatestCorrectedAt int64 ReaderCount int RaisedByReader bool // a Reader's report set LatestChapterNum - + // FinishedAt is the owner's finish stamp: unix ms, zero while the Series is + // not finished — the same shape as the Correction stamp, and its own undo. + FinishedAt int64 } // SeriesPage is one page of the owner's filtered Series list plus the count @@ -196,7 +198,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { `+where+` GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, - s.force_poll_at, s.latest_corrected_at, s.latest_raised_by + s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by `+having+` ORDER BY s.latest_checked_at, s.site, s.series_id LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) @@ -271,7 +273,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) { if err := scan( &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, - &a.ForcePollAt, &a.LatestCorrectedAt, + &a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.RaisedByReader, &a.ReaderCount, &total, ); err != nil { return AdminSeries{}, 0, err diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go index f5f7a73..da9efb3 100644 --- a/backend/internal/store/admin_test.go +++ b/backend/internal/store/admin_test.go @@ -394,3 +394,57 @@ func TestAdminSeriesCarriesForcePollAt(t *testing.T) { t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows) } } + +// SetSeriesFinished is the owner's finish stamp write: finishing writes the +// given ms, un-finishing writes zero — the one undo, the same shape as the +// correction stamp. Touching a missing series is not an error: the row may +// have been orphaned, and the caller's read decides what exists. +func TestSetSeriesFinishedStampsAndClears(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1}) + + if err := s.SetSeriesFinished("asura", "x", 42); err != nil { + t.Fatalf("SetSeriesFinished: %v", err) + } + var got int64 + if err := s.db.QueryRow( + `SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { + t.Fatalf("read finished_at: %v", err) + } + if got != 42 { + t.Fatalf("finished_at = %d, want 42", got) + } + + if err := s.SetSeriesFinished("asura", "x", 0); err != nil { + t.Fatalf("SetSeriesFinished un-finish: %v", err) + } + if err := s.db.QueryRow( + `SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { + t.Fatalf("read finished_at after un-finish: %v", err) + } + if got != 0 { + t.Fatalf("finished_at = %d, want 0 (un-finish writes zero)", got) + } + + if err := s.SetSeriesFinished("asura", "ghost", 42); err != nil { + t.Fatalf("SetSeriesFinished missing: %v", err) + } +} + +// The admin projection carries the finish stamp so the web layer can render +// the finished state without a second read. +func TestAdminSeriesCarriesFinishedAt(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1}) + if err := s.SetSeriesFinished("asura", "x", 5000); err != nil { + t.Fatalf("SetSeriesFinished: %v", err) + } + + page, err := s.SeriesPage(SeriesFilter{}) + if err != nil { + t.Fatalf("SeriesPage: %v", err) + } + if len(page.Rows) != 1 || page.Rows[0].FinishedAt != 5000 { + t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index e6e6c5d..96f8711 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1440,6 +1440,21 @@ func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error { return nil } +// SetSeriesFinished stamps or clears the owner's finish. at is unix ms to +// finish, zero to un-finish. A finished Series drops out of the Lane's reads +// (issue #157), and nothing else writes this column: it is the only writer +// outside migration 0016, so a machine write can never retire a Series +// silently. Touching a missing series is not an error: the row may have been +// orphaned, and the caller's read decides what exists. +func (s *Store) SetSeriesFinished(site, seriesID string, at int64) error { + if _, err := s.db.Exec( + `UPDATE series SET finished_at = $1 WHERE site = $2 AND series_id = $3`, + at, site, seriesID); err != nil { + return fmt.Errorf("set series finished %s:%s: %w", site, seriesID, err) + } + return nil +} + // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for // tests outside this package (the poller's own tests assert on rest // bookkeeping) — see MarkLatestChecked for why the field stays off the diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index c3f6aff..4ef0dff 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -47,6 +47,8 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/series", h.adminSeries}, {"GET /admin/series/{key}", h.adminSeriesDetail}, {"POST /admin/series/{key}/poll", h.adminSeriesPoll}, + {"POST /admin/series/{key}/finish", h.adminSeriesFinish}, + {"POST /admin/series/{key}/unfinish", h.adminSeriesUnfinish}, {"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest}, {"POST /admin/series/{key}/series-url", h.adminSeriesSetURL}, {"POST /admin/series/{key}/remove", h.adminSeriesRemove}, diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index e0cfc35..cc92339 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -110,6 +110,11 @@ type seriesRowView struct { // raced answer: the row stays and says a fresh Bookmark caught the press. CanRemove bool RemovalRefused bool + // Finished is the row's display of the owner's finish stamp: the list row + // shows the state and never offers the control — that lives on the detail + // page, where a press that retires a Series from the Lane is on purpose + // and confirm-gated (issue #158). + Finished bool } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, @@ -182,6 +187,96 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) { h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) } +// adminSeriesFinish is the owner's Finish control: it stamps the Series' +// finished_at and answers with the freshly rendered meta fragment, so the +// "finished ago" line describes the state after the press. The Lane's +// next pass reads the stamp and stops polling the Series (issue #157). The +// owner gate is the route's, not this handler's; the body is capped like the +// API path caps its bodies; the key is validated here — a malformed key is a +// 400 and an unknown one a 404. +func (h *Handler) adminSeriesFinish(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the stamp: the answer must describe the state after the + // press, so the line reads "finished just now". The control's one caller + // is the detail page, which swaps the meta fragment in place. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) +} + +// adminSeriesUnfinish is the reversal of the Finish control: it clears the +// stamp (writes zero) and answers with the freshly rendered meta fragment, so +// the Series is back in the Lane's queue from its next pass. Reversal, so it +// fires instantly with no confirm row (issue #158). +func (h *Handler) adminSeriesUnfinish(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.SetSeriesFinished(site, seriesID, 0); err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the write: the answer must describe the state after the + // press, so the fragment no longer carries the finished line. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) +} + // adminSeriesCorrectLatest is the Latest Chapter correction: the owner types // one number and the Series' Latest Chapter becomes it, stamped as a // Correction. The number must be a finite float greater than zero — a @@ -475,8 +570,6 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) { 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 @@ -572,6 +665,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { CanRemove: a.ReaderCount == 0, Pending: pending, Requested: requested, + Finished: a.FinishedAt > 0, } if a.LatestChapterNum != nil { row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 2f5e889..9d0f3d1 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -56,6 +56,14 @@ type seriesDetailView struct { // Series no Reader holds can be removed, so the owner is never offered a // button the database will always refuse. CanRemove bool + // Finished is the owner's finish stamp rendered for the control: while it + // stands, the page offers the instant Un-finish, not the confirm-gated + // Finish (issue #158). + Finished bool + // FinishedSince is the "finished ago" line, "" while no finish + // stands. It rides the meta fragment both presses swap, so the answer + // itself shows how long the Series has been finished. + FinishedSince string } // adminSeriesDetail renders one Series' page, keyed by the composite @@ -141,6 +149,8 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) } v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) + v.Finished = a.FinishedAt != 0 + v.FinishedSince = finishedAge(time.Now(), a.FinishedAt) // Provenance: the actor class behind the current number, evaluated in the // order the classes outrank one another — the owner's stamp, which a @@ -171,3 +181,13 @@ func correctedAge(now time.Time, at int64) string { } return "corrected " + since(now, time.UnixMilli(at)) } + +// finishedAge is the finish marker's text: "finished ago" while the +// stamp is set, "" when zero — zero means never finished, and the reversal +// (un-finish) must not read as history after a press (issue #158). +func finishedAge(now time.Time, at int64) string { + if at == 0 { + return "" + } + return "finished " + since(now, time.UnixMilli(at)) +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index 6eabb79..b442592 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -653,6 +653,22 @@ margin-left: auto; } +/* The finish confirm is the one calm row on the admin sheet: a reversible + move wears the recessed ash, never the remove wash above, and its + affirmative takes the sheet's patina accent (issue #158). */ +.admin-sheet .confirm-row.calm { + background: var(--ash); +} + +.admin-sheet .confirm-row.calm span { + color: var(--paper-dim); +} + +.admin-sheet .confirm-row.calm .go { + background: var(--patina); + color: var(--ink); +} + .admin-sheet .tbl.series .row-msg { grid-column: 1 / -1; margin-top: 6px; diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index 1bc2ba5..c2204de 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -39,6 +39,22 @@
{{end}} +{{if .Finished}} +
+
+
+{{else}} +
+
+ +
+{{end}} {{end}} {{/* series-detail-meta is the meta line, and the answer a Check now or @@ -54,6 +70,7 @@ {{.Readers}} readers {{if .Corrected}}{{.Corrected}}{{end}} {{if .Pending}}{{.Requested}}{{end}} + {{if .FinishedSince}}{{.FinishedSince}}{{end}} {{if .Unpollable}}unpollable{{end}} {{if .NoCover}}no cover{{end}} {{if .Orphan}}orphan{{end}} diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html index 0d51ff2..f0af815 100644 --- a/backend/internal/web/templates/series-list.html +++ b/backend/internal/web/templates/series-list.html @@ -47,7 +47,7 @@ {{.Ch}} {{.Age}} {{.Readers}} - {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}} + {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}}{{if .Finished}}finished{{end}} {{if .CanPoll}}Check now{{end}}{{if .CanRemove}}{{end}} {{if .RemovalRefused}}a Reader has bookmarked this Series again{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index b7b035c..41aa76e 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -3428,6 +3428,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 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, `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, `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, `finished`) { + 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