diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index bbd0819..98419a8 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -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 diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go index fd5c28b..b80dacf 100644 --- a/backend/internal/store/admin_test.go +++ b/backend/internal/store/admin_test.go @@ -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) + } +} diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go index 9857864..aa9db27 100644 --- a/backend/internal/web/admin_overview.go +++ b/backend/internal/web/admin_overview.go @@ -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:] { diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index 32ab04d..d6d9cbc 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -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 diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 9d0f3d1..dfa8bc7 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -64,6 +64,10 @@ type seriesDetailView struct { // stands. It rides the meta fragment both presses swap, so the answer // itself shows how long the Series has been finished. FinishedSince 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 @@ -151,6 +155,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) v.Finished = a.FinishedAt != 0 v.FinishedSince = finishedAge(time.Now(), a.FinishedAt) + 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 diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index c2204de..044d62f 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -46,6 +46,7 @@ {{else}}
{{.SiteCompleted}}
{{end}}