Hygiene filters for the Series view #118

Closed
opened 2026-08-17 15:22:43 +07:00 by sulthan · 4 comments
Owner

Part of #114
Blocked by: #116, #117

Question

Which "broken Series" classes can the Series view filter to?

Candidates raised while charting: no cover stored (cover_address = ''); never successfully
polled (latest_checked_at = 0, or latest_chapter_num IS NULL meaning never captured); last
success older than N days; series_url that fails fetchableSeriesURL; Latest Chapter that went
backwards; a Series no Reader is still reading.

Deliberately not pre-decided: each filter is a real query, and at least one — Latest Chapter
regressing — is only detectable if poll history exists, which is why this waits on that decision.

To decide: the final filter list, each one's exact predicate against the schema, which are
navigable as URLs, and which of them feed the summary line's problem count.

Part of #114 Blocked by: #116, #117 ## Question Which "broken Series" classes can the Series view filter to? Candidates raised while charting: no cover stored (`cover_address = ''`); never successfully polled (`latest_checked_at = 0`, or `latest_chapter_num IS NULL` meaning never captured); last success older than N days; `series_url` that fails `fetchableSeriesURL`; Latest Chapter that went backwards; a Series no Reader is still reading. Deliberately not pre-decided: each filter is a real query, and at least one — Latest Chapter regressing — is only detectable if poll history exists, which is why this waits on that decision. To decide: the final filter list, each one's exact predicate against the schema, which are navigable as URLs, and which of them feed the summary line's problem count.
sulthan added the wayfinder:grilling label 2026-08-17 15:22:43 +07:00
sulthan self-assigned this 2026-08-17 19:45:30 +07:00
Author
Owner

Resolution

The filter vocabulary closes at eight names, one candidate is rejected outright, and the
presentation rules are fixed: every count is its own entry point, a zero is a figure and not a
door, and nothing links to a list that cannot answer for it.

The eighth filter: no-chapter

Added here, on top of the seven #116 fixed:

?filter= predicate
no-chapter s.latest_chapter_num IS NULL AND s.latest_checked_at > 0 AND s.series_url <> ''

A Series the poller has attempted and has never once read a chapter number from. Free: no
column, no migration, no write on the poll path. It is queryable today because
MarkLatestChecked stamps latest_checked_at before the fetch (poller.go:428) while a failed
read never reaches SetLatestChapter — poller_test.go:345-348 asserts exactly that pair
(timestamp stamped, latest_chapter_num nil) across all four failure modes.

Named to match #117's no_chapter pass count deliberately: same observation, one made durable on
the row, the other counted over a 12h window. It does not make #127 redundant — this catches a
Series that never worked, #127 catches one that worked for a year and broke on Tuesday. The two
coexist, and neither is a superset.

Disjoint from unchecked by construction (latest_checked_at = 0 vs > 0), so a Series is in at
most one of them and the two counts never double-report the same row.

Rejected: a "Latest Chapter went backwards" filter

Not implementable and not wanted, in that order.

Not implementable: the row holds only the current number. SetLatestChapter (store.go:1063)
writes downward unconditionally — poller.go:474 skips only the equal case — and Upsert
(store.go:858-869) lets a Reader's PUT lower it too. Detection would need the fact captured at
write time in a new column; #117 already rejected per-Series check history on cost.

Not wanted: a downward write is the correction, not the fault. poller_test.go:481-497
seeds 400 and asserts 296 after a poll — lowering is tested, intended behaviour. Given #79
(lightnovelworld's chapter list maxing at 1317 while the site's own newest indicator says 1298),
an inflated number falsely lights the ember for every Reader of that Series; the write that brings
it back to 1298 is the system healing, and a regression filter would flag precisely the Series
that just got fixed.

