Series-level finished state, and the fate of the finished Lifecycle bucket #124

Closed
opened 2026-08-17 16:38:23 +07:00 by sulthan · 1 comment
Owner

Part of #114

Question

Does a Series itself become finished, and does the reader-facing finished Lifecycle bucket go away?

Raised while resolving #116. The owner wants to mark a Series finished so that its Poll Lane
never Polls it again, and wants the Reader to keep only reading and archived.

Today finished is a Lifecycle bucket on a Bookmark (bookmarks.status, one of
reading/archived/finished). It is a per-Reader value, but the Poll Lane reads it as if it were
a fact about the Series: DueForLatestCheck and EligibleSeriesCount
(backend/internal/store/store.go:972, 1010) both require
COUNT(*) FILTER (WHERE b.status<>'finished') > 0. So one Reader who finishes a Series does
not stop the Poll, but every Reader who finishes it does. That is a Series-level effect
assembled from per-Reader votes.

CONTEXT.md defines the Lifecycle bucket as three mutually exclusive states of a Bookmark.
A Series that a Site has completed is a fact about the Series, like Latest Chapter, and the
glossary says a Reader cannot change facts about a Series. The current model puts the fact in
the wrong place.

To decide:

  • Where the finished state lives: a new column on series, or something else.
  • What replaces the status<>'finished' test in the two Lane queries, and whether an
    archived Bookmark still keeps a Series eligible for a Poll.
  • Whether the reader-facing finished bucket is removed, and what happens to rows that hold it
    today. api.handlers rejects an unknown status with 400, and the userscripts and the web
    UI both offer the bucket, so removal reaches the userscript, the web UI, the API validation
    and a migration.
  • Whether an owner-finished Series is still shown, still gets a Cover refetch, and whether a
    Reader sees any sign of it.
  • Whether the owner can undo it.

