Spec #137: per-Series poll failure state, completion hint, and outbound owner notification #174

Merged
sulthan merged 30 commits from spec-137 into main 2026-08-23 11:31:11 +07:00
7 changed files with 363 additions and 81 deletions
Showing only changes of commit 2861f055b4 - Show all commits
+41 -28
View File
@@ -9,23 +9,26 @@ import (
// Series filter names (issue #140): the seven repair filters are ordered // Series filter names (issue #140): the seven repair filters are ordered
// permanent-then-fixable — the repairs nothing will ever undo first, the // permanent-then-fixable — the repairs nothing will ever undo first, the
// ones a Poll can make right after. SeriesFilterFinished is not part of // ones a Poll can make right after. SeriesFilterFinished and
// that ordering: a finished Series is a deliberate state, not a repair, so // SeriesFilterSiteCompleted are not part of that ordering: a finished
// it sits last, informational. A name is the repair a row needs, not the // Series is a deliberate state and a Site-completed one is the Site's own
// SQL that finds it; the values are the wire form the Series list URL // marker, not repairs, so the pair rides the tail, informational. A name is
// carries (#142). "all" is the absent and unknown case: every Series. // 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 ( const (
SeriesFilterAll = "all" SeriesFilterAll = "all"
SeriesFilterNoURL = "no_series_url" SeriesFilterNoURL = "no_series_url"
SeriesFilterNoChapter = "never_read_a_chapter" SeriesFilterNoChapter = "never_read_a_chapter"
SeriesFilterNoReaders = "no_readers" SeriesFilterNoReaders = "no_readers"
SeriesFilterNeverChecked = "never_checked" SeriesFilterNeverChecked = "never_checked"
SeriesFilterStale = "stale" SeriesFilterStale = "stale"
SeriesFilterNoCover = "no_cover" SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report" SeriesFilterReaderReport = "reader_report"
SeriesFilterFinished = "finished" SeriesFilterFinished = "finished"
SeriesFilterFailing = "failing" SeriesFilterSiteCompleted = "site_completed"
SeriesFilterUnverified = "unverified" SeriesFilterFailing = "failing"
SeriesFilterUnverified = "unverified"
) )
// SeriesFilter is one named filter predicate over the whole library. Site // 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. // and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, 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.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 // raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list — // 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 // 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. // not finished — the same shape as the Correction stamp, and its own undo.
FinishedAt int64 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 // 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 // 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 // a later repair), never-read-a-chapter and never-checked as disjoint halves
// (non-zero versus zero check stamp), stale, no cover, finished (the // (non-zero versus zero check stamp), stale, no cover, finished (the
// retirement stamp, read directly), Reader-report, and the failing pair — // retirement stamp, read directly), site completed (the Site's marker, read
// failing (the failure row exists, a chapter exists, and failing_since is // directly), Reader-report, and the failing pair — failing (the failure row
// past the cutoff) and unverified (the Reader-attributed subset of failing). // 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- // 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. // 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 // 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. // over the LEFT JOIN, where a bare WHERE has no row to test.
// //
// The clock-versus-outcome split decides which predicates exclude finished // 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 // 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 // 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, // fix; site-completed's stamp is the Site's, and it keeps standing after the
// unverified — read stored facts that simply stop arriving, so a finished // owner retires the row, so a finished Series would be reported as work
// Series needing a genuine repair still shows up under them. The failing pair // nobody is going to do. The outcome-driven ones — no-URL, no-readers,
// carries no finished guard for exactly that contrast: a failure row is a // Reader-report, failing, unverified — read stored facts that simply stop
// stored outcome, not a ticking clock. // 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 // 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 // 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) args = append(args, f.Cutoff)
case SeriesFilterFinished: case SeriesFilterFinished:
clauses = append(clauses, `s.finished_at > 0`) clauses = append(clauses, `s.finished_at > 0`)
case SeriesFilterSiteCompleted:
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
case SeriesFilterNoReaders: case SeriesFilterNoReaders:
having = `HAVING COUNT(b.reader_id) = 0` having = `HAVING COUNT(b.reader_id) = 0`
default: default:
@@ -240,7 +252,8 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
`+where+` `+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, 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.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 f.outcome, f.failing_since
`+having+` `+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id 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( if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, &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, &a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
); err != nil { ); err != nil {
return AdminSeries{}, 0, err return AdminSeries{}, 0, err
+87 -11
View File
@@ -12,14 +12,15 @@ import (
// Upsert path could not produce together: an orphan has no bookmark, and a // 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. // Reader-raised Latest Chapter needs a Sighting the store does not create.
type seriesSeed struct { type seriesSeed struct {
key string key string
kind string kind string
url string url string
cover string // cover_address cover string // cover_address
checkedAt int64 checkedAt int64
latestNum *float64 latestNum *float64
bookmarks int // readers that hold it; 0 = orphan bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser 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 // 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(` if _, err := s.db.Exec(`
INSERT INTO series (site, series_id, title, kind, series_url, cover_address, INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
latest_checked_at, latest_chapter, latest_chapter_num) latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover, 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) t.Fatalf("seed series %q: %v", seed.key, err)
} }
for i := range seed.bookmarks { 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"]) 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)
}
}
+9 -9
View File
@@ -23,10 +23,10 @@ type overviewView struct {
// as stale + never_checked: a never-checked Series is already counted on // as stale + never_checked: a never-checked Series is already counted on
// its own filter, and the verdict wants the inclusive number. // its own filter, and the verdict wants the inclusive number.
Unchecked int Unchecked int
// Hygiene is the seven problem filters in the Series list's own render // Hygiene is the problem filters in the Series list's own render order
// order plus the finished figure riding last (informational); Library is // plus the informational tail — finished, then site completed — riding
// the library split plus the roster. Every figure is a door into the list // last; Library is the library split plus the roster. Every figure is a
// that counts it, except a zero. // door into the list that counts it, except a zero.
Hygiene []fig Hygiene []fig
Library []fig Library []fig
// Sites is the per-Site library shape table, one row per Site with any // 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 // 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 // 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, // Site, and the roster. A failure in any read is a 500 with a logged reason,
// never a page of silent zeroes. // 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.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
view.Verdict, view.HasCounts = overviewVerdict(passes, now) view.Verdict, view.HasCounts = overviewVerdict(passes, now)
// The hygiene figures, in seriesFilterOrder's tail: the seven problem // The hygiene figures, in seriesFilterOrder's tail: the problem filters
// filters in permanent-then-fixable order, then the finished figure last — // in permanent-then-fixable order, then the informational tail — finished
// informational, not a problem, and last because seriesFilterOrder appends // and site completed — riding last because seriesFilterOrder appends them
// it there. The All filter's count belongs to the Library block, not to a // there. The All filter's count belongs to the Library block, not to a
// "hygiene" figure. // "hygiene" figure.
hygiene := make([]fig, 0, len(seriesFilterOrder)-1) hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
for _, name := range seriesFilterOrder[1:] { for _, name := range seriesFilterOrder[1:] {
+17 -14
View File
@@ -25,23 +25,25 @@ const seriesPageSize = 50
// the labels are read by later admin tickets too, so the map and the // the labels are read by later admin tickets too, so the map and the
// constants cannot drift apart. // constants cannot drift apart.
var seriesFilterLabels = map[string]string{ var seriesFilterLabels = map[string]string{
store.SeriesFilterAll: "All series", store.SeriesFilterAll: "All series",
store.SeriesFilterNoURL: "No series URL", store.SeriesFilterNoURL: "No series URL",
store.SeriesFilterNoChapter: "Never read a chapter", store.SeriesFilterNoChapter: "Never read a chapter",
store.SeriesFilterNoReaders: "No Readers", store.SeriesFilterNoReaders: "No Readers",
store.SeriesFilterNeverChecked: "Never checked", store.SeriesFilterNeverChecked: "Never checked",
store.SeriesFilterStale: "Not checked in 12h", store.SeriesFilterStale: "Not checked in 12h",
store.SeriesFilterNoCover: "No cover", store.SeriesFilterNoCover: "No cover",
store.SeriesFilterReaderReport: "Latest from a Reader", store.SeriesFilterReaderReport: "Latest from a Reader",
store.SeriesFilterFailing: "Failing over 12h", store.SeriesFilterFailing: "Failing over 12h",
store.SeriesFilterUnverified: "Unverified Reader number", store.SeriesFilterUnverified: "Unverified Reader number",
store.SeriesFilterFinished: "Finished", store.SeriesFilterFinished: "Finished",
store.SeriesFilterSiteCompleted: "Site says completed",
} }
// seriesFilterOrder is the select's render order: All first, then the // seriesFilterOrder is the select's render order: All first, then the
// permanent repairs, then the fixable ones (issue #140). Finished rides the // permanent repairs, then the fixable ones (issue #140). Finished and the
// tail, last — deliberate, not a repair — and the Overview's stats block // site-completed hint ride the tail — deliberate, not repairs — and the
// renders the same tail, which is what sits the finished figure last there. // Overview's stats block renders the same tail, which is what sits the
// finished and site-completed figures last there.
var seriesFilterOrder = []string{ var seriesFilterOrder = []string{
store.SeriesFilterAll, store.SeriesFilterAll,
store.SeriesFilterNoURL, store.SeriesFilterNoURL,
@@ -54,6 +56,7 @@ var seriesFilterOrder = []string{
store.SeriesFilterFailing, store.SeriesFilterFailing,
store.SeriesFilterUnverified, store.SeriesFilterUnverified,
store.SeriesFilterFinished, store.SeriesFilterFinished,
store.SeriesFilterSiteCompleted,
} }
// seriesListView is the Series list page's data. The template renders strings // 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 // owner window: the value is unconfirmed and the owner should not trust
// it. It never names the Reader. "" otherwise. // it. It never names the Reader. "" otherwise.
Unverified string 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 // 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() { 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." 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 // Provenance: the actor class behind the current number, evaluated in the
// order the classes outrank one another — the owner's stamp, which a // order the classes outrank one another — the owner's stamp, which a
@@ -47,6 +47,7 @@
{{else}} {{else}}
<div class="dform"> <div class="dform">
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div> <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> <div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
<span>Mark this Series finished?</span> <span>Mark this Series finished?</span>
<div> <div>
+201 -19
View File
@@ -2212,14 +2212,15 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
// cannot produce an orphan series or a Reader-raised Latest Chapter — the // cannot produce an orphan series or a Reader-raised Latest Chapter — the
// same reason the store's admin tests seed this way. // same reason the store's admin tests seed this way.
type seriesRowSeed struct { type seriesRowSeed struct {
key string key string
kind string kind string
url string url string
cover string // cover_address cover string // cover_address
checkedAt int64 checkedAt int64
latestNum *float64 latestNum *float64
bookmarks int // readers that hold it; 0 = orphan bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser raisedBy bool // a Reader's report is attributed as the raiser
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
} }
// seedSeriesRow inserts one series row and its bookmarks (owner first, then // seedSeriesRow inserts one series row and its bookmarks (owner first, then
@@ -2239,10 +2240,10 @@ func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed
} }
if _, err := db.Exec(` if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, kind, series_url, cover_address, INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
latest_checked_at, latest_chapter, latest_chapter_num) latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover, 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) t.Fatalf("seed series %q: %v", seed.key, err)
} }
for i := range seed.bookmarks { for i := range seed.bookmarks {
@@ -2378,6 +2379,46 @@ func TestSeriesListFinishedFilterOption(t *testing.T) {
} }
} }
// The site-completed filter is in the select's vocabulary like any other,
// and the list it opens shows exactly the hinted Series — the Site's marker
// with no owner's Finish, because a retired Series is not work a Poll is
// coming to do (issue #170).
func TestSeriesListSiteCompletedFilterOption(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()
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, siteCompletedAt: hinted})
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=site_completed")
if !strings.Contains(body, "Title of asura:hinted") {
t.Errorf("site-completed list misses its row:\n%s", body)
}
for _, banned := range []string{"Title of asura:plain", "Title of asura:retired"} {
if strings.Contains(body, banned) {
t.Errorf("site-completed list renders %q:\n%s", banned, body)
}
}
if !strings.Contains(body, "1 series") {
t.Errorf("site-completed heading lacks the count:\n%s", body)
}
if !strings.Contains(body, `Site says completed (1)`) {
t.Errorf("the site-completed option lacks its count:\n%s", body)
}
if !strings.Contains(body, `<option value="site_completed" selected>`) {
t.Errorf("the site-completed option is not selected:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the // ?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 // kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL. // filter and Site so the narrowing survives in the URL.
@@ -3151,10 +3192,11 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
} }
} }
// The finished figure rides last in the Hygiene block: seriesFilterOrder // The finished figure and the site-completed hint ride last in the Hygiene
// appends it at the tail, the block renders order[1:], and the count comes // block: seriesFilterOrder appends the pair at the tail, the block renders
// from the same aggregate the select is numbered from. Asserting on the body // order[1:], and each count comes from the same aggregate the select is
// order catches a render that merely shows the figure somewhere. // numbered from. Asserting on the body order catches a render that merely
// shows a figure somewhere.
func TestOverviewFinishedFigureRidesLast(t *testing.T) { func TestOverviewFinishedFigureRidesLast(t *testing.T) {
st, dsn := newTestStoreURL(t) st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn) db, err := sql.Open("pgx", dsn)
@@ -3179,17 +3221,69 @@ func TestOverviewFinishedFigureRidesLast(t *testing.T) {
} }
labels = append(labels, m[1]) labels = append(labels, m[1])
} }
if len(labels) != 10 { if len(labels) != 11 {
t.Fatalf("hygiene block renders %d figures, want 10 (nine problems + finished):\n%s", len(labels), body) t.Fatalf("hygiene block renders %d figures, want 11 (nine problems + finished + site completed):\n%s", len(labels), body)
} }
if labels[len(labels)-1] != "Finished" { if labels[len(labels)-2] != "Finished" || labels[len(labels)-1] != "Site says completed" {
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body) t.Errorf("the informational tail does not ride last; hygiene order = %v:\n%s", labels, body)
} }
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) { if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
t.Errorf("the finished figure lacks its count and door:\n%s", body) t.Errorf("the finished figure lacks its count and door:\n%s", body)
} }
} }
// The site-completed figure is a door into the list that counts it, and the
// figure agrees with the list's own heading: both come from the same
// aggregate, so they cannot drift apart.
func TestOverviewSiteCompletedFigureAgreesWithListHeading(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()
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "https://asurascans.com/comics/hinted", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "https://asurascans.com/comics/plain", cover: "bbb", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
fig := regexp.MustCompile(`href="/admin/series\?filter=site_completed">(\d+)</a>`).FindStringSubmatch(body)
if fig == nil {
t.Fatalf("overview has no site-completed figure:\n%s", body)
}
list := adminSeriesPage(t, srv, st, "?filter=site_completed")
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
if heading == nil {
t.Fatalf("site-completed list has no heading count:\n%s", list)
}
if fig[1] != heading[1] {
t.Errorf("the overview says %s, the list it links to says %s", fig[1], heading[1])
}
}
// A measured zero site-completed 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 TestOverviewSiteCompletedZeroIsAnUnlinkedDigit(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, `Site says completed</span><span class="fig zero">0</span>`) {
t.Errorf("a zero site-completed figure does not render as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=site_completed"`) {
t.Errorf("a zero site-completed figure is still a link:\n%s", body)
}
}
// A measured zero finished count stays on the page as a muted digit, never a // 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 // link: following it would land on an empty list, exactly like every other
// zero figure. // zero figure.
@@ -3698,6 +3792,94 @@ func TestAdminSeriesDetailFinishControl(t *testing.T) {
} }
} }
// The hint line rides the Finish control: present for a Series the Site
// marked completed, with its age, absent while the Site has said nothing
// (zero stamp) and gone once the owner finishes — the Site's stamp keeps
// standing after retirement, so the hint is suppressed by the same
// finished_at guard the clock-driven filters carry (issue #170).
func TestAdminSeriesDetailSiteCompletedHint(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()
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
t.Fatalf("finish asura:retired: %v", err)
}
srv := newRouter(st, testConfig())
body := seriesDetailPage(t, srv, st, "asura:hinted")
if !strings.Contains(body, "the site says this work is completed (since 3d ago)") {
t.Errorf("hinted Series lacks the hint with its age:\n%s", body)
}
// The hint sits between the Finish opener and the confirm row — a line
// beside the control, not the control itself.
opener := strings.Index(body, "document.getElementById('confirm-finish').hidden = false")
hintAt := strings.Index(body, "the site says this work is completed")
confirmAt := strings.Index(body, `id="confirm-finish"`)
if opener < 0 || hintAt < 0 || confirmAt < 0 || hintAt < opener || confirmAt < hintAt {
t.Errorf("the hint is not a line beside the Finish control (opener %d, hint %d, row %d):\n%s", opener, hintAt, confirmAt, body)
}
if strings.Contains(seriesDetailPage(t, srv, st, "asura:plain"), "the site says this work is completed") {
t.Errorf("an unmarked Series renders the hint:\n%s", body)
}
if strings.Contains(seriesDetailPage(t, srv, st, "asura:retired"), "the site says this work is completed") {
t.Errorf("a finished Series renders the hint:\n%s", body)
}
}
// The hint is a line of text, never a control: with and without a hint, the
// Finish control's markup is byte-identical (AC4). Two stores seed the same
// key so the hx-post URLs match byte for byte; only the hint line differs,
// and it sits outside the confirm row.
func TestAdminSeriesDetailFinishControlUnchangedWithHint(t *testing.T) {
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
var bodies [2]string
for i, sc := range []int64{hinted, 0} {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: sc})
db.Close()
srv := newRouter(st, testConfig())
bodies[i] = seriesDetailPage(t, srv, st, "asura:solo")
}
withHint, without := bodies[0], bodies[1]
if !strings.Contains(withHint, "the site says this work is completed") {
t.Fatalf("the hinted store did not render a hint:\n%s", withHint)
}
if strings.Contains(without, "the site says this work is completed") {
t.Fatalf("the plain store rendered a hint:\n%s", without)
}
if got, want := confirmRowMarkup(t, withHint), confirmRowMarkup(t, without); got != want {
t.Errorf("the Finish control's markup changed with a hint present:\nwith hint: %s\nwithout: %s", got, want)
}
}
// confirmRowMarkup returns the confirm-finish row's HTML, byte for byte —
// the Finish control AC4 says must not change with a hint present. The row
// is the outer div (the span plus the inner button pair), with no nested
// divs beyond that, so the first two </div> closes end it.
func confirmRowMarkup(t *testing.T, body string) string {
t.Helper()
start := strings.Index(body, `<div class="confirm-row calm" id="confirm-finish"`)
if start < 0 {
t.Fatalf("no confirm-finish row in:\n%s", body)
}
open := strings.Index(body[start:], ">") + start + 1
inner := strings.Index(body[open:], "</div>")
outer := strings.Index(body[open+inner+len("</div>"):], "</div>")
end := open + inner + len("</div>") + outer + len("</div>")
return body[start:end]
}
// The finish route is the owner's one writer: a finish press stamps // 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_at and answers with the swapped detail-meta fragment carrying the
// "finished <age> ago" mark, and an un-finish press writes zero and answers // "finished <age> ago" mark, and an un-finish press writes zero and answers