Commit Graph
89 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 74c1f74e87 feat: reorder games from settings
The games chip row becomes a row per game, because a reorder affordance needs
somewhere to put a handle and two arrows, and at fourteen games a list reads
better than a wrapped row of pills anyway.

Reordering lives here and nowhere else. The focus bar and the dailies strip are
the fastest tap targets in the app, and a drag target sitting on top of a tick
target costs somebody a streak the first time it misfires — so the live surfaces
stay drag-free and this is the screen you visit on purpose.

Both affordances ship. Touch fires no drag events at all, so the arrows are the
mechanism and the handle is the pointer fast path; being ordinary buttons is
also what makes the whole thing reachable by keyboard and screen reader without
a second implementation of the same interaction. An arrow at either end is
disabled rather than removed, because a control that disappears on the first row
slides the other one under the finger aiming at it.

A move writes back the whole displayed list: the indices are positions on
screen, so applying them to a stored order that names only some lanes would move
the wrong game. Reset writes the field away rather than storing an empty order.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:56 +02:00
Lucas WintherandClaude Opus 5 721c4c90fb feat: every list of games follows the reader's order, dailies included
The focus bar, the settings list, the timeline's lanes and the dailies strip all
go through `orderGames` now, resolved once in App. `games` itself stays in feed
order on purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it,
so ordering it at source would let a display preference reach the code that
decides which of a reader's games get hidden — a reordering bug would become a
game-silently-switched-off bug.

The dailies strip also groups. It was `[...chores, ...repeating]`, which put
Genshin's commissions and Genshin's own login event at opposite ends with a
dozen games between them; a game's chore and its repeating events are now
adjacent, chore first, events in the order they arrived. Collapsed that is
adjacency alone — no per-game headings, because this is the part of the page
answerable in ten seconds and a heading each would make it the tallest block on
it, pushing "next to expire" down the page.

Two things moved to where they belong. Skipping a standing chore for a lane the
reader invented is `dailyGroups`' rule, not the call site's: filtering those
lanes out in App also cost a reader's own game its place in the order its events
group under. And the expanded catch-up panel is its own exported component, so
which days it offers and whose clock they were cut on are testable rather than
trapped behind a `useState`.

`Welcome` drops its own comparator for the shared rule — the picker runs before
any stored order, so it asks for the A–Z case and cannot drift from the four
surfaces behind it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:37 +02:00
Lucas WintherandClaude Opus 5 bf7959c916 feat: let a day already gone be ticked
People play at midnight and tick at breakfast, or forget for a week and come
back. A log that only accepts today goes wrong on its first bad evening and is
never trusted again.

An event whose end was published already allowed this — its checklist draws the
whole run and past pips are clickable. `catchUpDays` answers the two cases
`dailyDays` cannot: a standing chore, which has no start or end because it is a
routine rather than an event, and an event with `endsAt: null`, where the
checklist could draw no run at all and so offered only today. That second one
is the worse of the two, because it looks like a working checklist.

Never a day past today. A tick claims you did it, and nobody can have done
tomorrow, so a future day is absent rather than rendered and disabled — a
control for a claim that cannot be true. An event's strip starts when the event
did, capped at the same fortnight so a login campaign that opened in March does
not become a wall of pips.

The window bounds display and never data: a tick older than it stays logged and
keeps counting toward the streak. `DayPip` moves to its own module rather than
being copied, so there is one answer to what a missed day looks like.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:08 +02:00
Lucas WintherandClaude Opus 5 88d5ea2e43 feat: decide the order games are listed in
Nothing did. Every surface that lists a game rendered App's `games`, which is
the order lanes first appear in the feed — whichever game happened to hold the
first event row. It is arbitrary, it moves as events come and go, and it was
the same complaint the first-run picker had.

So `orderGames` is one rule in one place, and `prefs.gameOrder` is where the
reader's own answer goes. Absent means they have never placed a game rather
than an empty order, which is the distinction `knownGames` already draws and
the same trap: every install predating the field is in that state, and reading
it the other way would hand them a blank list. They get A–Z by the name on
screen instead.

