Archived and finished buckets, userscript nav chips #4
Reference in New Issue
Block a user
Delete Branch "feat/status-buckets"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Gives every bookmark a lifecycle bucket —
reading,archived, orfinished— so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.Design:
docs/superpowers/specs/2026-07-27-status-buckets-design.mdData model
One additive column through the existing
addedColumnsmigration list:The
DEFAULTbackfills every pre-existing row asreading, so there is no separate migration step.favoriteis unchanged and orthogonal — a series can be an archived favourite.Rollback is safe: an old binary against the new database omits
statusfrom its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.The write rule
PUT /bookmarks/{key}decodes a wholeBookmarkandUpsertwrites every column it knows about.latest_checked_atescaped this by staying out ofbookmarkColumnsentirely —statuscannot, because the userscript must be able to archive and restore.So an empty incoming status means "no opinion", not a value, and resolves on the
VALUESside of the upsert:with
DO UPDATE SET status = excluded.status.It has to be this way round.
excluded.*is the row after theVALUESexpressions are evaluated, so applying the default there and then readingexcluded.statusin the conflict clause would see'reading'rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.TestUpsertEmptyStatusPreservesStoredis the guard on this.updated_atbehaviour is unchanged: it moves only whenlast_chapter_numchanges, so archiving, finishing, restoring, and favouriting never reorder the list.Validation
PUT /bookmarks/{key}returns 400 for any status outside{"", "reading", "archived", "finished"}, and for"finished"specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The/ui/*endpoints have their own session-guarded route and are unaffected.Visibility
Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".
Backend
store.go—Bookmark.Status, the column inschema/addedColumns/bookmarkColumns/scanBookmark/Upsert.scanBookmarknormalises anything outside the three known buckets toreading, so no row can land in no list at all.store.go—DueForLatestCheckgainsAND status IS NOT 'finished'. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge.IS NOTis null-safe, so a hand-edited NULL still qualifies.handlers.go— status validation onPUT, before any write.web.go—buildListViewfilters the new tabs and excludes both buckets fromall/new/favand the recent strip; newPOST /ui/bookmarks/{key}/status, session-guarded like its siblings, read-modify-writing throughStore.Get+Store.Upsertso theupdated_atrule stays in one place.templates/— two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.static/style.css— five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.Userscript (1.3.0)
statusreads asreading, so a list cached by the previous version still renders.finishedmatches no tab and is invisible everywhere.apiPut, adopt the server's returned row.target="_blank" rel="noopener".apiPutnow omitsstatusunless the caller opts in — see below.Still free of every
GM_*API: plainfetch, pagelocalStorage, on-page UI only.One bug worth calling out
The userscript's other mutations (
updateToCurrentChapter,setChapterManual,toggleFavorite,applyLatestChapterIfChanged) build their payload withObject.assign({}, existing, …), so they echoed the cachedstatusback to the server.GET /bookmarkshas no status filter — finished rows are instate.listand only hidden at render time — which made two failures reachable:"status":"finished", which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently."status":"reading"and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".Fixed at the single choke point:
apiPut(key, obj, { sendStatus = false })stripsstatusfrom a copy of the body unless the caller opts in, and onlytoggleArchiveopts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the invalid values would not have been enough — a stale cached"reading"still clobbers a remote archive.Also: the userscript's
backgroundRefreshLatestnow skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.Known limitations, deliberate
Both are marked in-code with
ponytail:comments naming the ceiling and the upgrade path:Store.Get+Store.Upsertis not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress inCLAUDE.md; it now costs a status change too. Accepted for a single-user deployment.Testing
go test ./...passes;CGO_ENABLED=0 go build ./...clean.store_test.go— a fresh row defaults toreading; a legacy database gains the column with every rowreading; anUpsertcarrying""preserves the stored bucket while a value replaces it; a status change does not moveupdated_at;DueForLatestCheckreturns archived and skips finished; the poller'sGet→Upsertround trip preservesarchived.main_test.go—PUTwithfinishedor garbage is 400,""/reading/archivedround-trip; a PUT that omits thestatuskey entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket and applies the chapter progress in the same request.web_test.go— each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not moveupdated_at; the card renders the right controls per bucket.Userscript has no automated harness, so it was checked against a live
https://asurascans.compage: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries"status":"archived"while a favourite toggle on that same archived series carries nostatuskey at all.