feat(web): overview page with verdict line and stats doors (#143)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user