Two properties carry the weight, and both are tested rather than asserted. The
result is always a permutation of the lanes given, because a game dropped
here looks exactly like a game switched off and switching it on would not
bring it back. And a lane the order does not name trails the ones it does, so
a game we add later never lands in the middle of a hand-made order and a
retired source keeps its slot for when it returns.

Nothing reads it yet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:00:32 +02:00
Lucas WintherandClaude Opus 5 7b6ee62235 feat: sort the first-run game picker alphabetically
The picker took `available` in feed order — whichever game happened to hold
the first event row — which means something everywhere else in the app and
nothing on this screen, where the reader is scanning for the two or three
names they already know.

Sorted on the name printed on the button rather than the LaneId, since the id
is not what a reader sees, and through localeCompare rather than `<`, because
a code-point sort files hololive Dreams after every capitalised game instead
of between Genshin and Honkai.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 05:34:00 +02:00
Lucas WintherandClaude Opus 5 3e463adf47 refresh: let the cron stand on the recorded robots permission
Four Fandom sources have skipped on a challenged robots.txt every cycle, so
their calendars are only ever as fresh as the last manual run. For a product
whose promise is a trustworthy end date, four stale lanes are the worse failure
— so the scheduled run now passes --assume-robots-on-403 and the owner re-reads
those files by hand over time.

Kept asymmetric on purpose: --force is still refused on an unattended run.
Forcing every cycle really is just a shorter interval with extra steps, and
nothing about this decision touches that.

The accepted risk is narrower than "crawling against robots.txt", and worth
stating precisely. A plain 403 still fails closed, so a host that turns us away
still stops the run — that is what the challenge-or-refusal split buys. What is
invisible is a robots.txt edited to disallow us, because from a challenged
address a withdrawal looks exactly like the challenge we already expect. No
code can catch that; only the re-read can.

Which makes the per-cycle warning the compensating control rather than a
courtesy, so it is now pinned as one: every host named, and surviving as a
run-page annotation on a completely green cycle where nothing else draws the
eye. A dispatch can set the input to false to see the real state.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:12:28 +02:00
Lucas WintherandClaude Opus 5 f12398a29b refresh.yml: offer both overrides to a dispatch
Four Fandom sources have been skipping on a challenged robots.txt every cycle,
and the only thing that clears it is a person passing the recorded permission.
Until now there was no way to do that from the workflow, so it meant checking
out the repo and running the refresh by hand. Both overrides are dispatch
inputs now, guarded on the literal string "true" and empty on the cron, so the
schedule still cannot reach either one.

The force-push guard needed narrowing to allow this. It asserted the workflow
contains no "--force" anywhere, as a proxy for never rewriting the branch, and
the refresh runner's own --force flag trips that substring while having nothing
to do with pushing. It now matches force on a git push specifically, including
--force-with-lease, which is still one — a tighter assertion than the string it
replaces, checked against both a real force push and the flag it must ignore.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:01:49 +02:00
Lucas WintherandClaude Opus 5 2fb944135d refresh: ask who authorised a run, not whether it is CI
Both overrides stand in for a person deciding something, and the gate that
enforced it asked the coarser question. `isCi()` got the case that matters
wrong: a `workflow_dispatch` sets CI=true, so somebody clicking "Run workflow"
was refused exactly as the cron was, which made both flags unreachable from the
workflow at all. A dispatch is a person, and a better-evidenced one than a local
shell — GitHub records which one, and the run log keeps it.

`runAttendance` draws the line where it belongs: a local shell is a person, a
dispatch is a person and names the actor, and a schedule — or any other runner
event, erring that way on purpose — is not. The run now prints which override
was used and who authorised it, because a local shell leaves no trace anybody
else can read.