And it misses the case that actually costs something: an adapter reading a stable wrong 1317
every hour never moves, so nothing ever fires. That is #79's real shape — a confidently wrong
number — and no filter can see it. sighting-raised remains the only honest suspicion lens (the
stored value came from a Reader's report, not a Poll), and hand repair is #121.

Labels

Query-string names are the API and stay exactly as #116 fixed them; these are display strings
only. Ordered permanent-and-fixable first, informational last — the order of the stats block rows
and of the filter row:

?filter= label
unpollable No series URL
no-chapter Never read a chapter
orphan No Readers
unchecked Never checked
stale Not checked in 12h
no-cover No cover
sighting-raised Latest from a Reader

unpollable and orphan read as the repair they need rather than as the SQL they are — the owner
arrives at this block to act, not to admire a predicate.

Every figure is an entry point, including the non-hygiene ones

#115 made the hygiene counts links. Extended here to the rest of the block, so it doubles as the
navigation for the whole surface and no number is a fact you then have to go hunting for:

figure href
total Series /admin/series
manga / novel split /admin/series?kind=manga , ?kind=novel
per-Site row label /admin/series?site=<site>
Reader roster count /admin/readers
each hygiene count /admin/series?filter=<name>
per-Site hygiene count /admin/series?site=<site>&filter=<name>

A zero renders the digit and is not a link. The figure stays — a measured zero is a real fact,
and hiding it would repeat the mistake lanes.html avoids — but no anchor, because following it
lands on an empty list, which is navigation that costs a click and returns nothing.

The filtered list

  • One filter at a time. SeriesFilter.Filter holds one value; ?site= and ?kind= stack on
    top and are separate controls, so narrowing to a Site never silently drops the filter.
  • Filter row of plain links, active one marked with the existing .attention class, plus an
    All series reset. Never --ember (New Chapter only), never --danger (destruction).
  • Heading states <N> series · <label>, N being #116's COUNT(*) OVER () total, so the figure the
    landing page promised and the figure the list shows come from one query.
  • The empty state names the filter it is empty for — "No Series with no cover" — not a bare
    "nothing here". An empty hygiene list is good news and should read as an answer.

No aggregate problem count

The summary line stays exactly as #117 left it: <verdict> · N series waiting · M unchecked over 12h. No third "N problems" figure, because the classes overlap — one orphaned Series with no
series_url and no cover is three counts and one row — so a sum over-reports while a DISTINCT
count is a number nothing can be done about. The stats block is the whole hygiene surface, and
every figure on it is individually actionable.

The per-Site outcome counts link nowhere yet

#117 pushed this here. The five sums (refused / unreachable / no_chapter / unfetchable /
errors) render as named text with the none observed zero wording, unlinked, because the
pass row stores counts and never identities — there is no list of the four refused Series to point
at, and "every Series on this Site" is a haystack posing as an answer.

After #127 lands, all five link to /admin/series?site=<site>&filter=failing.

Considered and dropped: wiring no_chapter up immediately, since this ticket gives it a filter.
The sets differ — the count means attempts that read no chapter in the last 12h, the filter means
never once succeeded, and a Series that broke this morning is in one and not the other.
no-chapter stays reachable from the stats block, where it is exact.

Boundary with #122

This ticket fixes the list's frame: filter row, heading, empty state, and what links where. Which
SeriesRow fields render on a row, in what order, at what density — plus #119's Check now
control — is the prototype's call, not settled by prose here.

Pushed onto other tickets

  • #116 — no-chapter joins the filter enum as an eighth predicate constant, and SeriesStats
    gains the matching COUNT(*) FILTER (WHERE latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> '') column so the landing block can show it. No new SQL shape, one more
    FILTER in the pass that already exists.
  • #127 — failing is the ninth name, not a replacement for no-chapter; its threshold must
    therefore only cover Series that have succeeded at least once. It also inherits the link target
    for all five outcome counts.
  • #122 — prototypes a filter row with one active filter, a <N> series · <label> heading, and
    a filter-specific empty state; the stats block is all links except the zeroes.
  • #121 — a Latest Chapter that is confidently wrong without ever moving is not findable by any
    filter, so hand repair carries that whole class alone.
