From 5b7adf5f2cc3d98488cf1ca19c4d28a405f206af Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 01:02:55 +0700 Subject: [PATCH] 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. --- backend/internal/web/admin_series.go | 45 +++++++++++++------ backend/internal/web/admin_series_detail.go | 9 ++-- .../internal/web/templates/series-list.html | 2 +- backend/web_test.go | 14 ++++++ 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index 4730863..e9099ad 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -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 // 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 - // 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) if err != nil { 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)) 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 @@ -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 // plus a +N tail; attention marks a row that carries any. -func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { - row := seriesRowView{ - Key: a.Key(), - Title: a.Title, - Site: a.Site, - Readers: a.ReaderCount, - Band: i%2 == 1, - CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, - } +// pollState derives the Check now control and the pending marker (issue +// #146), shared by the list row and the detail page: CanPoll is false on a +// Series with no page to fetch and on an orphan, so the owner is never +// offered a button that can never do anything. Pending is derived — the +// request stamp is newer than the check stamp — and requested is its ageing +// label, which never expires. +func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) { + canPoll = a.SeriesURL != "" && a.ReaderCount > 0 if a.ForcePollAt > a.LatestCheckedAt { - row.Pending = true - row.Requested = requestedAge(now, a.ForcePollAt) + pending = true + 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 { row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index c856926..74810aa 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -96,6 +96,7 @@ func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bo // seriesDetailView shapes one AdminSeries row for display: every judgement in // Go, the template left to print strings and flags. func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { + canPoll, pending, requested := pollState(a, time.Now()) v := seriesDetailView{ Key: a.Key(), Site: a.Site, @@ -107,11 +108,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { NoCover: a.CoverAddress == "", Orphan: a.ReaderCount == 0, SightingRaised: a.RaisedByReader, - CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, - } - if a.ForcePollAt > a.LatestCheckedAt { - v.Pending = true - v.Requested = requestedAge(time.Now(), a.ForcePollAt) + CanPoll: canPoll, + Pending: pending, + Requested: requested, } if a.LatestChapterNum == nil { v.Chapter = "—" diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html index 48eeb94..c2cce35 100644 --- a/backend/internal/web/templates/series-list.html +++ b/backend/internal/web/templates/series-list.html @@ -48,6 +48,6 @@ {{.Age}} {{.Readers}} {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}} - {{if .CanPoll}}Check now{{end}} + {{if .CanPoll}}Check now{{end}} {{end}} diff --git a/backend/web_test.go b/backend/web_test.go index a515d19..5e43da4 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -2645,6 +2645,20 @@ func TestSeriesPollRoundTrip(t *testing.T) { 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. req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil) req.AddCookie(sessionCookie(t, st))