diff --git a/CONTEXT.md b/CONTEXT.md index 57b887d..e602e8c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,7 +95,7 @@ _Avoid_: run, cycle, tick, batch, poll history **Forced Poll**: A Poll the owner asks for by hand instead of waiting for the Series's turn. It jumps its Lane's queue and ignores every waiting rule — the rest between Polls, a Sighting standing -in for a check, a Series only finished Readers hold — but never overrules a Site that is +in for a check, a finished Series — but never overrules a Site that is refusing us, the Lane's spacing between fetches, or a Series with no page to fetch. Asked for by marking the Series, never by commanding the poller, so it happens on the Lane's next pass rather than at the moment of asking. @@ -149,10 +149,22 @@ accent is permitted to signal. _Avoid_: unread, update available **Lifecycle bucket**: -Which of three mutually exclusive states a Bookmark sits in — reading, archived, or -finished. A Bookmark is in exactly one. Orthogonal to being a favourite. +Which of the two states a Bookmark sits in — reading or archived. A Bookmark is in exactly +one. Orthogonal to being a favourite. Finished is not a bucket: it is a fact about the +Series (see `series.finished_at`), owned by the owner and stamped once, and every Bookmark +on a finished Series is archived. _Avoid_: state, status (as a domain word), list +**Finished Series**: +A Series the owner has marked finished, stamped once in `series.finished_at` +(epoch ms, zero means not finished). The owner is its only writer — no +adapter, no Reader, no Poll can set it — and a Forced Poll reads a finished +Series once for that pass and never clears the flag. It is a fact about the +Series, not a Bookmark bucket: every Bookmark on a finished Series is +archived, the Lane stops polling it (the due gate reads `finished_at = 0`), +and Readers see a label and nothing more. +_Avoid_: completed, done, dropped, shelved (that is Archived), ended + **Favourite**: A reader's manual pin on a Bookmark. Orthogonal to the Lifecycle bucket, and never a reason to reorder the list. diff --git a/PRODUCT.md b/PRODUCT.md index 1c9e02b..a29f69c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -29,8 +29,8 @@ Not a public reading tracker or social app — a private, self-hosted sync layer ## Capabilities and Constraints -- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived / Finished. Search-filter by title (client-side, `filter.js`). -- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, finish, remove — each move out of the list confirm-gated. +- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived. Search-filter by title (client-side, `filter.js`). +- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, remove — each move out of the list confirm-gated. - "Continue reading" horizontal strip for series with an unread chapter. - A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page. - Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side. diff --git a/README.md b/README.md index 11e096b..a1b3624 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,10 @@ desktop for faster testing — install the same file unchanged. keeps checking it for new chapters, so it is worth coming back to. Archiving does not touch read progress, and reading an archived series leaves it archived. -- **Finished**: series you have completed live in a **Finished** tab in the web - UI only. It is set there and nowhere else — the API rejects the value — and - finished series are hidden from every userscript tab and are no longer polled - for new chapters. +- **Finished**: the owner marks a Series finished from its detail page; the + backend stops polling it, and every Reader sees a read-only label. It is a + fact about the Series, not a Reader's bucket: old `Finished` bookmarks were + folded into Archived in the cutover, so there is no Finished tab. - Bookmarks made on Asura appear when the panel is opened on Demonic, and vice versa — the backend is the shared store. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 49d3443..7501da1 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -200,12 +200,16 @@ stored** and clients must adopt that response rather than their own payload. ### Lifecycle buckets — `status` on each bookmark -`reading` | `archived` | `finished`, orthogonal to `favorite`. Archived and -finished appear only in their own tab, never in All, Updated, Favourites or the -recent strip. The poller keeps checking archived series and skips finished ones. +`reading` | `archived`, orthogonal to `favorite`. Archived rows appear only +in their own tab, never in All, Updated, Favourites or the recent strip. The +poller keeps checking archived series; a finished Series (issue #157) is a +`series.finished_at` fact the Lane gate reads, with every bookmark on it +archived. -- `finished` is settable only from the web UI; `PUT /bookmarks/{key}` rejects - it with 400. +- `PUT /bookmarks/{key}` accepts only the two values; anything else — + `finished` included — is a plain 400, and the web UI's own status control + validates the same way. The 0016 migration is the only writer of the flag + today; the undo is writing 0. - **An empty incoming status means "keep the stored one"**, and it is resolved on the `VALUES` side of `Store.Upsert`, not in the conflict clause: `excluded.*` is the post-evaluation row, so a default applied there would diff --git a/backend/api_test.go b/backend/api_test.go index 1037808..70e5923 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "database/sql" "encoding/json" "fmt" "net/http" @@ -298,7 +299,7 @@ func TestFlatWireFieldSet(t *testing.T) { "series_url": true, "cover": true, "last_chapter": true, "last_chapter_num": true, "last_chapter_url": true, "favorite": true, "latest_chapter": true, "latest_chapter_num": true, "updated_at": true, - "status": true, "kind": true, + "status": true, "kind": true, "finished": true, } checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage { t.Helper() @@ -362,6 +363,52 @@ func TestFlatWireFieldSet(t *testing.T) { checkFlat(t, body2) } +// finished is derived on the wire and read-only: a client PUT echoing a cached +// value, forward progress or not, must not change the Series' retired state, +// so GET still reports the truth after the echo (issues #157, #160). +func TestFinishedWireRoundTrip(t *testing.T) { + s, url := newTestStoreURL(t) + srv := newRouter(s, testConfig()) + key := "asura:done" + + putBookmark(t, srv, key, store.Bookmark{ + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done", + LastChapterNum: 10, + }) + + // The store writer for the flag is the admin surface's own and lands in + // the same wave (#158), so seed the fact with SQL, like store_test.go. + db, err := sql.Open("pgx", url) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if _, err := db.Exec( + `UPDATE series SET finished_at = 1000 WHERE site = 'asura' AND series_id = 'done'`); err != nil { + t.Fatalf("seed finished: %v", err) + } + + got := getBookmarks(t, srv) + if len(got) != 1 || !got[0].Finished { + t.Fatalf("GET = %+v, want one bookmark carrying finished: true", got) + } + + // A stale cache echoing the flag cannot un-finish (or finish) the Series. + for _, sent := range []bool{false, true} { + echoed := putBookmark(t, srv, key, store.Bookmark{ + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done", + LastChapterNum: 11, Finished: sent, + }) + if !echoed.Finished { + t.Fatalf("PUT echoing Finished: %v reported finished = false, want true", sent) + } + got = getBookmarks(t, srv) + if !got[0].Finished { + t.Fatalf("GET after echoing Finished: %v = false, want the Series state preserved", sent) + } + } +} + // A PUT naming an existing series must ignore client-supplied title, cover and // URL — the security boundary from ADR-0003, where a hostile site's scraped // values could otherwise land on a shared row — while progress still lands. diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 94f42bd..bc21ac5 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -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 diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index 376c3e1..0be59de 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -7,11 +7,13 @@ import ( "strings" ) -// Series filter names (issue #140), ordered permanent-then-fixable — the -// repairs nothing will ever undo first, the ones a Poll can make right after. -// A name is the repair a row needs, not the SQL that finds it; the values are -// the wire form the Series list URL carries (#142). "all" is the absent and -// unknown case: every Series. +// Series filter names (issue #140): the seven repair filters are ordered +// permanent-then-fixable — the repairs nothing will ever undo first, the +// ones a Poll can make right after. SeriesFilterFinished is not part of +// that ordering: a finished Series is a deliberate state, not a repair, so +// it sits last, informational. A name is the repair a row needs, not the +// SQL that finds it; the values are the wire form the Series list URL +// carries (#142). "all" is the absent and unknown case: every Series. const ( SeriesFilterAll = "all" SeriesFilterNoURL = "no_series_url" @@ -21,9 +23,10 @@ const ( SeriesFilterStale = "stale" SeriesFilterNoCover = "no_cover" SeriesFilterReaderReport = "reader_report" + SeriesFilterFinished = "finished" ) -// SeriesFilter is one named hygiene predicate over the whole library. Site +// SeriesFilter is one named filter predicate over the whole library. Site // and Kind narrow the row read; Name picks the predicate; Cutoff is the // staleness boundary the "stale" filter compares against, supplied by the // caller's clock — the store has no clock; Page is 1-based. @@ -42,7 +45,7 @@ type SeriesFilter struct { // and only the anonymous boolean in raisedByReaderAnswer crosses it. const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at, - s.latest_corrected_at` + s.latest_corrected_at, s.finished_at` // raisedByReaderAnswer answers "did a Reader's report set this number" without // naming which Reader. Kept apart from adminSeriesColumns so the column list — @@ -80,7 +83,9 @@ type AdminSeries struct { LatestCorrectedAt int64 ReaderCount int RaisedByReader bool // a Reader's report set LatestChapterNum - + // FinishedAt is the owner's finish stamp: unix ms, zero while the Series is + // not finished — the same shape as the Correction stamp, and its own undo. + FinishedAt int64 } // SeriesPage is one page of the owner's filtered Series list plus the count @@ -113,10 +118,19 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID } // The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch- // gate case is invisible to SQL, needs the Site registry in Go, and belongs to // a later repair), never-read-a-chapter and never-checked as disjoint halves -// (non-zero versus zero check stamp), stale, no cover, and Reader-report. +// (non-zero versus zero check stamp), stale, no cover, finished (the +// retirement stamp, read directly), and Reader-report. // no_readers is the one HAVING predicate: it is the orphan test, an aggregate // over the LEFT JOIN, where a bare WHERE has no row to test. // +// The clock-versus-outcome split decides which predicates exclude finished +// Series (`s.finished_at = 0` in each of the four): the clock-driven one — +// never-checked, stale, no-chapter, no-cover — keep ticking after the last +// Poll, so they would report a retired row as a problem no Poll is coming to +// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report — +// read stored facts that simply stop arriving, so a finished Series needing a +// genuine repair still shows up under them. +// // stale is the checked-but-old half of the stamp partition — because the // verdict line wants "not checked in twelve hours" as one figure, and a never // checked Series is already counted on its own "waiting"/never-checked @@ -133,16 +147,18 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) { case SeriesFilterNoURL: clauses = append(clauses, `s.series_url = ''`) case SeriesFilterNoChapter: - clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`) + clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`) case SeriesFilterNeverChecked: - clauses = append(clauses, `s.latest_checked_at = 0`) + clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`) case SeriesFilterStale: - clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)) + clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`) args = append(args, f.Cutoff) case SeriesFilterNoCover: - clauses = append(clauses, `s.cover_address = ''`) + clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`) case SeriesFilterReaderReport: clauses = append(clauses, `s.latest_raised_by IS NOT NULL`) + case SeriesFilterFinished: + clauses = append(clauses, `s.finished_at > 0`) case SeriesFilterNoReaders: having = `HAVING COUNT(b.reader_id) = 0` default: @@ -157,9 +173,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 @@ -196,7 +212,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { `+where+` GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, - s.force_poll_at, s.latest_corrected_at, s.latest_raised_by + s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by `+having+` ORDER BY s.latest_checked_at, s.site, s.series_id LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) @@ -271,7 +287,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) { if err := scan( &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, - &a.ForcePollAt, &a.LatestCorrectedAt, + &a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.RaisedByReader, &a.ReaderCount, &total, ); err != nil { return AdminSeries{}, 0, err diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go index f5f7a73..5f4077f 100644 --- a/backend/internal/store/admin_test.go +++ b/backend/internal/store/admin_test.go @@ -128,6 +128,85 @@ func TestAdminSeriesFilters(t *testing.T) { } } +// A finished Series is the owner's deliberate state, not a problem a Poll +// will fix: the four clock-driven hygiene predicates exclude it (their +// stamps stop advancing at the last Poll, so without the guard a retired row +// is reported forever), the three outcome-driven ones still include it, and +// the finished filter returns exactly the retired rows. +func TestAdminFinishedSeriesFilters(t *testing.T) { + s := newTestStore(t) + // Each fin-* row is shaped to trip exactly one predicate if its guard + // fails: checked-but-old for stale, a zero stamp for never-checked, a + // stamp with no chapter for no-chapter, an empty cover for no-cover, and + // the unguarded three shaped to trip their own. A healthy, unfinished + // neighbour keeps the exclusion checks honest: a filter that regressed to + // matching nothing would pass a bare "no finished rows" assertion. + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-stale", url: "u", cover: "c", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-neverchecked", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nocover", url: "u", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nourl", url: "", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-orphan", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 0}) + seedAdminSeries(t, s, seriesSeed{key: "asura:fin-report", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, raisedBy: true}) + seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1}) + finished := []string{ + "asura:fin-stale", "asura:fin-neverchecked", "asura:fin-nochapter", + "asura:fin-nocover", "asura:fin-nourl", "asura:fin-orphan", "asura:fin-report", + } + for _, key := range finished { + site, id, _ := strings.Cut(key, ":") + if err := s.SetSeriesFinished(site, id, 1000); err != nil { + t.Fatalf("finish %s: %v", key, err) + } + } + + cases := []struct { + name string + f SeriesFilter + want []string + }{ + {"stale excludes finished", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, nil}, + {"never checked excludes finished", SeriesFilter{Name: SeriesFilterNeverChecked}, nil}, + {"no chapter excludes finished", SeriesFilter{Name: SeriesFilterNoChapter}, nil}, + {"no cover excludes finished", SeriesFilter{Name: SeriesFilterNoCover}, nil}, + {"no url includes finished", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:fin-nourl"}}, + {"no readers includes finished", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:fin-orphan"}}, + {"reader report includes finished", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:fin-report"}}, + {"finished returns the retired rows", SeriesFilter{Name: SeriesFilterFinished}, finished}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pageKeys(t, s, tc.f) + want := map[string]bool{} + for _, k := range tc.want { + want[k] = true + } + if len(got) != len(want) { + t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want) + } + for k := range want { + if !got[k] { + t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got) + } + } + }) + } + + // The aggregate's finished total counts every retired row — the same + // predicate the Overview's finished figure is summed from. + shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterFinished}) + if err != nil { + t.Fatalf("SeriesShapes(finished): %v", err) + } + sum := 0 + for _, sh := range shapes { + sum += sh.Total + } + if sum != len(finished) { + t.Fatalf("finished aggregate = %d, want %d", sum, len(finished)) + } +} + // "Never read a chapter" and "never checked" are disjoint by construction: // the first requires a non-zero check stamp, the second a zero one. Over a // mix that should satisfy both, no row may be counted twice. @@ -394,3 +473,57 @@ func TestAdminSeriesCarriesForcePollAt(t *testing.T) { t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows) } } + +// SetSeriesFinished is the owner's finish stamp write: finishing writes the +// given ms, un-finishing writes zero — the one undo, the same shape as the +// correction stamp. Touching a missing series is not an error: the row may +// have been orphaned, and the caller's read decides what exists. +func TestSetSeriesFinishedStampsAndClears(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1}) + + if err := s.SetSeriesFinished("asura", "x", 42); err != nil { + t.Fatalf("SetSeriesFinished: %v", err) + } + var got int64 + if err := s.db.QueryRow( + `SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { + t.Fatalf("read finished_at: %v", err) + } + if got != 42 { + t.Fatalf("finished_at = %d, want 42", got) + } + + if err := s.SetSeriesFinished("asura", "x", 0); err != nil { + t.Fatalf("SetSeriesFinished un-finish: %v", err) + } + if err := s.db.QueryRow( + `SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { + t.Fatalf("read finished_at after un-finish: %v", err) + } + if got != 0 { + t.Fatalf("finished_at = %d, want 0 (un-finish writes zero)", got) + } + + if err := s.SetSeriesFinished("asura", "ghost", 42); err != nil { + t.Fatalf("SetSeriesFinished missing: %v", err) + } +} + +// The admin projection carries the finish stamp so the web layer can render +// the finished state without a second read. +func TestAdminSeriesCarriesFinishedAt(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1}) + if err := s.SetSeriesFinished("asura", "x", 5000); err != nil { + t.Fatalf("SetSeriesFinished: %v", err) + } + + page, err := s.SeriesPage(SeriesFilter{}) + if err != nil { + t.Fatalf("SeriesPage: %v", err) + } + if len(page.Rows) != 1 || page.Rows[0].FinishedAt != 5000 { + t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows) + } +} diff --git a/backend/internal/store/migrations/0016_finished_series.sql b/backend/internal/store/migrations/0016_finished_series.sql new file mode 100644 index 0000000..a565412 --- /dev/null +++ b/backend/internal/store/migrations/0016_finished_series.sql @@ -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'; \ No newline at end of file diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index ba57a0c..2552fa9 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -47,13 +47,16 @@ 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 // "no opinion" — see Upsert. Kind string `json:"kind"` + // Finished is the owner's retirement of the Series, derived: the flag is a + // Series fact and a client cannot write it — see Upsert. + Finished bool `json:"finished"` } // Series is one distinct work, shared by every bookmark that tracks it. It is @@ -214,10 +217,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 @@ -229,7 +233,8 @@ var migrations embed.FS // order, so the flat Bookmark reads back whole despite the split (ADR-0004). const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address, b.last_chapter, b.last_chapter_num, b.last_chapter_url, - b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind` + b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind, + s.finished_at > 0` // seriesColumns is the series row in scanSeries order, used by the poller's // due query. latest_checked_at lives only on series — see MarkLatestChecked @@ -608,6 +613,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) { &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress, &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind, + &b.Finished, ); err != nil { return Bookmark{}, err } @@ -619,9 +625,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 +1308,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 +1321,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 +1338,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 +1353,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 +1379,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 +1398,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) @@ -1433,6 +1445,21 @@ func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error { return nil } +// SetSeriesFinished stamps or clears the owner's finish. at is unix ms to +// finish, zero to un-finish. A finished Series drops out of the Lane's reads +// (issue #157), and nothing else writes this column: it is the only writer +// outside migration 0016, so a machine write can never retire a Series +// silently. Touching a missing series is not an error: the row may have been +// orphaned, and the caller's read decides what exists. +func (s *Store) SetSeriesFinished(site, seriesID string, at int64) error { + if _, err := s.db.Exec( + `UPDATE series SET finished_at = $1 WHERE site = $2 AND series_id = $3`, + at, site, seriesID); err != nil { + return fmt.Errorf("set series finished %s:%s: %w", site, seriesID, err) + } + return nil +} + // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for // tests outside this package (the poller's own tests assert on rest // bookkeeping) — see MarkLatestChecked for why the field stays off the diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 4cb05c5..8480fec 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -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 { @@ -796,6 +934,37 @@ func TestUpsertCreatesSeriesFromClient(t *testing.T) { } } +// The wire's finished flag is derived, never stored: the Upsert's explicit +// column list does not name finished_at — the same omission that protects +// cover — so a client echoing a cached flag, true or false, cannot change the +// Series' retired state (issues #157, #160). +func TestUpsertCannotWriteSeriesFinished(t *testing.T) { + store := newTestStore(t) + seedFinished(t, store, "asura:done", "https://asurascans.com/comics/asura:done") + + for _, sent := range []bool{true, false} { + stored, err := store.Upsert(store.OwnerID(), Bookmark{ + Key: "asura:done", Site: "asura", SeriesID: "done", + Finished: sent, UpdatedAt: 2000, + }) + if err != nil { + t.Fatalf("Upsert(Finished: %v): %v", sent, err) + } + if !stored.Finished { + t.Fatalf("stored.Finished = false after echoing %v, want true", sent) + } + } + var finishedAt int64 + if err := store.db.QueryRow( + `SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`, + "asura", "done").Scan(&finishedAt); err != nil { + t.Fatalf("read finished_at: %v", err) + } + if finishedAt != 1000 { + t.Fatalf("series.finished_at = %v, want the seeded 1000 untouched", finishedAt) + } +} + // The hook is what starts creation-time acquisition, so it must fire exactly // once per Series — on the PUT that created it, and on no later one, whichever // Reader sends it. @@ -1809,19 +1978,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 { diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index c3f6aff..4ef0dff 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -47,6 +47,8 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/series", h.adminSeries}, {"GET /admin/series/{key}", h.adminSeriesDetail}, {"POST /admin/series/{key}/poll", h.adminSeriesPoll}, + {"POST /admin/series/{key}/finish", h.adminSeriesFinish}, + {"POST /admin/series/{key}/unfinish", h.adminSeriesUnfinish}, {"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest}, {"POST /admin/series/{key}/series-url", h.adminSeriesSetURL}, {"POST /admin/series/{key}/remove", h.adminSeriesRemove}, diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go index 1851a17..9857864 100644 --- a/backend/internal/web/admin_overview.go +++ b/backend/internal/web/admin_overview.go @@ -24,8 +24,9 @@ type overviewView struct { // its own filter, and the verdict wants the inclusive number. Unchecked int // Hygiene is the seven problem filters in the Series list's own render - // order; Library is the library split plus the roster. Every figure is a - // door into the list that counts it, except a zero. + // order plus the finished figure riding last (informational); Library is + // the library split plus the roster. Every figure is a door into the list + // that counts it, except a zero. Hygiene []fig Library []fig // Sites is the per-Site library shape table, one row per Site with any @@ -59,7 +60,7 @@ type siteRow struct { } // overviewView assembles the landing page from the store's read model: one -// SeriesShapes pass per filter summed in Go (the shipped surface offers eight +// SeriesShapes pass per filter summed in Go (the shipped surface offers nine // grouped passes, not a stats query — #140), the pass log's latest pass per // Site, and the roster. A failure in any read is a 500 with a logged reason, // never a page of silent zeroes. @@ -93,8 +94,10 @@ func (h *Handler) overviewView() (overviewView, error) { view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked] view.Verdict, view.HasCounts = overviewVerdict(passes, now) - // The seven problem filters, in seriesFilterOrder's permanent-then-fixable - // order; the All filter's count belongs to the Library block, not to a + // The hygiene figures, in seriesFilterOrder's tail: the seven problem + // filters in permanent-then-fixable order, then the finished figure last — + // informational, not a problem, and last because seriesFilterOrder appends + // it there. The All filter's count belongs to the Library block, not to a // "hygiene" figure. hygiene := make([]fig, 0, len(seriesFilterOrder)-1) for _, name := range seriesFilterOrder[1:] { diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index e0cfc35..20298ad 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -20,10 +20,10 @@ import ( // the wrong page. The store does not export it (#140). const seriesPageSize = 50 -// seriesFilterLabels names every hygiene filter for the Series list select, -// keyed by the wire constant the URL carries. The render order is -// seriesFilterOrder; the labels are read by later admin tickets too, so the -// map and the constants cannot drift apart. +// seriesFilterLabels names every Series filter for the list select, keyed by +// the wire constant the URL carries. The render order is seriesFilterOrder; +// the labels are read by later admin tickets too, so the map and the +// constants cannot drift apart. var seriesFilterLabels = map[string]string{ store.SeriesFilterAll: "All series", store.SeriesFilterNoURL: "No series URL", @@ -33,10 +33,13 @@ var seriesFilterLabels = map[string]string{ store.SeriesFilterStale: "Not checked in 12h", store.SeriesFilterNoCover: "No cover", store.SeriesFilterReaderReport: "Latest from a Reader", + store.SeriesFilterFinished: "Finished", } // seriesFilterOrder is the select's render order: All first, then the -// permanent repairs, then the fixable ones (issue #140). +// permanent repairs, then the fixable ones (issue #140). Finished rides the +// tail, last — deliberate, not a repair — and the Overview's stats block +// renders the same tail, which is what sits the finished figure last there. var seriesFilterOrder = []string{ store.SeriesFilterAll, store.SeriesFilterNoURL, @@ -46,6 +49,7 @@ var seriesFilterOrder = []string{ store.SeriesFilterStale, store.SeriesFilterNoCover, store.SeriesFilterReaderReport, + store.SeriesFilterFinished, } // seriesListView is the Series list page's data. The template renders strings @@ -110,6 +114,11 @@ type seriesRowView struct { // raced answer: the row stays and says a fresh Bookmark caught the press. CanRemove bool RemovalRefused bool + // Finished is the row's display of the owner's finish stamp: the list row + // shows the state and never offers the control — that lives on the detail + // page, where a press that retires a Series from the Lane is on purpose + // and confirm-gated (issue #158). + Finished bool } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, @@ -182,6 +191,96 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) { h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) } +// adminSeriesFinish is the owner's Finish control: it stamps the Series' +// finished_at and answers with the freshly rendered meta fragment, so the +// "finished ago" line describes the state after the press. The Lane's +// next pass reads the stamp and stops polling the Series (issue #157). The +// owner gate is the route's, not this handler's; the body is capped like the +// API path caps its bodies; the key is validated here — a malformed key is a +// 400 and an unknown one a 404. +func (h *Handler) adminSeriesFinish(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the stamp: the answer must describe the state after the + // press, so the line reads "finished just now". The control's one caller + // is the detail page, which swaps the meta fragment in place. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series finish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) +} + +// adminSeriesUnfinish is the reversal of the Finish control: it clears the +// stamp (writes zero) and answers with the freshly rendered meta fragment, so +// the Series is back in the Lane's queue from its next pass. Reversal, so it +// fires instantly with no confirm row (issue #158). +func (h *Handler) adminSeriesUnfinish(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.SetSeriesFinished(site, seriesID, 0); err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the write: the answer must describe the state after the + // press, so the fragment no longer carries the finished line. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series unfinish %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) +} + // adminSeriesCorrectLatest is the Latest Chapter correction: the owner types // one number and the Series' Latest Chapter becomes it, stamped as a // Correction. The number must be a finite float greater than zero — a @@ -516,9 +615,9 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) { return view, nil } -// seriesFilterOptions renders every hygiene filter with its library-wide -// count, one SeriesShapes pass per filter summed in Go — the shipped surface -// offers eight grouped passes, not a single stats query (#140). The counts +// seriesFilterOptions renders every filter with its library-wide count, one +// SeriesShapes pass per filter summed in Go — the shipped surface offers nine +// grouped passes, not a single stats query (#140). The counts // are library-wide because the select sits next to the Site narrowing and // must not shift as the owner narrows the list itself. Cutoff travels with // the stale filter, or its count would always be zero. @@ -572,6 +671,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { CanRemove: a.ReaderCount == 0, Pending: pending, Requested: requested, + Finished: a.FinishedAt > 0, } if a.LatestChapterNum != nil { row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 2f5e889..9d0f3d1 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -56,6 +56,14 @@ type seriesDetailView struct { // Series no Reader holds can be removed, so the owner is never offered a // button the database will always refuse. CanRemove bool + // Finished is the owner's finish stamp rendered for the control: while it + // stands, the page offers the instant Un-finish, not the confirm-gated + // Finish (issue #158). + Finished bool + // FinishedSince is the "finished ago" line, "" while no finish + // stands. It rides the meta fragment both presses swap, so the answer + // itself shows how long the Series has been finished. + FinishedSince string } // adminSeriesDetail renders one Series' page, keyed by the composite @@ -141,6 +149,8 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) } v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) + v.Finished = a.FinishedAt != 0 + v.FinishedSince = finishedAge(time.Now(), a.FinishedAt) // Provenance: the actor class behind the current number, evaluated in the // order the classes outrank one another — the owner's stamp, which a @@ -171,3 +181,13 @@ func correctedAge(now time.Time, at int64) string { } return "corrected " + since(now, time.UnixMilli(at)) } + +// finishedAge is the finish marker's text: "finished ago" while the +// stamp is set, "" when zero — zero means never finished, and the reversal +// (un-finish) must not read as history after a press (issue #158). +func finishedAge(now time.Time, at int64) string { + if at == 0 { + return "" + } + return "finished " + since(now, time.UnixMilli(at)) +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index 6eabb79..b442592 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -653,6 +653,22 @@ margin-left: auto; } +/* The finish confirm is the one calm row on the admin sheet: a reversible + move wears the recessed ash, never the remove wash above, and its + affirmative takes the sheet's patina accent (issue #158). */ +.admin-sheet .confirm-row.calm { + background: var(--ash); +} + +.admin-sheet .confirm-row.calm span { + color: var(--paper-dim); +} + +.admin-sheet .confirm-row.calm .go { + background: var(--patina); + color: var(--ink); +} + .admin-sheet .tbl.series .row-msg { grid-column: 1 / -1; margin-top: 6px; diff --git a/backend/internal/web/static/filter.js b/backend/internal/web/static/filter.js index baedcbd..83874a5 100644 --- a/backend/internal/web/static/filter.js +++ b/backend/internal/web/static/filter.js @@ -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 diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 91c1526..1da224c 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -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; @@ -550,6 +550,7 @@ button { cursor: pointer; } .new-chapter { color: var(--ember); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state svg { width: 10px; height: 10px; } +.state.finished { color: var(--moss); } .is-dim .meta { color: var(--mute-2); } .is-dim .site-asura, .is-dim .site-demonic, .is-dim .site-comix, .is-dim .site-kagane, @@ -612,13 +613,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 +633,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 +713,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 +918,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)); } diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html index d761b95..84c9769 100644 --- a/backend/internal/web/templates/app.html +++ b/backend/internal/web/templates/app.html @@ -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 - Finished diff --git a/backend/internal/web/templates/card.html b/backend/internal/web/templates/card.html index 4b79804..6175c16 100644 --- a/backend/internal/web/templates/card.html +++ b/backend/internal/web/templates/card.html @@ -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. */}}
@@ -32,9 +32,10 @@ {{if eq .Status "archived"}} / archived - {{else if eq .Status "finished"}} + {{end}} + {{if .Finished}} / - finished + finished {{end}}