## Resolution The filter vocabulary closes at **eight** names, one candidate is rejected outright, and the presentation rules are fixed: every count is its own entry point, a zero is a figure and not a door, and nothing links to a list that cannot answer for it. ### The eighth filter: no-chapter Added here, on top of the seven #116 fixed: | `?filter=` | predicate | |---|---| | `no-chapter` | `s.latest_chapter_num IS NULL AND s.latest_checked_at > 0 AND s.series_url <> ''` | A Series the poller has attempted and has **never once** read a chapter number from. Free: no column, no migration, no write on the poll path. It is queryable today because `MarkLatestChecked` stamps `latest_checked_at` *before* the fetch (poller.go:428) while a failed read never reaches `SetLatestChapter` — `poller_test.go:345-348` asserts exactly that pair (timestamp stamped, `latest_chapter_num` nil) across all four failure modes. Named to match #117's `no_chapter` pass count deliberately: same observation, one made durable on the row, the other counted over a 12h window. **It does not make #127 redundant** — this catches a Series that never worked, #127 catches one that worked for a year and broke on Tuesday. The two coexist, and neither is a superset. Disjoint from `unchecked` by construction (`latest_checked_at = 0` vs `> 0`), so a Series is in at most one of them and the two counts never double-report the same row. ### Rejected: a "Latest Chapter went backwards" filter Not implementable and not wanted, in that order. *Not implementable*: the row holds only the current number. `SetLatestChapter` (store.go:1063) writes downward unconditionally — poller.go:474 skips only the equal case — and `Upsert` (store.go:858-869) lets a Reader's PUT lower it too. Detection would need the fact captured at write time in a new column; #117 already rejected per-Series check history on cost. *Not wanted*: a downward write is the **correction**, not the fault. `poller_test.go:481-497` seeds 400 and asserts 296 after a poll — lowering is tested, intended behaviour. Given #79 (lightnovelworld's chapter list maxing at 1317 while the site's own newest indicator says 1298), an inflated number falsely lights the ember for every Reader of that Series; the write that brings it back to 1298 is the system healing, and a regression filter would flag precisely the Series that just got fixed. And it misses the case that actually costs something: an adapter reading a *stable* wrong 1317 every hour never moves, so nothing ever fires. That is #79's real shape — a confidently wrong number — and no filter can see it. `sighting-raised` remains the only honest suspicion lens (the stored value came from a Reader's report, not a Poll), and hand repair is #121. ### Labels Query-string names are the API and stay exactly as #116 fixed them; these are display strings only. Ordered permanent-and-fixable first, informational last — the order of the stats block rows and of the filter row: | `?filter=` | label | |---|---| | `unpollable` | No series URL | | `no-chapter` | Never read a chapter | | `orphan` | No Readers | | `unchecked` | Never checked | | `stale` | Not checked in 12h | | `no-cover` | No cover | | `sighting-raised` | Latest from a Reader | `unpollable` and `orphan` read as the repair they need rather than as the SQL they are — the owner arrives at this block to act, not to admire a predicate. ### Every figure is an entry point, including the non-hygiene ones #115 made the hygiene counts links. Extended here to the rest of the block, so it doubles as the navigation for the whole surface and no number is a fact you then have to go hunting for: | figure | href | |---|---| | total Series | `/admin/series` | | manga / novel split | `/admin/series?kind=manga` , `?kind=novel` | | per-Site row label | `/admin/series?site=<site>` | | Reader roster count | `/admin/readers` | | each hygiene count | `/admin/series?filter=<name>` | | per-Site hygiene count | `/admin/series?site=<site>&filter=<name>` | **A zero renders the digit and is not a link.** The figure stays — a measured zero is a real fact, and hiding it would repeat the mistake `lanes.html` avoids — but no anchor, because following it lands on an empty list, which is navigation that costs a click and returns nothing. ### The filtered list - **One filter at a time.** `SeriesFilter.Filter` holds one value; `?site=` and `?kind=` stack on top and are separate controls, so narrowing to a Site never silently drops the filter. - Filter row of plain links, active one marked with the existing `.attention` class, plus an **All series** reset. Never `--ember` (New Chapter only), never `--danger` (destruction). - Heading states `<N> series · <label>`, N being #116's `COUNT(*) OVER ()` total, so the figure the landing page promised and the figure the list shows come from one query. - **The empty state names the filter it is empty for** — "No Series with no cover" — not a bare "nothing here". An empty hygiene list is good news and should read as an answer. ### No aggregate problem count The summary line stays exactly as #117 left it: `<verdict> · N series waiting · M unchecked over 12h`. No third "N problems" figure, because the classes **overlap** — one orphaned Series with no `series_url` and no cover is three counts and one row — so a sum over-reports while a `DISTINCT` count is a number nothing can be done about. The stats block is the whole hygiene surface, and every figure on it is individually actionable. ### The per-Site outcome counts link nowhere yet #117 pushed this here. The five sums (`refused` / `unreachable` / `no_chapter` / `unfetchable` / `errors`) render as named text with the *none observed* zero wording, **unlinked**, because the pass row stores counts and never identities — there is no list of the four refused Series to point at, and "every Series on this Site" is a haystack posing as an answer. After #127 lands, all five link to `/admin/series?site=<site>&filter=failing`. Considered and dropped: wiring `no_chapter` up immediately, since this ticket gives it a filter. The sets differ — the count means *attempts that read no chapter in the last 12h*, the filter means *never once succeeded*, and a Series that broke this morning is in one and not the other. `no-chapter` stays reachable from the stats block, where it is exact. ### Boundary with #122 This ticket fixes the list's *frame*: filter row, heading, empty state, and what links where. Which `SeriesRow` fields render on a row, in what order, at what density — plus #119's *Check now* control — is the prototype's call, not settled by prose here. ### Pushed onto other tickets - **#116** — `no-chapter` joins the filter enum as an eighth predicate constant, and `SeriesStats` gains the matching `COUNT(*) FILTER (WHERE latest_chapter_num IS NULL AND latest_checked_at > 0 AND series_url <> '')` column so the landing block can show it. No new SQL shape, one more `FILTER` in the pass that already exists. - **#127** — `failing` is the ninth name, not a replacement for `no-chapter`; its threshold must therefore only cover Series that have succeeded at least once. It also inherits the link target for all five outcome counts. - **#122** — prototypes a filter row with one active filter, a `<N> series · <label>` heading, and a filter-specific empty state; the stats block is all links except the zeroes. - **#121** — a Latest Chapter that is confidently wrong without ever moving is not findable by any filter, so hand repair carries that whole class alone.
Author
Owner

