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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user