From f85908d3b416f71b39108f88edda1874f0f9f76b Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 23 Aug 2026 00:36:12 +0700 Subject: [PATCH] web: a failure names itself on the page (issue #167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Series list row's fact line gains the failure word and its age in one mark before the Notes chips, spelled through outcomeWord — the one vocabulary shared with the Lanes chips. The detail page adds one sentence beside the correction control while a Reader's number stands behind a failure past the owner window, without naming the Reader. Each Lanes Site name is a plain link to that Site's failing Series; the outcome chips stay unlinked, and outcomeChips records why the promise is withdrawn. --- backend/internal/web/admin_lanes.go | 25 ++-- backend/internal/web/admin_series.go | 22 ++++ backend/internal/web/admin_series_detail.go | 13 ++ backend/internal/web/templates/lanes.html | 2 +- .../internal/web/templates/series-detail.html | 1 + .../internal/web/templates/series-list.html | 2 +- backend/web_test.go | 116 +++++++++++++++++- 7 files changed, 171 insertions(+), 10 deletions(-) diff --git a/backend/internal/web/admin_lanes.go b/backend/internal/web/admin_lanes.go index c70b972..d6fcb85 100644 --- a/backend/internal/web/admin_lanes.go +++ b/backend/internal/web/admin_lanes.go @@ -56,6 +56,10 @@ type laneRow struct { // offer Resume from the moment the owner presses Pause, with no pass // having run to record it (issue #147). Paused bool + // FailingHref is the one navigation the row offers: the Site's name + // links to that Site's failing Series. The chips beside it stay + // unlinked; the withdrawn promise lives on outcomeChips (issue #167). + FailingHref string } // chip is one named outcome count over the owner's window. @@ -223,6 +227,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow if p.GapMS > 0 { row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String() } + row.FailingHref = seriesListHref(store.SeriesFilterFailing, p.Site, "", 0) row.Chips = outcomeChips(o) row.HasChips = len(row.Chips) > 0 row.StatePhrase, row.StateGood, row.Attention = laneState(p, now) @@ -232,18 +237,24 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow // outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed // order, so the chips never reorder as the window changes. None observed is -// written by the template, not drawn as a confident zero count. +// written by the template, not drawn as a confident zero count. The names are +// spelled through outcomeWord, the one vocabulary the failure surface shares +// with the Series list's fact line (issue #167). The counts stay unlinked +// permanently — a withdrawn promise: two of the six words write no per-Series +// state, and the other four count attempts inside the owner window while the +// failing filter lists Series failing now, so neither set contains the other +// and no chip can be a door to its list. func outcomeChips(o store.SiteOutcomes) []chip { fixed := []struct { name string count int }{ - {"refused", o.Refused}, - {"unreachable", o.Unreachable}, - {"no chapter", o.NoChapter}, - {"unfetchable", o.Unfetchable}, - {"not found", o.NotFound}, - {"errors", o.Errors}, + {outcomeWord("refused"), o.Refused}, + {outcomeWord("unreachable"), o.Unreachable}, + {outcomeWord("no_chapter"), o.NoChapter}, + {outcomeWord("unfetchable"), o.Unfetchable}, + {outcomeWord("not_found"), o.NotFound}, + {outcomeWord("errors"), o.Errors}, } var out []chip for _, f := range fixed { diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index 32ab04d..2f2ae2e 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -123,6 +123,11 @@ type seriesRowView struct { // page, where a press that retires a Series from the Lane is on purpose // and confirm-gated (issue #158). Finished bool + // Failure names the standing failure and how long it has stood — "not + // found · 3d ago", "" while no failure row stands. Its own field, never a + // Notes chip: the chips cap at two plus a tail, so the one fact that + // names the failure would be the most likely to be truncated away. + Failure string } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, @@ -683,6 +688,9 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { row.Ch = "—" } row.Age = checkedAge(now, a.LatestCheckedAt) + if a.FailureOutcome != "" { + row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince) + } notes := seriesNotes(a, now) if n := len(notes); n > 2 { row.Notes, row.More = notes[:2], n-2 @@ -693,6 +701,20 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { return row } +// outcomeWord spells the wire failure word as the Lanes chips spell it — a +// space, not the underscore: not_found reads "not found", no_chapter "no +// chapter". The remaining words are their own spelling, so an unknown word +// degrades to itself rather than vanishing from the page. +func outcomeWord(wire string) string { + switch wire { + case "not_found": + return "not found" + case "no_chapter": + return "no chapter" + } + return wire +} + // seriesNotes are a row's hygiene chips in the design's order: no URL, no // cover, orphan, stale, reader sighting. func seriesNotes(a store.AdminSeries, now time.Time) []string { diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 9d0f3d1..08f6bde 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -64,6 +64,11 @@ 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 + // Unverified is the sentence beside the Latest Chapter correction + // control while a Reader's number stands behind a failure past the + // owner window: the value is unconfirmed and the owner should not trust + // it. It never names the Reader. "" otherwise. + Unverified string } // adminSeriesDetail renders one Series' page, keyed by the composite @@ -151,6 +156,14 @@ 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) + // Unverified: a Reader's number standing behind a failure that has outlived + // the owner window is a number nobody has re-checked since — say so next to + // the correction control, without naming the Reader. A machine read or a + // failure still inside the window carries no sentence; the failure itself + // has not yet outlived the twelve hours' worth of trust. + 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." + } // 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/lanes.html b/backend/internal/web/templates/lanes.html index c4a359e..0bf79ac 100644 --- a/backend/internal/web/templates/lanes.html +++ b/backend/internal/web/templates/lanes.html @@ -26,7 +26,7 @@ {{range .Rows}}
- {{.Site}} + {{.Site}} {{.Due}} {{.Checked}} {{.Gap}} diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index c2204de..21b619f 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -19,6 +19,7 @@
+ {{if .Unverified}}

{{.Unverified}}

{{end}}

Repair series URL

diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html index f0af815..f79a690 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}}{{if .Finished}}finished{{end}} + {{if .Failure}}{{.Failure}}{{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 08d191a..dcd4405 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -918,7 +918,7 @@ func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) { seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000}) body := lanesBody(t, router, st) - if !strings.Contains(body, `class="c-site">asura`) { + if !strings.Contains(body, `asura`) { t.Fatalf("asura row missing from a restart-read database:\n%s", body) } // The fragment carries its own single timer and answers the swap it asked @@ -989,6 +989,31 @@ func TestNamedOutcomeChips(t *testing.T) { } } +// Each Site row on the Lanes page carries one plain navigation link to that +// Site's failing Series — a navigation, not a number: the outcome chips +// beside it stay unlinked permanently, so the row offers one click and one +// answer. The clean Site links too, into an empty list (issue #167). +func TestLaneSiteLinksToFailingSeriesAndChipsStayUnlinked(t *testing.T) { + router, st := newWebTestServer(t, lanesConfig()) + now := time.Now() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), NotFound: 6}) + seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli()}) + + body := lanesBody(t, router, st) + if !strings.Contains(body, `asura`) { + t.Errorf("the failing Site's name is not a link to its failing Series:\n%s", body) + } + if !strings.Contains(body, `demonic`) { + t.Errorf("the clean Site's name is not a link to its (empty) failing Series:\n%s", body) + } + if !strings.Contains(body, `not found 6`) { + t.Errorf("the outcome chip no longer renders beside the link:\n%s", body) + } + if got := strings.Count(body, ` · ", and a Series +// with no failure row renders no marker at all. The marker is its own field, +// never a Notes chip — the chips cap at two plus a tail, so the failure +// would be the fact most likely to be truncated away (issue #167). +func TestSeriesListFailureMarkerAges(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() + now := time.Now() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hour", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:months", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + if err := st.RecordSeriesFailure("asura", "hour", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed failure row on hour: %v", err) + } + if err := st.RecordSeriesFailure("asura", "months", "no_chapter", now.Add(-90*24*time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed failure row on months: %v", err) + } + + body := adminSeriesPage(t, newRouter(st, testConfig()), st, "") + if !strings.Contains(body, `not found · 1h ago`) { + t.Errorf("the one-hour failure lacks its marker:\n%s", body) + } + if !strings.Contains(body, `no chapter · 90d ago`) { + t.Errorf("the three-month failure lacks its marker:\n%s", body) + } + // The clean row carries none, and neither word leaks anywhere else on + // the page: exactly one marker per failing row. + if strings.Count(body, "not found") != 1 || strings.Count(body, "no chapter") != 1 { + t.Errorf("failure words appear more than once, or on a clean row:\n%s", body) + } +} + // The per-Series page renders every Series-level fact the admin read model // holds for the key the list row already shows: title, the composite key with // Site and kind, the Latest Chapter, the check age and the anonymous Reader @@ -2768,6 +2831,57 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) { } } +// The detail page adds one sentence beside the Latest Chapter correction +// control only while the number came from a Reader and the failure row has +// stood past the owner window: a Reader-attributed failing Series carries it, +// a machine-read failing Series does not, and neither does a +// Reader-attributed Series whose failure is still inside the window. The +// sentence names no Reader (issue #167). +func TestAdminSeriesDetailUnverifiedSentence(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() + now := time.Now() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, raisedBy: true}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:machine-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-recent", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(3), bookmarks: 1, raisedBy: true}) + if err := st.RecordSeriesFailure("asura", "reader-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed failure row on reader-fail: %v", err) + } + if err := st.RecordSeriesFailure("asura", "machine-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed failure row on machine-fail: %v", err) + } + if err := st.RecordSeriesFailure("asura", "reader-recent", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed failure row on reader-recent: %v", err) + } + srv := newRouter(st, testConfig()) + + want := "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed." + fetch := func(key string) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code) + } + return rr.Body.String() + } + if body := fetch("asura:reader-fail"); !strings.Contains(body, want) { + t.Errorf("a Reader-attributed failing Series lacks the unverified sentence:\n%s", body) + } + if body := fetch("asura:machine-fail"); strings.Contains(body, want) { + t.Errorf("a machine-read failing Series carries the unverified sentence:\n%s", body) + } + if body := fetch("asura:reader-recent"); strings.Contains(body, want) { + t.Errorf("a Reader-attributed Series failing inside the window carries the sentence:\n%s", body) + } +} + // The provenance line beside the chapter names the actor class behind the // value — "machine read" for a checked Series, "correction" for the owner's // stamp, "sighting" for a Reader-raised one — and appears nowhere in the