Amended by #122 (closed): the filter control is a <select> whose option labels carry their counts (No cover (3)), not a row of eight plain links — eight hygiene labels do not read as chips. Site is a second select, kind a three-way segmented row. One filter at a time, URL-addressable; heading, empty state and the linked-figures-except-zero rule unchanged.

Amended by #122 (closed): the filter control is a `<select>` whose option labels carry their counts (`No cover (3)`), not a row of eight plain links — eight hygiene labels do not read as chips. Site is a second select, kind a three-way segmented row. One filter at a time, URL-addressable; heading, empty state and the linked-figures-except-zero rule unchanged.
Author
Owner

Amendment from #124 (closed) — the vocabulary you closed at eight takes a tenth name:

  • finished → s.finished_at > 0, labelled Finished, ordered last in the stats block as informational rather than fixable, with its own SeriesStats count. Your zero-renders-the-digit-unlinked rule and the one-filter-at-a-time <select> from #122 apply unchanged. failing stays #127's ninth.
  • I argued for adding no name at all (a deliberately finished Series is neither a problem nor actionable, which is the test that admitted your eight); the owner's call is that finished Series must be findable as a list, so the tenth name exists.
  • Four of your predicates gain AND s.finished_at = 0: stale, unchecked, no-cover, no-chapter. This is what keeps your "an empty hygiene list is good news" rule true — a finished Series is never checked again, so it would otherwise sit in stale permanently and the count would never return to zero.
  • unpollable, orphan and sighting-raised are deliberately not guarded: a finished Series with no series_url, no Readers, or a Latest Chapter that came from a Sighting is still a row worth repairing.
