From c432a3be30ed495240bf416e5c13addf6727b94f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 00:49:39 +0700 Subject: [PATCH 1/2] feat(web): overview page with verdict line and stats doors (#143) --- backend/internal/web/admin.go | 14 +- backend/internal/web/admin_overview.go | 199 ++++++++++++++++ backend/internal/web/static/admin.css | 25 ++ backend/internal/web/templates/admin.html | 4 +- backend/internal/web/templates/overview.html | 14 ++ backend/web_test.go | 238 +++++++++++++++++++ 6 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 backend/internal/web/admin_overview.go create mode 100644 backend/internal/web/templates/overview.html diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index b83bd19..db76596 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -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. diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go new file mode 100644 index 0000000..06a4304 --- /dev/null +++ b/backend/internal/web/admin_overview.go @@ -0,0 +1,199 @@ +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, fig{ + Label: seriesFilterLabels[name], + Href: zeroHref(totals[name], seriesListHref(name, "", "", 0)), + Count: totals[name], + }) + } + view.Hygiene = hygiene + + var manga, novel int + for _, sh := range shapes[store.SeriesFilterAll] { + manga += sh.Manga + novel += sh.Novel + } + view.Library = []fig{ + {Label: "Series", Href: zeroHref(totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)), Count: totals[store.SeriesFilterAll]}, + {Label: "Manga", Href: zeroHref(manga, seriesListHref("", "", store.KindManga, 0)), Count: manga}, + {Label: "Novels", Href: zeroHref(novel, seriesListHref("", "", store.KindNovel, 0)), Count: novel}, + {Label: "Readers", Href: zeroHref(len(readers), "/admin/readers"), Count: len(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{ + {Count: sh.Total, Href: zeroHref(sh.Total, seriesListHref("", sh.Site, "", 0))}, + {Count: siteCounts[store.SeriesFilterNoCover][sh.Site], Href: zeroHref(siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0))}, + {Count: siteCounts[store.SeriesFilterNeverChecked][sh.Site], Href: zeroHref(siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0))}, + {Count: siteCounts[store.SeriesFilterStale][sh.Site], Href: zeroHref(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 +} + +// zeroHref is the zero rule: a measured zero keeps its figure but loses its +// door, because following the link lands on an empty list. +func zeroHref(count int, href string) string { + if count == 0 { + return "" + } + return href +} + +// 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 +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index c867be6..138dc71 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -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); diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index ef5c87b..79e287d 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -42,9 +42,7 @@ {{template "series-list" .SeriesList}} {{else if eq .Page "series-detail"}} {{template "series-detail" .Detail}} - {{else}} -

Overview

- {{end}} + {{else}}{{template "overview" .Overview}}{{end}} diff --git a/backend/internal/web/templates/overview.html b/backend/internal/web/templates/overview.html new file mode 100644 index 0000000..29dadb5 --- /dev/null +++ b/backend/internal/web/templates/overview.html @@ -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"}} +

{{.Verdict}}{{if .HasCounts}}· {{.Waiting}} series waiting · {{.Unchecked}} unchecked over 12h{{end}}

+

Hygiene

+
{{range .Hygiene}}
{{.Label}}{{if .Href}}{{.Count}}{{else}}{{.Count}}{{end}}
{{end}}
+

Library

+
{{range .Library}}
{{.Label}}{{if .Href}}{{.Count}}{{else}}{{.Count}}{{end}}
{{end}}
+

Sites · last 12h

+
SiteSeriesNo coverNever chkStaleState
{{range .Sites}}
{{.Site}}{{range .Figs}}{{if .Href}}{{.Count}}{{else}}{{.Count}}{{end}}{{end}}{{.State}}
{{end}}
+{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index ad494c8..5ec6bc9 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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, "3 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+)`).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 0`) { + 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, "11 series waiting") { + t.Errorf("waiting figure is not the latest pass per Site summed (7+4=11):\n%s", body) + } + if strings.Contains(body, "1 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(`
(.*?)(?:]*>(\d+)|(\d+))
`) + 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+)SiteSeriesNo coverNever chkStaleState`) { + 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, `asura`) { + 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&site=asura">1`) { + 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&site=demonic">1`) { + 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, ``) { + 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, ""+col+"") { + t.Errorf("per-Site table carries an outcome column %q:\n%s", col, body) + } + } +} From 00fa2371518fbbb65866b744f8bdda32c3b7e17c Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 00:56:49 +0700 Subject: [PATCH 2/2] fix(web): verdict space per design mock, single door() fig builder (#143) --- backend/internal/web/admin_overview.go | 34 +++++++++----------- backend/internal/web/templates/overview.html | 2 +- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go index 06a4304..1851a17 100644 --- a/backend/internal/web/admin_overview.go +++ b/backend/internal/web/admin_overview.go @@ -98,11 +98,7 @@ func (h *Handler) overviewView() (overviewView, error) { // "hygiene" figure. hygiene := make([]fig, 0, len(seriesFilterOrder)-1) for _, name := range seriesFilterOrder[1:] { - hygiene = append(hygiene, fig{ - Label: seriesFilterLabels[name], - Href: zeroHref(totals[name], seriesListHref(name, "", "", 0)), - Count: totals[name], - }) + hygiene = append(hygiene, door(seriesFilterLabels[name], totals[name], seriesListHref(name, "", "", 0))) } view.Hygiene = hygiene @@ -112,10 +108,10 @@ func (h *Handler) overviewView() (overviewView, error) { novel += sh.Novel } view.Library = []fig{ - {Label: "Series", Href: zeroHref(totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)), Count: totals[store.SeriesFilterAll]}, - {Label: "Manga", Href: zeroHref(manga, seriesListHref("", "", store.KindManga, 0)), Count: manga}, - {Label: "Novels", Href: zeroHref(novel, seriesListHref("", "", store.KindNovel, 0)), Count: novel}, - {Label: "Readers", Href: zeroHref(len(readers), "/admin/readers"), Count: len(readers)}, + 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 @@ -139,10 +135,10 @@ func (h *Handler) overviewView() (overviewView, error) { Site: sh.Site, SiteHref: seriesListHref("", sh.Site, "", 0), Figs: []fig{ - {Count: sh.Total, Href: zeroHref(sh.Total, seriesListHref("", sh.Site, "", 0))}, - {Count: siteCounts[store.SeriesFilterNoCover][sh.Site], Href: zeroHref(siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0))}, - {Count: siteCounts[store.SeriesFilterNeverChecked][sh.Site], Href: zeroHref(siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0))}, - {Count: siteCounts[store.SeriesFilterStale][sh.Site], Href: zeroHref(siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0))}, + 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 { @@ -155,13 +151,15 @@ func (h *Handler) overviewView() (overviewView, error) { return view, nil } -// zeroHref is the zero rule: a measured zero keeps its figure but loses its -// door, because following the link lands on an empty list. -func zeroHref(count int, href string) string { +// 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 { - return "" + href = "" } - return href + return fig{Label: label, Href: href, Count: count} } // overviewVerdict decides the landing page's one line from the latest pass diff --git a/backend/internal/web/templates/overview.html b/backend/internal/web/templates/overview.html index 29dadb5..701772d 100644 --- a/backend/internal/web/templates/overview.html +++ b/backend/internal/web/templates/overview.html @@ -4,7 +4,7 @@ figures link, what a Lane's state means — is made in Go; this template only prints. */}} {{define "overview"}} -

{{.Verdict}}{{if .HasCounts}}· {{.Waiting}} series waiting · {{.Unchecked}} unchecked over 12h{{end}}

+

{{.Verdict}} {{if .HasCounts}}· {{.Waiting}} series waiting · {{.Unchecked}} unchecked over 12h{{end}}

Hygiene

{{range .Hygiene}}
{{.Label}}{{if .Href}}{{.Count}}{{else}}{{.Count}}{{end}}
{{end}}

Library