Consequences for the map: the admin Series list (#116) gains one filter value and one
predicate; the hygiene counts (#118) must not call a finished Series stale; the action itself
is an intervention in the sense of #119 — a flag the Lane reads, never a command sent to it.

Part of #114 ## Question Does a Series itself become finished, and does the reader-facing finished Lifecycle bucket go away? Raised while resolving #116. The owner wants to mark a Series finished so that its Poll Lane never Polls it again, and wants the Reader to keep only reading and archived. Today `finished` is a Lifecycle bucket on a Bookmark (`bookmarks.status`, one of reading/archived/finished). It is a per-Reader value, but the Poll Lane reads it as if it were a fact about the Series: `DueForLatestCheck` and `EligibleSeriesCount` (backend/internal/store/store.go:972, 1010) both require `COUNT(*) FILTER (WHERE b.status<>'finished') > 0`. So one Reader who finishes a Series does not stop the Poll, but every Reader who finishes it does. That is a Series-level effect assembled from per-Reader votes. CONTEXT.md defines the **Lifecycle bucket** as three mutually exclusive states of a Bookmark. A Series that a Site has completed is a fact about the Series, like Latest Chapter, and the glossary says a Reader cannot change facts about a Series. The current model puts the fact in the wrong place. To decide: - Where the finished state lives: a new column on `series`, or something else. - What replaces the `status<>'finished'` test in the two Lane queries, and whether an archived Bookmark still keeps a Series eligible for a Poll. - Whether the reader-facing finished bucket is removed, and what happens to rows that hold it today. `api.handlers` rejects an unknown `status` with 400, and the userscripts and the web UI both offer the bucket, so removal reaches the userscript, the web UI, the API validation and a migration. - Whether an owner-finished Series is still shown, still gets a Cover refetch, and whether a Reader sees any sign of it. - Whether the owner can undo it. Consequences for the map: the admin Series list (#116) gains one filter value and one predicate; the hygiene counts (#118) must not call a finished Series stale; the action itself is an intervention in the sense of #119 — a flag the Lane reads, never a command sent to it.
sulthan added the wayfinder:grilling label 2026-08-17 16:38:23 +07:00
sulthan self-assigned this 2026-08-19 10:07:14 +07:00
Author
Owner

Resolution

Finished becomes a fact about the Series, written only by the owner, and the reader-facing
Lifecycle bucket is deleted.
The Reader vote that could stop a Poll is gone. Recorded as
ADR-0012 (docs/adr/0012-finished-belongs-to-the-series.md) — the migration is one-way
and destroys a user-facing bucket, so it needed one.

The column

series.finished_at bigint NOT NULL DEFAULT 0, migration 0016. Epoch ms, zero means
not finished — the same shape as #121's latest_corrected_at, and it doubles as the undo
(write 0) and as the "since when" the detail page prints. No side table (one Series row
already exists), and not on #119's poll_lanes (that grain is per-Site).

Owner writes it, and nothing else ever does. No adapter, no Reader, no Poll. A
machine-written finish fails invisibly: the Series stops being Polled, so nothing
contradicts the mistake afterwards, and #127's failure lens cannot see it either because
there are no attempts left to fail.

The two Lane queries

The HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 clause is deleted from
both
. The plain JOIN bookmarks already answers "does any Reader hold this"; whether to
Poll becomes finished_at = 0. Archived keeps polling, unchanged, for the reason already at
store.go:956.

  • DueForLatestCheck — WHERE gains (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at),
    and s.finished_at joins the GROUP BY list alongside the other series columns.
  • EligibleSeriesCount — WHERE gains s.finished_at = 0, with no force clause. This
    asymmetry is deliberate: that count is the divisor in the Lane's pace (min(gap, hour/eligible)),
    so admitting a forced Series would make one press speed up every other fetch on the Site.

Amends #119. Its HAVING (… <> 'finished' … OR force) shape is void, and one of the three
waiting rules it named as overridable — "the finished-only bucket" — is replaced by
finished_at. Its rationale transfers verbatim: the owner asking is direct evidence someone
cares about a Series the owner shelved. Everything #119 said a Forced Poll never overrides
is untouched.

Servicing a forced request does not clear finished_at. Nothing writes force_poll_at
back to zero; pending is derived (force_poll_at > latest_checked_at) and self-clears when
checkOne stamps latest_checked_at before the fetch (poller.go:422-431). At that
instant nothing has been read, so clearing the finish would act on zero evidence and convert
a one-off check into permanently resumed polling. Same principle #127 already took from
#121: only a real success resets state the owner did not write. Un-finishing is an explicit
action.

Consequence to accept: on a Site where everything is finished, EligibleSeriesCount hits 0
and #117 logs the pass as nothing-eligible — a Lane that declined and said why, not a
stall. Correct as-is, no new handling.

The Lifecycle bucket is removed

CONTEXT.md now defines it as two states, reading or archived. Removal reaches:

  • store.StatusFinished (store.go:185) and the doc comment at store.go:49-50, deleted.
  • api/handlers.go:79-83 — the special-case 400 ("can only be set from the web UI") goes;
    finished is simply an invalid status like any other.
  • web.go:322 (tab filter), web.go:488's uiStatus switch (two values), app.html:74-77
    (the Finished tab), chrome.html:39-44, list.html:17-18, card.html:35-37 (badge),
    card.html:74-80 (finish button) and card.html:119-130 (its confirm row).
  • novel-bookmark.user.js:283 — the three-way merge rank (finished 2 / archived 1 / reading 0)
    drops to two values; both userscripts' comments about the rejected value go with it.

Migration 0016 seeds before it flips, and the order is load-bearing:

UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
 WHERE EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id)
   AND NOT EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id
                     AND b.status <> 'finished');

UPDATE bookmarks SET status = 'archived' WHERE status = 'finished';

The seed reads the buckets, so it must run first. This carries today's behaviour across the
cutover unchanged — every Series not being Polled yesterday is still not being Polled
tomorrow — while reinterpreting it as one owner decision instead of a Reader vote. It
knowingly over-approximates in the owner's favour (a Series everyone merely shelved is
declared finished, undone in one click) rather than seeding nothing and silently resuming
polling on a set nobody chose to resume.