The schedule stays refused, and for the robots override that is not ceremony.
From an address that gets a challenge we never receive robots.txt at all, so a
cron standing on the recorded permission has no way to notice the host
withdrawing it, and that permission has no expiry. The challenge-or-refusal
narrowing does not close this: a plain 403 still stops us, but a robots.txt
edited to disallow us would be invisible, because a challenge arrives instead of
a file. A person re-reading it is the only thing that ever re-validates it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:01:11 +02:00
Lucas WintherandClaude Opus 5 47967a3136 robots: tell a challenged 403 from a refusal
`--assume-robots-on-403` stands in for rules a person read in a browser, for a
host that will not serve us the file. It always claimed it would not override a
host that had turned us away — but it could not tell the two 403s apart, so it
excused both. A managed challenge ("we cannot tell what you are") is a question
our fetcher cannot answer and the operators never asked, which is what makes a
human reading the rules a fair substitute. A bare 403 is the site itself saying
no, and nothing recorded on our side may talk over that.

`isInterstitialChallenge` now decides, on Cloudflare's own `cf-mitigated` header
with the challenge page's markers as a fallback; an unreadable body counts as
unclassifiable rather than challenged. This only ever narrows what the flag
opens, so nothing that passed the gate before stops passing it.

Every 403 is classified whether or not the flag is set, and the kind goes into
the reason string the run reports. `robots.txt returned 403` read identically
whether the answer was to refresh by hand on the recorded permission or to stop
fetching the source, and the summary is where somebody has to decide that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 23:37:44 +02:00
Lucas WintherandClaude Opus 5 2cb4dd214c feat: "show events that haven't started" governs both views
It was the board's alone, sitting between two app-wide filters and
carrying a line of small print to explain why it was different. It is the
same events answering the same question in either view, so it is now one
switch: the board plots them, and the checklist keeps its "Not started
yet" section.

That means the section is off by default, which is a visible change for
every existing reader — deliberate, and the same argument the board makes.
This app answers what expires next; on fourteen lanes the queued patches
are more rows than the thing they came for.

`timelineUpcoming` becomes `showUpcoming`, because the old name would now
be false, and `adoptRenamed` carries a stored answer across on load.
Nothing would be lost by dropping it — `prefs` is one blob under one key,
not a key space — but a reader who had switched the future on would find
it off with no explanation, and they should not have to say a thing
twice. A stored new name always wins, so it cannot overwrite a fresher
answer with a stale one.

Gating the section alone would have opened a hole: nothing running and
everything held back rendered an empty column, because the "nothing to
show" line keyed off there being no rows at all rather than none listed.
It now counts what is held back and names the switch, as the board does.

The split pills stay the board's: the checklist gives these a section
with a heading either way, so there is nothing there to mix them into.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:20:14 +02:00
Lucas WintherandClaude Opus 5 3db93a1ccf feat(timeline): offer both readings of a board with the future on it
"In their own group" is the shape of a patch — what is on now, and what
is queued behind it. "Mixed in" is the question a Gantt chart exists for:
what runs out first, whether or not it has opened. On real data that is
not a cosmetic difference — mixed, five events currently running sort
below an Arknights rerun that has not started, because it closes before
they do.

Which is why it is a pair of pills and not a checkbox: neither answer is
the absence of the other, and "mixed in" is a different order rather than
the heading switched off. It sits under the row that puts the future on
the board at all, and only while that row is ticked — a choice about
arranging unstarted events is unanswerable with none of them on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:05:58 +02:00
Lucas WintherandClaude Opus 5 9dde82801a feat(prefs): remember which reading of the board the reader wants
Whether unstarted events keep to their own block or sit in one deadline
order with the running ones. Both are right for somebody, so it is the
reader's answer and it survives a reload like the rest of the board's
shape does.

Defaults to the block, which is the board as it was before the choice
existed. Only read while `timelineUpcoming` is on — with nothing
unstarted plotted there is no block to keep apart — but stored either
way, so switching that back on restores what they said rather than a
default.