Amendment from #124 (closed) — the vocabulary you closed at eight takes a tenth name: - **`finished`** → `s.finished_at > 0`, labelled **Finished**, ordered **last** in the stats block as informational rather than fixable, with its own `SeriesStats` count. Your zero-renders-the-digit-unlinked rule and the one-filter-at-a-time `<select>` from #122 apply unchanged. `failing` stays #127's ninth. - I argued for adding no name at all (a deliberately finished Series is neither a problem nor actionable, which is the test that admitted your eight); the owner's call is that finished Series must be findable as a list, so the tenth name exists. - **Four of your predicates gain `AND s.finished_at = 0`**: `stale`, `unchecked`, `no-cover`, `no-chapter`. This is what keeps your "an empty hygiene list is good news" rule true — a finished Series is never checked again, so it would otherwise sit in `stale` permanently and the count would never return to zero. - `unpollable`, `orphan` and `sighting-raised` are deliberately **not** guarded: a finished Series with no `series_url`, no Readers, or a Latest Chapter that came from a Sighting is still a row worth repairing.
Author
Owner

Amendments from #127 (closed):

  • failing is the ninth name, as you reserved it, and it is disjoint from your no-chapter by construction: its predicate carries AND s.latest_chapter_num IS NOT NULL, so a Series that never once succeeded is in no-chapter and never in failing. Full test: a row exists in the new poll_failures table (migration 0017) AND s.latest_chapter_num IS NOT NULL AND f.failing_since older than ownerWindow (12h).
  • unverified is the eleventh name (finished took tenth in #124), ordered beside sighting-raised as a suspicion lens: s.latest_raised_by IS NOT NULL plus the failing test — a Latest Chapter that came from a Reader's Sighting and that no Poll has confirmed for over 12 hours. #121's correction control is the action it leads to.
  • #122's <select> carries both new options with counts, per its amendment to this ticket; one filter at a time and ?site=/?kind= stacking are unchanged.
  • Your rule won, against your own promise. "Every figure links to what it counts" is why #127 withdraws the "all five outcome counts point at ?filter=failing" line: refused and unreachable now write no per-Series state, and the other four count attempts in a 12h window rather than Series failing now. You had already applied this test once by refusing to link the no_chapter count to the no-chapter filter — #127 applies it to the remaining five. The Lanes page gets one separate per-Site navigation link instead.
Amendments from #127 (closed): - **`failing` is the ninth name, as you reserved it**, and it is disjoint from your `no-chapter` by construction: its predicate carries `AND s.latest_chapter_num IS NOT NULL`, so a Series that never once succeeded is in `no-chapter` and never in `failing`. Full test: a row exists in the new `poll_failures` table (migration 0017) `AND s.latest_chapter_num IS NOT NULL AND f.failing_since` older than `ownerWindow` (12h). - **`unverified` is the eleventh name** (`finished` took tenth in #124), ordered beside `sighting-raised` as a suspicion lens: `s.latest_raised_by IS NOT NULL` plus the `failing` test — a Latest Chapter that came from a Reader's Sighting and that no Poll has confirmed for over 12 hours. #121's correction control is the action it leads to. - **#122's `<select>` carries both new options with counts**, per its amendment to this ticket; one filter at a time and `?site=`/`?kind=` stacking are unchanged. - **Your rule won, against your own promise.** "Every figure links to what it counts" is why #127 withdraws the "all five outcome counts point at `?filter=failing`" line: `refused` and `unreachable` now write no per-Series state, and the other four count attempts in a 12h window rather than Series failing now. You had already applied this test once by refusing to link the `no_chapter` count to the `no-chapter` filter — #127 applies it to the remaining five. The Lanes page gets one separate per-Site navigation link instead.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#118