Merge #170: The completion hint reaches the owner, and decides nothing
# Conflicts: # backend/internal/web/admin_series_detail.go
This commit is contained in:
@@ -9,23 +9,26 @@ import (
|
||||
|
||||
// Series filter names (issue #140): the seven repair filters 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.
|
||||
// ones a Poll can make right after. SeriesFilterFinished and
|
||||
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
||||
// Series is a deliberate state and a Site-completed one is the Site's own
|
||||
// marker, not repairs, so the pair rides the tail, 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"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterFailing = "failing"
|
||||
SeriesFilterUnverified = "unverified"
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterSiteCompleted = "site_completed"
|
||||
SeriesFilterFailing = "failing"
|
||||
SeriesFilterUnverified = "unverified"
|
||||
)
|
||||
|
||||
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||
@@ -48,7 +51,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.finished_at`
|
||||
s.latest_corrected_at, s.finished_at, s.site_completed_at`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
@@ -101,6 +104,10 @@ type AdminSeries struct {
|
||||
// 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
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
||||
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
||||
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
||||
SiteCompletedAt int64
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
@@ -134,23 +141,26 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
||||
// 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, finished (the
|
||||
// retirement stamp, read directly), Reader-report, and the failing pair —
|
||||
// failing (the failure row exists, a chapter exists, and failing_since is
|
||||
// past the cutoff) and unverified (the Reader-attributed subset of failing).
|
||||
// retirement stamp, read directly), site completed (the Site's marker, read
|
||||
// directly), Reader-report, and the failing pair — failing (the failure row
|
||||
// exists, a chapter exists, and failing_since is past the cutoff) and
|
||||
// unverified (the Reader-attributed subset of failing).
|
||||
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
||||
// chapter's IS NULL half, so the two are disjoint by construction.
|
||||
// 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 —
|
||||
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
||||
// 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 outcome-driven ones — no-URL, no-readers, Reader-report, failing,
|
||||
// unverified — read stored facts that simply stop arriving, so a finished
|
||||
// Series needing a genuine repair still shows up under them. The failing pair
|
||||
// carries no finished guard for exactly that contrast: a failure row is a
|
||||
// stored outcome, not a ticking clock.
|
||||
// fix; site-completed's stamp is the Site's, and it keeps standing after the
|
||||
// owner retires the row, so a finished Series would be reported as work
|
||||
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
|
||||
// Reader-report, failing, unverified — read stored facts that simply stop
|
||||
// arriving, so a finished Series needing a genuine repair still shows up
|
||||
// under them. The failing pair carries no finished guard for exactly that
|
||||
// contrast: a failure row is a stored outcome, not a ticking clock.
|
||||
//
|
||||
// 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
|
||||
@@ -186,6 +196,8 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterFinished:
|
||||
clauses = append(clauses, `s.finished_at > 0`)
|
||||
case SeriesFilterSiteCompleted:
|
||||
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
||||
case SeriesFilterNoReaders:
|
||||
having = `HAVING COUNT(b.reader_id) = 0`
|
||||
default:
|
||||
@@ -240,7 +252,8 @@ 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.finished_at, s.latest_raised_by,
|
||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
|
||||
s.latest_raised_by,
|
||||
f.outcome, f.failing_since
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
@@ -317,7 +330,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.FinishedAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
||||
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
|
||||
@@ -12,14 +12,15 @@ import (
|
||||
// Upsert path could not produce together: an orphan has no bookmark, and a
|
||||
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
||||
type seriesSeed 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
|
||||
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
|
||||
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||
}
|
||||
|
||||
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
||||
@@ -39,10 +40,10 @@ func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
|
||||
}
|
||||
if _, err := s.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)`,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||||
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||
}
|
||||
for i := range seed.bookmarks {
|
||||
@@ -658,3 +659,78 @@ func TestAdminFailureProjection(t *testing.T) {
|
||||
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
|
||||
}
|
||||
}
|
||||
|
||||
// The site-completed filter (issue #170) lists the Site's own marker as work
|
||||
// to work through, carrying the same finished guard the clock-driven
|
||||
// predicates gained: the Site's stamp keeps standing after the owner retires
|
||||
// the row, so a finished Series would be reported as work nobody is going to
|
||||
// do. A zero stamp never appears. Un-finishing puts the Series back in the
|
||||
// Poll query — the finished_at gate is #157's own, asserted through
|
||||
// DueForLatestCheck rather than re-derived here.
|
||||
func TestAdminSiteCompletedFilter(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:done", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:never", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
if err := s.SetSeriesFinished("asura", "retired", 1000); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
|
||||
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if len(got) != 1 || !got["asura:done"] {
|
||||
t.Fatalf("site-completed returned %v, want only asura:done", got)
|
||||
}
|
||||
|
||||
// The projection carries the stamp so the detail page can age it.
|
||||
page, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage(site_completed): %v", err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Rows[0].SiteCompletedAt != 5000 {
|
||||
t.Fatalf("row = %+v, want SiteCompletedAt 5000", page.Rows)
|
||||
}
|
||||
|
||||
// The aggregate counts the same row: the landing figure and the select's
|
||||
// option come from this pass, so they cannot disagree with the list.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(site_completed): %v", err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
if sum != 1 {
|
||||
t.Fatalf("site-completed aggregate = %d, want 1", sum)
|
||||
}
|
||||
|
||||
// Un-finishing puts the Series back in the Poll query: the due read's
|
||||
// finished_at gate (issue #157) admits it again — asserted through the
|
||||
// Lane's own read, not re-derived here.
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
t.Fatalf("a finished Series is still due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
if err := s.SetSeriesFinished("asura", "retired", 0); err != nil {
|
||||
t.Fatalf("un-finish asura:retired: %v", err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after un-finish: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("un-finished Series is not due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ type overviewView struct {
|
||||
// as stale + never_checked: a never-checked Series is already counted on
|
||||
// its own filter, and the verdict wants the inclusive number.
|
||||
Unchecked int
|
||||
// Hygiene is the seven problem filters in the Series list's own render
|
||||
// order plus the finished figure riding last (informational); Library is
|
||||
// the library split plus the roster. Every figure is a door into the list
|
||||
// that counts it, except a zero.
|
||||
// Hygiene is the problem filters in the Series list's own render order
|
||||
// plus the informational tail — finished, then site completed — riding
|
||||
// last; Library is the library split plus the roster. Every figure is a
|
||||
// door into the list that counts it, except a zero.
|
||||
Hygiene []fig
|
||||
Library []fig
|
||||
// Sites is the per-Site library shape table, one row per Site with any
|
||||
@@ -60,7 +60,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 nine
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers ten
|
||||
// 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.
|
||||
@@ -94,10 +94,10 @@ func (h *Handler) overviewView() (overviewView, error) {
|
||||
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||
|
||||
// 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
|
||||
// The hygiene figures, in seriesFilterOrder's tail: the problem filters
|
||||
// in permanent-then-fixable order, then the informational tail — finished
|
||||
// and site completed — riding last because seriesFilterOrder appends them
|
||||
// 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:] {
|
||||
|
||||
@@ -25,23 +25,25 @@ const seriesPageSize = 50
|
||||
// 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",
|
||||
store.SeriesFilterFailing: "Failing over 12h",
|
||||
store.SeriesFilterUnverified: "Unverified Reader number",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
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",
|
||||
store.SeriesFilterFailing: "Failing over 12h",
|
||||
store.SeriesFilterUnverified: "Unverified Reader number",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
store.SeriesFilterSiteCompleted: "Site says completed",
|
||||
}
|
||||
|
||||
// seriesFilterOrder is the select's render order: All first, then the
|
||||
// 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.
|
||||
// permanent repairs, then the fixable ones (issue #140). Finished and the
|
||||
// site-completed hint ride the tail — deliberate, not repairs — and the
|
||||
// Overview's stats block renders the same tail, which is what sits the
|
||||
// finished and site-completed figures last there.
|
||||
var seriesFilterOrder = []string{
|
||||
store.SeriesFilterAll,
|
||||
store.SeriesFilterNoURL,
|
||||
@@ -54,6 +56,7 @@ var seriesFilterOrder = []string{
|
||||
store.SeriesFilterFailing,
|
||||
store.SeriesFilterUnverified,
|
||||
store.SeriesFilterFinished,
|
||||
store.SeriesFilterSiteCompleted,
|
||||
}
|
||||
|
||||
// seriesListView is the Series list page's data. The template renders strings
|
||||
|
||||
@@ -69,6 +69,10 @@ type seriesDetailView struct {
|
||||
// owner window: the value is unconfirmed and the owner should not trust
|
||||
// it. It never names the Reader. "" otherwise.
|
||||
Unverified string
|
||||
// SiteCompleted is the hint's line, "" while the Site has said nothing or
|
||||
// the owner has finished the Series: "the site says this work is
|
||||
// completed (since 3d ago)". A hint, never a control (issue #170).
|
||||
SiteCompleted string
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -164,6 +168,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
||||
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||
}
|
||||
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
|
||||
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
|
||||
}
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
{{else}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
||||
{{if .SiteCompleted}}<p class="hint">{{.SiteCompleted}}</p>{{end}}
|
||||
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
||||
<span>Mark this Series finished?</span>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user