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..1851a17 --- /dev/null +++ b/backend/internal/web/admin_overview.go @@ -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 +} 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}} -