Closes #136. Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone. ## What landed - **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted. - **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there. - **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`. - **#161** — glossary and the stale Reader-count divergence note catch up. - **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not. ## Verification `go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge. Reviewed-on: #163 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #163.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# ADR-0015: Finished is a fact about the Series, not a bookmark bucket
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
"Finished" moves from the per-Reader `bookmarks.status` bucket to a
|
||||
Series-owned flag: `series.finished_at`, unix ms, zero while the Series is
|
||||
still running. The Lane's gate reads the flag — a Series is polled only while
|
||||
`finished_at = 0` — and `bookmarks.status` keeps exactly two values,
|
||||
`reading` and `archived`.
|
||||
|
||||
The cutover is one-way, done by migration 0016 in three load-bearing
|
||||
statements:
|
||||
|
||||
1. `ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0`.
|
||||
2. Seed it from the bookmarks: a Series is stamped finished when no bookmark
|
||||
on it is outside the `finished` bucket. This mirrors the pre-cutover due
|
||||
gate exactly — the old query skipped a Series only while
|
||||
`COUNT(*) FILTER (WHERE status <> 'finished') = 0` — so no Series changes
|
||||
polling state at the cutover.
|
||||
3. Rewrite every `finished` bookmark to `archived`. The bucket is gone; the
|
||||
seed ran first because it is the only statement that can still read it.
|
||||
|
||||
The JSON API rejects a `finished` status with the same plain 400 as any
|
||||
unknown value, and the web UI no longer offers a Finished tab, a finish
|
||||
button, or a finished state badge.
|
||||
|
||||
## Why a future reader will find this surprising
|
||||
|
||||
The bucket looked Reader-shaped but described a Series fact. A Series is
|
||||
finished once, and every Reader reading it is then on a finished Series —
|
||||
yet the bucket carried three copies of the answer, one per Reader, free to
|
||||
disagree. The disagreement is not theoretical: a second Reader who merely
|
||||
kept the Series (or never read it) kept it in `reading`, so the poll gate
|
||||
kept fetching a Series the first Reader had closed out, forever. Worse, the
|
||||
disagreement was never resolvable — nothing in the system could say "this
|
||||
Series is finished" without rewriting every bookmark, which silently edits
|
||||
another Reader's progress state.
|
||||
|
||||
The flag is also the only memory of the bucket after the flip. `finished`
|
||||
bookmarks become `archived` because a two-value status needs no third
|
||||
value, and an archived row must keep meaning "shelved, but the Series is
|
||||
being watched" — which is what the row says. The migration's seed is what
|
||||
keeps the legacy meaning: a Series every Reader finished is stamped, so the
|
||||
Lane stops polling it just as it would have pre-cutover; a Series any
|
||||
Reader still reads is left alone, exactly as the old gate left it. A
|
||||
Series whose every Reader only shelved (archived) continues to be polled,
|
||||
because an archived bookmark is *supposed* to be polled — the cutover
|
||||
changes the answer, it does not invent it. And because the flag is a series
|
||||
fact, the cutover also repairs the disagreement case: the moment one Reader
|
||||
has the Series open, it reads as finished to everyone.
|
||||
|
||||
The migration is the only writer of the flag today; the undo is writing 0,
|
||||
which returns the Series to the poll. An owner-facing "mark finished" write
|
||||
is deliberately not part of this change — the gate is what this ticket
|
||||
rewrites, and the write can land on top of it without touching anything
|
||||
here.
|
||||
|
||||
The userscript merge ranks `archived > reading` now. `finished` is not a
|
||||
value the wire can carry, so the merge cannot un-finish a row — it cannot
|
||||
even name the state it is protecting.
|
||||
|
||||
## Considered options
|
||||
|
||||
**Keep the bucket and add the flag alongside it, both live.**
|
||||
Rejected: two sources of truth for one fact, with the Lane forced to
|
||||
resolve "any Reader finished?" on every due query and every Reader write
|
||||
still able to resurrect a finished Series. The whole point of the change is
|
||||
that the finished state survives Readers.
|
||||
|
||||
**Stamp a Series finished when every bookmark is finished *or* archived.**
|
||||
Rejected: it flips polling state at the cutover. Shelved-only Series were
|
||||
polled before; making them finished stops the checks the reader knowingly
|
||||
asked to keep.
|
||||
|
||||
**Finish as "no bookmark is reading", leaving the buckets untouched.**
|
||||
Rejected for the same reason plus one: `archived` is a Reader's own state
|
||||
and the flip is what makes the flag the *only* source of finished. Keeping
|
||||
the `finished` value in the table would force every status validation,
|
||||
merge and UI branch to keep handling a value no write can produce.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `bookmarks.status` is validated to `reading | archived`, empty meaning
|
||||
"keep the stored value"; the API's 400 for `finished` is now the generic
|
||||
invalid-status rejection rather than a special case, and the web UI's own
|
||||
status control rejects it the same way.
|
||||
- The Lane due query and the eligible count read `finished_at`; a Forced
|
||||
Poll (issue #146) still overrides the flag — the owner asked, so the Lane
|
||||
looks — and the pending force clears itself when the pass stamps the
|
||||
check timestamp, never by touching `finished_at`.
|
||||
- The web UI has no Finished tab; finished Series render in Archived with
|
||||
their archived badge, dimmed like any shelved row.
|
||||
- Migration 0016 stamps `finished_at` with the migration's own clock
|
||||
(`now()` ms), which is also the undo: write 0 and the Series returns to
|
||||
the poll.
|
||||
|
||||
## Cost of reversing
|
||||
|
||||
The finished buckets are destroyed by the flip; reversing means re-deriving
|
||||
per-Reader finished state from a Series fact that now encodes the
|
||||
majority-agreement snapshot plus whatever the owner reset since. The stamp
|
||||
differentiates "finished at the cutover and untouched" from "finished
|
||||
later", but not which Reader's choice each Series carried, and any Series
|
||||
the owner has since restored is gone from the derivation entirely. The ADR
|
||||
is a statement of intent to ship the one-way cutover and live with its
|
||||
consequences; the per-Reader history is not kept anywhere in the schema.
|
||||
+14
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
Source of truth: the Claude Design project **BookmarkManager Web UI**
|
||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
|
||||
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
|
||||
`archived.html`/`fav.html`/`new.html`/`login.html`/`mobile.html`,
|
||||
`style.css`, `filter.js`). This file records the rules that got implemented so
|
||||
a future agent can extend the UI without re-reading the design.
|
||||
|
||||
@@ -48,7 +48,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
|
||||
| --- | --- | --- | --- |
|
||||
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived row background |
|
||||
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
||||
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
||||
| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline |
|
||||
@@ -70,7 +70,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
|
||||
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
|
||||
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
|
||||
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
|
||||
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
|
||||
| `--moss` | `#7fae86` | `#3d6c46` | finished Series label |
|
||||
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
|
||||
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
|
||||
| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
|
||||
@@ -164,9 +164,9 @@ rather than scaling the artwork down.
|
||||
|
||||
**Action key** (`.keyrow`): one permanent line under the tabs naming what
|
||||
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
|
||||
Delete — so the icon strip on a card is never a guess. The key follows the tab,
|
||||
not the row: Archive becomes Restore under Archived and Finished, and Finished
|
||||
drops Done. On a phone each pair stacks icon-over-word
|
||||
Delete — so the icon strip on a card is never a guess. The key follows the
|
||||
tab, not the row: under Archived, Archive becomes Restore. On a phone each
|
||||
pair stacks icon-over-word
|
||||
(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays
|
||||
out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash`
|
||||
carry their icon's resting accent so the key itself teaches the colour
|
||||
@@ -179,7 +179,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||
.row
|
||||
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
|
||||
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
||||
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
|
||||
.actions play, favourite, chapter | lifecycle: archive/restore, remove
|
||||
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
|
||||
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
|
||||
p.error-inline[hidden]
|
||||
@@ -188,7 +188,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||
Rules that are easy to break:
|
||||
|
||||
- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for
|
||||
`archived` and `finished`. Both are set on the `<article>` — every heat and
|
||||
`archived`. Both are set on the `<article>` — every heat and
|
||||
dim rule is a descendant selector off those two classes, so a new sub-element
|
||||
inherits the state for free.
|
||||
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
|
||||
@@ -196,19 +196,19 @@ Rules that are easy to break:
|
||||
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
||||
`border-right: 1px var(--rule)`, last child none.
|
||||
- Three clusters by consequence, in this order: navigate (`.play`) | organize
|
||||
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
|
||||
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.remove`,
|
||||
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
|
||||
`--ash` ground so the thumb reads "this one moves the series" before it
|
||||
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
|
||||
the phone's inset hairline.
|
||||
- Every lifecycle button that moves a series out of the list is
|
||||
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
|
||||
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
|
||||
ask in `.calm` grey since they're reversible; remove alone gets the
|
||||
**confirm-gated**: it opens its own `.confirm-row` (`archive`,
|
||||
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive asks in
|
||||
`.calm` grey since it's reversible; remove alone gets the
|
||||
`--danger-wash` treatment and names the series in its question. Restore
|
||||
fires instantly — no confirm — because it's the reversal.
|
||||
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
|
||||
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
|
||||
`.box` → `--slate`. `.play` stays paper/ember (ember
|
||||
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
|
||||
hover. Desktop cell borders follow the same accent on hover
|
||||
(`border-color: currentColor`); the two coloured *resting* states
|
||||
@@ -286,7 +286,7 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
never reuse `--ember` or `--danger` for anything but their one meaning.
|
||||
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
||||
third state class.
|
||||
4. If it removes a series from the current view (archive/finish/remove-shaped),
|
||||
4. If it removes a series from the current view (archive/remove-shaped),
|
||||
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
|
||||
the only instant action because it's the one that's reversible by nature.
|
||||
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
|
||||
|
||||
Reference in New Issue
Block a user