Concrete backend + userscript plan against the approved design doc, including the Store.Upsert return-value fix needed to keep ordering correct once updated_at becomes conditional, and background-refresh triggering on both init() and SPA navigation per user preference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
Implementation plan: bookmark reorder, latest-chapter tracking, favorites
Context
Design already approved and committed at
plans/2026-07-25-bookmark-list-favorites-design.md on branch
feat/bookmark-list-favorites-latest. It covers three requested userscript
features plus one incidental bug found while verifying feasibility live via
Playwright:
- Bookmark list reordered so the most-recently-progressed manga is first.
- Show the latest available chapter for a manga, not just the last one read — including a background same-origin refresh mechanism to get closer to "live" without server-side polling (Cloudflare blocks that; confirmed live, and confirmed no JSON API / RSS exists on either site to poll instead).
- A favorites mechanism (star toggle + tabs) that doesn't remove a manga from the normal list.
asuracomic.netdeep links now 301-redirect straight to theasurascans.comhomepage (path discarded) — a Cloudflare-edge redirect confirmed live, with no client-side fix possible. Doc-only correction.
This plan turns that design into concrete code changes against the actual
current backend (Go/SQLite) and userscript, informed by full reads of
backend/store.go, backend/handlers.go, backend/store_test.go,
backend/main.go, and the full 782-line
userscript/manga-bookmark.user.js.
Key design decision surfaced during planning
Today handlers.go's put() echoes back the client's decoded request
struct as the API response, not what was actually persisted. Once
updated_at is sometimes not bumped (this whole feature's core mechanic),
echoing the request struct back would return a wrong updated_at to the
caller on every no-bump write — silently breaking the ordering guarantee the
entire feature depends on, since the userscript's syncUpsert/mutation
helpers adopt whatever the server echoes back (upsertLocal(saved)) as the
new source of truth. Store.Upsert must therefore return the row as
actually written (read back inside the same transaction), and handlers.go
must respond with that, not the client's payload. This is a correctness
fix required by the design, not a new decision to re-litigate.
Confirmed via the user: background latest-chapter refresh should fire on
Asura's SPA in-app navigation too (onNavigate()), not only true browser
page loads (init()) — more refresh opportunities on a client-routed site
that rarely does full reloads, still bounded by the same throttle/batch
limits.
Phase 1 — Backend (backend/), TDD
1.1 Tests first — backend/store_test.go
Add four tests (all go through the existing newTestServer(t) /
httptest pattern already used in this file, since there are no direct
Store-level unit tests in the current style):
TestUpsertConditionalUpdatedAt— table-driven: new bookmark (bumps), unchanged progress (no bump), changed progress (bumps), favorite-only change (no bump), latest-chapter-only change (no bump). Assert on theupdated_atreturned by each PUT response.TestFavoriteRoundTrip— PUTfavorite: true, GET list, assert it round-trips.TestLatestChapterNullable— PUT withoutlatest_chapter_num, assert JSON response has"latest_chapter_num":null; PUT again with a value, assert it round-trips.TestOpenStoreMigratesLegacySchema— hand-create the old (10-column) schema in a temp DB file, seed one row, then callOpenStoreon it and assert the row survives with the new columns defaulting cleanly (favorite=false,latest_chapter="",latest_chapter_num=nil). This is the safety net for the already-deployed production DB.
Run cd backend && go test ./... — expect compile failures (red state is
correct/expected before 1.2).
1.2 backend/store.go
Bookmarkstruct: addFavorite booljson:"favorite", `LatestChapter string `json:"latest_chapter",LatestChapterNum *float64json:"latest_chapter_num"`` (nullable — only this one needs to be a pointer, per the design doc's data-model table).schema: extendCREATE TABLE IF NOT EXISTSwithfavorite INTEGER NOT NULL DEFAULT 0,latest_chapter TEXT NOT NULL DEFAULT '',latest_chapter_num REAL(covers fresh installs only).- Idempotent migration for the already-deployed DB: add a
migrateColumns(db)helper usingPRAGMA table_info(bookmarks)to check each new column's existence before running itsALTER TABLE ... ADD COLUMN(SQLite has noADD COLUMN IF NOT EXISTS). Call it inOpenStoreright after the existingschemaexec succeeds, same error-wrapping style as today. - Shared
scanBookmarkhelper: centralizes converting thefavoriteINTEGER(0/1) tobooland the nullablelatest_chapter_numREALto*float64viasql.NullFloat64, used by bothList()andUpsert()'s read-back. List(): extend theSELECTto the new columns, scan viascanBookmark.Upsert(b Bookmark) (Bookmark, error)— signature changes to return the stored row. Implementation: wrap indb.Begin()/tx.Commit()(explicit "read exactly what I just wrote" guarantee rather than relying onSetMaxOpenConns(1)staying 1 forever). TheINSERT ... ON CONFLICT DO UPDATE SETgets aCASEexpression forupdated_at:This is valid SQLite upsert syntax (bare column = pre-update row value,updated_at = CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num THEN excluded.updated_at ELSE bookmarks.updated_at ENDexcluded.col= proposed new row) and naturally handles "new row" for free —ON CONFLICT DO UPDATEonly fires on the update path, so a genuinely new row goes through the plainINSERT ... VALUESand always gets the freshupdated_at. After the exec,SELECTthe row back inside the same transaction and return it viascanBookmark.
1.3 backend/handlers.go
In put(): keep b.UpdatedAt = time.Now().UnixMilli() as a candidate
value (update its comment — it's no longer unconditionally authoritative),
then:
stored, err := h.store.Upsert(b)
...
writeJSON(w, http.StatusOK, stored)
No other changes — Favorite/LatestChapter/LatestChapterNum already
flow through untouched from the decoded body, which is correct (they're
fully client-set synced fields). list()/delete() unchanged.
1.4 Green + build
cd backend && go test ./... # all pass, including pre-existing TestBookmarkRoundTrip unmodified
cd backend && CGO_ENABLED=0 go build # static binary still builds
Phase 2 — Userscript (userscript/manga-bookmark.user.js)
No JS test harness in this repo — verification is manual (Phase 4).
-
Config constants (near
CACHE_KEY, ~line 24):LASTCHECKED_KEY = "mangabm:lastchecked",LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000(4h),LATEST_CHECK_BATCH = 1. -
Shared anchor extraction so the exact same per-site chapter-matching rule runs against both the live DOM and raw fetched HTML text (no HTML parser available for the fetch path):
anchorsFromDocument(doc)(viaquerySelectorAll("a[href]")) andanchorsFromHTML(html)(regex-based<a href="...">...</a>extraction). Add nearmeta()(~line 34). -
Per-adapter
latestChapterFromAnchors(anchors)added to bothasuraanddemonicadapter objects, implementing the regex rules from the design doc (Asura: href matches/chapter/([\d.]+)$/AND text matches/Chapter\s+[\d.]+/i, excluding the "First Chapter" quick-jump button; Demonic: allchaptered.php?manga=\d+&chapter=([\d.]+)matches, take max — no order assumption). Plus acomputeLatestChapter(site, anchors)dispatcher nearkeyOf(). -
mangabm:lastcheckedlocal helpers:loadLastChecked()/saveLastChecked(map), parallel to existingloadCache/saveCache(~line 154), storing{ [bookmarkKey]: timestampMs }. Client-local only, never synced. -
applyLatestChapterIfChanged(existing, latest)(~nearsyncUpsert, line 295): iflatest.numdiffers from the bookmark's storedlatest_chapter_num, optimistically update local cache + render, thenapiPutwithupdated_at: Date.now()as a candidate — the backend (Phase 1) decides whether to actually apply it, and the client adopts whatever comes back viaupsertLocal(saved), same pattern the rest of the file already uses. No client-side "don't reorder" logic needed beyond that — the server is the single source of truth for it. Silent on failure (no toast), per the design doc. -
Live-page capture:
maybeCaptureLatestOnSeriesPage()— on atype: "series"page for an already-bookmarked series, scan the live DOM viaanchorsFromDocument+computeLatestChapter, thenapplyLatestChapterIfChanged. Hooked intoonNavigate()(~line 633), after the existingmaybeAutoUpdate()call. -
Background opportunistic refresh:
backgroundRefreshLatest()— getcurrentSite()(which adapter matcheswindow.location), pick same-site bookmarks not checked withinLATEST_CHECK_THROTTLE_MS(oldest-checked-first), fetch+parse at mostLATEST_CHECK_BATCHof them viafetch(bm.series_url).then(r => r.text())→anchorsFromHTML→computeLatestChapter→applyLatestChapterIfChanged. Mark each attempted bookmark'slastcheckedtimestamp regardless of success/failure (advances the throttle window either way, avoiding hammering a consistently-failing fetch). Silent on failure. Hook into bothinit()andonNavigate()(per user's confirmed preference — more refresh opportunities on Asura's SPA navigation, same throttle/batch caps prevent request bursts either way). -
toggleFavorite(key)(~nearsetChapterManual, line 293): flipsfavorite, optimistic update,apiPutwithDate.now()candidate timestamp (again, backend decides), toast on success/failure (consistent with other explicit user-initiated actions like bookmark/remove). -
Tabs: new module state
let activeTab = "all";(~nearpanelOpen, line 374; not persisted, defaults to "all"). Wire click handlers inbuildUI()for new#tabAll/#tabFavelements. Inrender()(~line 568), toggle each tab's.activeclass and filter which array feeds the list-building loop (activeTab === "favorites" ? state.list.filter(b => b.favorite) : state.list) —state.listitself is never mutated/filtered, so a favorited manga always still appears in "All". -
renderItem(b)(~line 579): add a star toggle button (☆/★,onclick: () => toggleFavorite(b.key)) alongside the existing Continue/Edit/Remove buttons, and change the subtitle line to"Read: " + last_chapter + " · Latest: " + latest_chapterwhenlatest_chapter_numis known and strictly greater thanlast_chapter_num(avoids showing "Latest: Chapter 12" next to "Read: Chapter 12" when they're numerically equal); otherwise keep today's"<last_chapter> · <site>"text. -
TEMPLATE(~line 679): insert a tabs bar (<div id="tabs"><button id="tabAll" class="tab active">All</button> <button id="tabFav" class="tab">★ Favorites</button></div>) between#contextand#list. -
CSS(~near.ctx-sub/.btn.danger): add.tab/.tab.activeand.btn.star/.btn.star.activerules following the existing dark-theme.btnmodifier convention (.btn.primary,.btn.small,.btn.danger). -
Fix the misleading redirect comment (line 41, in the
asuraadapter object): replace "asuracomic.net currently 301s to asurascans.com; match both." with an accurate note that the redirect now discards the path (goes straight to the asurascans.com root), happens at the Cloudflare edge before any JS runs, so no client-side fix is possible, and the user should navigate via asurascans.com links directly. No change to thematches()regex itself. -
@version: bump1.1.0→1.2.0.
Phase 3 — Documentation
CLAUDE.md: update thePUT /bookmarks/{key}endpoint description (currently "upsert, server sets updated_at") to describe the new conditional rule, referencingplans/2026-07-25-bookmark-list-favorites-design.md§4.README.md: update the matching endpoint-table row, and correct the "Adapter reference" section's Asura row to noteasuracomic.netdeep links currently 301 to the asurascans.com root (broken/path discarded) — useasurascans.comlinks directly. While touching this, also fixCLAUDE.md's intro line ("asuracomic.net (formerly asurascans.com)"), which has the relationship backwards and is inconsistent with README's own phrasing ("asurascans.com (a.k.a. asuracomic.net)") — bundle this small adjacent correction in since it's directly related to the same finding.
Verification
Backend (automated):
cd backend && go test ./...
cd backend && CGO_ENABLED=0 go build
Backend (manual smoke test, extends the existing curl convention in
CLAUDE.md/README.md): PUT a new bookmark, then PUT again changing only
favorite, then only latest_chapter*, then a real last_chapter_num
advance — confirm via jq .updated_at that only the first and last calls
change updated_at.
Userscript (manual — no JS test harness exists in this repo, matching existing project convention):
- List reorders only on a genuine progress advance, not on plain re-visit, favorite toggle, or latest-chapter capture.
- Latest-chapter capture fires when visiting a bookmarked series page on both sites and displays the "Read: X · Latest: Y" subtitle correctly.
- Background refresh: check
localStorage['mangabm:lastchecked']in devtools to confirm throttling behavior; confirm via the Network tab that it never fires a cross-site request (only same-origin as the currently loaded site). - Favorite toggle persists across a panel close/reopen and a
refresh()round-trip through the backend; favorited manga still shows in "All". - Fastest iteration path: desktop Tampermonkey/Violentmonkey first (script
stays
GM_*-free), then confirm on Bromite per existing project convention.
Critical files
backend/store.gobackend/handlers.gobackend/store_test.gouserscript/manga-bookmark.user.jsCLAUDE.mdREADME.md