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
+108 -8
View File
@@ -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
@@ -110,6 +114,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 +191,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 <age> 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
@@ -516,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.
@@ -572,6 +671,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)