From 5aae6786686886cfd104525145b39d95e5451cf4 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:56:12 +0700 Subject: [PATCH] feat(web): filter tabs and recent strip by status bucket Co-Authored-By: Claude Opus 5 --- backend/web.go | 45 +++++++++++++++--------- backend/web_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/backend/web.go b/backend/web.go index 7d88a4e..952ff7d 100644 --- a/backend/web.go +++ b/backend/web.go @@ -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 } diff --git a/backend/web_test.go b/backend/web_test.go index b744162..b107630 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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") + } +}