Admin surface

  • Detail page only (/admin/series/{key}), never the list row. The decision needs the
    facts that page shows — Latest Chapter, when it last moved, whether the page still reads —
    and the 50-row grid's neighbouring ghost is a harmless Check now. #122's row shape is
    unchanged; the row still displays the state.
  • Finish is confirm-gated with an in-place .confirm-row (the card.html:58-59 rule:
    every move out goes through a confirm, a reversal fires straight away). Un-finish fires
    instantly
    , like Restore (card.html:67-72). Neither is --danger — nothing is destroyed
    — so --patina per #122, and never --ember.
  • SeriesRow gains FinishedAt int64, the way #119's amendment added force_poll_at.

Filters — finished is the tenth name

Against the recommendation to add none; the owner wants finished Series findable as a list.

?filter= predicate label placement
finished s.finished_at > 0 Finished last in #118's block, informational not fixable

SeriesStats gains the matching COUNT(*) FILTER (WHERE finished_at > 0); #118's
zero-renders-the-digit-unlinked rule applies unchanged. Amends #116 (tenth predicate
constant) and #118 (tenth <select> option with its count); failing stays #127's ninth.

Four hygiene predicates gain AND s.finished_at = 0: stale, unchecked, no-cover,
no-chapter. Without the guards a finished Series ages into stale for ever and never
leaves, breaking #118's "an empty hygiene list is good news". unpollable, orphan and
sighting-raised are left alone — each is still a genuine repair on a finished row.

Readers see a label, and only a label

bookmarkColumns gains a derived finished bool (s.finished_at > 0) on the flat Bookmark
(ADR-0004). It reuses the badge markup and --moss token that removing the bucket frees
(card.html:35-37, style.css:87), and renders in both userscripts via el(..., {text}) —
never {html}. No tab, no filter, no reordering, no effect on the ember. A Reader
mid-way through a finished Series keeps it exactly where they left it; the badge only
explains why nothing new will arrive.

A bool, not the timestamp: the date the owner pressed a button is an operations fact whose
only consumer is /admin.

Read-only inbound, enforced by omission. Clients PUT the whole flat object back
(novel-bookmark.user.js:1390-1396), so a cache written this morning would carry
finished: false tonight and un-finish the Series. Upsert's INSERT INTO series
(store.go:858-867) names its columns explicitly and finished_at is not among them —
the same mechanism that already protects cover (store.go:850-852).

Glossary

Three edits, applied: Lifecycle bucket is two states; Forced Poll's "a Series only
finished Readers hold" becomes "a Series the owner marked Finished"; new Finished Series
term.

Not decided here — the completion-marker hint

The owner wants the server to notice a Site's own completion marker and surface
"potentially finished" in the dashboard, with no false positives and the owner still the
only writer. That needs a live check of what each of six Sites publishes, plus containment
rules that cannot be designed before we know which signals exist. Split into two child
tickets rather than guessed at here. This ticket's boundary is the part that binds them:
a machine hint may never write finished_at.

Pushed onto other tickets

  • #119 — HAVING shape void; the finished override moves to finished_at; force is in
    the worklist query and not in the pacing count.
  • #116 — SeriesRow.FinishedAt; tenth predicate constant; SeriesStats count; the
    knowingly-divergent ReaderCount now agrees with the Lanes, as that ticket predicted.
  • #118 — tenth <select> option labelled Finished, ordered last; four predicates gain
    the finished_at = 0 guard.
  • #122 — one new detail-page action pair (confirm-gated Finish, instant un-finish,
    --patina); list rows unchanged but display the state.
  • #127 — a finished Series is excluded from stale/unchecked/no-chapter, so
    failing's threshold only ever sees Series still being attempted.
  • #125 — unaffected: finished and orphan are independent (a finished Series still has
    Readers), and this ticket deletes no bytes.
