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
+38 -9
View File
@@ -31,6 +31,15 @@ type seriesDetailView struct {
NoCover bool
Orphan bool // no Reader holds the Series
SightingRaised bool // a Reader's Sighting set the Latest Chapter
// Poll is the Check now control and the pending marker (issue #146): the
// same derivation and visibility as the list row. CanPoll is false on a
// Series with no page to fetch and on an orphan; Pending is derived —
// the request stamp is newer than the check stamp — and Requested is its
// ageing label.
CanPoll bool
Pending bool
Requested string
}
// adminSeriesDetail renders one Series' page, keyed by the composite
@@ -38,35 +47,50 @@ type seriesDetailView struct {
// the list's own SeriesPage read narrowed to the key's Site: the admin
// projection is the privacy boundary, and a dedicated single-row read would
// be a second definition of it.
// ponytail: a page scan per detail view, one query per page of the Site's
// rows up to the window total; a keyed read alongside SeriesPage when the
// library outgrows the page size.
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.NotFound(w, r)
return
}
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series detail %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
}
// adminSeriesByKey reads one Series through the list's own SeriesPage read
// narrowed to the key's Site: the admin projection is the privacy boundary,
// and a dedicated single-row read would be a second definition of it. Absence
// is reported with found=false, never an error.
// ponytail: a page scan per keyed read, one query per page of the Site's rows
// up to the window total; a keyed read alongside SeriesPage when the library
// outgrows the page size.
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
seen := 0
for page := 1; ; page++ {
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
if err != nil {
log.Printf("series detail %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return store.AdminSeries{}, false, err
}
seen += len(p.Rows)
for i := range p.Rows {
if p.Rows[i].SeriesID == seriesID {
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])})
return
return p.Rows[i], true, nil
}
}
if seen >= p.Total {
break
}
}
http.NotFound(w, r)
return store.AdminSeries{}, false, nil
}
// seriesDetailView shapes one AdminSeries row for display: every judgement in
@@ -83,6 +107,11 @@ 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)
}
if a.LatestChapterNum == nil {
v.Chapter = "—"