af07314bb6
Uncommitted work from three design runs on this branch, against one design system: docs/design-system.md is updated to match the CSS, not the reverse. Library (Reader-facing): - .chrome sticks at top: 0. Search and the tab row were unreachable three screens into a 300-item library, which is exactly where they earn their keep; everything above them still scrolls away on purpose. - One :focus-visible ring (2px --paper) on the nine controls that defined none and fell back to the UA blue. .searchbar keeps its border recolour as a resting cue but no longer stands in for a ring. - Mono labels lift 10px -> 11px everywhere. The brief names night reading and glare as the usage scene; 10px small-caps was where taste overrode it. - A card in flight past 2s says "Saving..." and carries aria-busy. htmx sets neither, so the wait up to its 15s timeout was silent in both channels. - Titles clamp at 3 lines; .is-new .title takes width: fit-content, or -webkit-box stretches the ember underline past the text it sizes to. /admin: - Overview routes into Lanes when a lane is unhealthy, prefixes each figure with its column word on the phone layout that drops the thead, labels state cells for a screen reader, and has an empty state where the sites table assumed rows. - The admin shell picks up the library's chrome: htmx 15s timeout, the shared #notice slot, #sr-announce, filter.js. admin.css follows the same pass. - admin_render_test.go and card_render_test.go render the templates directly, so markup regressions in either surface fail without a browser. Login: - DISCORD_GUILD_NAME (optional) names the community on the login screen and in the refusal message, so a stranger knows which Discord to ask for an invite. Unset degrades to a generic label; neither form names the guild id. Handlers: - maxChapterNum (9999) bounds both typed-chapter paths. uiChapter and adminSeriesCorrectLatest each parsed a float64 with no ceiling, so a hand-rolled POST stored 1e308 and every later reader of that row inherited it. Matches the max on the card's chapter input. go test ./... green.
231 lines
8.5 KiB
Go
231 lines
8.5 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/latest"
|
|
"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 the SeriesShapes, pass or roster reads
|
|
// is a 500 with a logged reason, never a page of silent zeroes. The three
|
|
// fault-input reads (RefusingSince, SidecarOK, NoChapterShare) fail open:
|
|
// a failing read logs and contributes no fault, so the landing page still
|
|
// renders — the same fail-open the poller uses for owner notices.
|
|
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]
|
|
var refusingSince map[string]int64
|
|
if m, err := h.store.RefusingSince(now.UnixMilli()); err != nil {
|
|
log.Printf("admin overview: refusing since: %v", err)
|
|
} else {
|
|
refusingSince = m
|
|
}
|
|
var sidecarOK map[string]int64
|
|
if m, err := h.store.SidecarOK(latest.BrowserBackedSites()); err != nil {
|
|
log.Printf("admin overview: sidecar ok: %v", err)
|
|
} else {
|
|
sidecarOK = m
|
|
}
|
|
var noChapterShare map[string]float64
|
|
if m, err := h.store.NoChapterShare(cutoff); err != nil {
|
|
log.Printf("admin overview: no-chapter share: %v", err)
|
|
} else {
|
|
noChapterShare = m
|
|
}
|
|
faults := latest.FaultsFrom(latest.FaultInput{
|
|
Passes: passes,
|
|
RefusingSince: refusingSince,
|
|
SidecarOK: sidecarOK,
|
|
NoChapterShare: noChapterShare,
|
|
}, now)
|
|
view.Verdict, view.HasCounts = overviewVerdict(passes, faults)
|
|
|
|
// 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),
|
|
// The labels are the thead's words, carried per cell because the
|
|
// phone layout drops the thead: four bare counts in a row are
|
|
// unreadable without them (they render as the cell's prefix).
|
|
Figs: []fig{
|
|
door("series", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
|
door("no cover", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
|
door("never chk", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
|
door("stale", 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: no passes at all is
|
|
// "no Lane has reported yet" — never confident zeroes; otherwise the count of
|
|
// faults from the shared FaultsFrom judgement, so the page and the push
|
|
// cannot disagree. Zero faults is "all lanes healthy". A sidecar-down fault
|
|
// carries Site == "" and is still one fault. The Lanes page's per-row
|
|
// laneState is a different question (is this Lane's last pass healthy) from
|
|
// the notice class, and its known false positives live there deliberately, so
|
|
// the two now differ.
|
|
func overviewVerdict(passes []store.LanePass, faults []latest.Fault) (phrase string, counts bool) {
|
|
if len(passes) == 0 {
|
|
return "no Lane has reported yet", false
|
|
}
|
|
n := len(faults)
|
|
if n == 0 {
|
|
return "all lanes healthy", true
|
|
}
|
|
if n == 1 {
|
|
return "1 lane needs a look", true
|
|
}
|
|
return fmt.Sprintf("%d lanes need a look", n), 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
|
|
}
|