@@ -960,6 +1004,13 @@
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
}
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
+ #nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
+ .chip {
+ background: #2a2a33; color: #c4b5fd; text-decoration: none;
+ padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
+ white-space: nowrap;
+ }
+ .chip:active { opacity: .8; }
#context {
padding: 12px 16px; border-bottom: 1px solid #33333d;
display: flex; flex-direction: column; gap: 8px;
--
2.52.0
From 896792d5044c7244c67c88b9510d8e1876657236 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Mon, 27 Jul 2026 13:25:25 +0700
Subject: [PATCH 08/10] docs: record status buckets and userscript nav chips
---
CLAUDE.md | 15 ++++++++++++++-
README.md | 9 +++++++++
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ee7b87c..dfc7fcb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,6 +57,16 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
value now differs, moving `updated_at` and reordering the list. This is a
known, accepted limitation for a single-user deployment, not a bug to fix.
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
+- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
+ `finished`, orthogonal to `favorite`. Archived and finished appear only in
+ their own tab — not in All, Updated, Favourites, or the recent strip. The
+ poller keeps checking archived series and skips finished ones. `finished` is
+ settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400.
+ **An empty incoming status means "keep the stored one"** — resolved on the
+ `VALUES` side of `Store.Upsert`, not in the conflict clause, because
+ `excluded.*` is the post-evaluation row and a default applied there would
+ wipe the bucket on every PUT from a client that predates the column. See
+ `docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
(gates the browser UI; unset disables it),
@@ -68,7 +78,10 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
-4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS (critical on mobile).
+4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
+ (critical on mobile). Three tabs (All / Favourites / Archived) and a row of
+ link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
+ block next to `API_BASE`.
5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
diff --git a/README.md b/README.md
index bb884be..b0aa729 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,15 @@ desktop for faster testing — install the same file unchanged.
- **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows
the list. Favourited series still appear under **All**. The flag syncs, so it
follows you across devices; the chosen tab does not persist.
+- **Archive**: the **Archive** button on any row parks a series — it drops out
+ of **All** and **★ Favourites** and moves to the **Archived** tab. The server
+ keeps checking it for new chapters, so it is worth coming back to. Archiving
+ does not touch read progress, and reading an archived series leaves it
+ archived.
+- **Finished**: series you have completed live in a **Finished** tab in the web
+ UI only. It is set there and nowhere else — the API rejects the value — and
+ finished series are hidden from every userscript tab and are no longer polled
+ for new chapters.
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
versa — the backend is the shared store.
--
2.52.0
From 7a16abd6a322b58a202ce65dabaa4e0e12190574 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Mon, 27 Jul 2026 13:35:46 +0700
Subject: [PATCH 09/10] fix: omit status on non-archive writes, skip finished
in refresh and scan
---
backend/main_test.go | 36 +++++++++++++++++++++++++++++++
backend/store.go | 7 +++---
backend/store_test.go | 33 ++++++++++++++++++++++++++++
userscript/manga-bookmark.user.js | 13 ++++++++---
4 files changed, 83 insertions(+), 6 deletions(-)
diff --git a/backend/main_test.go b/backend/main_test.go
index d199eb7..dd87c8e 100644
--- a/backend/main_test.go
+++ b/backend/main_test.go
@@ -160,3 +160,39 @@ func TestPutStatusValidation(t *testing.T) {
})
}
}
+
+// A PUT that omits the status field entirely (what a userscript build
+// predating the column sends) is the actual preserve path — the "" case
+// above only exercises the fresh-INSERT default and never touches an
+// existing bucket. This must both keep the archived bucket and still apply
+// the chapter progress carried in the same request.
+func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
+ srv := newTestServer(t)
+
+ seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
+ strings.NewReader(`{"title":"Solo","status":"archived"}`))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, auth(seed))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
+ strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`))
+ rr = httptest.NewRecorder()
+ srv.ServeHTTP(rr, auth(req))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+
+ var got Bookmark
+ if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Status != "archived" {
+ t.Fatalf("stored status = %q, want %q", got.Status, "archived")
+ }
+ if got.LastChapterNum != 12 {
+ t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
+ }
+}
diff --git a/backend/store.go b/backend/store.go
index 39e40d9..e3df5d2 100644
--- a/backend/store.go
+++ b/backend/store.go
@@ -193,10 +193,11 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if latestChapterNum.Valid {
b.LatestChapterNum = &latestChapterNum.Float64
}
- // A NULL or empty bucket would leave the row in no list at all, so it
- // reads as the default rather than being passed through.
+ // A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
+ // leave the row in no list at all, so anything outside the three known
+ // buckets reads as the default rather than being passed through.
b.Status = status.String
- if b.Status == "" {
+ if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
b.Status = statusReading
}
return b, nil
diff --git a/backend/store_test.go b/backend/store_test.go
index c1eddac..619a5ff 100644
--- a/backend/store_test.go
+++ b/backend/store_test.go
@@ -760,6 +760,39 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
}
}
+// Mirrors the poller's read-modify-write in latest.go: Get the current row,
+// mutate only the latest-chapter fields, and Upsert the whole struct back.
+// cur.Status comes from Get (never empty — see scanBookmark), so it must
+// round-trip through Upsert unchanged rather than being reset.
+func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
+ store := newTestStore(t)
+ base := Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
+ }
+ if _, err := store.Upsert(base); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ cur, found, err := store.Get(base.Key)
+ if err != nil || !found {
+ t.Fatalf("Get: found=%v err=%v", found, err)
+ }
+
+ num := 7.0
+ cur.LatestChapter = "7"
+ cur.LatestChapterNum = &num
+ cur.UpdatedAt = time.Now().UnixMilli()
+
+ stored, err := store.Upsert(cur)
+ if err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ if stored.Status != statusArchived {
+ t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
+ }
+}
+
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js
index 791799b..ee2d1d8 100644
--- a/userscript/manga-bookmark.user.js
+++ b/userscript/manga-bookmark.user.js
@@ -271,11 +271,17 @@
return res.json();
}
- async function apiPut(key, obj) {
+ // Only an explicit archive/restore has an opinion about the bucket. Every
+ // other write omits `status`, so the server keeps the stored one — otherwise
+ // a cached value would resend "finished" (which the API rejects with 400) or
+ // silently un-archive a series archived on another device.
+ async function apiPut(key, obj, { sendStatus = false } = {}) {
+ const body = Object.assign({}, obj);
+ if (!sendStatus) delete body.status;
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }),
- body: JSON.stringify(obj),
+ body: JSON.stringify(body),
});
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
return res.json();
@@ -433,7 +439,7 @@
upsertLocal(bm);
render();
try {
- const saved = await apiPut(bm.key, bm);
+ const saved = await apiPut(bm.key, bm, { sendStatus: true });
upsertLocal(saved); // adopt the stored row: the server owns updated_at
render();
toast(next === "archived" ? "Archived" : "Back in your list");
@@ -489,6 +495,7 @@
const now = Date.now();
const due = state.list
.filter((b) => b.site === site && b.series_url)
+ .filter((b) => statusOf(b) !== "finished")
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
.slice(0, LATEST_CHECK_BATCH);
--
2.52.0
From aaf5c3990a43d5041a9ac5ec05fd3e8a2d6abfd2 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Mon, 27 Jul 2026 13:55:45 +0700
Subject: [PATCH 10/10] chore: ignore the go build artifact, mark two accepted
limitations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`go build ./...` in backend/ emits `backend/backend` (the module is
`mangabm/backend`), but .gitignore only listed `backend/server` — the name
the Dockerfile uses for its container-internal build. The real artifact was
untracked and unignored, and came close to being committed twice. Keeps the
`server` entry, since a local `go build -o server` still matches the
Dockerfile's naming.
Also records two deliberate limitations as `ponytail:` comments so they are
greppable rather than living only in prose:
- latest.go: the poller's Store.Get + Store.Upsert is not wrapped in a
transaction, so a client PUT committing between the two is lost to the
stale re-read. Already documented in CLAUDE.md; the marker names the
upgrade path (wrap in a tx) and the trigger (more than one user). Note
this now costs a status change, not just read progress.
- web.go: a swapped card stays on screen when its new status no longer
matches the active tab. The alternative is a full list round trip per
toggle; the card showing its new state is the feedback that matters.
Comments only — no logic change. Tests pass, CGO_ENABLED=0 builds.
Co-Authored-By: Claude Opus 5
---
.gitignore | 1 +
backend/latest.go | 7 +++++++
backend/web.go | 7 +++++++
3 files changed, 15 insertions(+)
diff --git a/.gitignore b/.gitignore
index 5c85376..1762a52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
*.db-shm
*.db-wal
backend/server
+backend/backend
.playwright-mcp/
graphify-out/
plans/
diff --git a/backend/latest.go b/backend/latest.go
index 4d08d0d..40a36aa 100644
--- a/backend/latest.go
+++ b/backend/latest.go
@@ -148,6 +148,13 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
// Re-read: the row may have been updated or deleted while the fetch was in
// flight, and writing b back wholesale would undo that.
+ //
+ // ponytail: non-transactional read-modify-write, wrap Get+Upsert in a tx if
+ // this ever runs for more than one user. A client PUT that commits between
+ // these two statements is lost to the stale re-read — reverting read
+ // progress or a status change, and moving updated_at because the stored
+ // value now differs. Accepted for a single-user deployment: the window is
+ // milliseconds and the loser is one poll cycle.
cur, found, err := p.store.Get(b.Key)
if err != nil {
log.Printf("latest poll %q: reread: %v", b.Key, err)
diff --git a/backend/web.go b/backend/web.go
index 7a5487d..61f5840 100644
--- a/backend/web.go
+++ b/backend/web.go
@@ -262,6 +262,13 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
// whether updated_at moves, so the argument's timestamp is only a candidate and
// the response must come from the return value.
+//
+// ponytail: the swapped card stays put even when its new status no longer
+// matches the active tab, add an hx-swap-oob list refresh if that reads as a
+// bug rather than as feedback. Archiving from the All tab leaves the card on
+// screen until the next list load. The alternative costs a full list round
+// trip on every toggle, and the card visibly showing its new state is the
+// feedback the user needs.
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
stored, err := h.store.Upsert(b)
if err != nil {
--
2.52.0