A `<button>` defaults to `cursor: default`, so most of this app read as
text until you clicked it and found out otherwise — the headline
deadline, the daily chips, the settings pills. Sixty-five controls on the
checklist view alone, two of which had picked up a `cursor-pointer` at
some point because somebody noticed them.
So it is a base-layer rule rather than a class per call site: an
affordance that has to be remembered is one that goes missing. Utilities
still win over it, which is what keeps `cursor-not-allowed` on a disabled
zoom step and `cursor-default` on a checklist day that cannot be ticked
yet saying what they mean.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
It is the thing on the page a reader is most likely to tap and the only
clickable text in the panel that did nothing under the pointer — the
queued deadlines beneath it brighten, and every list row brightens, so
the headline read as a heading rather than as a way in.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F1 said the timeline plots what the reader can see, which is now half
true, and the prefs key space did not list the field the board writes.
Both record the argument the code only implies: the window is drawn from
what is plotted, so on-by-default is not a fuller board, it is a smaller
one for everything that is actually running.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two halves of one reading. The board answers "how does the time I am in
lay out?", and plotting every lane's next patch stretched the window
weeks past today and squeezed the running bars down to nothing — so it
holds them back, and holds them back itself rather than being handed a
shorter list, because the control has to say *how many*. A board that
quietly withheld a third of the schedule is indistinguishable from a
quiet fortnight, which is the wrong thing for this app to imply by
accident. A board with nothing left running says so and points at the
control instead of reading as an empty calendar.
Switched on, a bar drawn right of the now rule is only implicitly in the
future, and implicitly is not a standard we hold anywhere else a date is
involved. Gacha schedules are not a smooth stream of starts either — a
patch ships six things at once — so the unit is the clump: a dashed rule
per group of starts, labelled in words. Clumps merge by distance on
screen rather than by calendar, since a week apart is one mark at six
pixels a day and two at a hundred and eight, and a merged one states the
span it covers rather than claiming a date the board is not ruling at.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The timeline draws its window from what it plots, so a lane's next patch
sitting on the board pushes every running bar right to make room for
things nobody can do yet. That wants to be the reader's answer rather
than ours, and it wants to survive a reload like `view` and
`timelineGroup` do.
Defaults to false, which is what every existing board already looks
like — the rows were there but nothing was asked, so shipping this moves
nobody. A stored pref wins either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The run just made reported each of the four Fandom hosts twice — once from the
warning pushed in runRefresh and once from a second loop in main() that printed
summary.assumedRobots again. Eight lines for four facts, and the header above
them said "5 warnings" while nine printed under it, so the count a reader uses
to judge whether to read the list was wrong in the direction that matters.
Mine, from the commit that added the flag: I put the reminder in main() to make
it loud, having already made it loud in the summary.
The instruction folds into the single warning instead, so it still says to go
re-read the file in a browser, and it now also reaches the job summary and
annotations, which only ever read summary.warnings.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
First run of --force and --assume-robots-on-403 together, and the first time
every source has been confirmed in one cycle: 19/19, 0 broken. Nine game8 pages
answered 200 from a normal address, which is the CI block behaving exactly as
§ Scraping conduct describes it, and all four Fandom sources fetched on the
recorded permission.
Nothing a reader sees changed. No source's event count moved, and diffing the
parsed events of all eight changed pages against their previous snapshots finds
no event added, removed, or re-dated. The byte deltas are between -286 and +32
on pages of a quarter-megabyte — page furniture, not schedule.
That is worth stating rather than glossing: the value of this commit is the
negative result. Nine of these games are fixture-backed in CI and only ever
confirmed by hand, so "we looked, and the calendar is still right" is the thing
the refresh exists to establish, and lastConfirmedAt is what the footer reads to
tell a reader how old the answer is.
fgo is the odd one: identical length, different hash. Every one of its 101
changed characters sits in MediaWiki's `NewPP limit report` comment at the foot
of the body — cache timestamp and parser timings, fixed-width, outside the text
we parse. So a Fandom source will report `fetched` and produce a diff on any
forced refresh an hour apart, whether or not the wiki moved. Worth knowing
before reading a future Fandom diff as news.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The link sits last in the existing row beside @StereotypicalCat and Source
code, in that row's own muted grey, with the same hover and the same small
mark. No accent colour, no button, no sentence asking for anything, and the
footer still contains no appeal anywhere — a test asserts that. This page earns
its keep by being trusted about dates, and a tip jar competing with the
disclaimer two paragraphs above spends that trust to make an ask. As one more
link it costs nothing and is there for a reader already looking.
The row also moves above "Additional ideas and design from". That row is who
built this and where to find them; the credit below it is other people. Reading
order now follows that split, instead of routing a reader past five strangers'
handles to reach the author's own links.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The interval gate has no override, so a page you know has just changed cannot
be fetched until six hours after the last attempt. The only workaround was
deleting snapshots/<id>.state.json, which also wipes consecutiveFailures and
lastConfirmedAt — resetting the broken-source streak and making the footer age
every source from when its bytes last changed rather than when we last
confirmed them. A flag that says what it means is better than a side effect
nobody documented.
It sets aside the interval and nothing else. Conditional headers still go out,
which is what makes forcing defensible at all: the host is asked, not
re-served, and an unchanged page costs it a 304. Per-host spacing, robots, one
request per source and the no-retry rule all still apply — a source that was
not due and is also disallowed stays skipped, for the reason that matters.
Refused under CI, like --assume-robots-on-403 and for the same reason: a
schedule that forces every cycle is a shorter interval with extra steps, and
the interval is the obligation, not the default. So AGENTS.md § Scraping
conduct is amended rather than left to be quietly contradicted by a flag.
Every source asked early is named in summary.forced and warned about. A run
that was due anyway is never reported as forced — a summary that cried "forced"
on an ordinary run would train the reader to ignore the word.
Also repoints the "unknown flag" test, which used --force as its example and
stopped testing anything the moment --force existed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The previous commit landed the first live pages for nikke and nikki, which
retires the last version of a sentence that has been wrong twice in one session
— it counted which of the four had ever been fetched, and that number kept
moving underneath it.
So it no longer counts. What matters about these four is not how many have a
snapshot but that every one was taken by hand and always will be: their
freshness is exactly as old as the last person who ran the refresh themselves,
because the schedule cannot read Fandom's robots.txt. Points at
--assume-robots-on-403, which is what makes that run possible.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both were built on 2026-08-19 and have been served from their fixtures ever
since, because CI cannot read Fandom's robots.txt and the gate fails closed.
Refreshed by hand from an address Fandom serves, which is the only way these
two ever move.
Each body is a real action=parse envelope and each parses to exactly what its
fixture does — three events for Nikke, seven for Infinity Nikki, same titles and
same dates. That is the expected result rather than a weak one: both fixtures
were captured from these pages today, so agreement is the check that the stored
bytes are the wiki and not a challenge page.
All four Fandom sources now have a snapshot. None of them will get another one
from the schedule.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The "only ever commit your own work" rule already said not to `git add -A`, and
it was written as though the tree only ever holds work someone left there
earlier. It also holds work someone is doing *right now*: sessions run
concurrently here and the index is shared, so a status that was clean when a task
started can carry four staged files by the time it commits.
Today that cost a real commit — a source assessment finished in one session was
swept into another's, and published under a one-line message about snapshot
freshness. So the bullet now names the concurrent case, asks for a
`git status --porcelain` immediately before committing rather than at the start,
and gives the form that is safe when someone else's work is already staged:
`git commit -- <paths>`, which never reads the rest of the index.
The second half is the recovery, and it is deliberately not automatic. Splitting
a mixed commit is easy; doing it to a commit another session authored while that
session may still be running is not, so it is a thing to ask about rather than
fix quietly.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Its conduct is the cleanest of anything looked at here — a robots.txt of pure
comments, no directive, no Content-Signal, and a plain 200 to our own
User-Agent — so the reason not to build it is entirely in the data, and is worth
writing down rather than re-deriving next time the URL is offered.
The calendar prints no year anywhere, and heads its only date row "Estimated
date for Regional Servers" over a page that says the schedule is the CN
server's. Every date it could publish would therefore be a guess twice over: a
year we inferred, on a boundary the page itself declines to stand behind. The
week-column grid also rounds an end to a bucket rather than to a printed day,
and the URL carries the version number, so a SOURCES entry pinned to calendar89
would be publishing history from tomorrow.
The markup is recorded anyway, because it is readable and that is not the
problem — if the site ever states a year and a real regional schedule, the work
is a positional parser and nothing else.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A hand-run refresh landed one at 02:36 on 2026-08-19, four events. The sentence
two commits ago said it had never had one, which was true when written and is
the kind of claim that decides whether someone bothers trying — so it is worth
being exact about: r1999 and fgo have snapshots, both taken by hand today;
nikke and nikki are still served from their fixtures.
The conclusion is unchanged. None of the four will ever be refreshed on a
schedule, because CI cannot read Fandom's robots.txt.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fandom answers our fetcher 403 on /robots.txt itself, from every address we
have, while api.php?action=parse answers the same User-Agent with a 200. The
gate fails closed on the unreadable file, so r1999, fgo, nikke and nikki can
never refresh — even though their rules are not unknown: a person read them in
a browser and wrote them into AGENTS.md, verbatim, which is how three of those
four were cleared in the first place.
This flag is that recorded permission, and nothing wider. What it deliberately
does not do is most of the design:
- 403 only. A 401, a 5xx, a timeout or a soft 404 still mean we do not know
what the site permits, and unknown is still not permission.
- It never overrides a robots.txt we could read. A file that answers and
disallows us is an answer, and it still wins. So this is no use for game8.co
— whose robots.txt reads fine and welcomes us while its edge refuses the
pages — and it must not become one.
- Refused under CI. It stands in for a human having read a file this morning,
and there is no human on a runner. A scheduled job asserting a permission
nobody re-checked is exactly how "temporary" stops being temporary.
- Loud. Every host it applied to is warned about by name, in the run log and
in the summary, with a line saying to go re-read the file. An override that
reports nothing is one nobody withdraws.
Nothing else about being a guest relaxes: one request per source, six hours
apart, spaced per host, conditional headers, no retries.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nothing in the client prunes reader data today — no store filters against the
feed, knownGames only appends, metaFor renders a lane whose game is gone — so a
retired source, page or game leaves completion marks, streaks and hand-entered
events untouched. That was true by construction and pinned nowhere.
The load path is what makes it worth pinning. useCustom reads through
validRecords, which drops a record that fails its schema, and the survivors are
what the next write persists. A record that stops parsing is therefore not
hidden until someone notices — it is deleted from the device, permanently, by
the act of opening the app, with no server-side copy to recover it from.
Which makes CustomEvent.game being z.string() rather than GameId the whole
safety property, and it currently reads like validation someone forgot. Anyone
narrowing it to the enum would be tightening a schema and arming every future
game removal to erase reader data on next launch. The tests state the premise,
the survival, and that one unreadable neighbour still does not take the rest
down; the field says why it is loose.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nikke and Infinity Nikki were added on 2026-08-19 and the paragraph describing
what the Fandom robots gate costs was not extended to cover them. It still said
"the two built Fandom sources", named only r1999 and fgo as permanently
fixture-backed, and counted three wikis where the API answers our own
User-Agent.
The correction matters more than the arithmetic: it is this paragraph that
tells the next agent which lanes the scheduled refresh will never update, and
two of the four it now silently omitted are the two most recently built. Only
r1999 has ever had a snapshot; fgo, nikke and nikki have never had one and will
not get one from CI.
Also drops "Two Fandom sources now" from the Fate/Grand Order heading, which
counted the sources rather than describing the thing the paragraph is about.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Retiring nikki-game8-events left its snapshot behind. snapshots/ is defined as
the current page per source, and there is no such source — so these two files
are 200 KB of a wiki that stopped being updated in August 2025, tracked in git,
described by metadata naming a sourceId nothing resolves.
Harmless today: build-feed.ts iterates ADAPTERS, so an orphan is never read.
That is also what makes it worth removing rather than leaving. The one path
that would read it again is someone re-registering the id, and what they would
silently get back is the stale page the lane was moved off — the failure the
move was made to fix, arriving through a file nobody looked at.
The fixture stays. fixtures/nikki/game8-events-2026-08-17 is pinned and
permanent, it is the only page here carrying Game8's labelled "Start: ... End:
Permanent" shape, and test/adapters still drives it through the parser.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three parsers decide currency against ctx.now, because their page has no
"ongoing" heading worth trusting: bawiki.ts, and the Fate/Grand Order and
Infinity Nikki branches of fandom.ts. All three compared Date.parse(endsAt) to
now, and all three publish day-precision ends — so the comparison was against
the 00:00Z placeholder, which § Domain rules says is not an instant and nothing
may read literally.
clockFor already knows that and resolves such a boundary to the reset opening
that game-day on the reader's server. The parsers did not, so the feed retired
a row up to nine hours before the app, the countdown and the game all agreed it
was over. On the pinned Infinity Nikki fixture, "Inspiration Burst" left the
feed at 2026-08-22T01:00Z while clockFor still called it live for an American
reader until 09:00Z, and the page said it ran to 03:59 server time. The reader
does not see a stale row; they see the deadline they were counting down to
disappear on its last day, which is the silent drop this codebase treats as the
dangerous failure.
latestBoundaryMs answers clockFor's question for the last region rather than
one reader's, so a row is history only once it is history everywhere. It follows
a game's own server map and reset hour for the same reason clockFor does —
Endfield and Reverse: 1999 both move. Nothing stored changes: this is one
comparison, not a resolved boundary written into the feed, and no expected.json
moves.
Being generous by nine hours costs an expired row at the bottom of a list.
Being strict costs a live one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Eleven sources were inside the 6h floor and were left alone. Of the eight the
runner planned, five answered: Reverse: 1999, GFL2, Stella Sora, Chaos Zero
Nightmare and Umamusume — the last four getting their first snapshot, so those
games stop being built from checked-in fixtures.
Fate/Grand Order, Infinity Nikki and Nikke were skipped on a 403 from
robots.txt, which is the documented Fandom posture and fails closed as intended.
Reverse: 1999 is the one Fandom wiki that answered this time; its page bytes
moved but it parses to the same six events, so nothing on the calendar changed
for it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two are worth building. MementoMori's current-events page parses today with no
parser change at all — seven events out of a seven-row table, six of them live.
Fire Emblem Heroes has the freshest page found anywhere here and states both
boundaries in their own columns, which is a shape the parser cannot read: not
two header words, but a title with a separate start and end, where the existing
Start/End reader wants the rowspan pair. It also publishes one real seven-month
banner, which the 180-day rule rejects as a misread year — a decision to make
before the fixture is written, because the fixture is the test.
The other eleven fail in three ways worth telling apart. Seven wikis are
abandoned, newest page between 2021 and 2025: they parse perfectly and publish
history, which is the failure a stale source hides behind. Gundam prints ends
with no starts, so no event ID exists to mint. Pokemon UNITE is current and
still refused, because its template fails canParse — the structural check doing
exactly its job rather than emptying a lane quietly.
Two method notes, both of which cost time. A hub's navigation is not an index of
its pages: four of these games have a schedule page linked from nowhere in the
nav, found only by searching. And "Last updated on:" separates a live wiki from
a dead one in a single grep, which is the check that was missing when Infinity
Nikki went a year stale.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
As written, the Blue Archive rule said a wall clock with no stated zone is
unparseable and that rounding it to a day "does not save it". Taken literally
that condemns every Game8 date in this repository — those state no zone either,
and are published at day precision without anyone minding.
What those Blue Archive tables actually were is narrower and worse: three of the
five did not say which server they described. A clock whose server is unknown
cannot even be labelled with a day, because you do not know whose day it is.
That is the case the rule governs, and it now says so.
Converting an unzoned clock by picking an offset stays forbidden, which is the
part that protects event IDs: the offset moves the day, and the start's day is
half of every ID.
Recorded because the rule and the Infinity Nikki source contradicted each other,
and AGENTS.md says that disagreement is a bug to fix rather than reconcile in
your head.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The Game8 page stopped being updated on 31 August 2025 and mentions 2026 zero
times, and it had been publishing five year-old events with endsAt: null — which
the app renders as live-with-unknown-end indefinitely. That is worse than an
empty lane, and nothing in the pipeline catches it, because a stale page is not
a broken one: it fetches, it parses, it passes every test.
The replacement is the game's Fandom wiki, permitted by the same standard
robots.txt that cleared Nikke, maintained to within two days, and shaped as
Current / Upcoming / Permanent / Past tables of Event | Duration | Description
| Type.
Its durations state a wall clock on both sides and no zone anywhere for that
column, so parseZonelessClockRange reads the clock and discards it, publishing
the printed date at day precision. That invents nothing and treats these cells
exactly as every Game8 date already is. Converting instead would mean picking an
offset, and the offset moves the day: July 16 20:00 read as UTC-7 is July 17,
and the start's day is half of every event ID this game will ever have.
The Game8 entry is removed rather than kept as a second opinion, since a source
whose every row is wrong is not corroboration. Its fixture stays: it is the only
page here carrying the labelled "Start: ... End: Permanent" shape, and the test
now drives it through the parser directly.
Titles come from a link's title attribute, which never passes through text(), so
they are entity-decoded by hand — otherwise "Alison's Travel Shop" becomes a
slug, and a slug is a localStorage key. The ingest sanitiser caught exactly that
during the build.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The lane's Game8 page is a year stale, and the last pass concluded there was
nowhere to move it. That was wrong: infinity-nikki.fandom.com is maintained,
permitted by the same standard Fandom robots.txt as Nikke, and its Event page
carries Current and Upcoming tables with full dates and clocks on both sides.
What it does not carry is a timezone on the Duration column. The page says
UTC-7 in prose about version launches and notes a 04:00 Server Time reset, and
the durations run 04:00 to 03:59, which only lands on a reset boundary if the
column is server-local. Strong, and circumstantial. It matters because the
offset moves the day, and the start's day is half an event ID.
Four options are written out with what each costs. The recommendation is to
take the printed date at day precision, which invents nothing and treats these
cells exactly as every Game8 date is already treated — and which contradicts
the Blue Archive rule as written, so that rule needs narrowing to say which
case it governs. Flagged rather than resolved: it changes an existing lane, and
retiring the game instead would drop a GameId that prefixes every completion
key its readers hold.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Its robots.txt was read in a browser and is the standard Fandom file: no
Disallow: / for *, /api.php?action= explicitly allowed, and only the namespaces
we never want refused. The AI crawlers it blocks by name are not us. That was
the one thing missing — the API itself has always answered our own User-Agent
with a 200, and it is only robots.txt that a datacentre address cannot read.
The zone lives in the column header, Start(UTC+9) / End(UTC+9), and no date in
any cell carries an offset. So the header is the safety property: a table that
stops naming its zone is refused rather than read as UTC, which is the Blue
Archive hazard arriving one column to the left.
Two shapes worth knowing. Every title is an image read from <a title>, and the
newest row is the one whose logo has not been uploaded yet — it renders as a
red link reading "File:Persona on Frontline logo.png", so a reader that only
understood the link title would silently drop today's live event. And story
events state a clock only on the end, so the start keeps the day the page
printed rather than being shifted nine hours into the previous one; the start's
day is half an event ID. That is the FGO rule applied to the opposite gap.
resetOffsets and resetHourLocal ship with the game because the page evidences
both: events end 04:59:59 and the banner replacing them starts 05:00:00, one
second later, on a stated UTC+9. Adding that later would re-label day keys
readers had already logged ticks under.
Seconds now survive the date helpers, since rounding 04:59:59 to the minute
would make an event overlap the banner that succeeds it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every one of the fourteen sources was fetched and parsed. Twelve are healthy.
The two findings that are not about a game we just added:
Fandom's posture tightened on 2026-08-19 and now covers every wiki. Yesterday
blhx.fandom.com served robots.txt a 200, which is what the r1999 permission
argument leaned on; today every Fandom wiki tried answers 403 to our fetcher
and gives a real browser a challenge that never resolves. So r1999 and fgo are
permanently fixture-backed, and no new Fandom source can be added at all —
which is what blocks Nikke, whose data is the best of anything unbuilt. It is
blocked rather than declined: one person on an address Fandom serves can read
that file and unblock it.
Infinity Nikki's source is stale and there is nowhere to move it. The Game8
page was last updated 31 August 2025, mentions 2026 zero times, and publishes
five events with endsAt: null that read as live indefinitely — worse than an
empty lane, through a source that looks healthy to the runner because a stale
page is not a broken one. Every replacement was checked and none works. That
one is a decision for the owner, not a fix: retiring the lane drops a GameId
that prefixes every completion key its readers hold.
SOURCES.md carries each game's outcome and the reasoning behind the declines,
so the next pass does not re-derive any of it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A normal `bun run refresh` from an address game8 serves, which is the only way
these nine pages move — the scheduled run in CI gets a 202 from game8's edge
and never lands them.
The two Fandom sources reported skipped_robots, as they now do from every
address available here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Near matching exists to reconcile two sources describing one event under
different titles. Within one source it has no such job: the page has already
told us these are two rows, the parser has already dropped repeats of the same
id, and fusing them overrules a distinction the publisher made on purpose.
The loose 0.80 threshold that makes the rule useful across sources makes it
actively wrong within one. Umamusume's banner list runs "3 Star Guaranteed 1.5
Anniversary Scout (Character)" and "(Support)" concurrently — titles differing
by a single parenthetical, starting the same day — and fusing them dropped a
live banner off the calendar with nothing anywhere reporting it. A silent drop
is the failure this codebase ranks as the dangerous one.
Same-id fusion is untouched, so nothing duplicates.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
uma.moe stays declined — its API sits behind a Cloudflare Turnstile proof
header, and an adapter would mean defeating a deliberate access control. This
is the surface that is simply open, and it needed the parser change in the
commit before this one to be readable at all.
The stable URL matters: Game8 also publishes monthly "August 2026 Release
Schedule" pages whose id changes every month, which a static registry cannot
follow. "List of All Banners" does not move.
Four current banners, both columns of the side-by-side table, and none of the
Previous Banners below them. The tenth game8 source, so it carries the same CI
blindness as the other nine and only a manual refresh moves it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Game8's banner pages lay two schedules side by side inside one <table> under a
spanning label row: "Standard Banners | Banner | Rating | Availability | Paid
Banners | ...". That row is not merely unhelpful, it is plausible — it contains
both column words, so it resolves and puts the range at index 3, which no
three-cell data row has. Every row then fails to date and the table yields
nothing at all, with no error anywhere. So readColumnTable now falls back to
row 1, and only when row 0 produced nothing, which is what keeps every page
that parses today parsing identically.
Those pages also schedule banners rather than events and head their sections
accordingly, so the vocabulary learns "List of All Banners", "All Current
Banners" and — the one that matters — "Previous Banners", whose rows are dated
identically to the live ones directly above them.
This parser serves nine sources, so the widening was measured rather than
trusted: every pinned fixture and every live snapshot was parsed before and
after, and no existing source's output changed by a single event.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
No parser work at all — a GameId, a games entry, a SOURCES line, a fixture and
a test. The page lists six current events and the parser publishes four; the
two it skips print "Start Date: -", and no start means no event ID, so that is
the skip-never-guess rule working rather than a silent drop.
The cost is worth stating plainly: this is the ninth game8 source, and game8's
edge answers the Actions runner with a 202 and a bot-management body, so the
lane is built from a checked-in fixture in CI from day one and only a manual
`bun run refresh` moves it. freshness() discloses that in the footer, which is
what it is for.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This wiki publishes the same schedule twice and the fuller surface is the worse
one. /wiki/Banner_List has 55 clean rows with full wall clocks and states no
timezone anywhere on the page; the front page's Current Banners module emits
the same instants as real <time datetime="...-07:00"> elements. The two agree
exactly, which is strong evidence the list is UTC and is still only evidence —
so we read the surface that says what it means and pay four live banners
instead of a full history for it. If an editor ever states the zone on
Banner_List, that page becomes the better source immediately.
Two ways this could have failed silently. The template writes its BEM
underscores as __, so a selector written against the class name a
browser shows matches nothing at all; and banner names are red links to
?action=edit&redlink=1, which Miraheze's robots.txt disallows, so a href
carrying a query is refused and the page URL stands in.
No resetOffsets, and here the source did state an offset: -07:00, which is US
Pacific and therefore shifts twice a year. That is the Fate/Grand Order gap
arriving through a page that looks like it answered the question.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The best date material this project has after wiki.gg: every row states an
exact instant on both boundaries and names the zone, so nothing is converted
and nothing is assumed. `parseIsoClockRangeUtc` requires that `(UTC)` rather
than defaulting to it — a row that ever loses it should drop out instead of
landing hours off a boundary the reader is watching in-game.
The hazard here is the Server column. CN, EN and JP rows share one table and
the Chinese schedule runs about a year ahead, which is the akwiki CN-column
problem verbatim: publish EN, skip the rest. Betas are fenced off separately,
being dated exactly like everything else and playable by nobody, and the page
is an archive back to 2023, so currency is decided against ctx.now as in
bawiki. That leaves one live event today — thin, and true.
No resetOffsets: the EN boundaries land on three different clocks, which is a
patch window rather than a reset hour.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
PRD gets F15 and the fork that asked for it, DATA-MODEL the prefs field
and the fact that a script outside the bundle now reads that key, and
ARCHITECTURE the pre-paint script — the one piece of this that is neither
CSS nor React.
The AGENTS rule is the part worth reading twice: a component that names a
colour instead of a token now looks right in one theme and wrong in the
other, and nothing will tell you which.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both are "how do I read this?" rather than what the page knows, so they
sit together. Dark reads first because it is what the control is being
offered from, System last because it defers rather than decides.
Switching saves nothing and costs nothing: no reload, and nothing marked,
typed or ticked is touched.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`prefs.theme` is dark, light or system, defaulting to dark: a reader
whose laptop is in light mode has said something about their laptop, not
about this page, and following the device would move every existing
reader the first time they loaded this. No control yet — the value can
only arrive from storage.
App resolves it and writes it to the document. It is also where a hue
meets the theme: doing that in the lane resolver means every label, chip,
rail and bar gets the readable answer without a component knowing a theme
exists, which is why the colophon moves onto that resolver too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The dark tokens re-struck under `[data-theme="light"]`. Nothing sets that
attribute yet, so the app still renders exactly as it did.
Two things could not be tokens. Game hues are data — ours and the
reader's — and every one was picked against a near-black ground, so
`readableHue` darkens them along the same hue until they read on paper,
and returns dark untouched by construction. And the attribute has to be
on the root before React can mount, or a reader who chose light gets a
dark flash on every load, so the shell reads the preference itself.
That leaves the ground colour written in three files that cannot import
each other, which is what the test pins together.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
They sat between "Built by" and the two GitHub links, splitting one thought
about who made this and where it lives. Below the links they read as the
tail of that block instead of an interruption in it, and the paragraph that
invites a bug report still comes last, where a reader who has just found a
wrong date is looking.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The loudest feedback on the first release was "not enough games", so the
same wikis get researched again every pass — and the expensive half of
that work is not finding a page, it is establishing that a page which
fetches and parses cleanly is a 2021 archive, or states a wall clock with
no timezone, or belongs to a game that is shutting down in October.
This writes down what was actually checked on 2026-08-18: what each page
publishes, which existing parser can already read it, whether CI could
ever fetch it, and the hazard that would produce a confidently wrong date.
The verdicts stay proposals — once one is acted on the ruling belongs in
AGENTS.md § Scraping conduct, next to the sources already declined.
Indexed in the doc table so it is found before the search starts, not
after.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
u/lotus_lunaris, u/Neoragex13 and u/ShiroWaffles, on the same terms as the
two already there — thanks for ideas, carrying no licence obligation.
Five entries is where the line starts wrapping on a narrow screen, which is
what the existing comma-and-"and" join was written for, so nothing else had
to change.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F1 described the scale only by how far out it goes — three months on a
screen — which left the close end reading as decoration rather than the
answer to two events ending a day apart. The suite count moves with the
test the previous commit added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Events routinely end within a day of each other, and at the old close
end of the ladder their bar ends were a few pixels apart — the board
being asked the one question it exists to answer and telling the reader
to squint. Seventy-two and 108 px/day separate them, and give a one-day
event a bar wide enough to read and to tap rather than the 34px every
short bar collapses to.
The clamp test now reads the ladder's last rung instead of naming 48, so
extending it again does not need the test rewritten.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Second name on the list, and the first from somewhere other than a fork —
so the doc comment stops calling this a list of forks, and says what the
`handle` field now carries: it renders verbatim, a bare handle meaning
GitHub and `u/` meaning Reddit, rather than a `platform` field nobody would
keep consistent across two entries.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A public fork of this project worked out several things worth having — a
theme toggle, an archive for finished events — and ideas are not code:
nothing here carries a licence obligation, so this is thanks rather than a
term, and it belongs in the credits rather than in NOTICE with the terms.
Credited by GitHub handle and fork URL, not by name. The commits behind that
fork carry a third name again, and a handle is the one identity that is
verifiable and stable.
Hardcoded, unlike the source and studio credits above it — nothing in the
feed knows a fork exists, so there is nothing to derive it from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Thirteen px/day opened the board on about a quarter of calendar, which
is more time than these schedules are written in: a patch is six weeks,
so most bars were short enough that their length stopped reading as a
duration and their title no longer fit inside the bar it named. Thirty-
two opens on roughly one patch cycle on a laptop.
Only new readers move. `prefs` is written on every load, so anyone who
has opened the app has a timelineDayWidth of their own and keeps it. A
phone now shows about twelve days rather than thirty, which is the cost;
the longer view is one press of − away and that answer is remembered.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F1 described "one lane per game" as a fact about the timeline, which is
now false half the time, and the prefs key space did not list the pref
that decides it. Both say why the merged board sorts with
endingSoonestFirst rather than a bare end date, since that is the part a
later change is most likely to "simplify".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The board's header said "One lane per game" and left it at that; now it
offers the other stacking too, in the same place and the same pill shape
the list's sort control uses — you reach for it while looking at the
board, not in settings.
Merged, there is no lane heading to say whose event a bar is, and hue
alone cannot answer that once every game shares one stack. So each bar
carries its game's short name, and its tooltip carries the full one. A
bar under 96px has no room for a tag and a title both, and a chopped
game name reads as a broken word — those keep the title and say the game
in the tooltip.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Lanes keep a game's events adjacent, which is what makes the board
readable for someone playing four of them — but a reader with four games
has one queue of deadlines, and lanes scatter it: the thing ending
tonight sits three lanes below the thing ending next month, and no
amount of scrolling puts them side by side. So the stacking becomes a
choice, and the choice is remembered like the scale and the view are.
The merged mode sorts with endingSoonestFirst rather than a bare end
date, so the timeline and the list cannot mean different things by the
same words — and an unannounced end keeps its place behind every dated
one instead of pretending to a deadline.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F4a called it "a bar at the top of the page", which the previous two
commits made false above `lg` — the working agreement's two-column bullet
and the PRD's desktop split both described a rail the bar was not in.
"Above everything it affects" is the rule that survived the move; where
that lands now depends on whether the layout has a rail.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
In a 20rem column the scrolling strip shows four chips of a thirteen-game
reader's set behind a hairline scrollbar, and a game you cannot see is a
game you will not focus. Past `lg` they wrap instead: the rail is short and
the list beside it is long, so the extra rows cost that column nothing it
was using. On a phone the strip is right and stays.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Past `lg` the page is a pinned rail of instructions beside the lists, and
the focus bar was still a full-width strip above both columns: it spent
the whole width of a wide screen on a row of chips and pushed "next to
expire" — the thing the reader opened the page for — down behind it, while
sitting nowhere near the panels it narrows.
The top of the rail is still above everything it affects, and now it pins
with the deadlines and dailies it filters. On a phone, and on the
timeline, which has no rail, nothing moves: the same rule puts it back at
the top of the page. One element, rendered once per view.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The detail sheet formatted the stored ISO, so a day-precision end rendered
as 00:00Z — the previous day for every reader west of UTC, and now a
different day from the timer sitting beside it. Both dates come off the
clock instead, which resolves them the same way.
The note under them said the end was "accurate to the day only" and left
it there. It can say where the count actually lands, and should: a reader
deciding whether to log in tonight wants the reset, not a shrug. It still
says this is a reading rather than a time the source printed.
REGION_LABEL moves next to the region table it names, which is also how
the note gets "Europe" rather than the raw id.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A source that prints "August 19, 2026" and no time is stored at 00:00Z,
which is a placeholder for "somewhere in this day" rather than a claim
about when the day opens. The countdown read it literally, which turns it
into exactly that claim — and the UTC day is nobody's. Wuthering Waves
events dated the 19th were still running three hours after we had retired
them, because a European player's day opens at 04:00 on a UTC+1 server;
Asia was four hours out the other way and America nine. Sixty-two of the
eighty-six published events were affected, across ten of thirteen games,
so the reader was reading a wrong number far more often than a right one.
A day-precision boundary now resolves to the reset that opens that
game-day on the reader's server — the same grid daily.ts keys every tick
by, and the only clock we hold for a game. That is a reading of the date
the source printed, not a time invented for it: nothing stored moves, no
event ID moves, and the parsers still refuse to guess.
Two boundaries are never re-anchored. A regionEnds value is already the
instant a source stated per server, so anchoring it would throw a fact
away. And an event the reader typed in was resolved by readerInstant in
their own timezone, which is a stated time too — theirs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ticks are about to stop being its only caller: the countdown needs the same
grid, and daily.ts already imports time.ts, so asking for it the other way
would close a cycle. time.ts is where it belongs anyway — it has held
REGION_RESET_UTC_OFFSET all along, and serverOffsetUtc is that table's
accessor. Nothing changes shape; `shift` is exported as `resetShiftMs`
because dayKey now reads it across a module boundary.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
fgo-html.html was the rendered HTML already stored inside
fandom-events-2026-08-18.html, byte for byte — 793KB of it, in a
directory whose contents are pinned and kept forever.
agents/skills/add-game-source/ was a byte-identical copy of
.claude/skills/add-game-source/SKILL.md in a path nothing loads, so it
could only ever drift away from the one that does.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The adapter shipped with no docs at all, which left the one thing worth
writing down unwritten: this wiki publishes two servers' schedules on
two cross-linked pages, and the Japanese one parses just as cleanly as
ours. That now has a row in the assessed-sources table, a section under
Fandom, and a line in the ingestion pitfalls table alongside the akwiki
CN column and the bawiki JP tab — three sources bitten the same way is
a pattern, not a coincidence.
Also records what resetOffsets cannot express, since fgo is the first
game to need it: a server clock that shifts with daylight saving.
DATA-MODEL's GameId listing was two games behind the schema, missing
holodori as well as fgo.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Daily Quests is what the game calls the thing, not "daily missions".
The offset is the part worth recording. The English server is a single
worldwide machine on US Pacific — every duration on Event_List_(US)
says PDT — which normally earns a resetOffsets entry the way holodori's
JST did. It cannot have one: Pacific observes daylight saving and the
page itself alternates PDT and PST, while resetOffsets holds one fixed
number per region. Both -7 and -8 are wrong for half the year, and
either would re-label day keys twice a year for readers who logged
ticks under the other. So fgo takes the default and the gap is a
comment rather than a guess.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every game names its gacha something else, and inferType only knew the
five words the game8 sources use. Fate/Grand Order calls its banners
"Summoning Campaign" — the whole of that page's banner vocabulary — so
without this every FGO pull lands as "other" and the type filter stops
meaning anything for that lane.
Nothing on the current English page carries the word, so no checked-in
expected output moves; the unit case is what pins the behaviour until
the next refresh brings a banner in.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The source landed pointed at Event_List, whose first sentence is "This
page lists all Events in Fate/Grand Order Japan". Every date it
published was a JP date on a calendar whose other twelve games are
global — the akwiki CN column again, and the failure this product
exists to prevent. Event_List_(US) is the English schedule and is what
the adapter reads now; a test asserts the URL so it cannot drift back.
The page is a different template from the Reverse: 1999 one, so the
parser branches, the way game8 branches over seven shapes. Its sections
are fenced by banner images with the label drawn in a positioned div —
no heading, no id — and only ONGOING EVENTS is read. FUTURE EVENTS
states a month with no day, and PAST EVENTS states no year at all, so
neither can be dated and neither is guessed at. canParse asserts both
dividers and the Duration label, which turns a redesign into a stalled
source rather than a lane that quietly empties.
The rewrite drops what the JP branch needed and this page does not: the
hand-rolled month/year rollover (parseFullRange already reads both
stated years), the any-typed locals, and a heading-to-duration regex
whose nested lazy quantifiers hung for minutes on a 620KB body the
moment one anchor stopped matching. Durations name a zone but no clock,
so the stated calendar day is kept rather than shifted into UTC —
there is nothing to anchor a conversion to, and the start's day is half
the event ID.
Also reverts two things done to quiet tests rather than answer them.
The parser no longer normalises NFKC: that was pre-empting the
sanitizer so "real fixtures need no repair at all" would pass, and the
seam is where repairs belong. And renderedHtml no longer reaches into
a legacy {"*": …} envelope, which every source here rules out by
pinning formatversion=2.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.
A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.
Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.
The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adding a source is our decision, not the reader's. Eleven lanes became
fourteen this week, and for someone who plays two that is not a feature
arriving — it is their calendar filling with events they will never open,
which is the thing the first-run picker exists to prevent.
`knownGames` records every lane a reader has been offered; a lane missing from
it is new to them, so it is recorded and hidden on sight. The games chips in
settings list every lane, on or off, which is where they take one up.
The case that had to be right is the reader who installed before any of this
existed: they have no `knownGames` at all, and reading that as "has been
offered nothing" would switch off every game they already read. Absent means
unrecorded — the first pass records what is already on their screen and
changes nothing else. Lanes they invented are recorded but never hidden;
typing a game in is asking for it.
The decision is a pure function so this is provable rather than watched for.
One real cost, stated in the PRD rather than hidden: a reader whose game
finally arrives is not told, which makes the colophon roadmap matter more.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Ending soon" described the sort, not the job — and it sat one line above a
sort control whose own option reads "Ending soonest", so the tab and the
ordering looked like the same control said twice. Checklist is what a reader
with four games is doing with it.
The stored id stays "soon". That value is in `prefs.view` on real devices, and
a label is copy: renaming one must never move a reader to the other view.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two changes to how the board reads.
The window was drawn from the earliest start, so one long-running event — a
standing login campaign can have been going half a year — bought months of
empty calendar that nobody scrolls back through and that pushed every other
bar off to the right. It now floors at two months, a patch cycle and a half:
far enough that a running event's start is usually still on the board, past
the point where the answer changes what anyone does today. A bar older than
the board keeps its faded left edge rather than being redrawn as though it
started there.
The bars themselves were 28px with 4px between them, which read as a stack of
hairlines rather than a schedule. They are 36px now, with more padding, more
air between events, and more between lanes.
`boardWindow` comes out of the component while it is being changed: it
decides what a reader can and cannot see, which is worth a test rather than a
rendering.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New holodoriwiki parser plus the holodori-holodoriwiki-events source,
giving the game its two live and upcoming events at exact precision.
holodori.wiki is Miraheze, so this is the same call bawiki makes and for
the same reason: robots.txt disallows /w/ and /*?action=, which closes the
API route and leaves the rendered /wiki/Events page as the surface * is
allowed. It serves our own User-Agent a 200, sets no Content-Signal and no
Crawl-delay for us, and the wiki is CC BY-SA 4.0.
What makes this source unusually good is that it states its timezone on
every cell — 08/17/2026 8:00PM (JST) — so it is the only wiki source here
publishing exact precision on both boundaries without a per-region timer.
parseSlashClockZone requires that zone rather than defaulting to UTC: a row
that ever loses it drops out instead of silently landing nine hours off,
and an abbreviation absent from the table is refused rather than guessed,
which is why the table holds only JST and not the plausible-looking
candidates that are not fixed offsets. The 12-hour clock gets its own
handling and its own tests, because 12PM and 12AM are the two readings a
naive parser gets wrong while still producing a valid-looking instant.
Inclusion is fenced by the Current Events <h2> and bounded by the next one.
That matters more than it looks: Past Events sits directly below with
identical columns, so a reader taking every wikitable would put the back
catalogue on the calendar with nothing to mark it. Rows are still checked
against ctx.now on top of the heading, because Current is maintained by
hand and goes stale before anyone moves a row down.
Two rows are deliberately missing. Beginner Mission runs Game Launch →
Unknown, and no start means no event ID — a permanent tutorial chore is not
what a calendar of deadlines is for. An Unknown end is kept as endsAt null,
and unlike bawiki this parser does not drop a started-but-undated row: the
heading has already said the event is running, which is the fact bawiki
lacks when reading an archive.
Every event title is still a red link — the wiki has no article for any of
them yet — so each points at ?action=edit&redlink=1, a create-page form on
a path this robots.txt disallows. Refusing a href with a query keeps those
out and falls back to the events page; the articles get linked when they
exist, with no change here.
holodori gets resetOffsets of UTC+9 across all three regions, evidenced
rather than assumed the way Arknights' is: the game launched worldwide
simultaneously on one service, every boundary is stated in JST, and
Training Support Missions ends at 3:59AM JST, one minute before a 04:00
local reset. Only the offset is overridden; the hour is the default.
Setting it now is free, because no reader has a day key for a game the app
has never shipped — the same override a year from now would re-label ticks
already logged.
Both boundaries and the exclusions are asserted against an independent
extraction off the fixture, computed through a timezone library rather than
by the parser's own arithmetic.
Several counts in AGENTS.md and PRD.md were already stale before this — the
Fate/Grand Order source did not update them — and are corrected to what the
tree now holds rather than to what it held yesterday.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A cross-reference by position ("two bullets down") was already wrong by one,
and would go wrong again the next time something is inserted above it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reader who was asked what good looks like named paimon.moe's timeline,
and the thing ours was missing is what that view never loses: on a wide
window we drew more calendar and nothing that said which day, whose game, or
which event was on screen. The date axis, the lane names and the event names
all scrolled away together — and a six-week bar starts weeks off the left
edge, so its name went with its start date and left a coloured rectangle.
So it is a pane that scrolls in both directions, with the axis stuck to the
top and every name stuck to the left. A name is sticky inside its own bar, so
it can never wander onto an event it does not belong to. The axis gained the
week the schedules are actually written in: a dated tick every Monday and a
brighter rule at each month.
The frozen label column that started this went away again — it stood on the
calendar and ate the names it was meant to keep. Lane names sit on their own
line instead, and "jump to today" sits in the board's header rather than
floating over the dates it sends you back to.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every screen got the same 672px column, so a desktop reader read a phone page
with two thirds of the window empty — and the one design criticism the release
thread produced was formed from a screenshot.
Past lg the page splits along the line this codebase already draws: what it
tells the reader to do — the next deadlines, tonight's dailies — pins to a
rail on the left and stays there while the lists it shows them scroll beside
it. The footer and the settings panel become columns rather than a long fall
of small grey text. Below that breakpoint nothing moves: the same split puts
the instructions first in one column.
The rail's rule sits on the panel rather than the column. The panel is short
and the list beside it is long, so a full-height divider would spend most of
its length walling off a gap.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reader who reported this had two games switched on, twenty-one live
events, and said the list stopped being usable — and every game added doubles
down on that. Each section now shows six rows with an explicit "show all N".
It truncates the view and nothing else. The slice comes off the front of a
list already in the order the reader chose, so the hidden rows keep their
place, stay counted in the header, and stay on the timeline. Expanding is
per-visit state: it is something you do while reading one list, not a
statement about how the app should work.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`view` was component state, so a reader who preferred the timeline was put
back on the list by every reload — and which view opened at all had been
decided for them twice over: the PRD said calendar, the app shipped the list.
Neither was the reader's answer.
So the first run asks, and the tabs write to `prefs` from then on. The
question ships pre-answered with the list, because a reader cannot choose
between two layouts they have not seen and that is the one that answers "what
expires next" in a look — and each option is drawn rather than described, for
the same reason. The screen says where to change it afterwards, since the tabs
are small text in a corner.
Adds `view` to the prefs key space. Additive and defaulted, so an existing
reader's stored prefs open exactly where they did before.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A reader with two games switched on said the list lost its point at
twenty-one rows and asked for the three closest deadlines up front. One row
was also fragile on its own terms: ticking off the headline event left the
panel pointing at something the reader had no context for.
Three equal panels would be a stat grid, and a reader arrives with one
question — so the shape is one answer at full size and two follow-ups under
it, with no meter, summary or badges to turn the panel into a second copy of
the list below it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The headline panel asked for one row and got one row, so nothing else could
ask this question. `nextToExpire` answers it for any count and `firstToExpire`
is now that function asked for one — one definition, so a big countdown and the
lines under it can never disagree about which deadline is next.
Unannounced ends still sort behind every dated one however long they have been
running: a panel of deadlines that leads with "unknown" is not a panel of
deadlines. It sorts a copy, because the array it is handed is the one the list
on screen is rendering from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The check I added for `?template=` links read README.md unconditionally, and the
image build is the one place that runs this suite without it: `.dockerignore`
omits the README along with docs/ and AGENTS.md, so that editing a doc does not
invalidate the layer that installs, typechecks, tests and builds. `.github/` is
copied in for the opposite reason, which is why every other check in the file
ran there fine.
So this failed in a way CI could not see — the check job has the whole
repository and stayed green, while the container build went red on a file that
context leaves out on purpose. Skipping it there keeps the gate honest about
what it can actually inspect; the assertion still runs in CI and locally, which
is where a README link is edited in the first place.
Verified by running the suite inside the build stage: 516 pass, 1 skip, 0 fail,
against 517 pass and no skips in the full tree.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New bawiki parser plus the ba-bawiki-events source, giving the game its
two live and upcoming events at day precision.
It reads the rendered /wiki/Events page, which is the opposite call to the
Fandom source next door and for the same kind of reason: bluearchive.wiki
is Miraheze, whose robots.txt disallows /w/ and /*?action=, so there the
API is the closed route and the page is the surface * is allowed. It
serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay
for us. The Fandom wiki was declined earlier as a JP archive yielding
nothing live; this is the live source that assessment pointed at.
Three page facts shape the parser, each a way to publish a confidently
wrong date. The schedule is a JP/Global tabber and the Japanese version
runs four to nine months ahead, so only Global is published — the akwiki
hazard. The Global tab's nav button carries the id
tabber-Global_version-label and precedes both panels, so slicing from the
first matching id reads the Japanese schedule while believing it read
ours; the first version of this parser did exactly that and published a
JP-only event. And three tabs on the page are named Global, the schedule
plus Mini-Event and Joint Firing Drill, so the schedule is found by its
Name (EN) header rather than by position, with canParse asserting the same
lookup — a renamed tab or column fails the run instead of emptying the
lane.
The page states no time of day and no timezone anywhere, which is why the
dates are day precision and why ba gets no resetOffsets. It also settles
the five other schedule tables, which do carry a wall clock but name no
zone and mostly do not say which server: reading those as UTC would invent
the fact that matters most, and rounding to a day would not save it,
because a 04:00 local boundary falls either side of UTC midnight depending
on the offset assumed and the start's day is part of the event ID. They
are left unparsed deliberately.
The two-event count is asserted against an independent extraction off the
fixture, and both starts corroborate against the per-event infoboxes
elsewhere on the page — GL 2026-08-04 and GL 2026-09-01, where the JP
reruns those tabs would have given are 2026-04-01 and 2026-05-06.
Three comment counts were already stale before this and are corrected to
what the tree now holds rather than to what it held yesterday.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
bluearchive.wiki gives each boundary its own column and writes it as a
plain 2026-08-04, so unlike every reader here there is no range to split
and no field order to infer. Day precision, because the page states no
time of day.
Anchored at both ends, which matters more for this shape than for any
other in the module: it is the least distinctive one, and unanchored it
would happily find a date inside an article slug or a version string.
Also names the two readers the paragraph below the table was describing.
"The last two" stopped being true when rows were appended after them, and
the examples belong to parseAdjacentFullRange either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nothing a reader marks, types or ticks ever leaves their browser, so there is no
account and no server-side record to look anything up in after a report arrives.
Whatever the form captured is all there will ever be — which is the argument for
structured forms over a blank box, and for the bug form carrying the footer's
"event data last refreshed" line as a prefill. A stale calendar and a genuinely
wrong parse are indistinguishable from outside, eight sources cannot be fetched
from CI at all, and that line is the only thing that separates the two cases.
The feature form states the three constraints up front, because "sync across my
devices" and "have a model read the page" both have settled answers and a reader
deserves them before typing rather than after. It also points source requests at
the assessed-and-declined table: the loudest feedback on the first release was
that the game list is too short, so that is the request that arrives most, and a
wiki that forbids automated access or only archives finished events cannot be
used no matter how often it is asked for.
Blank issues stay enabled. A form that turns someone away loses the report, not
the noise.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The working tree is shared. An agent that reaches for `git add -A` sweeps up
whatever the user or another agent left there and signs it, which puts work in
the log nobody reviewed and breaks the one-coherent-change rule right below it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AGENTS.md now sends agents here to decide what to build next, which makes
a list where half the items have shipped actively misleading — P1a, three
of the games, the Timeline rename and custom events are all done, and
nothing here said so.
The thread reading stays fixed at the date it was made; only a status
table moves. Each item keeps its diagnosis, because those still hold —
what changed is whether anyone acted. Recording the not-dones is the point
as much as the dones: NextUp is still one row, the list still has no
expander, view is still component state, and those were the cheapest items
on the list.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The footer only spoke up past the two-day threshold, and then only as a
count of stale sources. A page silent about its own age reads as current,
so it now says when the data last refreshed on every load — absolute date
plus relative age, next to where the warning appears. This is the half of
PRD F7 that was never built.
Where the number comes from is the whole point. `freshness()` in
src/shared/feed.ts takes the newest lastSuccessAt and never generatedAt:
the feed is rebuilt on every deploy whether or not anything was refetched,
so a build stamp would report a calendar as minutes old while its events
came from a fixture captured months ago. A game is also only as fresh as
its oldest source, or Endfield's live wiki would vouch for its stalled
Game8 page. That matters concretely here — eight sources cannot be fetched
from CI at all, so this notice is what stands between a reader and a
confidently stale calendar.
The stale warning now names the lagging games and how far behind each is,
because a count is not something a reader can act on while a name tells
them which source page to check. Two exceptions found by rendering it
against the real feed: past four games it summarises the remainder, and
when every game is behind — what a refresh that stopped running looks like
— it collapses to one sentence rather than ten names repeating one age.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file was written before sanitize, the refresh runner, the daily
checklists and F13 landed, and had not been revisited since. Its tree
omitted eleven files that exist — akwiki, fandom, sanitize, robots,
snapshots, daily, effort, custom, useCustom, gameMeta and the two custom
components — plus refresh-sources.ts, the only code here that touches the
network. A layout doc that silently omits the trust boundary is worse than
no layout doc.
Three counts contradicted the docs they pointed at: seven stages against
INGESTION's six, six SQLite tables against DATA-MODEL's five, six date
formats against ten. The diagram dropped merge and labelled localStorage
"completions", which DATA-MODEL marks superseded.
The larger repair is that it presented the in-process 6h scheduler as
what runs. It is not built; refresh-sources.ts on an Actions cron is,
writing snapshots to disk with the feed built from files rather than
SQLite. Rather than rewrite the design as though it had always been this,
the diagram keeps it and a note says which half is real — the stages and
the review gate are unchanged either way, and only what wakes them up and
where they land differ. The broken tier is recorded in the same place,
since three failures raising health to failing and the runner reporting
broken are one fact described from two ends.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Each of these was true when written and stopped being true when a source
landed. dates.ts holds ten formats, not nine; INGESTION says Game8 uses
"at least four page templates" directly above a list of seven; its testing
note still credits "both current adapters" when there are eleven; PRD
lists the seven launch games against ten in GameId. AGENTS also missed
fandom from the parsers-are-keyed-by-site bullet and build-static.ts from
the tree, which matters because another section leans on the latter.
The PRD table stays as launch scope rather than becoming a roster to keep
in sync — GameId is named as the live answer instead, which is the same
move that keeps this drift from recurring. Its no-schema-change claim is
sharpened to name GachaEvent, because Reverse: 1999 did add a field:
resetHourLocal, to game metadata, which moves no stored key. The claim as
written read as false against a commit that plainly added one.
Also draws sanitize into the stage diagram as 2.5. It runs on every source
without the pipeline arranging it, which is exactly why it kept being
absent from the picture.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Self-containment was a clause on the end of the commit-straight-to-main
bullet and covered only half of what it should: one coherent change,
green on its own. Nothing said how to split a change that spans layers,
and nothing at all said what the message should contain.
Both halves are now stated. Splitting goes model → store → UI → docs with
each step green by itself, even where that means widening a type in the
model commit that only the UI commit uses — and docs/FEEDBACK.md already
makes the same argument for adapters, so it is cited rather than repeated.
Succinct is about the message: a one-line subject and a body saying why,
which a diff cannot show, rather than a list of files it can. The
no-stray-reformatting line is there because a formatter this project does
not run once buried a 100-line change in a 550-line diff.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AGENTS.md cited three of the five docs in passing and never said to read
one. An agent could therefore repair a rule it had just rediscovered the
expensive way, which is how ARCHITECTURE.md came to describe a tree eleven
files out of date without anyone noticing.
So: a table of what each doc settles and what to read it before, plus the
two rules that follow. The docs are part of the change, because they hold
the only record of *why* and a false sentence there costs the next reader
the whole argument rather than a detail. And a disagreement between this
file and a doc is a bug to report — this file summarises, the doc holds the
reasoning, so neither silently wins and the wrong one gets fixed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
bluearchive.fandom.com/wiki/Event/Event_List fetches and parses cleanly,
so the reason to decline it is not obvious from trying it: every one of its
88 rows is JP-server history, the newest ending 2026-02-18, so it yields
zero live or upcoming events. That reads as an empty lane plus a
permanently rejected snapshot, since the runner rejects a body parsing to
nothing — a failure mode worth naming in the assessment table, because
"clean table, no error" invites writing the adapter first.
bluearchive.wiki is the live alternative and stays unbuilt for now: it is
Miraheze, its robots closes the API route we used for Fandom, and it lists
JP and Global in separate tables — the akwiki hazard, which wants settling
before a line is written rather than after.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New `fandom` parser plus the r1999-fandom-events source, giving the game
six live and upcoming events at exact precision.
It fetches api.php?action=parse, not /wiki/Events. The rendered page
answers a non-browser client with a Cloudflare challenge, and getting past
that would be defeating an access control — the reason uma.moe was
declined. The wiki's robots.txt instead allows /api.php?action= for *, and
that endpoint serves our real User-Agent a 200, so this reads the
sanctioned surface with our own headers and no impersonation. The body is
therefore JSON, which is what canParse checks first: a challenge page or
an error payload must fail loudly, not parse to zero events.
Two page facts shape the parser. Titles come from each row's <b>, because
a missing banner image renders as a red link reading "File:<Event>
Banner.png" that a cell-text reader would publish as the event name. And
the page is an archive of 154 rows since v1.1 with no ongoing section to
gate on, so inclusion is decided against ctx.now — the six-event count is
asserted against an independent extraction off the fixture, per
docs/INGESTION.md § Testing.
Because robots.txt is unreadable from a challenged address, the gate fails
closed in CI and the source is skipped there — a warning, not a broken
build. Refreshing it means running `bun run refresh` from an address
Fandom serves.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the r1999 GameId and its metadata, plus GameMeta.resetHourLocal for
the fact that makes it unlike every other game here: it rolls its day at
05:00 server time rather than 04:00, on a single global UTC-5 server, so
its day-key boundary is 10:00 UTC.
resetOffsets could not carry that. Landing 10:00 UTC through the offset
would mean claiming a UTC-6 server, and serverOffsetUtc answers more
questions than this one. The new field overrides RESET_HOUR_LOCAL per game
and is absent everywhere else, so no existing reader's logged day moved —
a test pins that for the games that already had readers.
Both facts are read off the source, not assumed: all 154 rows of the
wiki's event list state (UTC-5) and run 05:00 to 04:59, an event ending
one minute before the reset the next one begins on.
No adapter yet, so the game has no events until one lands.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds parseOrdinalDateTimeRange for "November 9th, 05:00 - December 4th,
2023, 04:59 (UTC-5)". Ordinal days anchor the pattern at both ends so it
cannot claim text the looser readers should get, and the year rolls back
across New Year as parseMonthDayRange does.
The offset is required rather than defaulted. parseSlashDateTimeRange has
to read its wall-clock times as UTC because its source states no zone;
here the zone is part of the format, so a cell without one returns null
instead of being silently read five hours early. Calendar validation runs
on the stated local fields, before the shift, or "February 30th, 23:00
(UTC-5)" would convert into a real instant in March.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The carve-out was 180 words appended to LICENSE, which pushes the file below
the similarity threshold licence detectors match against — a repository whose
licence reads "other" is less inviting to a forker than one that reads MIT,
and the carve-out was never part of MIT's text anyway.
LICENSE is verbatim MIT again, plus three lines binding NOTICE into the terms
so the carve-out travels with any copy rather than being optional reading.
NOTICE carries the substance. README points at both.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The section still read "not yet chosen" after the LICENSE file landed, which
is the one wrong answer worse than no answer for someone deciding whether
they may fork. Says MIT, and says in the same breath that the schedules are
not MIT's to give.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The code is mine to license; the schedules under fixtures/ and snapshots/
were compiled by wiki editors and describe events run by the publishers, so
relicensing them under MIT would be claiming something I never held. A reader
who forks this needs to know the data does not come with it.
Kept as a note under the unmodified MIT text rather than woven into it, so
the license itself still reads verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The guidance in here is not Claude-specific — it is what this project is,
what it refuses to do, and the rules that are invisible from the code. Any
agent working here needs it, and AGENTS.md is the name they all look for.
CLAUDE.md stays as a pointer so Claude Code still finds it, and holds no
guidance of its own; two copies would drift and the wrong one would be read.
Every cross-reference in the source comments, docs, workflow and skills now
names AGENTS.md, and the image ignores both.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The assistant's default is to cut a branch whenever it is asked to commit
on a default branch. That default is for shared repos; this one is solo and
its history is a single line, so a branch per change is a merge to clean up
after and nothing gained. CLAUDE.md overrides the default, so it says so here.
Restates the self-contained-commit rule alongside it, since that is the part
worth keeping.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>