The Controls test builds a whole `Prefs`, so a new required field lands
there in the same commit or nothing typechecks in between.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:05:31 +02:00
Lucas WintherandClaude Opus 5 c0e523bdc4 feat(timeline): name the line where the running bars stop
A dashed edge and a thinner wash say "this bar has not started". They do
not say where the running ones ended, so a board with the future switched
on had to be decoded bar by bar to answer the question a reader opens it
with — what is on now.

So the boundary gets a label, and it is the same object as a lane's name:
a small eyebrow pinned to the left edge, surviving any scroll position. In
muted ink rather than a hue, because a hue on this board means "whose
event is this" and this is not about a game. One per lane, since "where
does this stop running?" is a different answer for each of them.

Where it goes is a single index rather than a per-row test, because there
is only one boundary: every order this board can be given puts live rows
before upcoming ones, and `splitAt` is exported so that is a test rather
than a comment.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:55:16 +02:00
Lucas WintherandClaude Opus 5 a66803384a refactor(timeline): move the unstarted-events switch into settings
It shipped as a pill in the board's own header, beside the stacking and
scale controls. That is the wrong company: those two reshape what is
already on the board, which is why they are reached for while reading it,
and this one decides what is on it at all — the same question
"Show events I've finished" and "Show events I'm ignoring" answer, from
the panel where both of those live.

It is the only one of the three scoped to a single view, so the row says
so instead of reading as a promise about the whole app. The board loses
the count the pill carried; with nothing left running it still says how
many are waiting and names the setting, and the page header has said
"N live · N upcoming" all along.