@@ -56,7 +57,7 @@ {{/* 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"}} {{end}} - {{if ne .Status "finished"}} - - {{end}} - - - - {{end}} diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index 367df34..a6abc5b 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -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) diff --git a/backend/main_test.go b/backend/main_test.go index 5960b48..f2a0146 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -78,7 +78,7 @@ func TestPutStatusValidation(t *testing.T) { {"empty is no opinion", "", http.StatusOK}, {"reading", "reading", http.StatusOK}, {"archived", "archived", http.StatusOK}, - {"finished is web-only", "finished", http.StatusBadRequest}, + {"finished is no longer a bucket", "finished", http.StatusBadRequest}, {"garbage", "dropped", http.StatusBadRequest}, } for _, tc := range cases { diff --git a/backend/web_test.go b/backend/web_test.go index e1b60e8..bdfe09c 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1523,8 +1523,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { body := rr.Body.String() want := `hx-target="[id='card-asura:solo']"` - if strings.Count(body, want) != 5 { - t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)", + if strings.Count(body, want) != 4 { + t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, delete buttons, chapter form)", strings.Count(body, want), want) } if strings.Contains(body, `hx-target="#card-asura:solo"`) { @@ -1748,8 +1748,6 @@ func seedStatusRows(t *testing.T, st *store.Store) { {Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne", Status: store.StatusArchived, LastChapterNum: 5, Favorite: true, LatestChapter: "99", LatestChapterNum: floatPtr(99)}, - {Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne", - Status: store.StatusFinished, LastChapterNum: 200, Favorite: true}, } for _, b := range rows { b.UpdatedAt = time.Now().UnixMilli() @@ -1758,7 +1756,6 @@ func seedStatusRows(t *testing.T, st *store.Store) { } } } - func TestTabsShowOnlyTheirBucket(t *testing.T) { cfg := testConfig() srv, st := newWebTestServer(t, cfg) @@ -1768,11 +1765,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) { tab string want, dontWant []string }{ - {"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, - {"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, - {"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, - {"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}}, - {"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}}, + {"all", []string{"ReadingOne"}, []string{"ArchivedOne"}}, + {"new", []string{"ReadingOne"}, []string{"ArchivedOne"}}, + {"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}}, + {"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}}, } for _, tc := range cases { t.Run(tc.tab, func(t *testing.T) { @@ -1835,12 +1831,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { if !strings.Contains(strip, "ReadingOne") { t.Fatal("strip dropped the series with an unread chapter") } - for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} { + for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne"} { if strings.Contains(strip, unwanted) { t.Fatalf("strip included %s", unwanted) } } - for _, tab := range []string{"new", "fav", "archived", "finished"} { + for _, tab := range []string{"new", "fav", "archived"} { if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") { t.Fatalf("tab %s rendered the strip", tab) } @@ -1903,7 +1899,7 @@ func TestUIStatusSetsBucket(t *testing.T) { srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) - for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} { + for _, want := range []string{store.StatusArchived, store.StatusReading} { if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK { t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String()) } @@ -1971,12 +1967,11 @@ func TestCardShowsStatusControls(t *testing.T) { tab string want, dontWant []string }{ - // A series being read can be shelved or completed, not restored. - {"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil}, - // An archived one can come back or be completed. - {"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil}, - // A finished one can only come back. - {"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}}, + // A series being read can be shelved, not restored, and there is no + // finish control any more (issue #157). + {"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}}, + // An archived one can come back. + {"archived", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}}, } for _, tc := range cases { t.Run(tc.tab, func(t *testing.T) { @@ -2010,11 +2005,14 @@ func TestAppRendersNewTabs(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) - for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} { + for _, want := range []string{`href="/?tab=archived"`} { if !strings.Contains(rr.Body.String(), want) { t.Fatalf("app page missing %s", want) } } + if strings.Contains(rr.Body.String(), `href="/?tab=finished"`) { + t.Fatal("app page still offers a Finished tab") + } } // A mutation has to bring the chrome with it: the strip and the badge live @@ -2131,7 +2129,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) { for _, want := range []string{ "/?lib=novel&tab=fav", "/?lib=novel&tab=archived", - "/?lib=novel&tab=finished", } { if !strings.Contains(body, want) { t.Fatalf("novel page missing tab link %s", want) @@ -2310,6 +2307,42 @@ func TestSeriesListFilterWiring(t *testing.T) { } } +// The finished filter is a first-class option: the select carries it with +// its label and library-wide count, and entering it lists exactly the +// retired rows — picking it up from order-plus-label like every other +// filter, with no per-filter branch in the handler. +func TestSeriesListFinishedFilterOption(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil { + t.Fatalf("finish asura:retired: %v", err) + } + srv := newRouter(st, testConfig()) + + body := adminSeriesPage(t, srv, st, "?filter=finished") + if !strings.Contains(body, "Title of asura:retired") { + t.Errorf("finished list misses its row:\n%s", body) + } + if strings.Contains(body, "Title of asura:healthy") { + t.Errorf("finished list renders an unfinished row:\n%s", body) + } + if !strings.Contains(body, "1 series") || !strings.Contains(body, "Finished") { + t.Errorf("finished heading lacks the count and label:\n%s", body) + } + if !strings.Contains(body, "Finished (1)") { + t.Errorf("the finished option lacks its count:\n%s", body) + } + if !strings.Contains(body, `