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 -6
View File
@@ -73,14 +73,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
}
// An empty status is "no opinion" and Upsert keeps the stored bucket.
// Finishing a series is a web-UI decision, so the JSON API refuses it
// rather than trusting every client to leave it alone.
// Anything else outside the two lifecycle buckets is a client bug, not
// something to silently coerce — finished included, which is no longer a
// bucket at all (issue #157).
switch b.Status {
case "", store.StatusReading, store.StatusArchived:
case store.StatusFinished:
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
http.StatusBadRequest)
return
default:
http.Error(w, "invalid status", http.StatusBadRequest)
return
+3 -3
View File
@@ -157,9 +157,9 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
// SeriesPage returns one page of the Series matching the filter, least
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
// orphans that hygiene has to find — an inner join would hide them, exactly
// as the Lane's join does. ReaderCount is a plain count of every Bookmark on
// the Series, which knowingly disagrees with the two Lane queries for as long
// as the finished lifecycle bucket exists (#140).
// as the Lane's join does. Every bookmark keeps its Series polled now that
// finished is a Series flag, so this plain ReaderCount agrees with the Lane
// queries (issue #157).
//
// The tie-break is mandatory, not decorative: every unpollable Series shares a
// zero check stamp, so ordering on that column alone gives no stable page
@@ -0,0 +1,24 @@
-- finished_at is "the owner marked this Series finished" (#157): epoch ms,
-- zero means not finished, and it doubles as the undo (write zero). The poll
-- gate reads it — a Series is polled only while finished_at = 0 — never a
-- bookmark's status.
ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0;
-- Column first, seed second, flip third — the order is load-bearing: a seed
-- that ran after the flip would read the buckets it just destroyed, declare
-- nothing finished, and silently resume polling on Series nobody chose to
-- resume. The seed mirrors the pre-cutover due gate exactly: a Series stays
-- polled while any bookmark is outside the finished bucket, so a Series whose
-- every bookmark sits in it is stamped, one click from being read again
-- afterwards. The stamp is the only memory of the bucket the flip is about to
-- erase.
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');
-- The Lifecycle bucket is gone; a finished bookmark is an archived one. The
-- flip must come after the seed, which still reads the bucket.
UPDATE bookmarks SET status = 'archived' WHERE status = 'finished';
+39 -32
View File
@@ -47,8 +47,8 @@ type Bookmark struct {
LatestChapter string `json:"latest_chapter"`
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
// Status is the lifecycle bucket: reading, archived, or finished.
// Archived series stay polled for new chapters; finished ones do not.
// Status is the lifecycle bucket: reading or archived.
// Archived series stay polled for new chapters.
// Empty on the way in means "no opinion" — see Upsert.
Status string `json:"status"`
// Kind is the library bucket: manga or novel. Empty on the way in means
@@ -214,10 +214,11 @@ const (
)
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
// Finished is not a bucket: it is a fact about the Series (series.finished_at),
// never about a Reader's bookmark.
const (
StatusReading = "reading"
StatusArchived = "archived"
StatusFinished = "finished"
)
//go:embed migrations/*.sql
@@ -619,9 +620,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
// bookmark is keyed (reader_id, site, series_id) (issue #22).
b.Key = b.Site + ":" + b.SeriesID
// An unrecognised bucket (a hand-edited row) would leave the row in no list
// at all, so anything outside the three known buckets reads as the default
// at all, so anything outside the two known buckets reads as the default
// rather than being passed through.
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
if b.Status != StatusReading && b.Status != StatusArchived {
b.Status = StatusReading
}
return b, nil
@@ -1302,10 +1303,12 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
//
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
// clause and the finished-only bucket. It never overrides an empty
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
// the result — so those stay unconditional. Forced rows sort to the front of
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
// clause and a finished Series. It never overrides an empty series_url or the
// Bookmarks join — nothing to fetch, and no consumer for the result — so
// those stay unconditional, and it never clears the finish: nothing here
// writes finished_at, and pending force clears itself when the pass stamps
// the check timestamp. Forced rows sort to the front of the queue; the
// reader-count-then-age ordering among the rest is ADR-0003.
//
// The reader_count ordering is the point of the split (ADR-0003): a series
// shared by several readers is fetched once per due cycle, and the popular
@@ -1313,14 +1316,15 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// reader count, oldest-first keeps the poll fair when the backlog outgrows
// throughput: the most neglected series is always next, so a large collection
// refreshes uniformly slower rather than leaving a tail that never refreshes
// at all. The userscript sorts its own queue the same way (L453).
// at all. The userscript sorts its own queue the same way.
//
// Series with no series_url are skipped — there is nothing to fetch, which is
// the same filter the userscript applies at L452. Series whose only bookmarks
// are finished are skipped too: nothing more is coming, so fetching them only
// burns requests. Archived bookmarks still count — knowing what a shelved
// series is up to is the whole reason for archiving instead of deleting.
// A series with no bookmarks at all never appears: the join excludes it.
// the same filter the userscript applies before refreshing. A finished Series is
// skipped unless forced: nothing more is coming, so fetching it only burns
// requests (issue #157). Archived bookmarks still count — knowing what a
// shelved series is up to is the whole reason for archiving instead of
// deleting. A series with no bookmarks at all never appears: the join
// excludes it.
//
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
// real Poll is older than it appears however recently it was sighted. That is
@@ -1329,10 +1333,10 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// decided here, from two facts the query already computes, so a Lane gains no
// query per round: a Sighting younger than cutoffMs holds the Series back, but
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
// schedule, so a wrong value the whole guild can see is corrected by a check
// that was never postponed; on a solitary Series the only person a wrong value
// reaches is the Reader who reported it. Whether the reporting Reader is
// allowed to defer at all was settled when the Sighting was recorded — see
// the schedule, so a wrong value the guild can see is corrected by a check
// that was never postponed; on a solitary Series the only person a wrong
// value reaches is the Reader who reported it. Whether the reporting Reader
// is allowed to defer at all was settled when the Sighting was recorded — see
// RecordSighting.
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
@@ -1344,12 +1348,11 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
AND s.series_url <> ''
AND (s.latest_checked_at <= $2::bigint
OR s.force_poll_at > s.latest_checked_at)
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
OR s.force_poll_at > s.latest_checked_at)
AND (COUNT(*) > 1
s.force_poll_at, s.finished_at
HAVING (COUNT(*) > 1
OR s.latest_sighted_at <= $2::bigint
OR s.latest_checked_at <= $3::bigint
OR s.force_poll_at > s.latest_checked_at)
@@ -1371,14 +1374,18 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
return out, rows.Err()
}
// EligibleSeriesCount returns how many of a Site's Series still have at least
// one bookmark outside the finished bucket. It is the denominator of the
// Lane's pace (issue #100): the effective gap is the smaller of the registry
// gap and one hour divided by this count, so Series that will never be Polled
// do not make the Lane faster than it needs to be, and counting every eligible
// Series rather than only those currently due keeps the pace steady — the
// single worst moment to be fastest is startup, when everything is due at
// once.
// EligibleSeriesCount returns how many of a Site's Series are not finished —
// the flag, never a Reader vote. It is the denominator of the Lane's pace
// (issue #100): the effective gap is the smaller of the registry gap and one
// hour divided by this count, so Series that will never be Polled do not make
// the Lane faster than it needs to be, and counting every eligible Series
// rather than only those currently due keeps the pace steady — the single
// worst moment to be fastest is startup, when everything is due at once.
//
// The deliberate asymmetry with DueForLatestCheck's WHERE: a forced Series
// is due but never admitted here, because a forced pass must not speed up
// every other fetch on the Site — one impassioned press is not a reason to
// hammer the Site (issue #157).
func (s *Store) EligibleSeriesCount(site string) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
@@ -1386,8 +1393,8 @@ func (s *Store) EligibleSeriesCount(site string) (int, error) {
FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.site = $1
AND s.finished_at = 0
GROUP BY s.site, s.series_id
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
) e`, site).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
+166 -35
View File
@@ -279,6 +279,27 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
}
}
// seedFinished inserts a bookmark (and with it its series) and stamps the
// series finished — the series-level fact the due gate reads (issue #157).
func seedFinished(t *testing.T, s *Store, key, seriesURL string) {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: key, Site: site, SeriesID: seriesID, SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if _, err := s.db.Exec(
`UPDATE series SET finished_at = 1000 WHERE site = $1 AND series_id = $2`,
site, seriesID); err != nil {
t.Fatalf("seed finish %q: %v", key, err)
}
}
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
// that predate the ceiling and are about rest, ordering or buckets instead.
const noCeiling = int64(-1)
@@ -486,24 +507,15 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
}
}
// Archiving is the reason to keep polling — the point is to come back to a
// series that has moved on. A finished series has nothing left to publish.
// series that has moved on. A finished series has nothing left to publish,
// whichever Reader marked it (issue #157).
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
store := newTestStore(t)
for _, tc := range []struct{ key, status string }{
{"asura:reading", StatusReading},
{"asura:archived", StatusArchived},
{"asura:finished", StatusFinished},
} {
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: tc.key, Site: "asura", SeriesID: strings.TrimPrefix(tc.key, "asura:"),
SeriesURL: "https://asurascans.com/comics/" + tc.key,
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
}); err != nil {
t.Fatalf("seed %s: %v", tc.key, err)
}
for _, key := range []string{"asura:reading", "asura:archived"} {
seedForCheck(t, store, key, "https://asurascans.com/comics/"+key, 0)
}
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
@@ -522,27 +534,26 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
}
// The gap's denominator counts every Series the Lane will ever Poll: a
// finished Series must not make the Lane faster than it needs to be, and
// another Site's Series must not leak into this Site's count.
// finished Series must not make the Lane faster than it needs to be, another
// Site's Series must not leak into this Site's count, and a forced Series is
// not admitted either — it is due once, not a reason to tighten the pace for
// every other fetch on the Site (issue #157).
func TestEligibleSeriesCount(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:finished", Site: "asura", SeriesID: "finished",
SeriesURL: "https://asurascans.com/comics/finished",
Status: StatusFinished, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed finished: %v", err)
}
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
if err := store.ForceSeriesPoll("asura", "archived", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
n, err := store.EligibleSeriesCount("asura")
if err != nil {
t.Fatalf("EligibleSeriesCount: %v", err)
}
if n != 2 {
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
t.Fatalf("eligible = %d, want 2 (finished and forced excluded, demonic excluded)", n)
}
n, err = store.EligibleSeriesCount("demonic")
if err != nil {
@@ -755,6 +766,133 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
}
}
// 0016's three statements are order-dependent: the finished_at seed must see
// the pre-flip 'finished' buckets, and the flip must come after it. A seed
// that ran after the flip would read bookmarks that no longer say
// 'finished', declare nothing finished, and silently resume polling series
// somebody marked done. This seeds a pre-0016 database the way every
// deployment looked — finished a bookmark bucket, no finished_at column —
// runs the migration, and asserts on what it preserved.
func TestMigration0016SeedsFinishedAtBeforeFlippingBucket(t *testing.T) {
url := pgtest.URL(t)
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := migrate(db, 15); err != nil {
t.Fatalf("migrate to 0015: %v", err)
}
if err := seedOwner(db, testOwner); err != nil {
t.Fatalf("seed owner: %v", err)
}
// A second reader, so "mixed" can carry two bookmarks on one series.
if _, err := db.Exec(
`INSERT INTO readers (discord_id, token_sha256) VALUES ('second', '\x01'::bytea)`); err != nil {
t.Fatalf("seed second reader: %v", err)
}
owner := func() int64 {
t.Helper()
var id int64
if err := db.QueryRow(
`SELECT id FROM readers WHERE discord_id = $1`, testOwner.DiscordID).Scan(&id); err != nil {
t.Fatalf("owner id: %v", err)
}
return id
}()
seedBookmark := func(readerID int64, site, seriesID, status string) {
t.Helper()
// The bookmark FK demands the series row; Upsert would create it on
// the fly, raw SQL has to spell it out.
if _, err := db.Exec(
`INSERT INTO series (site, series_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
site, seriesID); err != nil {
t.Fatalf("seed series %s:%s: %v", site, seriesID, err)
}
if _, err := db.Exec(
`INSERT INTO bookmarks (reader_id, site, series_id, status, updated_at)
VALUES ($1, $2, $3, $4, 1000)`, readerID, site, seriesID, status); err != nil {
t.Fatalf("seed %s:%s/%s: %v", site, seriesID, status, err)
}
}
// done: the one-bookmark series every finished series looked like.
seedBookmark(owner, "asura", "done", "finished")
// shared: every reader finished — the multi-reader equivalent.
seedBookmark(owner, "asura", "shared", "finished")
// mixed: a second reader still reading keeps the series alive.
seedBookmark(owner, "asura", "mixed", "finished")
seedBookmark(owner+1, "asura", "mixed", "archived")
// live: nobody finished it.
seedBookmark(owner, "asura", "live", "reading")
// orphan: no bookmarks at all, nothing to decide from.
if _, err := db.Exec(
`INSERT INTO series (site, series_id) VALUES ('asura', 'orphan')`); err != nil {
t.Fatalf("seed orphan: %v", err)
}
if err := migrate(db, 0); err != nil {
t.Fatalf("migrate 0016: %v", err)
}
finishedAt := func(site, seriesID string) int64 {
t.Helper()
var at int64
if err := db.QueryRow(
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&at); err != nil {
t.Fatalf("finished_at %s:%s: %v", site, seriesID, err)
}
return at
}
status := func(readerID int64, site, seriesID string) string {
t.Helper()
var s string
if err := db.QueryRow(
`SELECT status FROM bookmarks WHERE reader_id = $1
AND site = $2 AND series_id = $3`, readerID, site, seriesID).Scan(&s); err != nil {
t.Fatalf("status %s:%s: %v", site, seriesID, err)
}
return s
}
// The seed ran before the flip: had the flip gone first, done's bookmarks
// would read archived and nothing would be stamped. The stamp is a real
// timestamp, not a sentinel.
before := time.Now().Add(-time.Minute).UnixMilli()
after := time.Now().Add(time.Minute).UnixMilli()
for _, sr := range []struct{ site, seriesID string }{
{"asura", "done"}, {"asura", "shared"},
} {
if at := finishedAt(sr.site, sr.seriesID); at < before || at > after {
t.Fatalf("%s:%s finished_at = %d, want now-ish", sr.site, sr.seriesID, at)
}
// The flip followed the seed: every finished bookmark is archived.
if s := status(owner, sr.site, sr.seriesID); s != StatusArchived {
t.Fatalf("%s:%s status = %q, want archived after the flip", sr.site, sr.seriesID, s)
}
}
// The seed ignores a series any bookmark keeps alive.
if at := finishedAt("asura", "mixed"); at != 0 {
t.Fatalf("mixed finished_at = %d, want 0", at)
}
// The flip is a bucket rewrite, not a series-wide one: the finished
// bookmark becomes archived and the sibling rows keep their buckets.
if s := status(owner, "asura", "mixed"); s != StatusArchived {
t.Fatalf("mixed finished bookmark = %q, want archived", s)
}
if s := status(owner+1, "asura", "mixed"); s != StatusArchived {
t.Fatalf("mixed archived bookmark = %q, want untouched archived", s)
}
if s := status(owner, "asura", "live"); s != StatusReading {
t.Fatalf("live status = %q, want untouched reading", s)
}
if at := finishedAt("asura", "orphan"); at != 0 {
t.Fatalf("orphan finished_at = %d, want 0", at)
}
}
// readSeries reads the series row directly, for asserting on what Upsert
// actually stored rather than what the joined Bookmark reports.
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
@@ -1809,19 +1947,12 @@ func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
}
}
// The finished-only bucket excludes a series whose only bookmarks are
// finished; a forced request overrides it — the owner asked, so the Lane
// looks.
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
// The finished flag excludes a series; a forced request overrides it — the
// owner asked, so the Lane looks.
func TestDueForLatestCheckForcedOverridesFinishedFlag(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: "asura:finished", Site: "asura", SeriesID: "finished",
SeriesURL: "https://asurascans.com/comics/finished",
Status: StatusFinished, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed finished: %v", err)
}
seedFinished(t, s, "asura:finished", "https://asurascans.com/comics/asura:finished")
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
+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)