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 // ":", 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" // Corrected is the correction marker's text, "" while no Correction // stands: "corrected 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 Readers int // 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 } // adminSeriesDetail renders one Series' page, keyed by the composite // ":" 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), Readers: a.ReaderCount, Unpollable: a.SeriesURL == "", NoCover: a.CoverAddress == "", Orphan: a.ReaderCount == 0, SightingRaised: a.RaisedByReader, CanPoll: canPoll, 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) return v } // correctedAge is the correction marker's text: "corrected 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)) }