feat(web,store,latest): forced poll — Check now stamps a Series for the next Lane pass (#146)

The control writes series.force_poll_at (column landed in migration 0014)
and never commands the poller: pending is derived as force_poll_at >
latest_checked_at and self-clears because the check stamp is written before
the fetch. The due query's forced flag overrides the rest cutoff, the
Sighting-deferral and finished-only clauses, jumps the queue, and wakes a
sleeping browser Lane; it never overrides an empty series_url, the Bookmarks
join, the refusal backoff, the sidecar-down skip or the Lane gap.

ADRs: 0013-commands-through-the-database.
This commit is contained in:
2026-08-22 00:53:51 +07:00
parent 6f9109c885
commit fe2cd12049
14 changed files with 818 additions and 44 deletions
+74
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"bookmarkmanager/backend/internal/latest"
@@ -80,6 +81,11 @@ type seriesFilterOption struct {
// the alternating row tint by class rather than nth-of-type, so the confirm
// rows later tickets add are row siblings without breaking the alternation.
// Attention tints the title patina: a row with any hygiene chip needs one.
//
// CanPoll is the Check now control's visibility: absent 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.
type seriesRowView struct {
Key string
Title string
@@ -91,6 +97,9 @@ type seriesRowView struct {
More int // chips past the cap, rendered as a +N tail
Band bool
Attention bool
CanPoll bool
Pending bool
Requested string // "requested 3m ago", rendered only while pending
}
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
@@ -106,6 +115,58 @@ func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
}
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
// and answers with the freshly rendered row, so the figures describe the
// state after the press. The control never commands the poller — the request
// is a fact about the Series, and the Lane's next pass reads it through
// DueForLatestCheck (ADR-0013). The owner gate is the route's, not this
// handler's; the body is capped like the API path caps its bodies; the key is
// validated here — a malformed key is a 400 and an unknown one a 404.
func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.Error(w, "bad series key", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if !found {
http.NotFound(w, r)
return
}
if err := h.store.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// 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.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
if r.Header.Get("HX-Target") == "detail-meta" {
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
return
}
h.render(w, http.StatusOK, "series-row", seriesRow(a, 0, time.Now()))
}
// seriesListView assembles one Series list view from the request's query
// string. An unknown filter value is the absent All case, never an error: the
// select's options are not the only way this URL can be reached.
@@ -213,6 +274,11 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
Site: a.Site,
Readers: a.ReaderCount,
Band: i%2 == 1,
CanPoll: a.SeriesURL != "" && a.ReaderCount > 0,
}
if a.ForcePollAt > a.LatestCheckedAt {
row.Pending = true
row.Requested = requestedAge(now, a.ForcePollAt)
}
if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
@@ -273,6 +339,14 @@ func checkedAge(now time.Time, ts int64) string {
}
}
// requestedAge is the pending marker's text: how long ago the owner asked,
// and nothing about when the request will run — the page does not know when a
// sleeping browser will wake (issue #146). An unanswered request ages forever;
// there is no expiry.
func requestedAge(now time.Time, ts int64) string {
return "requested " + checkedAge(now, ts)
}
// pagerRange is the pager's "1–50 of 120" line. The template renders the
// pager only over rows (the empty state replaces it), so it is never asked
// to describe an empty list.