refactor(web): share poll-state derivation; keep zebra parity across a row swap (#146)

Review round: pollState() unifies the CanPoll/Pending/Requested derivation
used by the list row and the detail page, and the row anchor carries its
band parity with the press (hx-vals) so the swapped answer keeps the
alternation.
This commit is contained in:
2026-08-22 01:02:55 +07:00
parent fe2cd12049
commit 5b7adf5f2c
4 changed files with 51 additions and 19 deletions
+32 -13
View File
@@ -149,7 +149,8 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
// Re-read after the stamp: the answer must describe the state after the // Re-read after the stamp: the answer must describe the state after the
// press. The detail page's control swaps its meta in place and the list // press. The detail page's control swaps its meta in place and the list
// row's swaps the row; htmx names an id target in HX-Target, so the // row's swaps the row; htmx names an id target in HX-Target, so the
// response matches the surface it came from. // response matches the surface it came from. The row's band parity travels
// with the press (hx-vals), so the swap keeps the zebra alternation.
a, found, err := h.adminSeriesByKey(site, seriesID) a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil { if err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err) log.Printf("series poll %s: %v", site+":"+seriesID, err)
@@ -164,7 +165,11 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
return return
} }
h.render(w, http.StatusOK, "series-row", seriesRow(a, 0, time.Now())) band := 0
if r.PostFormValue("band") == "1" {
band = 1
}
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
} }
// seriesListView assembles one Series list view from the request's query // seriesListView assembles one Series list view from the request's query
@@ -267,18 +272,32 @@ func (h *Handler) seriesFilterOptions(selected string, cutoff int64) ([]seriesFi
// seriesRow shapes one store row for the template, capping its chips at two // seriesRow shapes one store row for the template, capping its chips at two
// plus a +N tail; attention marks a row that carries any. // plus a +N tail; attention marks a row that carries any.
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { // pollState derives the Check now control and the pending marker (issue
row := seriesRowView{ // #146), shared by the list row and the detail page: CanPoll is false on a
Key: a.Key(), // Series with no page to fetch and on an orphan, so the owner is never
Title: a.Title, // offered a button that can never do anything. Pending is derived — the
Site: a.Site, // request stamp is newer than the check stamp — and requested is its ageing
Readers: a.ReaderCount, // label, which never expires.
Band: i%2 == 1, func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) {
CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, canPoll = a.SeriesURL != "" && a.ReaderCount > 0
}
if a.ForcePollAt > a.LatestCheckedAt { if a.ForcePollAt > a.LatestCheckedAt {
row.Pending = true pending = true
row.Requested = requestedAge(now, a.ForcePollAt) requested = requestedAge(now, a.ForcePollAt)
}
return canPoll, pending, requested
}
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
canPoll, pending, requested := pollState(a, now)
row := seriesRowView{
Key: a.Key(),
Site: a.Site,
Title: a.Title,
Readers: a.ReaderCount,
Band: i%2 == 1,
CanPoll: canPoll,
Pending: pending,
Requested: requested,
} }
if a.LatestChapterNum != nil { if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
+4 -5
View File
@@ -96,6 +96,7 @@ func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bo
// seriesDetailView shapes one AdminSeries row for display: every judgement in // seriesDetailView shapes one AdminSeries row for display: every judgement in
// Go, the template left to print strings and flags. // Go, the template left to print strings and flags.
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
canPoll, pending, requested := pollState(a, time.Now())
v := seriesDetailView{ v := seriesDetailView{
Key: a.Key(), Key: a.Key(),
Site: a.Site, Site: a.Site,
@@ -107,11 +108,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
NoCover: a.CoverAddress == "", NoCover: a.CoverAddress == "",
Orphan: a.ReaderCount == 0, Orphan: a.ReaderCount == 0,
SightingRaised: a.RaisedByReader, SightingRaised: a.RaisedByReader,
CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, CanPoll: canPoll,
} Pending: pending,
if a.ForcePollAt > a.LatestCheckedAt { Requested: requested,
v.Pending = true
v.Requested = requestedAge(time.Now(), a.ForcePollAt)
} }
if a.LatestChapterNum == nil { if a.LatestChapterNum == nil {
v.Chapter = "—" v.Chapter = "—"
@@ -48,6 +48,6 @@
<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}}</span> <span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</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" href="#">Check now</a>{{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}}</span>
</div> </div>
{{end}} {{end}}
+14
View File
@@ -2645,6 +2645,20 @@ func TestSeriesPollRoundTrip(t *testing.T) {
t.Errorf("poll response lacks the pending marker:\n%s", body) t.Errorf("poll response lacks the pending marker:\n%s", body)
} }
// The press answers with the row's own band parity (hx-vals), so the swap
// keeps the zebra alternation.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader("band=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (banded) status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `class="trow`) || !strings.Contains(rr.Body.String(), ` band"`) {
t.Errorf("banded press answer lost the zebra parity:\n%s", rr.Body.String())
}
// Detail surface: the meta fragment carries the same marker. // Detail surface: the meta fragment carries the same marker.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil) req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st)) req.AddCookie(sessionCookie(t, st))