Commit Graph
267 Commits
Author SHA1 Message Date
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 c1cd1148d9 docs: write down the second ground, and the rule it creates
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]>
2026-08-18 23:30:41 +02:00
Lucas WintherandClaude Opus 5 eca5ad7a0a feat: offer the theme in settings, beside the region
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]>
2026-08-18 23:30:41 +02:00
Lucas WintherandClaude Opus 5 3ad74df5ea feat: let a stored preference decide the ground
`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]>
2026-08-18 23:30:41 +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 adab688e0e feat(colophon): move the idea credits under the GitHub links
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]>
2026-08-18 22:40:41 +02:00
Lucas WintherandClaude Opus 5 88c6a7bee9 docs: record what sources exist for the games we still don't cover
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]>
2026-08-18 22:38:05 +02:00
Lucas WintherandClaude Opus 5 678fad1879 feat(colophon): credit three more readers for ideas
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]>
2026-08-18 22:36:32 +02:00
Lucas WintherandClaude Opus 5 1c7e9bb290 docs: say how far in the ladder goes, and why
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]>
2026-08-18 22:34:50 +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 0ccb5a144e feat(colophon): credit u/eriatilox for ideas too
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]>
2026-08-18 22:33:53 +02:00
Lucas WintherandClaude Opus 5 fa4f801108 feat(colophon): thank the forks whose ideas landed here
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]>
2026-08-18 22:32:04 +02:00
Lucas WintherandClaude Opus 5 5761e89163 feat: open the timeline two steps further in
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]>
2026-08-18 22:23:58 +02:00
Lucas WintherandClaude Opus 5 e587e7e106 docs: say that the board has two shapes now
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]>
2026-08-18 22:16:24 +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 36f4316dba docs: say where the focus bar sits per layout, not per page
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]>
2026-08-18 22:13:09 +02:00
Lucas WintherandClaude Opus 5 ae5ac3aa98 fix: wrap the focus chips where the bar is a rail, not a page
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]>
2026-08-18 22:12:54 +02:00
Lucas WintherandClaude Opus 5 16fcfed9b3 feat: move the focus bar into the deadline rail past lg
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]>
2026-08-18 22:12:43 +02:00
Lucas WintherandClaude Opus 5 7a90158139 fix: print the date the countdown is actually counting to
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]>
2026-08-18 21:48:48 +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 Winther c2fe249d83 Merge remote-tracking branch 'origin/main' 2026-08-18 21:34:39 +02:00
Lucas WintherandClaude Opus 5 ce5e2419ac chore: drop two files that were copies of files we already had
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]>
2026-08-18 21:34:03 +02:00
Lucas WintherandClaude Opus 5 efae87ae59 docs: record the FGO source, and the trap it fell into first
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]>
2026-08-18 21:30:38 +02:00
Lucas WintherandClaude Opus 5 9f0bb62c98 feat(games): name FGO's daily chore, and say why it has no reset offset
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]>
2026-08-18 21:30:38 +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
github-actions[bot] 526fc91ef9 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32167590238.
2026-08-18 17:49:07 +00:00
github-actions[bot] bab32328da chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32104436026.
2026-08-18 05:50:46 +00: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 Winther 4435347299 chore: refresh source snapshots. 2026-08-18 03:44:00 +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 f3d0df9fda docs(prd): wrap two lines to the width the file uses
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 03:34:32 +02:00
Lucas WintherandClaude Opus 5 07937b4c14 docs: name the sorting rule rather than counting bullets to it
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]>
2026-08-18 03:33:33 +02:00
Lucas WintherandClaude Opus 5 e489697540 feat(timeline): make it a board, with the axis and the names pinned
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]>
2026-08-18 03:31:58 +02:00
Lucas WintherandClaude Opus 5 1cd9579280 feat(layout): use the width instead of a column in the middle of the screen
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]>
2026-08-18 03:30:47 +02:00
Lucas WintherandClaude Opus 5 6403dec944 feat(list): cap each section and offer the rest
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]>
2026-08-18 03:30:04 +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 c2179e29e0 fix: failing test case. 2026-08-18 02:47:35 +02:00
Lucas Winther 5cc46a0c9a feat: add fgo to games. 2026-08-18 02:43:56 +02:00
Lucas Winther 94ecf9ccbb feat: Make a template skill for AI agents to add a new source data. 2026-08-18 02:02:23 +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