a533e4f769
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).
201 lines
7.6 KiB
Go
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))
|
|
}
|