Add genshin-impact.fandom.com as a second source for Genshin Impact,
which has been fixture-backed in CI because Game8 CloudFront-blocks the
Actions runner with 202s.
- Add fifth Fandom template parser in src/ingest/parsers/fandom.ts
extracting titles from caption display text, stripping dated subpage
suffixes, and checking currency via latestBoundaryMs.
- Register genshin-fandom-events in src/ingest/adapters/index.ts and
set genshin-game8-events priority: 10 so existing event IDs survive
near-match ties, preventing localStorage completion marks from
orphaning.
- Explicitly sort adapter groups by priority descending in
scripts/build-feed.ts to align with parseGame.
- Pin fixtures and add test coverage in test/adapters/game8.test.ts.
- Update AGENTS.md, README.md, docs/INGESTION.md, and docs/SOURCES.md.
Patch cycles are ~6 weeks, so a span over 180 days previously indicated
a misread year. However, real year-long events exist in this domain:
Genshin's anniversary 5-star selection runs 336 days (2025-10-22 to
2026-09-23), correctly dated on its wiki, and Fire Emblem Heroes runs a
seven-month new-player banner (docs/SOURCES.md § 12b).
Widening the ceiling to 365 days admits genuinely year-long events while
still catching misread years, which place the end 12 months out (span +
~365, or 405+ days for a 6-week event).
Update test/adapters/game8.test.ts duration test, AGENTS.md domain rules,
and docs/INGESTION.md sanity checks.
The build log prints a count per source, and CI now declines to fail on
a page that states its own emptiness — which leaves that source showing
an unexplained 0, reading as exactly the fault the gate just decided it
was not. The zero we are content with is the one that has to say why.
It is also the only line that would ever prompt anyone to ask whether a
lane quiet for a month is a game between patches or a wiki that reworded
the sentence a statesNoEvents check is still matching. Nothing else in
the pipeline can tell those apart.
The test count in the layout block was stale at 898 and moves with these
two commits, so it is corrected here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Infinity Nikki's wiki prints "There are no Events in this category"
between versions, and the refresh runner has honoured that since
2026-09-03 — it stores the empty parse as the source's real answer
rather than letting a quiet lane reach the broken tier. The feed build
never asked, so the same page arrived at CI as parsedCount 0,
indistinguishable from a parser that has stopped reading a redesigned
page, and brokenSources failed every build while every refresh stayed
green.
The runner's verdict cannot travel on its own: only a parser has seen
the page, and by the time brokenSources runs there is nothing left but
the feed. So the fact rides on SourceHealth, defaulted so an older feed
the service worker cached keeps validating and reads as the strict
answer.
Both ends now ask it the same way — of an empty parse only, from the
page's own words only — because a redesign yields zero rows too, and
excusing that is the silently emptied calendar the gate exists for.
The rule sits in a module rather than in build-feed.ts, which writes
public/ and so runs a build if a test imports it. That is how the two
ends drifted apart with nothing to catch it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
First successful fetch since 2026-08-26. The page currently lists no events
in either section, so eventCount is 0 and the lane is honestly empty rather
than holding rows that expired on 27 August.
Run by hand with --force, since the six-hour floor was not up and the
source had four failed cycles behind it. robots.txt read normally.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The two preceding changes make sentences in docs/ false. INGESTION.md
described `canParse` as a structural check without saying that "structural"
excludes the presence of a row — which is exactly the drift that let this
bug in — and described the Stage 1 gate as rejecting every empty parse.
SOURCES.md § 11 left a reader to conclude that an empty Infinity Nikki lane
means the source has died.
AGENTS.md gets the rule in both places somebody would look: § Working on
parsers, next to the bullet about a source resting on one row, and § Fandom
where the fourth template is described.
The general lesson is worth more than the incident. Any page that can list
nothing can produce this, so the guidance is about what identifies a page
versus what happens to be on it — not about this wiki.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Refusing every zero-event body is right when the alternative is silently
emptying a calendar, and wrong when the page has told us it is empty. A
gacha game goes quiet between versions, and read strictly that is a source
failing every cycle until the next patch ships: three of them reach the
broken tier and fail the workflow over a lane that is correctly empty,
while the snapshot being held ages out of date. Infinity Nikki sat in
exactly that state for four cycles.
So an empty parse is stored when the source's `statesNoEvents` vouches for
it, and the cycle counts as confirmed rather than failed. Everything else
is unchanged: a body that parses to nothing on its own still keeps the
previous snapshot, and a first fetch that yields nothing still stores
nothing.
The gate turns on the page's statement, never on the adapter merely being
able to make one — otherwise implementing `statesNoEvents` would quietly
switch the zero-events gate off for that source. A test pins that.
The run says which of the two empties it saw. "0 events — down from 6"
reads as the shape change it is not, and that note is what somebody
checking on a quiet lane actually sees.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The wiki emptied both its Current and Upcoming tables on 2026-09-03 — 2.7's
events had ended and 2.8 was not listed yet — and said so in words: "There
are no Events in this category". Nothing else moved; Past Events still
carries twenty tables of the same shape.
`isInfinityNikkiEventPage` looked for a *populated* table, so it read that
as a redesign. The source spent four cycles reporting "the source has
likely been redesigned" at a page nobody had touched, reached the broken
tier, and went on serving a snapshot whose every row expired on 27 August.
A check that reads data cannot tell a rewrite from a quiet week. It now
reads the section headings, which an empty table does not take with it —
either heading rather than both, since requiring the pair would fail the
source over a renamed heading it does not even read, and one already
separates this page from the other three Fandom templates. docs/INGESTION.md
asked for structural checks all along; this one had drifted into content.
`statesNoEvents` then reports the page's own declaration, so the runner can
tell an answer from a failure. It insists on the statement rather than an
empty table, because a redesign yields an empty table too.
The emptied page is pinned as a fixture: an empty answer is a shape, and it
is the one that used to read as a redesign.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A count of zero has two causes and nothing downstream can tell them apart:
a source whose selectors all broke, and a game that is simply between
patches. Both parse to nothing, so `canParse` plus a row count cannot
separate them — and the pipeline currently has to assume the worst, which
is right for a redesign and wrong for a quiet week.
`statesNoEvents` is the seam for the one thing that can settle it: the
page's own words. Optional, because most pages say nothing either way, and
absent means the strict gate stands.
No parser implements it yet and nothing reads it — the contract widens here
so the parser and the refresh gate can land as their own changes.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The footer's staleness notice is an instruction, and the only remedy it
offers is "go and check that game's source page". That is not something a
reader can act on for a game they turned off and cannot see a single row
of — and with nineteen lanes, naming eleven they do not play buries the one
they do. A new lane arrives switched off (F8), so an untouched install was
being warned about most of the catalogue.
Scoped in two places, not one: the named list, and the count the
summarising branch measures against. Without the second, that branch would
never fire for a reader with most of the calendar off and they would get a
list where a sentence was the readable answer.
The notice now also states whose games it counted, in the words NextUp
already uses for the same set. Narrowing what the footer measured while
still printing "nothing has refreshed" would turn a claim about four games
into one about eighteen, in the one paragraph on the page whose job is
being trusted about age.
The headline age and the credits stay whole: the first is a fact about the
feed and feeds the bug form, the second is attribution owed to every source
we read regardless of what is on screen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI failed on Infinity Nikki yielding no events, and was wrong to. Its
snapshot parses to six events; every one of them had ended by the morning
the build ran. The parser is fine — `--now 2026-08-14` gives six, today
gives none — and the page simply has nothing current left on it.
eventCount is counted after expired events are dropped, so "this parser has
stopped reading a redesigned page" and "this page's events have all
finished" arrived as the same zero. Only the first means our code is wrong,
and only the first should redden a build. So the feed now records what each
document yields parsed as of its own capture date, before expiry, and the
check fails on that instead.
Nikki cannot refresh itself out of this, either: docs/SOURCES.md records
that Fandom refuses the Actions runner. A lane with an empty calendar is a
real problem, but it is a refresh problem, so it is reported on the build
log and left visible rather than thrown.
parsedCount is nullable and defaulted, never required: the client validates
the whole feed with safeParse and the service worker serves the last feed it
downloaded, so a required field would have made every cached feed fail
validation and taken the offline promise with it. Null also covers a source
whose bytes were never confirmed live — there is no date to parse "as of",
and a build is not failed on missing information.
The rule moved to shared/feed.ts. A test did pin the old one, by grepping
the workflow for the string — which proved the check existed, never that it
was right.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Today's dailies carries two different things: each game's standing chore —
"Commissions, resin", a fixed list nobody publishes — and any event with a
checklist. The first is the app guessing at a routine the reader never asked
for, and it was the only part of that strip with no way out.
The switch removes exactly that. Events keep their checklists whatever their
source, including ones the reader added themselves and marked daily, which
was the requirement most at risk of being filtered away by a switch aimed at
something else.
Nothing is discarded. The ticks live under `dailies:<game>`, nothing here
reads or writes them, and review traced every writer of that store to
confirm it — so switching back on restores every logged day and every
streak. Defaulted on, and the default is now pinned, because flipping it is
the one change that would silently empty the strip for everyone.
Gating where the chore is built rather than where it is drawn means the
counts follow for free: "N still waiting on you today" derives from the
items, and a game left with nothing contributes no group, so the strip's own
empty guard drops it rather than leaving a heading with no rows.
1041 tests.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review pointed out that `dailyGroups` was well covered and the wiring to it
was not: passing the wrong pref from App, binding the checkbox to a
neighbour, or hardcoding `true` in Dailies each shipped with the suite
green. Three of those are now caught by rendering the strip and the settings
panel rather than the function behind them — verified by performing the
mutations, not by reading the code.
The fourth, App handing Dailies the wrong pref, is still uncovered: no test
renders App, and closing it needs a feed and a click library this project
does not have.
Also pins the default. Flipping that one line silently empties Today's
dailies for every existing reader, which is the whole argument of the
comment above it, and nothing was watching — `defaults` is exported so the
value and the upgrade path can both be asserted.
And DATA-MODEL's enumeration of the prefs blob had gone stale; AGENTS.md
names it as the doc to update when a stored key moves.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>