Spec #136: finished becomes a Series fact, Lane gate rewritten on it (#157)

finished_at lands on series (0016 seeds it from the pre-flip finished
bookmarks, then flips those bookmarks to archived), the poll gate and
eligible count read the flag instead of a per-Reader vote, PUT rejects
the finished status like any unknown value, and the web UI drops the
Finished tab, badge and finish button. Novel merge rank is archived >
reading. Per-Reader disagreement (one Reader keeps a finished Series in
reading forever) is what the flag repairs; the cutover keeps polling
state unchanged for every Series.
This commit is contained in:
2026-08-22 13:02:09 +07:00
parent faa80c41ea
commit 77e3f710ec
21 changed files with 423 additions and 189 deletions
+3 -3
View File
@@ -61,7 +61,7 @@ function setActiveTab(el) {
document.dispatchEvent(new Event("bmgr:refilter"));
}
// The chapter-edit form and the archive/finish/remove confirm rows are the
// The chapter-edit form and the archive/remove confirm rows are the
// per-card disclosure panels; only one makes sense open at a time. The button
// that owns an open panel carries .open, which is how the strip shows which
// cell the panel belongs to.
@@ -97,10 +97,10 @@ function toggleChapterForm(key) {
if (form && !form.hidden) form.querySelector("input").focus();
}
// kind is "archive" | "finish" | "remove" — the panel id and the owning action
// kind is "archive" | "remove" — the panel id and the owning action
// cell share it.
function toggleConfirmRow(key, kind) {
var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind];
var cls = { archive: ".box", remove: ".remove" }[kind];
var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
// Focus the answer rather than trusting aria-live on a container that merely
// unhides: it makes the announcement deterministic, keeps tab order inside
+8 -12
View File
@@ -54,7 +54,7 @@
--ink: #100f0e; /* page */
--ash: #161413; /* recessed panel (chapter form) */
--dim: #0d0c0b; /* archived / finished rows sink */
--dim: #0d0c0b; /* archived rows sink */
--rule: #221f1d; /* hairline between sheets */
--rule-soft: #1a1817; /* measure edges */
--field-line: #2c2926;
@@ -84,7 +84,6 @@
/* One accent per action, so a press says which lane it belongs to. All three
are held at the same weight as --brass: muted, no ember competition. */
--slate: #7fa0c0; /* archive */
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
@@ -148,7 +147,6 @@
--danger-soft: #7c2c22;
--brass: #8a681c;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--patina: #1f6f66;
@@ -612,13 +610,13 @@ button { cursor: pointer; }
.actions > *:last-child { border-right: none; }
.actions svg { width: 17px; height: 17px; }
.actions > *:hover { color: var(--paper); }
/* Per-action accent on hover and press: gold favourite, slate archive, moss
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */
/* Per-action accent on hover and press: gold favourite, slate archive, clay
chapter. Remove keeps --danger, play keeps paper/ember. */
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
.actions .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
.actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); }
.actions .on { color: var(--brass); }
@@ -632,8 +630,8 @@ button { cursor: pointer; }
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
.is-dim .actions > * { color: var(--mute-2); }
/* Three clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells
/* Two clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
sit on a recessed ground so the thumb reads "this one moves the series"
before it reads which icon it landed on. */
.actions > .lifecycle { background: var(--ash); }
@@ -712,7 +710,7 @@ button { cursor: pointer; }
color: var(--danger-ink);
font-weight: 600;
}
/* Archive and finish are reversible, so their confirm asks in grey — only the
/* Archive is reversible, so its confirm asks in grey — only the
irreversible remove gets the danger wash. */
.confirm-row.calm { background: var(--ash); }
.confirm-row.calm span { color: var(--paper-dim); }
@@ -917,9 +915,7 @@ button { cursor: pointer; }
/* The cell border follows the icon on hover, so the accent reads as a state
rather than a stray colour. */
.actions .fav:hover, .actions .pencil:hover,
.actions .box:hover, .actions .finish:hover { border-color: currentColor; }
/* Panels line up with the body text, i.e. past the cover and its gap. */
.actions .box:hover { border-color: currentColor; }
.chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap));
}
-4
View File
@@ -71,10 +71,6 @@
{{if eq .Tab "archived"}}aria-current="page"{{end}}
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
{{if eq .Tab "finished"}}aria-current="page"{{end}}
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
</nav>
</div>
+2 -24
View File
@@ -1,6 +1,6 @@
{{define "card"}}
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
is-dim sinks archived and finished rows into italic grey. */}}
is-dim sinks archived rows into italic grey. */}}
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
id="card-{{.Key}}" data-title="{{.Title}}">
<div class="row">
@@ -32,9 +32,6 @@
{{if eq .Status "archived"}}
<span class="sep">/</span>
<span class="state">archived</span>
{{else if eq .Status "finished"}}
<span class="sep">/</span>
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span>
{{end}}
</p>
</div>
@@ -56,7 +53,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
</button>
{{/* Restore is a reversal, so it fires straight away; every move *out* of
the list (archive, finish, remove) goes through a confirm row. */}}
the list (archive, remove) goes through a confirm row. */}}
{{if eq .Status "reading"}}
<button class="lifecycle box" title="Archive" aria-label="Archive"
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
@@ -71,13 +68,6 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{end}}
{{if ne .Status "finished"}}
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
</button>
{{end}}
<button class="lifecycle remove" title="Remove" aria-label="Remove"
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
@@ -116,18 +106,6 @@
</div>
</div>
{{end}}
{{if ne .Status "finished"}}
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Mark finished?</span>
<div>
<button class="go"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
</div>
</div>
{{end}}
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Remove “{{.Title}}”? Chapter progress is lost.</span>
<div>
+2 -6
View File
@@ -29,21 +29,17 @@
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
line under the tabs names every glyph. It follows the tab rather than the
row: the archived and finished buckets swap Archive for Restore, and a
finished series has no Done to offer. */}}
row: the archived bucket swaps Archive for Restore. */}}
{{define "keyrow"}}
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
{{if eq .Tab "archived"}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
{{else}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
{{end}}
{{if ne .Tab "finished"}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
{{end}}
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
</div>
{{end}}
@@ -7,7 +7,6 @@
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4 12.5l5.2 5.5L20 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
-2
View File
@@ -14,8 +14,6 @@
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
{{else if eq .Tab "archived"}}
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
{{else if eq .Tab "finished"}}
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
{{else if .EmptyLibrary}}
{{/* Nothing in either library, so the links are the only thing this page can
usefully say. Both scripts: the two libraries are separate installs. */}}
+10 -11
View File
@@ -293,10 +293,11 @@ func libOf(q string) string {
// buildListView loads one reader's list once and derives both the tab-filtered
// items and the recent strip from it.
//
// Archived and finished series appear in their own tab and nowhere else — not
// in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading".
// Archived series appear in their own tab and nowhere else — not in All, not
// in Updated, not in Favourites, and not in the recent strip. An archived
// favourite therefore shows only under Archived: Favourites means "favourites
// I am currently reading". There is no Finished tab: finished is a fact about
// the Series, not a bookmark bucket (issue #157).
func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
all, err := h.store.List(readerID) // already ordered updated_at DESC
if err != nil {
@@ -327,8 +328,6 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
items = withNew
case "archived":
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
case "finished":
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
default:
tab = "all"
items = reading
@@ -392,7 +391,7 @@ func currentLib(r *http.Request) string {
// writeChromeOOB appends the regions that live outside #list — the recent
// strip, the Updated badge and the action key — as out-of-band swaps, so a
// mutation cannot leave them describing the library as it was before the tap.
// The key is in here because it is tab-shaped too: archived and finished swap
// The key is in here because it is tab-shaped too: the archived bucket swaps
// Archive for Restore.
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true
@@ -492,9 +491,9 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
h.saveAndRenderCard(w, r, b)
}
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
// a series can be marked finished — the JSON API refuses that value, so the
// userscript cannot set it even by accident.
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
// one of them: it is a fact about the Series, decided from the admin surface,
// so the web UI's per-bookmark control cannot set it (issue #157).
//
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
// list does not reorder.
@@ -508,7 +507,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
return
}
switch s := r.PostFormValue("status"); s {
case store.StatusReading, store.StatusArchived, store.StatusFinished:
case store.StatusReading, store.StatusArchived:
b.Status = s
default:
http.Error(w, "invalid status", http.StatusBadRequest)