Merge ticket/143-overview

This commit is contained in:
2026-08-22 01:03:33 +07:00
6 changed files with 487 additions and 5 deletions
+12 -2
View File
@@ -25,6 +25,8 @@ type adminView struct {
SeriesList seriesListView
// Detail is the per-Series page data; zero on every other page.
Detail seriesDetailView
// Overview is the landing page data; zero on every other page.
Overview overviewView
}
// adminRoute pairs a route pattern with its handler so the route list and the
@@ -76,9 +78,17 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
})
}
// admin renders the Overview shell. Its data arrives in later admin tickets.
// admin renders the Overview landing page: a verdict line, a stats block
// where every figure is a door into the list it counts, and the per-Site
// library shape table — all read from the database, never from a poller.
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "overview"})
view, err := h.overviewView()
if err != nil {
log.Printf("admin overview: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAdmin(w, adminView{Page: "overview", Overview: view})
}
// adminReaders renders the Reader roster on its own bookmarkable page.
+197
View File
@@ -0,0 +1,197 @@
package web
import (
"fmt"
"time"
"bookmarkmanager/backend/internal/store"
)
// overviewView is the Overview landing page's data: one verdict line, the
// hygiene and library stats blocks, and the per-Site library shape table.
// Every judgement — the verdict state, which figures link, what a Lane's
// state means — is made here; the template only prints.
type overviewView struct {
// Verdict is the attention phrase that leads the page.
Verdict string
// HasCounts is false on a virgin pass log: the waiting figure would be a
// confident zero, and "nothing has happened" must not render as health.
HasCounts bool
// Waiting is the sum of Due over the latest pass per Site.
Waiting int
// Unchecked is the number of Series not checked in the window, computed
// as stale + never_checked: a never-checked Series is already counted on
// its own filter, and the verdict wants the inclusive number.
Unchecked int
// Hygiene is the seven problem filters in the Series list's own render
// order; Library is the library split plus the roster. Every figure is a
// door into the list that counts it, except a zero.
Hygiene []fig
Library []fig
// Sites is the per-Site library shape table, one row per Site with any
// Series, in the store's Site order.
Sites []siteRow
}
// fig is one stats figure: its label, the list it counts, and the count
// itself. Href empty means the count is zero: a measured zero is a real
// figure that stays on the page, but it is not a door, because following it
// lands on an empty list.
type fig struct {
Label string
Href string
Count int
}
// siteRow is one Site's share of the library: the Series total and the three
// hygiene counts the per-Site table carries, each a door to the list narrowed
// to that Site, plus the Lane state phrase derived from its latest pass. The
// table is library shape only — the Poll outcome sums live on the Lanes page.
type siteRow struct {
Site string
SiteHref string
Figs []fig
// State is the Lane's own sentence; "" means the last pass read normally.
// StateGood / StateBad pick the ok / bad second class.
State string
StateGood bool
StateBad bool
}
// overviewView assembles the landing page from the store's read model: one
// SeriesShapes pass per filter summed in Go (the shipped surface offers eight
// grouped passes, not a stats query — #140), the pass log's latest pass per
// Site, and the roster. A failure in any read is a 500 with a logged reason,
// never a page of silent zeroes.
func (h *Handler) overviewView() (overviewView, error) {
now := time.Now()
cutoff := now.Add(-ownerWindow).UnixMilli()
shapes := make(map[string][]store.SiteSeriesShape, len(seriesFilterOrder))
totals := make(map[string]int, len(seriesFilterOrder))
for _, name := range seriesFilterOrder {
rows, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
if err != nil {
return overviewView{}, err
}
shapes[name] = rows
for _, sh := range rows {
totals[name] += sh.Total
}
}
passes, err := h.store.LatestLanePasses()
if err != nil {
return overviewView{}, err
}
readers, err := h.store.Readers()
if err != nil {
return overviewView{}, err
}
view := overviewView{Waiting: waiting(passes)}
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable
// order; the All filter's count belongs to the Library block, not to a
// "hygiene" figure.
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
for _, name := range seriesFilterOrder[1:] {
hygiene = append(hygiene, door(seriesFilterLabels[name], totals[name], seriesListHref(name, "", "", 0)))
}
view.Hygiene = hygiene
var manga, novel int
for _, sh := range shapes[store.SeriesFilterAll] {
manga += sh.Manga
novel += sh.Novel
}
view.Library = []fig{
door("Series", totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)),
door("Manga", manga, seriesListHref("", "", store.KindManga, 0)),
door("Novels", novel, seriesListHref("", "", store.KindNovel, 0)),
door("Readers", len(readers), "/admin/readers"),
}
// One row per Site with any Series, from the All shapes; the hygiene
// counts come from the same per-Site projection so the table cannot
// disagree with the library-wide figures above it.
siteCounts := make(map[string]map[string]int, len(shapes))
for name, rows := range shapes {
m := make(map[string]int, len(rows))
for _, sh := range rows {
m[sh.Site] = sh.Total
}
siteCounts[name] = m
}
passBySite := make(map[string]store.LanePass, len(passes))
for _, p := range passes {
passBySite[p.Site] = p
}
view.Sites = make([]siteRow, 0, len(shapes[store.SeriesFilterAll]))
for _, sh := range shapes[store.SeriesFilterAll] {
row := siteRow{
Site: sh.Site,
SiteHref: seriesListHref("", sh.Site, "", 0),
Figs: []fig{
door("", sh.Total, seriesListHref("", sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
},
}
if p, ok := passBySite[sh.Site]; ok {
row.State, row.StateGood, row.StateBad = laneState(p, now)
} else {
row.State = "no pass yet"
}
view.Sites = append(view.Sites, row)
}
return view, nil
}
// door is one figure with its door: the list that counts it. A measured zero
// is still a real figure, but the door closes — following it would land on an
// empty list. The count is written once so the figure and what it links to
// cannot drift apart.
func door(label string, count int, href string) fig {
if count == 0 {
href = ""
}
return fig{Label: label, Href: href, Count: count}
}
// overviewVerdict decides the landing page's one line from the latest pass
// per Site: no passes at all is "no Lane has reported yet" — never confident
// zeroes; otherwise the count of Lanes whose last pass needs the owner, or
// "all lanes healthy". The count comes from the same laneState judgement the
// Lanes page colours on, so the two pages cannot disagree on what a fault is.
func overviewVerdict(passes []store.LanePass, now time.Time) (phrase string, counts bool) {
if len(passes) == 0 {
return "no Lane has reported yet", false
}
attention := 0
for _, p := range passes {
if _, _, attn := laneState(p, now); attn {
attention++
}
}
if attention == 0 {
return "all lanes healthy", true
}
if attention == 1 {
return "1 lane needs a look", true
}
return fmt.Sprintf("%d lanes need a look", attention), true
}
// waiting sums Due over the latest pass per Site: how many Series the Lanes
// found waiting, from the durable log rather than a running poller.
func waiting(passes []store.LanePass) int {
n := 0
for _, p := range passes {
n += p.Due
}
return n
}
+25
View File
@@ -214,6 +214,25 @@
color: var(--mute-2);
}
/* The verdict line is set in the data face, not the display face: it is
three counts, not a page title. The judgement is the only bright thing. */
.admin-sheet .verdict {
padding: 16px 0 12px;
border-bottom: 1px solid var(--rule);
font: 500 15px/1.6 var(--font-mono);
letter-spacing: .04em;
color: var(--mute);
}
.admin-sheet .verdict .attn {
color: var(--patina);
}
.admin-sheet .verdict .counts b {
color: var(--paper);
font-weight: 500;
}
.admin-sheet .tbl {
display: grid;
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
@@ -319,6 +338,12 @@
color: var(--mute);
}
.admin-sheet .stat a.fig {
/* The hover rule below sets the border on :hover only, which shifts the
row a pixel on hover; the transparent baseline holds the layout. */
border-bottom: 1px solid transparent;
}
.admin-sheet .stat a.fig:hover {
color: var(--patina);
border-bottom: 1px solid var(--patina);
+1 -3
View File
@@ -42,9 +42,7 @@
{{template "series-list" .SeriesList}}
{{else if eq .Page "series-detail"}}
{{template "series-detail" .Detail}}
{{else}}
<h2 class="sec">Overview</h2>
{{end}}
{{else}}{{template "overview" .Overview}}{{end}}
</main>
</div>
</body>
@@ -0,0 +1,14 @@
{{/* The Overview landing page: one verdict line leading, then a stats block
where every figure is a door into the list that counts it, and the
per-Site library shape table. Every judgement — the verdict state, which
figures link, what a Lane's state means — is made in Go; this template
only prints. */}}
{{define "overview"}}
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h</span>{{end}}</p>
<h2 class="sec">Hygiene</h2>
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
<h2 class="sec">Library</h2>
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
<h2 class="sec">Sites · last 12h</h2>
<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}">{{.State}}</span></div>{{end}}</div>
{{end}}
+238
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
@@ -2599,3 +2600,240 @@ func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
t.Errorf("title rendered raw:\n%s", body)
}
}
// overviewBody fetches the Overview landing page as the owner and returns the
// rendered body, failing the test on anything but a 200.
func overviewBody(t *testing.T, srv http.Handler, st *store.Store) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
}
return rr.Body.String()
}
// The verdict line has three states, never two: healthy, the count of Lanes
// whose last pass needs the owner, and — on a pass log with no rows at all —
// "no Lane has reported yet". "Nothing has happened" must never render as
// "everything is fine": the virgin state draws no figures at all, least of
// all a confident zero waiting count.
func TestOverviewVerdictThreeStates(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
t.Run("healthy", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 2, Checked: 2})
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 1})
body := overviewBody(t, router, st)
if !strings.Contains(body, "all lanes healthy") {
t.Errorf("healthy verdict missing:\n%s", body)
}
if !strings.Contains(body, "<b>3</b> series waiting") {
t.Errorf("waiting figure missing from the verdict line:\n%s", body)
}
})
t.Run("lanes need a look", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Skip: latest.SkipRefusing})
body := overviewBody(t, router, st)
if !strings.Contains(body, "1 lane needs a look") {
t.Errorf("attention verdict missing:\n%s", body)
}
})
t.Run("no lane has reported", func(t *testing.T) {
virgin, fresh := newWebTestServer(t, testConfig())
body := overviewBody(t, virgin, fresh)
if !strings.Contains(body, "no Lane has reported yet") {
t.Errorf("virgin verdict missing:\n%s", body)
}
if strings.Contains(body, "all lanes healthy") || strings.Contains(body, "series waiting") {
t.Errorf("virgin verdict draws confident zeroes:\n%s", body)
}
})
}
// The Overview never refreshes itself: the Lane rest is an hour, so a timer
// would re-run a cross-Series join to redraw identical rows. Only the Lanes
// block carries a refresh attribute.
func TestOverviewCarriesNoRefreshTimer(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 1, Checked: 1})
if body := overviewBody(t, router, st); strings.Contains(body, "hx-trigger") {
t.Errorf("overview carries a refresh timer:\n%s", body)
}
}
// A hygiene figure on the landing page and the heading count on the list it
// links to come from the same store predicate — the acceptance criterion most
// likely to rot. Both endpoints are exercised in one test: each figure's href
// is read off the overview and fetched, and its number must equal the count
// in the list's own heading.
func TestOverviewHygieneFigureAgreesWithListHeading(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: now, latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: now, latestNum: floatPtr(3), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: now - 24*3600*1000, latestNum: floatPtr(4), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
for _, filter := range []string{"no_cover", "no_series_url", "never_checked"} {
fig := regexp.MustCompile(`href="/admin/series\?filter=` + filter + `">(\d+)</a>`).FindStringSubmatch(body)
if fig == nil {
t.Fatalf("overview has no %s figure:\n%s", filter, body)
}
list := adminSeriesPage(t, srv, st, "?filter="+filter)
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
if heading == nil {
t.Fatalf("%s list has no heading count:\n%s", filter, list)
}
if fig[1] != heading[1] {
t.Errorf("%s: the overview says %s, the list it links to says %s", filter, fig[1], heading[1])
}
}
}
// A measured zero is a real fact and stays on the page, but it is not a door:
// following it would land on an empty list. The zero renders as a muted
// digit, never an anchor.
func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// Every row carries a cover and a URL, so the no-cover and no-URL figures
// are measured zeroes.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
if !strings.Contains(body, `<span class="fig zero">0</span>`) {
t.Errorf("no zero figure renders as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_cover"`) {
t.Errorf("a zero no-cover figure is still a link:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_series_url"`) {
t.Errorf("a zero no-URL figure is still a link:\n%s", body)
}
}
// The waiting figure sums Due over the latest pass per Site — older passes
// for the same Site must not double-count, so the verdict reads the same
// latest-per-Site projection the Lanes page reads.
func TestOverviewWaitingSumsLatestPassPerSite(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 7})
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.Add(-2 * time.Hour).UnixMilli(), Due: 4})
body := overviewBody(t, router, st)
if !strings.Contains(body, "<b>11</b> series waiting") {
t.Errorf("waiting figure is not the latest pass per Site summed (7+4=11):\n%s", body)
}
if strings.Contains(body, "<b>1</b> series waiting") {
t.Errorf("an older pass for the same Site counted into waiting:\n%s", body)
}
}
// The hygiene classes overlap — one orphaned, URL-less, cover-less Series is
// three counts and one row — so no figure on the page may equal the naive sum
// of the hygiene counts: a sum over-reports and a distinct count is a number
// nothing can be done about.
func TestOverviewRendersNoAggregateProblemCount(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// One Series with no URL, no cover, no bookmark and no check stamp: four
// hygiene filters each count it (NoURL, NoReaders, NeverChecked, NoCover).
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
hygieneSum := 0
for _, s := range stat.FindAllStringSubmatch(body, -1) {
switch s[1] {
case "Series", "Manga", "Novels", "Readers":
continue
}
n, _ := strconv.Atoi(s[2] + s[3])
hygieneSum += n
}
if hygieneSum != 4 {
t.Fatalf("seeded library's hygiene figures sum to %d, want 4 (one row in four overlapping filters):\n%s", hygieneSum, body)
}
figs := regexp.MustCompile(`class="fig[^"]*"[^>]*>(\d+)</`).FindAllStringSubmatch(body, -1)
if len(figs) == 0 {
t.Fatalf("no rendered figures found:\n%s", body)
}
for _, f := range figs {
if n, _ := strconv.Atoi(f[1]); n == hygieneSum {
t.Errorf("rendered figure %d equals the naive hygiene sum %d:\n%s", n, hygieneSum, body)
}
}
}
// The per-Site table is library shape only: exactly six columns, every figure
// a door to the list narrowed by both filter and Site, and the State cell
// carrying the Lane's own sentence. No Poll outcome column belongs here.
func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "https://asurascans.com/comics/a", cover: "aaa", checkedAt: now, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "https://asurascans.com/comics/b", checkedAt: now, latestNum: floatPtr(2), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now, Due: 2, Checked: 2})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
// The landed grid pins six columns; the header states them in order.
if !strings.Contains(body, `<div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>`) {
t.Errorf("per-Site table is not the six pinned columns:\n%s", body)
}
// The Site label is a door to the Site-narrowed list.
if !strings.Contains(body, `<a class="c-site site-asura" href="/admin/series?site=asura">asura</a>`) {
t.Errorf("per-Site label is not a door to its Site-narrowed list:\n%s", body)
}
// A per-Site hygiene figure carries both the filter and the Site.
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=asura">1</a>`) {
t.Errorf("per-Site no-cover figure does not narrow by Site:\n%s", body)
}
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=demonic">1</a>`) {
t.Errorf("per-Site no-cover figure missing for demonic:\n%s", body)
}
// The State cell carries the Lane's own sentence: a normal pass says
// nothing, a Site with no pass says so.
if !strings.Contains(body, `<span class="c-state"></span>`) {
t.Errorf("a Lane that read normally renders no state phrase:\n%s", body)
}
if !strings.Contains(body, `no pass yet`) {
t.Errorf("a Site with series but no pass row does not say so:\n%s", body)
}
// Library shape only: no Poll outcome column anywhere.
for _, col := range []string{"Due", "Checked", "Gap", "Refused", "Errors"} {
if strings.Contains(body, "<span>"+col+"</span>") {
t.Errorf("per-Site table carries an outcome column %q:\n%s", col, body)
}
}
}