Compare commits

...

11 Commits

Author SHA1 Message Date
sulthan 0081a6100c Merge #159: Finished joins the admin filter vocabulary 2026-08-22 17:28:05 +07:00
sulthan cdd2e1d72c Fix stale comment counts found in review (#159) 2026-08-22 17:27:43 +07:00
sulthan 0029cff27c Add finished Series filter; clock-driven hygiene predicates exclude it (#159) 2026-08-22 17:25:10 +07:00
sulthan f85641da61 Merge #161: Glossary and stale Reader-count note catch-up 2026-08-22 17:14:23 +07:00
sulthan f66b63f6de Merge #160: Reader-facing finished label on the flat Bookmark 2026-08-22 17:14:20 +07:00
sulthan 0be578449b Merge #158: Owner Finish control on the Series detail page 2026-08-22 17:14:18 +07:00
sulthan 410509097a Restore truncated overflow-page comment dropped by the finish diff (#158) 2026-08-22 17:13:33 +07:00
sulthan 948ecae2cc Owner Finish control on the Series detail page: confirm-gated finish, instant un-finish (#158) 2026-08-22 17:06:45 +07:00
sulthan ea7bb97112 Spec #136: reader-facing finished label on the flat Bookmark (#160) 2026-08-22 17:05:09 +07:00
sulthan 6fa9972bea Docs: glossary and stale notes catch up with the finished-Series cutover (#161) 2026-08-22 17:03:02 +07:00
sulthan 77e3f710ec 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.
2026-08-22 16:45:09 +07:00
34 changed files with 1225 additions and 241 deletions
+15 -3
View File
@@ -95,7 +95,7 @@ _Avoid_: run, cycle, tick, batch, poll history
**Forced Poll**: **Forced Poll**:
A Poll the owner asks for by hand instead of waiting for the Series's turn. It jumps its 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 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 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 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. next pass rather than at the moment of asking.
@@ -149,10 +149,22 @@ accent is permitted to signal.
_Avoid_: unread, update available _Avoid_: unread, update available
**Lifecycle bucket**: **Lifecycle bucket**:
Which of three mutually exclusive states a Bookmark sits in — reading, archived, or Which of the two states a Bookmark sits in — reading or archived. A Bookmark is in exactly
finished. A Bookmark is in exactly one. Orthogonal to being a favourite. 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 _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**: **Favourite**:
A reader's manual pin on a Bookmark. Orthogonal to the Lifecycle bucket, and never a A reader's manual pin on a Bookmark. Orthogonal to the Lifecycle bucket, and never a
reason to reorder the list. reason to reorder the list.
+2 -2
View File
@@ -29,8 +29,8 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
## Capabilities and Constraints ## Capabilities and Constraints
- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived / Finished. Search-filter by title (client-side, `filter.js`). - 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, finish, remove — each move out of the list confirm-gated. - 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. - "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. - 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. - 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.
+4 -4
View File
@@ -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 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 does not touch read progress, and reading an archived series leaves it
archived. archived.
- **Finished**: series you have completed live in a **Finished** tab in the web - **Finished**: the owner marks a Series finished from its detail page; the
UI only. It is set there and nowhere else — the API rejects the value — and backend stops polling it, and every Reader sees a read-only label. It is a
finished series are hidden from every userscript tab and are no longer polled fact about the Series, not a Reader's bucket: old `Finished` bookmarks were
for new chapters. 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 - Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
versa — the backend is the shared store. versa — the backend is the shared store.
+9 -5
View File
@@ -200,12 +200,16 @@ stored** and clients must adopt that response rather than their own payload.
### Lifecycle buckets — `status` on each bookmark ### Lifecycle buckets — `status` on each bookmark
`reading` | `archived` | `finished`, orthogonal to `favorite`. Archived and `reading` | `archived`, orthogonal to `favorite`. Archived rows appear only
finished appear only in their own tab, never in All, Updated, Favourites or the in their own tab, never in All, Updated, Favourites or the recent strip. The
recent strip. The poller keeps checking archived series and skips finished ones. 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 - `PUT /bookmarks/{key}` accepts only the two values; anything else —
it with 400. `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 - **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: on the `VALUES` side of `Store.Upsert`, not in the conflict clause:
`excluded.*` is the post-evaluation row, so a default applied there would `excluded.*` is the post-evaluation row, so a default applied there would
+48 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -298,7 +299,7 @@ func TestFlatWireFieldSet(t *testing.T) {
"series_url": true, "cover": true, "last_chapter": true, "series_url": true, "cover": true, "last_chapter": true,
"last_chapter_num": true, "last_chapter_url": true, "favorite": true, "last_chapter_num": true, "last_chapter_url": true, "favorite": true,
"latest_chapter": true, "latest_chapter_num": true, "updated_at": 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 { checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage {
t.Helper() t.Helper()
@@ -362,6 +363,52 @@ func TestFlatWireFieldSet(t *testing.T) {
checkFlat(t, body2) 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 // 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 // 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. // values could otherwise land on a shared row — while progress still lands.
+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. // 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 // Anything else outside the two lifecycle buckets is a client bug, not
// rather than trusting every client to leave it alone. // something to silently coerce — finished included, which is no longer a
// bucket at all (issue #157).
switch b.Status { switch b.Status {
case "", store.StatusReading, store.StatusArchived: 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: default:
http.Error(w, "invalid status", http.StatusBadRequest) http.Error(w, "invalid status", http.StatusBadRequest)
return return
+34 -18
View File
@@ -7,11 +7,13 @@ import (
"strings" "strings"
) )
// Series filter names (issue #140), ordered permanent-then-fixable — the // Series filter names (issue #140): the seven repair filters are ordered
// repairs nothing will ever undo first, the ones a Poll can make right after. // permanent-then-fixable — the repairs nothing will ever undo first, the
// A name is the repair a row needs, not the SQL that finds it; the values are // ones a Poll can make right after. SeriesFilterFinished is not part of
// the wire form the Series list URL carries (#142). "all" is the absent and // that ordering: a finished Series is a deliberate state, not a repair, so
// unknown case: every Series. // 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 ( const (
SeriesFilterAll = "all" SeriesFilterAll = "all"
SeriesFilterNoURL = "no_series_url" SeriesFilterNoURL = "no_series_url"
@@ -21,9 +23,10 @@ const (
SeriesFilterStale = "stale" SeriesFilterStale = "stale"
SeriesFilterNoCover = "no_cover" SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report" 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 // and Kind narrow the row read; Name picks the predicate; Cutoff is the
// staleness boundary the "stale" filter compares against, supplied by the // staleness boundary the "stale" filter compares against, supplied by the
// caller's clock — the store has no clock; Page is 1-based. // 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. // and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, 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.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 // raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list — // naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -80,7 +83,9 @@ type AdminSeries struct {
LatestCorrectedAt int64 LatestCorrectedAt int64
ReaderCount int ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum 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 // 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- // 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 // 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 // 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 // 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. // 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 // 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 // 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 // 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: case SeriesFilterNoURL:
clauses = append(clauses, `s.series_url = ''`) clauses = append(clauses, `s.series_url = ''`)
case SeriesFilterNoChapter: 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: 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: 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) args = append(args, f.Cutoff)
case SeriesFilterNoCover: case SeriesFilterNoCover:
clauses = append(clauses, `s.cover_address = ''`) clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
case SeriesFilterReaderReport: case SeriesFilterReaderReport:
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`) clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
case SeriesFilterFinished:
clauses = append(clauses, `s.finished_at > 0`)
case SeriesFilterNoReaders: case SeriesFilterNoReaders:
having = `HAVING COUNT(b.reader_id) = 0` having = `HAVING COUNT(b.reader_id) = 0`
default: 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 // SeriesPage returns one page of the Series matching the filter, least
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the // 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 // 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 // as the Lane's join does. Every bookmark keeps its Series polled now that
// the Series, which knowingly disagrees with the two Lane queries for as long // finished is a Series flag, so this plain ReaderCount agrees with the Lane
// as the finished lifecycle bucket exists (#140). // queries (issue #157).
// //
// The tie-break is mandatory, not decorative: every unpollable Series shares a // 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 // 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+` `+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, 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.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+` `+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) 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( if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt, &a.LatestCorrectedAt, &a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
&a.RaisedByReader, &a.ReaderCount, &total, &a.RaisedByReader, &a.ReaderCount, &total,
); err != nil { ); err != nil {
return AdminSeries{}, 0, err return AdminSeries{}, 0, err
+133
View File
@@ -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: // "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 // 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. // 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) 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)
}
}
@@ -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';
+60 -33
View File
@@ -47,13 +47,16 @@ type Bookmark struct {
LatestChapter string `json:"latest_chapter"` LatestChapter string `json:"latest_chapter"`
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
// Status is the lifecycle bucket: reading, archived, or finished. // Status is the lifecycle bucket: reading or archived.
// Archived series stay polled for new chapters; finished ones do not. // Archived series stay polled for new chapters.
// Empty on the way in means "no opinion" — see Upsert. // Empty on the way in means "no opinion" — see Upsert.
Status string `json:"status"` Status string `json:"status"`
// Kind is the library bucket: manga or novel. Empty on the way in means // Kind is the library bucket: manga or novel. Empty on the way in means
// "no opinion" — see Upsert. // "no opinion" — see Upsert.
Kind string `json:"kind"` 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 // 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. // 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 ( const (
StatusReading = "reading" StatusReading = "reading"
StatusArchived = "archived" StatusArchived = "archived"
StatusFinished = "finished"
) )
//go:embed migrations/*.sql //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). // 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, 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.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 // seriesColumns is the series row in scanSeries order, used by the poller's
// due query. latest_checked_at lives only on series — see MarkLatestChecked // 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.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind, &b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
&b.Finished,
); err != nil { ); err != nil {
return Bookmark{}, err 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). // bookmark is keyed (reader_id, site, series_id) (issue #22).
b.Key = b.Site + ":" + b.SeriesID b.Key = b.Site + ":" + b.SeriesID
// An unrecognised bucket (a hand-edited row) would leave the row in no list // 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. // 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 b.Status = StatusReading
} }
return b, nil 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) // A forced Series (force_poll_at newer than latest_checked_at, issue #146)
// overrides exactly three gates: the rest cutoff, the Sighting-deferral // overrides exactly three gates: the rest cutoff, the Sighting-deferral
// clause and the finished-only bucket. It never overrides an empty // clause and a finished Series. It never overrides an empty series_url or the
// series_url or the Bookmarks join — nothing to fetch, and no consumer for // Bookmarks join — nothing to fetch, and no consumer for the result — so
// the result — so those stay unconditional. Forced rows sort to the front of // those stay unconditional, and it never clears the finish: nothing here
// the queue; the reader-count-then-age ordering among the rest is ADR-0003. // 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 // 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 // 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 // reader count, oldest-first keeps the poll fair when the backlog outgrows
// throughput: the most neglected series is always next, so a large collection // throughput: the most neglected series is always next, so a large collection
// refreshes uniformly slower rather than leaving a tail that never refreshes // 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 // 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 // the same filter the userscript applies before refreshing. A finished Series is
// are finished are skipped too: nothing more is coming, so fetching them only // skipped unless forced: nothing more is coming, so fetching it only burns
// burns requests. Archived bookmarks still count — knowing what a shelved // requests (issue #157). Archived bookmarks still count — knowing what a
// series is up to is the whole reason for archiving instead of deleting. // shelved series is up to is the whole reason for archiving instead of
// A series with no bookmarks at all never appears: the join excludes it. // 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 // 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 // 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 // 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 // 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 // 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 // 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 // that was never postponed; on a solitary Series the only person a wrong
// reaches is the Reader who reported it. Whether the reporting Reader is // value reaches is the Reader who reported it. Whether the reporting Reader
// allowed to defer at all was settled when the Sighting was recorded — see // is allowed to defer at all was settled when the Sighting was recorded — see
// RecordSighting. // RecordSighting.
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) { func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
rows, err := s.db.Query(`SELECT `+seriesColumns+`, 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.series_url <> ''
AND (s.latest_checked_at <= $2::bigint AND (s.latest_checked_at <= $2::bigint
OR s.force_poll_at > s.latest_checked_at) 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, 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.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at s.force_poll_at, s.finished_at
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 HAVING (COUNT(*) > 1
OR s.force_poll_at > s.latest_checked_at)
AND (COUNT(*) > 1
OR s.latest_sighted_at <= $2::bigint OR s.latest_sighted_at <= $2::bigint
OR s.latest_checked_at <= $3::bigint OR s.latest_checked_at <= $3::bigint
OR s.force_poll_at > s.latest_checked_at) 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() return out, rows.Err()
} }
// EligibleSeriesCount returns how many of a Site's Series still have at least // EligibleSeriesCount returns how many of a Site's Series are not finished —
// one bookmark outside the finished bucket. It is the denominator of the // the flag, never a Reader vote. It is the denominator of the Lane's pace
// Lane's pace (issue #100): the effective gap is the smaller of the registry // (issue #100): the effective gap is the smaller of the registry gap and one
// gap and one hour divided by this count, so Series that will never be Polled // hour divided by this count, so Series that will never be Polled do not make
// do not make the Lane faster than it needs to be, and counting every eligible // the Lane faster than it needs to be, and counting every eligible Series
// Series rather than only those currently due keeps the pace steady — the // rather than only those currently due keeps the pace steady — the single
// single worst moment to be fastest is startup, when everything is due at // worst moment to be fastest is startup, when everything is due at once.
// 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) { func (s *Store) EligibleSeriesCount(site string) (int, error) {
var n int var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM ( err := s.db.QueryRow(`SELECT COUNT(*) FROM (
@@ -1386,8 +1398,8 @@ func (s *Store) EligibleSeriesCount(site string) (int, error) {
FROM series s FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.site = $1 WHERE s.site = $1
AND s.finished_at = 0
GROUP BY s.site, s.series_id GROUP BY s.site, s.series_id
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
) e`, site).Scan(&n) ) e`, site).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count eligible series %s: %w", site, err) 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 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 // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on rest // tests outside this package (the poller's own tests assert on rest
// bookkeeping) — see MarkLatestChecked for why the field stays off the // bookkeeping) — see MarkLatestChecked for why the field stays off the
+197 -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 // 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. // that predate the ceiling and are about rest, ordering or buckets instead.
const noCeiling = int64(-1) 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) 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 // 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) { func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
for _, tc := range []struct{ key, status string }{ for _, key := range []string{"asura:reading", "asura:archived"} {
{"asura:reading", StatusReading}, seedForCheck(t, store, key, "https://asurascans.com/comics/"+key, 0)
{"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)
}
} }
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling) due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil { 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 // 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 // finished Series must not make the Lane faster than it needs to be, another
// another Site's Series must not leak into this Site's count. // 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) { func TestEligibleSeriesCount(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0) seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0) seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
if _, err := store.Upsert(store.OwnerID(), Bookmark{ seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
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)
}
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0) 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") n, err := store.EligibleSeriesCount("asura")
if err != nil { if err != nil {
t.Fatalf("EligibleSeriesCount: %v", err) t.Fatalf("EligibleSeriesCount: %v", err)
} }
if n != 2 { 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") n, err = store.EligibleSeriesCount("demonic")
if err != nil { 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 // readSeries reads the series row directly, for asserting on what Upsert
// actually stored rather than what the joined Bookmark reports. // actually stored rather than what the joined Bookmark reports.
func readSeries(t *testing.T, s *Store, site, seriesID string) Series { 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 // 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 // once per Series — on the PUT that created it, and on no later one, whichever
// Reader sends it. // Reader sends it.
@@ -1809,19 +1978,12 @@ func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
} }
} }
// The finished-only bucket excludes a series whose only bookmarks are // The finished flag excludes a series; a forced request overrides it — the
// finished; a forced request overrides it — the owner asked, so the Lane // owner asked, so the Lane looks.
// looks. func TestDueForLatestCheckForcedOverridesFinishedFlag(t *testing.T) {
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0) seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
if _, err := s.Upsert(s.OwnerID(), Bookmark{ seedFinished(t, s, "asura:finished", "https://asurascans.com/comics/asura:finished")
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)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling) due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil { if err != nil {
+2
View File
@@ -47,6 +47,8 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/series", h.adminSeries}, {"GET /admin/series", h.adminSeries},
{"GET /admin/series/{key}", h.adminSeriesDetail}, {"GET /admin/series/{key}", h.adminSeriesDetail},
{"POST /admin/series/{key}/poll", h.adminSeriesPoll}, {"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}/latest", h.adminSeriesCorrectLatest},
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL}, {"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
{"POST /admin/series/{key}/remove", h.adminSeriesRemove}, {"POST /admin/series/{key}/remove", h.adminSeriesRemove},
+8 -5
View File
@@ -24,8 +24,9 @@ type overviewView struct {
// its own filter, and the verdict wants the inclusive number. // its own filter, and the verdict wants the inclusive number.
Unchecked int Unchecked int
// Hygiene is the seven problem filters in the Series list's own render // 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 // order plus the finished figure riding last (informational); Library is
// door into the list that counts it, except a zero. // the library split plus the roster. Every figure is a door into the list
// that counts it, except a zero.
Hygiene []fig Hygiene []fig
Library []fig Library []fig
// Sites is the per-Site library shape table, one row per Site with any // 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 // 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 // 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, // Site, and the roster. A failure in any read is a 500 with a logged reason,
// never a page of silent zeroes. // 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.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
view.Verdict, view.HasCounts = overviewVerdict(passes, now) view.Verdict, view.HasCounts = overviewVerdict(passes, now)
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable // The hygiene figures, in seriesFilterOrder's tail: the seven problem
// order; the All filter's count belongs to the Library block, not to a // 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" figure.
hygiene := make([]fig, 0, len(seriesFilterOrder)-1) hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
for _, name := range seriesFilterOrder[1:] { for _, name := range seriesFilterOrder[1:] {
+108 -8
View File
@@ -20,10 +20,10 @@ import (
// the wrong page. The store does not export it (#140). // the wrong page. The store does not export it (#140).
const seriesPageSize = 50 const seriesPageSize = 50
// seriesFilterLabels names every hygiene filter for the Series list select, // seriesFilterLabels names every Series filter for the list select, keyed by
// keyed by the wire constant the URL carries. The render order is // the wire constant the URL carries. The render order is seriesFilterOrder;
// seriesFilterOrder; the labels are read by later admin tickets too, so the // the labels are read by later admin tickets too, so the map and the
// map and the constants cannot drift apart. // constants cannot drift apart.
var seriesFilterLabels = map[string]string{ var seriesFilterLabels = map[string]string{
store.SeriesFilterAll: "All series", store.SeriesFilterAll: "All series",
store.SeriesFilterNoURL: "No series URL", store.SeriesFilterNoURL: "No series URL",
@@ -33,10 +33,13 @@ var seriesFilterLabels = map[string]string{
store.SeriesFilterStale: "Not checked in 12h", store.SeriesFilterStale: "Not checked in 12h",
store.SeriesFilterNoCover: "No cover", store.SeriesFilterNoCover: "No cover",
store.SeriesFilterReaderReport: "Latest from a Reader", store.SeriesFilterReaderReport: "Latest from a Reader",
store.SeriesFilterFinished: "Finished",
} }
// seriesFilterOrder is the select's render order: All first, then the // 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{ var seriesFilterOrder = []string{
store.SeriesFilterAll, store.SeriesFilterAll,
store.SeriesFilterNoURL, store.SeriesFilterNoURL,
@@ -46,6 +49,7 @@ var seriesFilterOrder = []string{
store.SeriesFilterStale, store.SeriesFilterStale,
store.SeriesFilterNoCover, store.SeriesFilterNoCover,
store.SeriesFilterReaderReport, store.SeriesFilterReaderReport,
store.SeriesFilterFinished,
} }
// seriesListView is the Series list page's data. The template renders strings // 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. // raced answer: the row stays and says a fresh Bookmark caught the press.
CanRemove bool CanRemove bool
RemovalRefused 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, // 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())) 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 <age> 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 // adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
// one number and the Series' Latest Chapter becomes it, stamped as a // one number and the Series' Latest Chapter becomes it, stamped as a
// Correction. The number must be a finite float greater than zero — 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 return view, nil
} }
// seriesFilterOptions renders every hygiene filter with its library-wide // seriesFilterOptions renders every filter with its library-wide count, one
// count, one SeriesShapes pass per filter summed in Go — the shipped surface // SeriesShapes pass per filter summed in Go — the shipped surface offers nine
// offers eight grouped passes, not a single stats query (#140). The counts // grouped passes, not a single stats query (#140). The counts
// are library-wide because the select sits next to the Site narrowing and // 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 // must not shift as the owner narrows the list itself. Cutoff travels with
// the stale filter, or its count would always be zero. // 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, CanRemove: a.ReaderCount == 0,
Pending: pending, Pending: pending,
Requested: requested, Requested: requested,
Finished: a.FinishedAt > 0,
} }
if a.LatestChapterNum != nil { if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
@@ -56,6 +56,14 @@ type seriesDetailView struct {
// Series no Reader holds can be removed, so the owner is never offered a // Series no Reader holds can be removed, so the owner is never offered a
// button the database will always refuse. // button the database will always refuse.
CanRemove bool 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 <age> 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 // 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.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
} }
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) 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 // Provenance: the actor class behind the current number, evaluated in the
// order the classes outrank one another — the owner's stamp, which a // 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)) return "corrected " + since(now, time.UnixMilli(at))
} }
// finishedAge is the finish marker's text: "finished <age> 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))
}
+16
View File
@@ -653,6 +653,22 @@
margin-left: auto; 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 { .admin-sheet .tbl.series .row-msg {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: 6px; margin-top: 6px;
+3 -3
View File
@@ -61,7 +61,7 @@ function setActiveTab(el) {
document.dispatchEvent(new Event("bmgr:refilter")); 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 // 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 // that owns an open panel carries .open, which is how the strip shows which
// cell the panel belongs to. // cell the panel belongs to.
@@ -97,10 +97,10 @@ function toggleChapterForm(key) {
if (form && !form.hidden) form.querySelector("input").focus(); 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. // cell share it.
function toggleConfirmRow(key, kind) { 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); var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
// Focus the answer rather than trusting aria-live on a container that merely // Focus the answer rather than trusting aria-live on a container that merely
// unhides: it makes the announcement deterministic, keeps tab order inside // unhides: it makes the announcement deterministic, keeps tab order inside
+9 -10
View File
@@ -54,7 +54,7 @@
--ink: #100f0e; /* page */ --ink: #100f0e; /* page */
--ash: #161413; /* recessed panel (chapter form) */ --ash: #161413; /* recessed panel (chapter form) */
--dim: #0d0c0b; /* archived / finished rows sink */ --dim: #0d0c0b; /* archived rows sink */
--rule: #221f1d; /* hairline between sheets */ --rule: #221f1d; /* hairline between sheets */
--rule-soft: #1a1817; /* measure edges */ --rule-soft: #1a1817; /* measure edges */
--field-line: #2c2926; --field-line: #2c2926;
@@ -550,6 +550,7 @@ button { cursor: pointer; }
.new-chapter { color: var(--ember); } .new-chapter { color: var(--ember); }
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
.state svg { width: 10px; height: 10px; } .state svg { width: 10px; height: 10px; }
.state.finished { color: var(--moss); }
.is-dim .meta { color: var(--mute-2); } .is-dim .meta { color: var(--mute-2); }
.is-dim .site-asura, .is-dim .site-demonic, .is-dim .site-asura, .is-dim .site-demonic,
.is-dim .site-comix, .is-dim .site-kagane, .is-dim .site-comix, .is-dim .site-kagane,
@@ -612,13 +613,13 @@ button { cursor: pointer; }
.actions > *:last-child { border-right: none; } .actions > *:last-child { border-right: none; }
.actions svg { width: 17px; height: 17px; } .actions svg { width: 17px; height: 17px; }
.actions > *:hover { color: var(--paper); } .actions > *:hover { color: var(--paper); }
/* Per-action accent on hover and press: gold favourite, slate archive, moss /* Per-action accent on hover and press: gold favourite, slate archive, clay
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */ chapter. Remove keeps --danger, play keeps paper/ember. */
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); } .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 .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 .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); } .actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); } .is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); } .actions .play:hover { background: var(--hover); }
.actions .on { color: var(--brass); } .actions .on { color: var(--brass); }
@@ -632,8 +633,8 @@ button { cursor: pointer; }
.actions .remove.open { background: var(--danger-wash); color: var(--danger); } .actions .remove.open { background: var(--danger-wash); color: var(--danger); }
.is-dim .actions > * { color: var(--mute-2); } .is-dim .actions > * { color: var(--mute-2); }
/* Three clusters by consequence: navigate (play) | organize (favourite, /* Two clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells chapter) | lifecycle (archive/restore, remove). The lifecycle cells
sit on a recessed ground so the thumb reads "this one moves the series" sit on a recessed ground so the thumb reads "this one moves the series"
before it reads which icon it landed on. */ before it reads which icon it landed on. */
.actions > .lifecycle { background: var(--ash); } .actions > .lifecycle { background: var(--ash); }
@@ -712,7 +713,7 @@ button { cursor: pointer; }
color: var(--danger-ink); color: var(--danger-ink);
font-weight: 600; 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. */ irreversible remove gets the danger wash. */
.confirm-row.calm { background: var(--ash); } .confirm-row.calm { background: var(--ash); }
.confirm-row.calm span { color: var(--paper-dim); } .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 /* The cell border follows the icon on hover, so the accent reads as a state
rather than a stray colour. */ rather than a stray colour. */
.actions .fav:hover, .actions .pencil:hover, .actions .fav:hover, .actions .pencil:hover,
.actions .box:hover, .actions .finish:hover { border-color: currentColor; } .actions .box:hover { border-color: currentColor; }
/* Panels line up with the body text, i.e. past the cover and its gap. */
.chapter-form, .confirm-row, .error-inline { .chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap)); margin-left: calc(var(--cover-w) + var(--row-gap));
} }
-4
View File
@@ -71,10 +71,6 @@
{{if eq .Tab "archived"}}aria-current="page"{{end}} {{if eq .Tab "archived"}}aria-current="page"{{end}}
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a> 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> </nav>
</div> </div>
+5 -23
View File
@@ -1,6 +1,6 @@
{{define "card"}} {{define "card"}}
{{/* One sheet per series. is-new turns the title crimson over an ember rule; {{/* 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}}" <article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
id="card-{{.Key}}" data-title="{{.Title}}"> id="card-{{.Key}}" data-title="{{.Title}}">
<div class="row"> <div class="row">
@@ -32,9 +32,10 @@
{{if eq .Status "archived"}} {{if eq .Status "archived"}}
<span class="sep">/</span> <span class="sep">/</span>
<span class="state">archived</span> <span class="state">archived</span>
{{else if eq .Status "finished"}} {{end}}
{{if .Finished}}
<span class="sep">/</span> <span class="sep">/</span>
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span> <span class="state finished">finished</span>
{{end}} {{end}}
</p> </p>
</div> </div>
@@ -56,7 +57,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
</button> </button>
{{/* Restore is a reversal, so it fires straight away; every move *out* of {{/* 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"}} {{if eq .Status "reading"}}
<button class="lifecycle box" title="Archive" aria-label="Archive" <button class="lifecycle box" title="Archive" aria-label="Archive"
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}" aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
@@ -71,13 +72,6 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button> </button>
{{end}} {{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" <button class="lifecycle remove" title="Remove" aria-label="Remove"
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}" aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'remove')"> onclick="toggleConfirmRow('{{.Key}}', 'remove')">
@@ -116,18 +110,6 @@
</div> </div>
</div> </div>
{{end}} {{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> <div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Remove “{{.Title}}”? Chapter progress is lost.</span> <span>Remove “{{.Title}}”? Chapter progress is lost.</span>
<div> <div>
+2 -6
View File
@@ -29,21 +29,17 @@
{{/* The action key. The icon strip on a card is unlabelled, so one permanent {{/* 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 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 row: the archived bucket swaps Archive for Restore. */}}
finished series has no Done to offer. */}}
{{define "keyrow"}} {{define "keyrow"}}
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}> <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"><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 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> <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> <span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
{{else}} {{else}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span> <span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
{{end}} {{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> <span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
</div> </div>
{{end}} {{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-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-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-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-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-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> <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> <div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
{{else if eq .Tab "archived"}} {{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> <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}} {{else if .EmptyLibrary}}
{{/* Nothing in either library, so the links are the only thing this page can {{/* Nothing in either library, so the links are the only thing this page can
usefully say. Both scripts: the two libraries are separate installs. */}} usefully say. Both scripts: the two libraries are separate installs. */}}
@@ -39,6 +39,22 @@
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div> <div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
</div> </div>
{{end}} {{end}}
{{if .Finished}}
<div class="dform">
<div class="field"><button type="button" class="ghost" hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML">Un-finish</button></div>
</div>
{{else}}
<div class="dform">
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
<span>Mark this Series finished?</span>
<div>
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML">Finish</button>
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true">Cancel</button>
</div>
</div>
</div>
{{end}}
{{end}} {{end}}
{{/* series-detail-meta is the meta line, and the answer a Check now or {{/* series-detail-meta is the meta line, and the answer a Check now or
@@ -54,6 +70,7 @@
<span>{{.Readers}} readers</span> <span>{{.Readers}} readers</span>
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}} {{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}} {{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
{{if .FinishedSince}}<span class="mark">{{.FinishedSince}}</span>{{end}}
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}} {{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
{{if .NoCover}}<span class="mark">no cover</span>{{end}} {{if .NoCover}}<span class="mark">no cover</span>{{end}}
{{if .Orphan}}<span class="mark">orphan</span>{{end}} {{if .Orphan}}<span class="mark">orphan</span>{{end}}
@@ -47,7 +47,7 @@
<span class="c-ch">{{.Ch}}</span> <span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span> <span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span> <span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span> <span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span> <span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}} {{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
</div> </div>
+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 // buildListView loads one reader's list once and derives both the tab-filtered
// items and the recent strip from it. // items and the recent strip from it.
// //
// Archived and finished series appear in their own tab and nowhere else — not // Archived series appear in their own tab and nowhere else — not in All, not
// in All, not in Updated, not in Favourites, and not in the recent strip. An // in Updated, not in Favourites, and not in the recent strip. An archived
// archived favourite therefore shows only under Archived: Favourites means // favourite therefore shows only under Archived: Favourites means "favourites
// "favourites I am currently reading". // 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) { func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
all, err := h.store.List(readerID) // already ordered updated_at DESC all, err := h.store.List(readerID) // already ordered updated_at DESC
if err != nil { if err != nil {
@@ -327,8 +328,6 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
items = withNew items = withNew
case "archived": case "archived":
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived }) 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: default:
tab = "all" tab = "all"
items = reading items = reading
@@ -392,7 +391,7 @@ func currentLib(r *http.Request) string {
// writeChromeOOB appends the regions that live outside #list — the recent // 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 // 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. // 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. // Archive for Restore.
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) { func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true view.OOB = true
@@ -492,9 +491,9 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
h.saveAndRenderCard(w, r, b) h.saveAndRenderCard(w, r, b)
} }
// uiStatus moves a bookmark between lifecycle buckets. This is the only place // uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
// a series can be marked finished — the JSON API refuses that value, so the // one of them: it is a fact about the Series, decided from the admin surface,
// userscript cannot set it even by accident. // 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 // last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
// list does not reorder. // list does not reorder.
@@ -508,7 +507,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
switch s := r.PostFormValue("status"); s { switch s := r.PostFormValue("status"); s {
case store.StatusReading, store.StatusArchived, store.StatusFinished: case store.StatusReading, store.StatusArchived:
b.Status = s b.Status = s
default: default:
http.Error(w, "invalid status", http.StatusBadRequest) http.Error(w, "invalid status", http.StatusBadRequest)
+1 -1
View File
@@ -78,7 +78,7 @@ func TestPutStatusValidation(t *testing.T) {
{"empty is no opinion", "", http.StatusOK}, {"empty is no opinion", "", http.StatusOK},
{"reading", "reading", http.StatusOK}, {"reading", "reading", http.StatusOK},
{"archived", "archived", 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}, {"garbage", "dropped", http.StatusBadRequest},
} }
for _, tc := range cases { for _, tc := range cases {
+278 -21
View File
@@ -1523,8 +1523,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
body := rr.Body.String() body := rr.Body.String()
want := `hx-target="[id='card-asura:solo']"` want := `hx-target="[id='card-asura:solo']"`
if strings.Count(body, want) != 5 { if strings.Count(body, want) != 4 {
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)", t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, delete buttons, chapter form)",
strings.Count(body, want), want) strings.Count(body, want), want)
} }
if strings.Contains(body, `hx-target="#card-asura:solo"`) { 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", {Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true, Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)}, 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 { for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
@@ -1758,7 +1756,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
} }
} }
} }
func TestTabsShowOnlyTheirBucket(t *testing.T) { func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := testConfig() cfg := testConfig()
srv, st := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
@@ -1768,11 +1765,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
tab string tab string
want, dontWant []string want, dontWant []string
}{ }{
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, {"all", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, {"new", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, {"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}},
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}}, {"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}},
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) { t.Run(tc.tab, func(t *testing.T) {
@@ -1835,12 +1831,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
if !strings.Contains(strip, "ReadingOne") { if !strings.Contains(strip, "ReadingOne") {
t.Fatal("strip dropped the series with an unread chapter") 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) { if strings.Contains(strip, unwanted) {
t.Fatalf("strip included %s", 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") { if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
t.Fatalf("tab %s rendered the strip", tab) t.Fatalf("tab %s rendered the strip", tab)
} }
@@ -1903,7 +1899,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
srv, st := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st) 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 { 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()) 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 tab string
want, dontWant []string want, dontWant []string
}{ }{
// A series being read can be shelved or completed, not restored. // A series being read can be shelved, not restored, and there is no
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil}, // finish control any more (issue #157).
// An archived one can come back or be completed. {"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil}, // An archived one can come back.
// A finished one can only come back. {"archived", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) { t.Run(tc.tab, func(t *testing.T) {
@@ -2010,11 +2005,14 @@ func TestAppRendersNewTabs(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req) 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) { if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("app page missing %s", 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 // 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{ for _, want := range []string{
"/?lib=novel&amp;tab=fav", "/?lib=novel&amp;tab=fav",
"/?lib=novel&amp;tab=archived", "/?lib=novel&amp;tab=archived",
"/?lib=novel&amp;tab=finished",
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Fatalf("novel page missing tab link %s", 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, `<option value="finished" selected>`) {
t.Errorf("the finished option is not selected:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the // ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
// kagane manga stale row renders, and every link the page emits carries the // kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL. // filter and Site so the narrowing survives in the URL.
@@ -2994,6 +3027,67 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
} }
} }
// The finished figure rides last in the Hygiene block: seriesFilterOrder
// appends it at the tail, the block renders order[1:], and the count comes
// from the same aggregate the select is numbered from. Asserting on the body
// order catches a render that merely shows the figure somewhere.
func TestOverviewFinishedFigureRidesLast(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: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "https://asurascans.com/comics/retired", cover: "bbb", 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 := overviewBody(t, srv, st)
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
var labels []string
for _, m := range stat.FindAllStringSubmatch(body, -1) {
switch m[1] {
case "Series", "Manga", "Novels", "Readers":
continue // the Library block, not the hygiene figures
}
labels = append(labels, m[1])
}
if len(labels) != 8 {
t.Fatalf("hygiene block renders %d figures, want 8 (seven problems + finished):\n%s", len(labels), body)
}
if labels[len(labels)-1] != "Finished" {
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
}
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
t.Errorf("the finished figure lacks its count and door:\n%s", body)
}
}
// A measured zero finished count stays on the page as a muted digit, never a
// link: following it would land on an empty list, exactly like every other
// zero figure.
func TestOverviewFinishedZeroIsAnUnlinkedDigit(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:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
if !strings.Contains(body, `Finished</span><span class="fig zero">0</span>`) {
t.Errorf("a zero finished figure does not render as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=finished"`) {
t.Errorf("a zero finished figure is still a link:\n%s", body)
}
}
// The waiting figure sums Due over the latest pass per Site — older passes // The waiting figure sums Due over the latest pass per Site — older passes
// for the same Site must not double-count, so the verdict reads the same // for the same Site must not double-count, so the verdict reads the same
// latest-per-Site projection the Lanes page reads. // latest-per-Site projection the Lanes page reads.
@@ -3430,6 +3524,169 @@ func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
} }
} }
// The finish control lives on the Series detail page only: a neighbouring
// Finish in the fifty-row grid is not a harmless read the way Check now is,
// so the list row renders the state as a mark and offers no control to set
// it. On the detail page the press is confirm-gated — the opener does not
// post and the only finish POST sits inside the confirm row, targeting the
// meta fragment it will swap (#158).
func TestAdminSeriesDetailFinishControl(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`id="confirm-finish"`,
"Mark this Series finished?",
`hx-post="/admin/series/asura:solo/finish"`,
`hx-target="#detail-meta"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
// The press travels through the confirm row: the page's only finish POST
// sits after the row's id (inside the row), and the opener that reveals
// it posts nothing.
if opener := strings.Index(body, `id="confirm-finish"`); opener < 0 ||
strings.Index(body, `hx-post="/admin/series/asura:solo/finish"`) < opener {
t.Errorf("finish POST is not inside the confirm row:\n%s", body)
}
if strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`) != 1 {
t.Errorf("finish POST count = %d, want exactly one (the affirmative):\n%s",
strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`), body)
}
// The list row displays nothing to set: no finish control, no finished
// mark on an unfinished Series.
list := adminSeriesPage(t, router, st, "")
for _, banned := range []string{
`/admin/series/asura:solo/finish`,
`/admin/series/asura:solo/unfinish`,
`mark-faint">finished`,
} {
if strings.Contains(list, banned) {
t.Errorf("list row carries %q:\n%s", banned, list)
}
}
}
// The finish route is the owner's one writer: a finish press stamps
// finished_at and answers with the swapped detail-meta fragment carrying the
// "finished <age> ago" mark, and an un-finish press writes zero and answers
// with a fragment that has no finished line. A malformed key is a 400 and an
// unknown one a 404, like the other Series mutations (#158).
func TestSeriesFinishRoute(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:solo", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
for path, want := range map[string]int{
"/admin/series/solo/finish": http.StatusBadRequest,
"/admin/series/ghost:x/finish": http.StatusNotFound,
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != want {
t.Errorf("POST %s: status = %d, want %d", path, rr.Code, want)
}
}
// A finish press stamps and answers with the swapped meta fragment whose
// mark reads "finished just now".
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/finish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST finish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("finish answer is not the meta fragment:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("finish answer lacks the fresh finished mark:\n%s", body)
}
var at int64
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at: %v", err)
}
if at == 0 {
t.Error("finished_at = 0, want the finish stamp written")
}
// The page after the press offers the instant reversal, no confirm.
body = seriesDetailPage(t, router, st, "asura:solo")
if !strings.Contains(body, `hx-post="/admin/series/asura:solo/unfinish"`) {
t.Errorf("finished detail page lacks the un-finish control:\n%s", body)
}
if strings.Contains(body, "Mark this Series finished?") {
t.Errorf("finished detail page still carries the confirm row:\n%s", body)
}
// An un-finish press fires instantly and writes zero.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/unfinish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST unfinish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("unfinish answer is not the meta fragment:\n%s", body)
}
if strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("unfinish answer still carries the finished mark:\n%s", body)
}
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at after un-finish: %v", err)
}
if at != 0 {
t.Errorf("finished_at = %d after un-finish, want 0", at)
}
}
// A finished Series shows its state in the list row as a faint mark, and the
// row offers no control to set or clear it — the reversal lives on the
// detail page, its press, and the mark is the whole of the row's share
// (#158).
func TestAdminSeriesListShowsFinished(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
if err := st.SetSeriesFinished("asura", "solo", time.Now().Add(-2*time.Minute).UnixMilli()); err != nil {
t.Fatalf("SetSeriesFinished: %v", err)
}
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, `<span class="mark mark-faint">finished</span>`) {
t.Errorf("list row lacks the finished mark:\n%s", body)
}
for _, banned := range []string{"solo/finish", "solo/unfinish"} {
if strings.Contains(body, banned) {
t.Errorf("list row offers %q:\n%s", banned, body)
}
}
}
// The series URL repair validates with the poller's own fetch gate and // The series URL repair validates with the poller's own fetch gate and
// answers 400 before anything reaches the store; a URL that passes the gate // answers 400 before anything reaches the store; a URL that passes the gate
// is stored where an Upsert would have ignored it. The request performs no // is stored where an Upsert would have ignored it. The request performs no
+109
View File
@@ -0,0 +1,109 @@
# ADR-0015: Finished is a fact about the Series, not a bookmark bucket
Date: 2026-08-22
Status: accepted
## Decision
"Finished" moves from the per-Reader `bookmarks.status` bucket to a
Series-owned flag: `series.finished_at`, unix ms, zero while the Series is
still running. The Lane's gate reads the flag — a Series is polled only while
`finished_at = 0` — and `bookmarks.status` keeps exactly two values,
`reading` and `archived`.
The cutover is one-way, done by migration 0016 in three load-bearing
statements:
1. `ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0`.
2. Seed it from the bookmarks: a Series is stamped finished when no bookmark
on it is outside the `finished` bucket. This mirrors the pre-cutover due
gate exactly — the old query skipped a Series only while
`COUNT(*) FILTER (WHERE status <> 'finished') = 0` — so no Series changes
polling state at the cutover.
3. Rewrite every `finished` bookmark to `archived`. The bucket is gone; the
seed ran first because it is the only statement that can still read it.
The JSON API rejects a `finished` status with the same plain 400 as any
unknown value, and the web UI no longer offers a Finished tab, a finish
button, or a finished state badge.
## Why a future reader will find this surprising
The bucket looked Reader-shaped but described a Series fact. A Series is
finished once, and every Reader reading it is then on a finished Series —
yet the bucket carried three copies of the answer, one per Reader, free to
disagree. The disagreement is not theoretical: a second Reader who merely
kept the Series (or never read it) kept it in `reading`, so the poll gate
kept fetching a Series the first Reader had closed out, forever. Worse, the
disagreement was never resolvable — nothing in the system could say "this
Series is finished" without rewriting every bookmark, which silently edits
another Reader's progress state.
The flag is also the only memory of the bucket after the flip. `finished`
bookmarks become `archived` because a two-value status needs no third
value, and an archived row must keep meaning "shelved, but the Series is
being watched" — which is what the row says. The migration's seed is what
keeps the legacy meaning: a Series every Reader finished is stamped, so the
Lane stops polling it just as it would have pre-cutover; a Series any
Reader still reads is left alone, exactly as the old gate left it. A
Series whose every Reader only shelved (archived) continues to be polled,
because an archived bookmark is *supposed* to be polled — the cutover
changes the answer, it does not invent it. And because the flag is a series
fact, the cutover also repairs the disagreement case: the moment one Reader
has the Series open, it reads as finished to everyone.
The migration is the only writer of the flag today; the undo is writing 0,
which returns the Series to the poll. An owner-facing "mark finished" write
is deliberately not part of this change — the gate is what this ticket
rewrites, and the write can land on top of it without touching anything
here.
The userscript merge ranks `archived > reading` now. `finished` is not a
value the wire can carry, so the merge cannot un-finish a row — it cannot
even name the state it is protecting.
## Considered options
**Keep the bucket and add the flag alongside it, both live.**
Rejected: two sources of truth for one fact, with the Lane forced to
resolve "any Reader finished?" on every due query and every Reader write
still able to resurrect a finished Series. The whole point of the change is
that the finished state survives Readers.
**Stamp a Series finished when every bookmark is finished *or* archived.**
Rejected: it flips polling state at the cutover. Shelved-only Series were
polled before; making them finished stops the checks the reader knowingly
asked to keep.
**Finish as "no bookmark is reading", leaving the buckets untouched.**
Rejected for the same reason plus one: `archived` is a Reader's own state
and the flip is what makes the flag the *only* source of finished. Keeping
the `finished` value in the table would force every status validation,
merge and UI branch to keep handling a value no write can produce.
## Consequences
- `bookmarks.status` is validated to `reading | archived`, empty meaning
"keep the stored value"; the API's 400 for `finished` is now the generic
invalid-status rejection rather than a special case, and the web UI's own
status control rejects it the same way.
- The Lane due query and the eligible count read `finished_at`; a Forced
Poll (issue #146) still overrides the flag — the owner asked, so the Lane
looks — and the pending force clears itself when the pass stamps the
check timestamp, never by touching `finished_at`.
- The web UI has no Finished tab; finished Series render in Archived with
their archived badge, dimmed like any shelved row.
- Migration 0016 stamps `finished_at` with the migration's own clock
(`now()` ms), which is also the undo: write 0 and the Series returns to
the poll.
## Cost of reversing
The finished buckets are destroyed by the flip; reversing means re-deriving
per-Reader finished state from a Series fact that now encodes the
majority-agreement snapshot plus whatever the owner reset since. The stamp
differentiates "finished at the cutover and untouched" from "finished
later", but not which Reader's choice each Series carried, and any Series
the owner has since restored is gone from the derivation entirely. The ADR
is a statement of intent to ship the one-way cutover and live with its
consequences; the per-Reader history is not kept anywhere in the schema.
+14 -14
View File
@@ -2,7 +2,7 @@
Source of truth: the Claude Design project **BookmarkManager Web UI** Source of truth: the Claude Design project **BookmarkManager Web UI**
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings (`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`, `archived.html`/`fav.html`/`new.html`/`login.html`/`mobile.html`,
`style.css`, `filter.js`). This file records the rules that got implemented so `style.css`, `filter.js`). This file records the rules that got implemented so
a future agent can extend the UI without re-reading the design. a future agent can extend the UI without re-reading the design.
@@ -48,7 +48,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--ink` | `#100f0e` | `#f7f4ef` | page | | `--ink` | `#100f0e` | `#f7f4ef` | page |
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) | | `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background | | `--dim` | `#0d0c0b` | `#f1ede7` | archived row background |
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders | | `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges | | `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline | | `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline |
@@ -70,7 +70,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash | | `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal | | `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent | | `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
| `--moss` | `#7fae86` | `#3d6c46` | finished accent | | `--moss` | `#7fae86` | `#3d6c46` | finished Series label |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent | | `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 | | `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked | | `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
@@ -164,9 +164,9 @@ rather than scaling the artwork down.
**Action key** (`.keyrow`): one permanent line under the tabs naming what **Action key** (`.keyrow`): one permanent line under the tabs naming what
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done / every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
Delete — so the icon strip on a card is never a guess. The key follows the tab, Delete — so the icon strip on a card is never a guess. The key follows the
not the row: Archive becomes Restore under Archived and Finished, and Finished tab, not the row: under Archived, Archive becomes Restore. On a phone each
drops Done. On a phone each pair stacks icon-over-word pair stacks icon-over-word
(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays (`flex-direction: column`) so the word gets the full cell width; ≥720px it lays
out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash` out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash`
carry their icon's resting accent so the key itself teaches the colour carry their icon's resting accent so the key itself teaches the colour
@@ -179,7 +179,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
.row .row
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass] a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
.body .title-line (h3.title + svg.fav-mark) , p.meta .body .title-line (h3.title + svg.fav-mark) , p.meta
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove .actions play, favourite, chapter | lifecycle: archive/restore, remove
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known) form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel) .confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
p.error-inline[hidden] p.error-inline[hidden]
@@ -188,7 +188,7 @@ article.card[.is-new|.is-dim]#card-<key>[data-title]
Rules that are easy to break: Rules that are easy to break:
- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for - `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for
`archived` and `finished`. Both are set on the `<article>` — every heat and `archived`. Both are set on the `<article>` — every heat and
dim rule is a descendant selector off those two classes, so a new sub-element dim rule is a descendant selector off those two classes, so a new sub-element
inherits the state for free. inherits the state for free.
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a - `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
@@ -196,19 +196,19 @@ Rules that are easy to break:
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
`border-right: 1px var(--rule)`, last child none. `border-right: 1px var(--rule)`, last child none.
- Three clusters by consequence, in this order: navigate (`.play`) | organize - Three clusters by consequence, in this order: navigate (`.play`) | organize
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`, (`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.remove`,
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
`--ash` ground so the thumb reads "this one moves the series" before it `--ash` ground so the thumb reads "this one moves the series" before it
reads which icon it landed on; ≥720px they separate by a 10px gap instead of reads which icon it landed on; ≥720px they separate by a 10px gap instead of
the phone's inset hairline. the phone's inset hairline.
- Every lifecycle button that moves a series out of the list is - Every lifecycle button that moves a series out of the list is
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`, **confirm-gated**: it opens its own `.confirm-row` (`archive`,
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish `remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive asks in
ask in `.calm` grey since they're reversible; remove alone gets the `.calm` grey since it's reversible; remove alone gets the
`--danger-wash` treatment and names the series in its question. Restore `--danger-wash` treatment and names the series in its question. Restore
fires instantly — no confirm — because it's the reversal. fires instantly — no confirm — because it's the reversal.
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`, - Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember `.box` → `--slate`. `.play` stays paper/ember (ember
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
hover. Desktop cell borders follow the same accent on hover hover. Desktop cell borders follow the same accent on hover
(`border-color: currentColor`); the two coloured *resting* states (`border-color: currentColor`); the two coloured *resting* states
@@ -286,7 +286,7 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
never reuse `--ember` or `--danger` for anything but their one meaning. never reuse `--ember` or `--danger` for anything but their one meaning.
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a 3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
third state class. third state class.
4. If it removes a series from the current view (archive/finish/remove-shaped), 4. If it removes a series from the current view (archive/remove-shaped),
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
the only instant action because it's the one that's reversible by nature. the only instant action because it's the one that's reversible by nature.
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays 5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
+23 -10
View File
@@ -471,9 +471,10 @@
} }
// Only an explicit archive/restore has an opinion about the bucket. Every // Only an explicit archive/restore has an opinion about the bucket. Every
// other write omits `status`, so the server keeps the stored one — otherwise // other write omits `status`, so the server keeps the stored one —
// a cached value would resend "finished" (which the API rejects with 400) or // otherwise a cached value would resend a stale status (the API rejects
// silently un-archive a series archived on another device. // unknown values with 400) or silently un-archive a series archived on
// another device.
async function apiPut(key, obj, { sendStatus = false } = {}) { async function apiPut(key, obj, { sendStatus = false } = {}) {
const body = Object.assign({}, obj); const body = Object.assign({}, obj);
if (!sendStatus) delete body.status; if (!sendStatus) delete body.status;
@@ -550,6 +551,12 @@
return b.kind || "manga"; return b.kind || "manga";
} }
// finished is a fact about the Series, decided from the owner's side and
// derived on the wire (issue #157); the panel only labels it.
function finishedLabel(b) {
return b && b.finished ? "Finished" : "";
}
// ============================================================ // ============================================================
// Retry queue // Retry queue
// //
@@ -888,8 +895,8 @@
} }
// Archive parks a series: it leaves All and Favourites but the server keeps // Archive parks a series: it leaves All and Favourites but the server keeps
// polling it for new chapters. "finished" is deliberately not reachable from // polling it for new chapters. finished is not a bucket the script can
// here — the API rejects that value, it is a web-UI decision. // reach — it is a fact about the Series now (issue #157).
async function toggleArchive(key) { async function toggleArchive(key) {
const existing = state.byKey[key]; const existing = state.byKey[key];
if (!existing) return; if (!existing) return;
@@ -966,7 +973,6 @@
const now = Date.now(); const now = Date.now();
const due = state.list const due = state.list
.filter((b) => b.site === site && b.series_url) .filter((b) => b.site === site && b.series_url)
.filter((b) => statusOf(b) !== "finished")
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
.slice(0, LATEST_CHECK_BATCH); .slice(0, LATEST_CHECK_BATCH);
@@ -1335,8 +1341,8 @@
} }
// Tabs narrow what is drawn; state.list always holds every bookmark. // Tabs narrow what is drawn; state.list always holds every bookmark.
// Archived rows are hidden from All and Favourites, and finished ones — // Archived rows are hidden from All and Favourites; a Series that is
// which only the web UI can set — are hidden from every tab. // finished is the web UI's business and arrives archived (issue #157).
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
root.getElementById(id).classList.toggle("active", activeTab === tab); root.getElementById(id).classList.toggle("active", activeTab === tab);
} }
@@ -1399,6 +1405,7 @@
el("div", { class: "meta" }, [ el("div", { class: "meta" }, [
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }), el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
b.finished && el("span", { class: "finished", text: finishedLabel(b) }),
el("div", { class: "actions" }, [ el("div", { class: "actions" }, [
el("button", { el("button", {
class: "btn small star" + (b.favorite ? " on" : ""), class: "btn small star" + (b.favorite ? " on" : ""),
@@ -1669,7 +1676,7 @@
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb; --paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b; --mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907; --ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --moss: #7fae86;
--font-display: Georgia, "Times New Roman", serif; --font-display: Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--font-body: system-ui, -apple-system, sans-serif; --font-body: system-ui, -apple-system, sans-serif;
@@ -1788,6 +1795,12 @@
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim); font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
/* finished: a Series fact, not a state class (issue #157) — same meta
typography as .c, moss instead of mute. */
.finished {
margin: 0; color: var(--moss);
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
}
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; } .item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
.item.dim .t { font-style: italic; color: var(--mute); } .item.dim .t { font-style: italic; color: var(--mute); }
.c { .c {
@@ -1838,7 +1851,7 @@
// Exposes pure logic only — see userscript/test/logic.test.js. // Exposes pure logic only — see userscript/test/logic.test.js.
// ============================================================ // ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) { if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf }; module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf, finishedLabel };
} }
// ============================================================ // ============================================================
+27 -13
View File
@@ -268,8 +268,9 @@
// Duplicate case (spec user story 2): one row must survive, and it is // Duplicate case (spec user story 2): one row must survive, and it is
// the one already under the repaired key — with the stale row's // the one already under the repaired key — with the stale row's
// progress carried across when it is ahead, favourite OR'd, and the // progress carried across when it is ahead, favourite OR'd, and the
// stronger lifecycle bucket kept (finished > archived > reading, so a // stronger lifecycle bucket kept (archived > reading, so a merge can
// merge can never silently un-archive or un-finish a row). // never silently un-archive a row). finished is not a bucket the
// script can see (issue #157).
const merged = Object.assign({}, existing); const merged = Object.assign({}, existing);
if ( if (
existing.last_chapter_num == null || existing.last_chapter_num == null ||
@@ -280,7 +281,7 @@
merged.last_chapter_url = stale.last_chapter_url; merged.last_chapter_url = stale.last_chapter_url;
} }
merged.favorite = !!(existing.favorite || stale.favorite); merged.favorite = !!(existing.favorite || stale.favorite);
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0); const rank = (s) => ({ archived: 1, reading: 0 }[s || "reading"] || 0);
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status; merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0); merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b)); listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
@@ -405,9 +406,10 @@
} }
// Only an explicit archive/restore has an opinion about the bucket. Every // Only an explicit archive/restore has an opinion about the bucket. Every
// other write omits `status`, so the server keeps the stored one — otherwise // other write omits `status`, so the server keeps the stored one —
// a cached value would resend "finished" (which the API rejects with 400) or // otherwise a cached value would resend a stale status (the API rejects
// silently un-archive a series archived on another device. // unknown values with 400) or silently un-archive a series archived on
// another device.
async function apiPut(key, obj, { sendStatus = false } = {}) { async function apiPut(key, obj, { sendStatus = false } = {}) {
const body = Object.assign({}, obj); const body = Object.assign({}, obj);
if (!sendStatus) delete body.status; if (!sendStatus) delete body.status;
@@ -484,6 +486,12 @@
return b.kind || "manga"; return b.kind || "manga";
} }
// finished is a fact about the Series, decided from the owner's side and
// derived on the wire (issue #157); the panel only labels it.
function finishedLabel(b) {
return b && b.finished ? "Finished" : "";
}
// ============================================================ // ============================================================
// Retry queue // Retry queue
// //
@@ -792,8 +800,8 @@
} }
// Archive parks a series: it leaves All and Favourites but the server keeps // Archive parks a series: it leaves All and Favourites but the server keeps
// polling it for new chapters. "finished" is deliberately not reachable from // polling it for new chapters. finished is not a bucket the script can
// here — the API rejects that value, it is a web-UI decision. // reach — it is a fact about the Series now (issue #157).
async function toggleArchive(key) { async function toggleArchive(key) {
const existing = state.byKey[key]; const existing = state.byKey[key];
if (!existing) return; if (!existing) return;
@@ -878,7 +886,6 @@
const now = Date.now(); const now = Date.now();
const due = state.list const due = state.list
.filter((b) => b.site === site && b.series_url) .filter((b) => b.site === site && b.series_url)
.filter((b) => statusOf(b) !== "finished")
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
.slice(0, LATEST_CHECK_BATCH); .slice(0, LATEST_CHECK_BATCH);
@@ -1241,8 +1248,8 @@
} }
// Tabs narrow what is drawn; state.list always holds every bookmark. // Tabs narrow what is drawn; state.list always holds every bookmark.
// Archived rows are hidden from All and Favourites, and finished ones — // Archived rows are hidden from All and Favourites; a Series that is
// which only the web UI can set — are hidden from every tab. // finished is the web UI's business and arrives archived (issue #157).
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
root.getElementById(id).classList.toggle("active", activeTab === tab); root.getElementById(id).classList.toggle("active", activeTab === tab);
} }
@@ -1305,6 +1312,7 @@
el("div", { class: "meta" }, [ el("div", { class: "meta" }, [
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }), el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
b.finished && el("span", { class: "finished", text: finishedLabel(b) }),
el("div", { class: "actions" }, [ el("div", { class: "actions" }, [
el("button", { el("button", {
class: "btn small star" + (b.favorite ? " on" : ""), class: "btn small star" + (b.favorite ? " on" : ""),
@@ -1593,7 +1601,7 @@
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb; --paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b; --mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907; --ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --moss: #7fae86;
--font-display: Georgia, "Times New Roman", serif; --font-display: Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--font-body: system-ui, -apple-system, sans-serif; --font-body: system-ui, -apple-system, sans-serif;
@@ -1719,6 +1727,12 @@
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
} }
.c.behind { color: var(--ember); } .c.behind { color: var(--ember); }
/* finished: a Series fact, not a state class (issue #157) — same meta
typography as .c, moss instead of mute. */
.finished {
margin: 0; color: var(--moss);
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
}
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; } .actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
.btn { .btn {
background: none; color: var(--mute); border: 1px solid var(--rule); background: none; color: var(--mute); border: 1px solid var(--rule);
@@ -1762,7 +1776,7 @@
// Exposes pure logic only — see userscript/test/novel-logic.test.js. // Exposes pure logic only — see userscript/test/novel-logic.test.js.
// ============================================================ // ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) { if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow }; module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow, finishedLabel };
} }
// ============================================================ // ============================================================
+13 -1
View File
@@ -58,6 +58,7 @@ const {
anchorsFromHTML, anchorsFromHTML,
statusOf, statusOf,
kindOf, kindOf,
finishedLabel,
} = require("../manga-bookmark.user.js"); } = require("../manga-bookmark.user.js");
// detect() reads only these four properties off location. // detect() reads only these four properties off location.
@@ -395,7 +396,18 @@ test("statusOf defaults a missing status to reading", () => {
assert.equal(statusOf({}), "reading"); assert.equal(statusOf({}), "reading");
assert.equal(statusOf({ status: "" }), "reading"); assert.equal(statusOf({ status: "" }), "reading");
assert.equal(statusOf({ status: "archived" }), "archived"); assert.equal(statusOf({ status: "archived" }), "archived");
assert.equal(statusOf({ status: "finished" }), "finished"); });
// ============================================================
// finishedLabel — finished is a fact about the Series (issue #157), which is
// the server's business; the panel only names it. The label is text through
// the el() helper, never markup.
// ============================================================
test("finishedLabel names a finished series and nothing else", () => {
assert.equal(finishedLabel({ finished: true }), "Finished");
assert.equal(finishedLabel({ finished: false }), "");
assert.equal(finishedLabel({}), "");
}); });
// ============================================================ // ============================================================
+30
View File
@@ -49,6 +49,7 @@ const {
kindOf, kindOf,
maxChapter, maxChapter,
repairLnwStaleRow, repairLnwStaleRow,
finishedLabel,
} = require("../novel-bookmark.user.js"); } = require("../novel-bookmark.user.js");
function loc(href) { function loc(href) {
@@ -563,6 +564,23 @@ test("repairLnwStaleRow does not regress progress when the repaired-key row is a
assert.equal(out.list[0].last_chapter_num, 100); assert.equal(out.list[0].last_chapter_num, 100);
}); });
// ============================================================
// Merge rank — exactly two values: archived beats reading, and nothing else
// can ever win (issue #157 retired the finished bucket).
// ============================================================
test("repairLnwStaleRow merge keeps archived over reading and lets no third value win", () => {
const canonical = (over) => staleRow(Object.assign({ key: "lightnovelworld:immortality-simulator" }, over));
// archived beats reading whether it arrives as the stale row or the repaired one
let out = repairLnwStaleRow([canonical({ status: "archived" }), staleRow({ status: "reading" })], [], {}, lnwPage());
assert.equal(out.list[0].status, "archived");
out = repairLnwStaleRow([staleRow({ status: "archived" }), canonical({ status: "reading" })], [], {}, lnwPage());
assert.equal(out.list[0].status, "archived");
// "finished" is not a bucket anymore: a row carrying it reads as the
// default, so it can never win (issue #157).
out = repairLnwStaleRow([staleRow({ status: "finished" }), canonical({ status: "reading" })], [], {}, lnwPage());
assert.equal(out.list[0].status, "reading");
});
// ============================================================ // ============================================================
// kindOf // kindOf
// ============================================================ // ============================================================
@@ -575,6 +593,18 @@ test("kindOf passes through novel", () => {
assert.equal(kindOf({ kind: "novel" }), "novel"); assert.equal(kindOf({ kind: "novel" }), "novel");
}); });
// ============================================================
// finishedLabel — finished is a fact about the Series (issue #157), which is
// the server's business; the panel only names it. The label is text through
// the el() helper, never markup.
// ============================================================
test("finishedLabel names a finished series and nothing else", () => {
assert.equal(finishedLabel({ finished: true }), "Finished");
assert.equal(finishedLabel({ finished: false }), "");
assert.equal(finishedLabel({}), "");
});
// ============================================================ // ============================================================
// Source guard // Source guard
// //