Controls had no tests at all, so the three view filters get some: what
each checkbox is bound to is invisible in a diff and obvious only to the
reader it happens to.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:46:38 +02:00
Lucas WintherandClaude Opus 5 7a3588fbf6 feat(timeline): hold back unstarted events, and say where they begin
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]>
2026-08-19 05:31:01 +02:00
Lucas WintherandClaude Opus 5 f38fe49d30 fix(refresh): report an assumed-robots host once, not twice
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]>
2026-08-19 05:07:49 +02:00
Lucas WintherandClaude Opus 5 72d57dd02a feat(colophon): add a Ko-fi link, and group the author's links together
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]>
2026-08-19 05:03:17 +02:00
Lucas WintherandClaude Opus 5 6552528acb feat(refresh): --force, to ask before the 6h floor is up
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]>
2026-08-19 04:59:52 +02:00
Lucas WintherandClaude Opus 5 f67118913a feat(refresh): --assume-robots-on-403, for a host that hides only its robots.txt
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]>
2026-08-19 04:45:54 +02:00
Lucas WintherandClaude Opus 5 239e970471 test: pin that retiring a game cannot delete a reader's own events
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]>
2026-08-19 04:40:04 +02:00
Lucas WintherandClaude Opus 5 c782a265ca fix(ingest): retire a day-precision row on the clock the app reads it on
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]>
2026-08-19 04:35:19 +02:00
Lucas WintherandClaude Opus 5 2985484883 feat: rebuild Infinity Nikki on a live source, at day precision
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&#39;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]>
2026-08-19 04:23:24 +02:00
Lucas WintherandClaude Opus 5 bd385855c0 feat: add Nikke, the third Fandom template
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]>
2026-08-19 04:11:03 +02:00
Lucas WintherandClaude Opus 5 6b00d9a9c0 fix(merge): fuse near matches only across sources
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]>
2026-08-19 03:52:57 +02:00
Lucas WintherandClaude Opus 5 04c69fd3df feat: add Umamusume, from Game8's banner list
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]>
2026-08-19 03:52:02 +02:00
Lucas WintherandClaude Opus 5 220e73ff7a fix(game8): decide a column table's header by what it produces
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]>
2026-08-19 03:51:48 +02:00
Lucas WintherandClaude Opus 5 6c7369db10 feat: add Chaos Zero Nightmare, which the game8 parser already reads
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]>
2026-08-19 03:50:33 +02:00
Lucas WintherandClaude Opus 5 ecbf9c0422 feat: add Stella Sora, from the wiki's front page rather than its banner list
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 &#95;&#95;, 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]>
2026-08-19 03:50:20 +02:00
Lucas WintherandClaude Opus 5 4dff14087f feat: add Girls' Frontline 2, from IOP Wiki
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]>
2026-08-19 03:50:05 +02:00
Lucas WintherandClaude Opus 5 448c70968d feat: add the light palette, and the module that resolves a theme
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]>
2026-08-18 23:30:26 +02:00
Lucas WintherandClaude Opus 5 d491437e32 feat: let the reader zoom in two steps closer than a week
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]>
2026-08-18 22:34:37 +02:00
Lucas WintherandClaude Opus 5 1c8b71e7bf feat: put the stacking toggle on the board
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]>
2026-08-18 22:16:00 +02:00
Lucas WintherandClaude Opus 5 663196b5ea feat: let the timeline stack by deadline, not only by game
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]>
2026-08-18 22:15:22 +02:00
Lucas WintherandClaude Opus 5 4334988ecf fix: count down to a game's reset, not to UTC midnight
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]>
2026-08-18 21:48:29 +02:00
Lucas WintherandClaude Opus 5 00c3f5e2da refactor: move the server-reset clock out of daily.ts
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]>
2026-08-18 21:48:00 +02:00
Lucas WintherandClaude Opus 5 e42ac84b40 feat(ingest): read a summoning campaign as a banner
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]>
2026-08-18 21:30:09 +02:00
Lucas WintherandClaude Opus 5 4698ebc0c7 feat(fgo): read the English server, not the Japanese one
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]>
2026-08-18 21:29:59 +02:00
Lucas WintherandClaude Opus 5 ba39ad2bce feat(timeline): let the reader set the scale
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]>
2026-08-18 04:13:31 +02:00
Lucas WintherandClaude Opus 5 d5dbe72e17 feat(prefs): a game we add arrives switched off
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]>
2026-08-18 04:05:23 +02:00
Lucas WintherandClaude Opus 5 b3aea00cbb feat(ui): call the list view Checklist
"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]>
2026-08-18 03:59:52 +02:00
Lucas WintherandClaude Opus 5 c4f6551844 feat(timeline): stop two months back, and give the bars room
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]>
2026-08-18 03:54:23 +02:00
Lucas WintherandClaude Opus 5 7ab801f409 feat(holodori): track hololive Dreams from its own wiki
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]>
2026-08-18 03:39:49 +02:00
Lucas WintherandClaude Opus 5 7804a08863 feat(prefs): ask which view to open on, and remember the answer
`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]>
2026-08-18 03:28:03 +02:00
Lucas WintherandClaude Opus 5 41046899f9 feat(next-up): show the next three deadlines, not one
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]>
2026-08-18 03:26:53 +02:00
Lucas WintherandClaude Opus 5 6e95e54ec6 feat(lens): order the deadlines, not just the closest one
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]>
2026-08-18 03:25:46 +02:00
Lucas Winther 5cc46a0c9a feat: add fgo to games. 2026-08-18 02:43:56 +02:00
Lucas WintherandClaude Opus 5 09716054f4 fix(test): let the README link check stand down where there is no README
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]>
2026-08-17 23:31:12 +02:00
Lucas WintherandClaude Opus 5 d843c7cae7 feat(ba): track Blue Archive from its own wiki
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]>
2026-08-17 23:24:11 +02:00
Lucas WintherandClaude Opus 5 77921e88f5 feat(dates): read a bare ISO date as one boundary
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]>
2026-08-17 23:23:06 +02:00
Lucas WintherandClaude Opus 5 02dd7b3ed4 feat(github): give readers two issue forms and link to them
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]>
2026-08-17 23:19:47 +02:00