Files
mangaBookmark/backend/internal/web/admin_series_detail.go
T
sulthan a533e4f769 web: site-completed hint reaches the owner, decides nothing (#170)
The Site's own completed marker (issue #168) joins the owner's surface as
an informational hint, never a decision:

- store: SeriesFilterSiteCompleted ('site_completed') sits beside Finished
  in the filter vocabulary; its predicate carries the same finished_at
  guard as the clock-driven filters, because the Site's stamp keeps
  standing after the owner retires the row. The projection and SeriesPage
  GROUP BY carry site_completed_at so the detail page can age it.
- web: the filter joins the Series list select as 'Site says completed'
  and rides the tail of seriesFilterOrder, so the landing stats figure
  comes for free and sits after the finished figure.
- detail: one hint line beside the Finish control - 'the site says this
  work is completed (since 3d ago)' - rendered only while the stamp
  stands and no Finish stands; the Finish control itself is untouched.
- tests: filter's finished exclusion and un-finish return to the Poll
  query (store); figure at zero and nonzero, hint present/absent, and the
  Finish control's markup byte-identical with a hint present (web).
2026-08-23 00:35:10 +07:00

201 lines
7.6 KiB
Go

package web
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"bookmarkmanager/backend/internal/store"
)
// seriesDetailView is one Series' page as the owner sees it: strings and
// flags, every judgement made here, the template left to print. ReaderCount
// is the only figure that crosses the privacy boundary — the owner learns how
// many Readers hold the Series, never which Reader reads what.
type seriesDetailView struct {
Key string // "<site>:<series_id>", the page's address and the Series' identity
Site string
Kind string
// Title, Cover and Chapter come from the shared Series row; the Cover is
// the wire URL of the stored bytes, "" before any exist.
Title string
Cover string
Chapter string // Latest Chapter number, or "—" before the first capture
Checked string // how long ago the poller last checked, or "never"
// URL is the stored source address, prefilled into the repair input —
// the one stored string this page renders back into a form (issue #151).
URL string
Readers int
// Corrected is the correction marker's text, "" while no Correction
// stands: "corrected <age> ago" — the copy that says the value is the
// owner's, and it dies with the stamp (a machine write of the number).
Corrected string
// Provenance is the actor class behind the current Chapter: "correction",
// "sighting" or "machine read"; "" while the Series was never read, when
// the line is not rendered. Derived from the same anonymous stamps the
// marks above read — no Reader identity crosses here.
Provenance string
// Marks, one per hygiene fact, rendered only while it holds.
Unpollable bool // no SeriesURL to fetch
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
// CanRemove is the Remove control's visibility (issue #155): only a
// Series no Reader holds can be removed, so the owner is never offered a
// button the database will always refuse.
CanRemove bool
// Finished is the owner's finish stamp rendered for the control: while it
// stands, the page offers the instant Un-finish, not the confirm-gated
// Finish (issue #158).
Finished bool
// FinishedSince is the "finished <age> ago" line, "" while no finish
// stands. It rides the meta fragment both presses swap, so the answer
// itself shows how long the Series has been finished.
FinishedSince string
// SiteCompleted is the hint's line, "" while the Site has said nothing or
// the owner has finished the Series: "the site says this work is
// completed (since 3d ago)". A hint, never a control (issue #170).
SiteCompleted string
}
// adminSeriesDetail renders one Series' page, keyed by the composite
// "<site>:<series_id>" the list row already shows. The row is read 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.
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 {
return store.AdminSeries{}, false, err
}
seen += len(p.Rows)
for i := range p.Rows {
if p.Rows[i].SeriesID == seriesID {
return p.Rows[i], true, nil
}
}
if seen >= p.Total {
break
}
}
return store.AdminSeries{}, false, nil
}
// 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,
Kind: a.Kind,
Title: a.Title,
Cover: h.store.CoverWireURL(a.CoverAddress),
URL: a.SeriesURL,
Readers: a.ReaderCount,
Unpollable: a.SeriesURL == "",
NoCover: a.CoverAddress == "",
Orphan: a.ReaderCount == 0,
SightingRaised: a.RaisedByReader,
CanPoll: canPoll,
CanRemove: a.ReaderCount == 0,
Pending: pending,
Requested: requested,
}
if a.LatestChapterNum == nil {
v.Chapter = "—"
} else {
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
}
if a.LatestCheckedAt == 0 {
v.Checked = "never"
} else {
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
}
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
v.Finished = a.FinishedAt != 0
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
}
// Provenance: the actor class behind the current number, evaluated in the
// order the classes outrank one another — the owner's stamp, which a
// Correction leaves standing and a machine write clears (issue #149); a
// raising Reader, which a Correction drops; then any check stamp at all.
// An Acquisition reads as a machine read because it stamps
// latest_checked_at exactly as a Poll does, so the two are
// indistinguishable the moment it finishes; telling them apart would need
// the column this project declines to add (spec #135), and the one
// actionable case — acquired once, never read again — is already the
// unchecked filter.
if a.LatestCorrectedAt != 0 {
v.Provenance = "correction"
} else if a.RaisedByReader {
v.Provenance = "sighting"
} else if a.LatestCheckedAt != 0 {
v.Provenance = "machine read"
}
return v
}
// correctedAge is the correction marker's text: "corrected <age> ago" while
// the stamp is set, "" when zero — zero means never corrected, and the marker
// must not read as history once a machine wrote the number.
func correctedAge(now time.Time, at int64) string {
if at == 0 {
return ""
}
return "corrected " + since(now, time.UnixMilli(at))
}
// finishedAge is the finish marker's text: "finished <age> ago" while the
// stamp is set, "" when zero — zero means never finished, and the reversal
// (un-finish) must not read as history after a press (issue #158).
func finishedAge(now time.Time, at int64) string {
if at == 0 {
return ""
}
return "finished " + since(now, time.UnixMilli(at))
}