Archived and finished buckets, userscript nav chips #4

Merged
sulthan merged 10 commits from feat/status-buckets into main 2026-07-27 16:58:06 +07:00
Owner

Gives every bookmark a lifecycle bucket — reading, archived, or finished — 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.md

Data model

One additive column through the existing addedColumns migration list:

ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'

The DEFAULT backfills every pre-existing row as reading, so there is no separate migration step. favorite is unchanged and orthogonal — a series can be an archived favourite.

Rollback is safe: an old binary against the new database omits status from 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 whole Bookmark and Upsert writes every column it knows about. latest_checked_at escaped this by staying out of bookmarkColumns entirely — status cannot, 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 VALUES side of the upsert:

COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')

with DO UPDATE SET status = excluded.status.

It has to be this way round. excluded.* is the row after the VALUES expressions are evaluated, so applying the default there and then reading excluded.status in 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.

TestUpsertEmptyStatusPreservesStored is the guard on this.

updated_at behaviour is unchanged: it moves only when last_chapter_num changes, 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

Surface All Updated Favourites Archived Finished
Web reading reading reading archived finished
Userscript reading — reading archived not shown

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 in schema / addedColumns / bookmarkColumns / scanBookmark / Upsert. scanBookmark normalises anything outside the three known buckets to reading, so no row can land in no list at all.
  • store.go — DueForLatestCheck gains AND 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 NOT is null-safe, so a hand-edited NULL still qualifies.
  • handlers.go — status validation on PUT, before any write.
  • web.go — buildListView filters the new tabs and excludes both buckets from all / new / fav and the recent strip; new POST /ui/bookmarks/{key}/status, session-guarded like its siblings, read-modify-writing through Store.Get + Store.Upsert so the updated_at rule 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)

  • Third tab Archived beside All and Favourites. A missing status reads as reading, so a list cached by the previous version still renders. finished matches no tab and is invisible everywhere.
  • Per-item Archive / Unarchive button on the existing optimistic path: mutate local state and cache, apiPut, adopt the server's returned row.
  • Header chip row linking the web UI and both manga sites, each target="_blank" rel="noopener".
  • apiPut now omits status unless the caller opts in — see below.

Still free of every GM_* API: plain fetch, page localStorage, on-page UI only.

One bug worth calling out

The userscript's other mutations (updateToCurrentChapter, setChapterManual, toggleFavorite, applyLatestChapterIfChanged) build their payload with Object.assign({}, existing, …), so they echoed the cached status back to the server. GET /bookmarks has no status filter — finished rows are in state.list and only hidden at render time — which made two failures reachable:

  1. Reading a chapter of a series marked finished sent "status":"finished", which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
  2. Archiving on desktop and then reading on a phone whose cache predated the archive sent "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 }) strips status from a copy of the body unless the caller opts in, and only toggleArchive opts 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 backgroundRefreshLatest now 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:

  • The poller's Store.Get + Store.Upsert is 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 in CLAUDE.md; it now costs a status change too. Accepted for a single-user deployment.
  • A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.

Testing

go test ./... passes; CGO_ENABLED=0 go build ./... clean.

  • store_test.go — a fresh row defaults to reading; a legacy database gains the column with every row reading; an Upsert carrying "" preserves the stored bucket while a value replaces it; a status change does not move updated_at; DueForLatestCheck returns archived and skips finished; the poller's Get → Upsert round trip preserves archived.
  • main_test.go — PUT with finished or garbage is 400, "" / reading / archived round-trip; a PUT that omits the status key 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 move updated_at; the card renders the right controls per bucket.

Userscript has no automated harness, so it was checked against a live https://asurascans.com page: 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 no status key at all.

Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — 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.md` ## Data model One additive column through the existing `addedColumns` migration list: ```sql ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading' ``` The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite. Rollback is safe: an old binary against the new database omits `status` from 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 whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, 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 `VALUES` side of the upsert: ```sql COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading') ``` with `DO UPDATE SET status = excluded.status`. It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in 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. `TestUpsertEmptyStatusPreservesStored` is the guard on this. `updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, 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 | Surface | All | Updated | Favourites | Archived | Finished | |---|---|---|---|---|---| | Web | reading | reading | reading | archived | finished | | Userscript | reading | — | reading | archived | not shown | 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 in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all. - **`store.go`** — `DueForLatestCheck` gains `AND 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 NOT` is null-safe, so a hand-edited NULL still qualifies. - **`handlers.go`** — status validation on `PUT`, before any write. - **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule 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) - Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere. - Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row. - Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`. - `apiPut` now omits `status` unless the caller opts in — see below. Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only. ## One bug worth calling out The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable: 1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently. 2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"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 })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts 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 `backgroundRefreshLatest` now 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: - The poller's `Store.Get` + `Store.Upsert` is 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 in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment. - A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback. ## Testing `go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean. - **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`. - **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key 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 move `updated_at`; the card renders the right controls per bucket. Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: 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 no `status` key at all.
sulthan added 9 commits 2026-07-27 13:39:57 +07:00
sulthan added 1 commit 2026-07-27 13:55:54 +07:00
`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 <noreply@anthropic.com>
sulthan merged commit 6af49e6790 into main 2026-07-27 16:58:06 +07:00
sulthan deleted branch feat/status-buckets 2026-07-31 01:02:18 +07:00
Sign in to join this conversation.