## Resolution **Finished becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is deleted.** The Reader vote that could stop a Poll is gone. Recorded as **ADR-0012** (`docs/adr/0012-finished-belongs-to-the-series.md`) — the migration is one-way and destroys a user-facing bucket, so it needed one. ### The column `series.finished_at bigint NOT NULL DEFAULT 0`, migration **0016**. Epoch ms, zero means not finished — the same shape as #121's `latest_corrected_at`, and it doubles as the undo (write 0) and as the "since when" the detail page prints. No side table (one Series row already exists), and not on #119's `poll_lanes` (that grain is per-Site). **Owner writes it, and nothing else ever does.** No adapter, no Reader, no Poll. A machine-written finish fails invisibly: the Series stops being Polled, so nothing contradicts the mistake afterwards, and #127's failure lens cannot see it either because there are no attempts left to fail. ### The two Lane queries The `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0` clause is **deleted from both**. The plain `JOIN bookmarks` already answers "does any Reader hold this"; whether to Poll becomes `finished_at = 0`. Archived keeps polling, unchanged, for the reason already at `store.go:956`. - `DueForLatestCheck` — `WHERE` gains `(s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)`, and `s.finished_at` joins the `GROUP BY` list alongside the other series columns. - `EligibleSeriesCount` — `WHERE` gains `s.finished_at = 0`, **with no force clause**. This asymmetry is deliberate: that count is the divisor in the Lane's pace (`min(gap, hour/eligible)`), so admitting a forced Series would make one press speed up every *other* fetch on the Site. **Amends #119.** Its `HAVING (… <> 'finished' … OR force)` shape is void, and one of the three waiting rules it named as overridable — "the finished-only bucket" — is replaced by `finished_at`. Its rationale transfers verbatim: the owner asking is direct evidence someone cares about a Series *the owner* shelved. Everything #119 said a Forced Poll never overrides is untouched. **Servicing a forced request does not clear `finished_at`.** Nothing writes `force_poll_at` back to zero; pending is derived (`force_poll_at > latest_checked_at`) and self-clears when `checkOne` stamps `latest_checked_at` **before** the fetch (`poller.go:422-431`). At that instant nothing has been read, so clearing the finish would act on zero evidence and convert a one-off check into permanently resumed polling. Same principle #127 already took from #121: only a real success resets state the owner did not write. Un-finishing is an explicit action. Consequence to accept: on a Site where everything is finished, `EligibleSeriesCount` hits 0 and #117 logs the pass as `nothing-eligible` — a Lane that declined and said why, not a stall. Correct as-is, no new handling. ### The Lifecycle bucket is removed `CONTEXT.md` now defines it as **two** states, reading or archived. Removal reaches: - `store.StatusFinished` (`store.go:185`) and the doc comment at `store.go:49-50`, deleted. - `api/handlers.go:79-83` — the special-case 400 ("can only be set from the web UI") goes; `finished` is simply an invalid status like any other. - `web.go:322` (tab filter), `web.go:488`'s `uiStatus` switch (two values), `app.html:74-77` (the Finished tab), `chrome.html:39-44`, `list.html:17-18`, `card.html:35-37` (badge), `card.html:74-80` (finish button) and `card.html:119-130` (its confirm row). - `novel-bookmark.user.js:283` — the three-way merge rank (`finished 2 / archived 1 / reading 0`) drops to two values; both userscripts' comments about the rejected value go with it. **Migration 0016 seeds before it flips, and the order is load-bearing:** ```sql UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint WHERE EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id) AND NOT EXISTS (SELECT 1 FROM bookmarks b WHERE b.site = s.site AND b.series_id = s.series_id AND b.status <> 'finished'); UPDATE bookmarks SET status = 'archived' WHERE status = 'finished'; ``` The seed reads the buckets, so it must run first. This carries today's *behaviour* across the cutover unchanged — every Series not being Polled yesterday is still not being Polled tomorrow — while reinterpreting it as one owner decision instead of a Reader vote. It knowingly over-approximates in the owner's favour (a Series everyone merely shelved is declared finished, undone in one click) rather than seeding nothing and silently resuming polling on a set nobody chose to resume. ### Admin surface - **Detail page only** (`/admin/series/{key}`), never the list row. The decision needs the facts that page shows — Latest Chapter, when it last moved, whether the page still reads — and the 50-row grid's neighbouring ghost is a harmless *Check now*. #122's row shape is unchanged; the row still *displays* the state. - **Finish is confirm-gated** with an in-place `.confirm-row` (the `card.html:58-59` rule: every move out goes through a confirm, a reversal fires straight away). **Un-finish fires instantly**, like Restore (`card.html:67-72`). Neither is `--danger` — nothing is destroyed — so `--patina` per #122, and never `--ember`. - `SeriesRow` gains `FinishedAt int64`, the way #119's amendment added `force_poll_at`. ### Filters — `finished` is the tenth name Against the recommendation to add none; the owner wants finished Series findable as a list. | `?filter=` | predicate | label | placement | |---|---|---|---| | `finished` | `s.finished_at > 0` | Finished | **last** in #118's block, informational not fixable | `SeriesStats` gains the matching `COUNT(*) FILTER (WHERE finished_at > 0)`; #118's zero-renders-the-digit-unlinked rule applies unchanged. **Amends #116** (tenth predicate constant) **and #118** (tenth `<select>` option with its count); `failing` stays #127's ninth. **Four hygiene predicates gain `AND s.finished_at = 0`**: `stale`, `unchecked`, `no-cover`, `no-chapter`. Without the guards a finished Series ages into `stale` for ever and never leaves, breaking #118's "an empty hygiene list is good news". `unpollable`, `orphan` and `sighting-raised` are **left alone** — each is still a genuine repair on a finished row. ### Readers see a label, and only a label `bookmarkColumns` gains a derived `finished bool` (`s.finished_at > 0`) on the flat Bookmark (ADR-0004). It reuses the badge markup and `--moss` token that removing the bucket frees (`card.html:35-37`, `style.css:87`), and renders in both userscripts via `el(..., {text})` — never `{html}`. **No tab, no filter, no reordering, no effect on the ember.** A Reader mid-way through a finished Series keeps it exactly where they left it; the badge only explains why nothing new will arrive. A bool, not the timestamp: the date the owner pressed a button is an operations fact whose only consumer is `/admin`. **Read-only inbound, enforced by omission.** Clients PUT the whole flat object back (`novel-bookmark.user.js:1390-1396`), so a cache written this morning would carry `finished: false` tonight and un-finish the Series. `Upsert`'s `INSERT INTO series` (`store.go:858-867`) names its columns explicitly and `finished_at` is not among them — the same mechanism that already protects `cover` (`store.go:850-852`). ### Glossary Three edits, applied: `Lifecycle bucket` is two states; `Forced Poll`'s "a Series only finished Readers hold" becomes "a Series the owner marked Finished"; new **Finished Series** term. ### Not decided here — the completion-marker hint The owner wants the server to *notice* a Site's own completion marker and surface "potentially finished" in the dashboard, with no false positives and the owner still the only writer. That needs a live check of what each of six Sites publishes, plus containment rules that cannot be designed before we know which signals exist. Split into two child tickets rather than guessed at here. This ticket's boundary is the part that binds them: **a machine hint may never write `finished_at`.** ### Pushed onto other tickets - **#119** — `HAVING` shape void; the finished override moves to `finished_at`; force is in the worklist query and *not* in the pacing count. - **#116** — `SeriesRow.FinishedAt`; tenth predicate constant; `SeriesStats` count; the knowingly-divergent `ReaderCount` now agrees with the Lanes, as that ticket predicted. - **#118** — tenth `<select>` option labelled *Finished*, ordered last; four predicates gain the `finished_at = 0` guard. - **#122** — one new detail-page action pair (confirm-gated Finish, instant un-finish, `--patina`); list rows unchanged but display the state. - **#127** — a finished Series is excluded from `stale`/`unchecked`/`no-chapter`, so `failing`'s threshold only ever sees Series still being attempted. - **#125** — unaffected: finished and orphan are independent (a finished Series still has Readers), and this ticket deletes no bytes.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#124