package web import ( "fmt" "log" "net/http" "net/url" "strconv" "time" "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/store" ) // seriesPageSize matches the store's row-read page length: the pager's range // must agree with the LIMIT the store applies or the "of N" figure describes // the wrong page. The store does not export it (#140). const seriesPageSize = 50 // seriesFilterLabels names every hygiene filter for the Series list select, // keyed by the wire constant the URL carries. The render order is // seriesFilterOrder; the labels are read by later admin tickets too, so the // map and the constants cannot drift apart. var seriesFilterLabels = map[string]string{ store.SeriesFilterAll: "All series", store.SeriesFilterNoURL: "No series URL", store.SeriesFilterNoChapter: "Never read a chapter", store.SeriesFilterNoReaders: "No Readers", store.SeriesFilterNeverChecked: "Never checked", store.SeriesFilterStale: "Not checked in 12h", store.SeriesFilterNoCover: "No cover", store.SeriesFilterReaderReport: "Latest from a Reader", } // seriesFilterOrder is the select's render order: All first, then the // permanent repairs, then the fixable ones (issue #140). var seriesFilterOrder = []string{ store.SeriesFilterAll, store.SeriesFilterNoURL, store.SeriesFilterNoChapter, store.SeriesFilterNoReaders, store.SeriesFilterNeverChecked, store.SeriesFilterStale, store.SeriesFilterNoCover, store.SeriesFilterReaderReport, } // seriesListView is the Series list page's data. The template renders strings // and flags, and every judgement about what a value means is made here. type seriesListView struct { Filters []seriesFilterOption Sites []string Site string // "" = every Site Kind string // "" = both libraries FilterLabel string Rows []seriesRowView Total int // KindBoth / KindManga / KindNovel are the Library segment links, and // PrevHref / NextHref the pager's, all carrying the active filter, Site // and Kind so narrowing never drops state. KindBoth string KindManga string KindNovel string PrevHref string NextHref string Range string } // seriesFilterOption is one entry of the Show select: its wire value, its // rendered label with the library-wide count, and whether it is the active // filter. type seriesFilterOption struct { Name string Label string Count int Selected bool } // seriesRowView is one Series row formatted for the template. Band carries // the alternating row tint by class rather than nth-of-type, so the confirm // rows later tickets add are row siblings without breaking the alternation. // Attention tints the title patina: a row with any hygiene chip needs one. type seriesRowView struct { Key string Title string Site string Ch string // chapter number; "—" until first captured Age string // checked age; "never" until first check Readers int Notes []string // chips, capped at two More int // chips past the cap, rendered as a +N tail Band bool Attention bool } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, // Library and page all live in the query string, so the list's state is an // address rather than a click path. func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) { view, err := h.seriesListView(r) if err != nil { log.Printf("admin series: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } h.renderAdmin(w, adminView{Page: "series", SeriesList: view}) } // seriesListView assembles one Series list view from the request's query // string. An unknown filter value is the absent All case, never an error: the // select's options are not the only way this URL can be reached. func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) { q := r.URL.Query() filter := q.Get("filter") if _, ok := seriesFilterLabels[filter]; !ok { filter = store.SeriesFilterAll } site := q.Get("site") kind := q.Get("kind") if kind != store.KindManga && kind != store.KindNovel { kind = "" } page := 1 if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 { page = p } sf := store.SeriesFilter{ Site: site, Kind: kind, Name: filter, Cutoff: time.Now().Add(-ownerWindow).UnixMilli(), Page: page, } data, err := h.store.SeriesPage(sf) if err != nil { return seriesListView{}, err } // A page past the end is not an empty list: the store's window count runs // over the rows the result set carries, so an overflow page reports zero // rows and zero total, and the list re-reads at page 1 to know the truth. if len(data.Rows) == 0 && page > 1 { page = 1 sf.Page = 1 data, err = h.store.SeriesPage(sf) if err != nil { return seriesListView{}, err } } view := seriesListView{ Site: site, Kind: kind, FilterLabel: seriesFilterLabels[filter], Rows: make([]seriesRowView, 0, len(data.Rows)), Total: data.Total, Sites: latest.SiteNames(), } now := time.Now() for i, a := range data.Rows { view.Rows = append(view.Rows, seriesRow(a, i, now)) } view.Filters, err = h.seriesFilterOptions(filter) if err != nil { return seriesListView{}, err } view.KindBoth = seriesListHref(filter, site, "", 0) view.KindManga = seriesListHref(filter, site, store.KindManga, 0) view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0) if page > 1 { view.PrevHref = seriesListHref(filter, site, kind, page-1) } if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last { view.NextHref = seriesListHref(filter, site, kind, page+1) } view.Range = pagerRange(data.Total, len(data.Rows), page) return view, nil } // seriesFilterOptions renders every hygiene filter with its library-wide // count, one SeriesShapes pass per filter summed in Go — the shipped surface // offers eight grouped passes, not a single stats query (#140). The counts // are library-wide because the select sits next to the Site narrowing and // must not shift as the owner narrows the list itself. func (h *Handler) seriesFilterOptions(selected string) ([]seriesFilterOption, error) { out := make([]seriesFilterOption, 0, len(seriesFilterOrder)) for _, name := range seriesFilterOrder { shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name}) if err != nil { return nil, err } count := 0 for _, sh := range shapes { count += sh.Total } out = append(out, seriesFilterOption{ Name: name, Label: seriesFilterLabels[name], Count: count, Selected: name == selected, }) } return out, nil } // seriesRow formats one store row for the template. The note chips are the // row's own hygiene facts, capped at two with a +N tail; attention marks a // row that carries any. func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { row := seriesRowView{ Key: a.Key(), Title: a.Title, Site: a.Site, Readers: a.ReaderCount, Band: i%2 == 1, } if a.LatestChapterNum != nil { row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) } else { row.Ch = "—" } row.Age = checkedAge(now, a.LatestCheckedAt) notes := seriesNotes(a, now) if n := len(notes); n > 2 { row.Notes, row.More = notes[:2], n-2 } else { row.Notes = notes } row.Attention = len(notes) > 0 return row } // seriesNotes are a row's hygiene chips in the design's order: no URL, no // cover, orphan, stale, reader sighting. func seriesNotes(a store.AdminSeries, now time.Time) []string { notes := []string{} if a.SeriesURL == "" { notes = append(notes, "no URL") } if a.CoverAddress == "" { notes = append(notes, "no cover") } if a.ReaderCount == 0 { notes = append(notes, "orphan") } if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() { notes = append(notes, "stale") } if a.RaisedByReader { notes = append(notes, "reader sighting") } return notes } // checkedAge formats how long ago a Series was last checked, at the // granularity the list reads at — minutes, hours, days. Zero means never. func checkedAge(now time.Time, ts int64) string { if ts == 0 { return "never" } d := now.Sub(time.UnixMilli(ts)) switch { case d < time.Hour: m := int(d / time.Minute) if m < 1 { m = 1 } return fmt.Sprintf("%dm ago", m) case d < 24*time.Hour: return fmt.Sprintf("%dh ago", int(d/time.Hour)) default: return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) } } // pagerRange is the pager's "1–50 of 120" line. Zero rows reads as "0 of 0": // the empty state carries the page then, and a window number for a list that // rendered nothing would be a number nothing points at. func pagerRange(total, rows, page int) string { if rows == 0 { return "0 of 0" } from := (page-1)*seriesPageSize + 1 return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total) } // seriesListHref is one Series list address carrying the filter, Site, Kind // and page. The All filter and page 1 are the absent cases and stay out of // the URL, so the default address is the shortest one. func seriesListHref(filter, site, kind string, page int) string { q := url.Values{} if filter != "" && filter != store.SeriesFilterAll { q.Set("filter", filter) } if site != "" { q.Set("site", site) } if kind != "" { q.Set("kind", kind) } if page > 1 { q.Set("page", strconv.Itoa(page)) } if len(q) == 0 { return "/admin/series" } return "/admin/series?" + q.Encode() }