Merge #167: A failure names itself on the page

This commit is contained in:
2026-08-23 00:52:58 +07:00
7 changed files with 171 additions and 10 deletions
+18 -7
View File
@@ -56,6 +56,10 @@ type laneRow struct {
// offer Resume from the moment the owner presses Pause, with no pass // offer Resume from the moment the owner presses Pause, with no pass
// having run to record it (issue #147). // having run to record it (issue #147).
Paused bool 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. // 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 { if p.GapMS > 0 {
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String() 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.Chips = outcomeChips(o)
row.HasChips = len(row.Chips) > 0 row.HasChips = len(row.Chips) > 0
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now) 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 // 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 // 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 { func outcomeChips(o store.SiteOutcomes) []chip {
fixed := []struct { fixed := []struct {
name string name string
count int count int
}{ }{
{"refused", o.Refused}, {outcomeWord("refused"), o.Refused},
{"unreachable", o.Unreachable}, {outcomeWord("unreachable"), o.Unreachable},
{"no chapter", o.NoChapter}, {outcomeWord("no_chapter"), o.NoChapter},
{"unfetchable", o.Unfetchable}, {outcomeWord("unfetchable"), o.Unfetchable},
{"not found", o.NotFound}, {outcomeWord("not_found"), o.NotFound},
{"errors", o.Errors}, {outcomeWord("errors"), o.Errors},
} }
var out []chip var out []chip
for _, f := range fixed { for _, f := range fixed {
+22
View File
@@ -123,6 +123,11 @@ type seriesRowView struct {
// page, where a press that retires a Series from the Lane is on purpose // page, where a press that retires a Series from the Lane is on purpose
// and confirm-gated (issue #158). // and confirm-gated (issue #158).
Finished bool 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, // 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.Ch = "—"
} }
row.Age = checkedAge(now, a.LatestCheckedAt) row.Age = checkedAge(now, a.LatestCheckedAt)
if a.FailureOutcome != "" {
row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince)
}
notes := seriesNotes(a, now) notes := seriesNotes(a, now)
if n := len(notes); n > 2 { if n := len(notes); n > 2 {
row.Notes, row.More = notes[:2], 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 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 // seriesNotes are a row's hygiene chips in the design's order: no URL, no
// cover, orphan, stale, reader sighting. // cover, orphan, stale, reader sighting.
func seriesNotes(a store.AdminSeries, now time.Time) []string { func seriesNotes(a store.AdminSeries, now time.Time) []string {
@@ -64,6 +64,11 @@ type seriesDetailView struct {
// stands. It rides the meta fragment both presses swap, so the answer // stands. It rides the meta fragment both presses swap, so the answer
// itself shows how long the Series has been finished. // itself shows how long the Series has been finished.
FinishedSince string 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 // 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.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
v.Finished = a.FinishedAt != 0 v.Finished = a.FinishedAt != 0
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt) 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 // 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
+1 -1
View File
@@ -26,7 +26,7 @@
</div> </div>
{{range .Rows}} {{range .Rows}}
<div class="trow{{if .Attention}} attention{{end}}"> <div class="trow{{if .Attention}} attention{{end}}">
<span class="c-site">{{.Site}}</span> <a class="c-site" href="{{.FailingHref}}">{{.Site}}</a>
<span>{{.Due}}</span> <span>{{.Due}}</span>
<span>{{.Checked}}</span> <span>{{.Checked}}</span>
<span>{{.Gap}}</span> <span>{{.Gap}}</span>
@@ -19,6 +19,7 @@
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required> <input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
<button type="submit" class="ghost">Set</button> <button type="submit" class="ghost">Set</button>
</div> </div>
{{if .Unverified}}<p class="hint">{{.Unverified}}</p>{{end}}
</form> </form>
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML"> <form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
<h3>Repair series URL</h3> <h3>Repair series URL</h3>
@@ -47,7 +47,7 @@
<span class="c-ch">{{.Ch}}</span> <span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span> <span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span> <span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span> <span class="c-note">{{if .Failure}}<span class="mark">{{.Failure}}</span>{{end}}{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span> <span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}} {{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
</div> </div>
+115 -1
View File
@@ -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}) 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) body := lanesBody(t, router, st)
if !strings.Contains(body, `class="c-site">asura</span>`) { if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&amp;site=asura">asura</a>`) {
t.Fatalf("asura row missing from a restart-read database:\n%s", body) 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 // 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, `<a class="c-site" href="/admin/series?filter=failing&amp;site=asura">asura</a>`) {
t.Errorf("the failing Site's name is not a link to its failing Series:\n%s", body)
}
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&amp;site=demonic">demonic</a>`) {
t.Errorf("the clean Site's name is not a link to its (empty) failing Series:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">not found 6</span>`) {
t.Errorf("the outcome chip no longer renders beside the link:\n%s", body)
}
if got := strings.Count(body, `<a class="c-site"`); got != 2 {
t.Errorf("Site anchor count = %d, want exactly 2 (only the two Site names navigate):\n%s", got, body)
}
}
// Browser configuration is a deployment fact and reachability is derived from // Browser configuration is a deployment fact and reachability is derived from
// the latest browser-Site passes inside the refusal backoff — no poller in any // the latest browser-Site passes inside the refusal backoff — no poller in any
// of the three. // of the three.
@@ -2629,6 +2654,44 @@ func TestSeriesListRowShape(t *testing.T) {
} }
} }
// The row's fact line names the standing failure and how long it has stood,
// through one format for every age: a one-hour-old failure and a
// three-month-old one both render as "<word> · <checkedAge>", 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, `<span class="mark">not found · 1h ago</span>`) {
t.Errorf("the one-hour failure lacks its marker:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">no chapter · 90d ago</span>`) {
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 // 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 // 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 // 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 // The provenance line beside the chapter names the actor class behind the
// value — "machine read" for a checked Series, "correction" for the owner's // value — "machine read" for a checked Series, "correction" for the owner's
// stamp, "sighting" for a Reader-raised one — and appears nowhere in the // stamp, "sighting" for a Reader-raised one — and appears nowhere in the