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 }