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 problem filters in the Series list's own render order // plus the informational tail — finished, then site completed — riding // last; 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 ten // 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 hygiene figures, in seriesFilterOrder's tail: the problem filters // in permanent-then-fixable order, then the informational tail — finished // and site completed — riding last because seriesFilterOrder appends them // there. 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 }