feat(web): filter tabs and recent strip by status bucket

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 12:56:12 +07:00
parent f783b72a81
commit 5aae678668
2 changed files with 115 additions and 16 deletions
+29 -16
View File
@@ -145,38 +145,51 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "app", view)
}
// filterBookmarks returns the subset keep reports true for, preserving order.
// It always returns a non-nil slice so an empty tab renders its empty state.
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
out := []Bookmark{}
for _, b := range all {
if keep(b) {
out = append(out, b)
}
}
return out
}
// buildListView loads the list once and derives both the tab-filtered items and
// the recent strip from it. The strip always reflects overall recency, not the
// active tab, so it is built before filtering.
// the recent strip from it.
//
// Archived and finished series appear in their own tab and nowhere else — not
// in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading".
func (h *webHandler) buildListView(tab string) (listView, error) {
all, err := h.store.List() // already ordered updated_at DESC
if err != nil {
return listView{}, err
}
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
recent := all
// The strip reflects overall reading recency, not the active tab.
recent := reading
if len(recent) > recentCount {
recent = recent[:recentCount]
}
items := all
var items []Bookmark
switch tab {
case "fav":
items = []Bookmark{}
for _, b := range all {
if b.Favorite {
items = append(items, b)
}
}
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
case "new":
items = []Bookmark{}
for _, b := range all {
if b.HasNewChapter() {
items = append(items, b)
}
}
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
case "archived":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
case "finished":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
default:
tab = "all"
items = reading
}
return listView{Tab: tab, Recent: recent, Items: items}, nil
}
+86
View File
@@ -513,3 +513,89 @@ func TestUIListNewTab(t *testing.T) {
t.Fatal("new tab included a series already caught up")
}
}
// seedStatusRows puts one series in each bucket, the archived one also
// favourited and with a new chapter out, so a leak into any reading-bucket tab
// shows up as a failure rather than passing by accident.
func seedStatusRows(t *testing.T, store *Store) {
t.Helper()
// floatPtr already exists in store_test.go — same package, reuse it.
rows := []Bookmark{
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
Status: statusReading, LastChapterNum: 10, Favorite: true,
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: statusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
Status: statusFinished, LastChapterNum: 200, Favorite: true},
}
for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli()
if _, err := store.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
cases := []struct {
tab string
want, dontWant []string
}{
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
}
for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, w := range tc.want {
if !strings.Contains(body, w) {
t.Fatalf("tab %s missing %s", tc.tab, w)
}
}
for _, d := range tc.dontWant {
if strings.Contains(body, d) {
t.Fatalf("tab %s leaked %s", tc.tab, d)
}
}
})
}
}
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
strip := rr.Body.String()
if i := strings.Index(strip, `id="list"`); i >= 0 {
strip = strip[:i] // everything above the list is the recent section
}
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
t.Fatal("recent strip included an archived or finished series")
}
if !strings.Contains(strip, "ReadingOne") {
t.Fatal("recent strip dropped the reading series")
}
}