The dark tokens re-struck under `[data-theme="light"]`. Nothing sets that
attribute yet, so the app still renders exactly as it did.
Two things could not be tokens. Game hues are data — ours and the
reader's — and every one was picked against a near-black ground, so
`readableHue` darkens them along the same hue until they read on paper,
and returns dark untouched by construction. And the attribute has to be
on the root before React can mount, or a reader who chose light gets a
dark flash on every load, so the shell reads the preference itself.
That leaves the ground colour written in three files that cannot import
each other, which is what the test pins together.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
They sat between "Built by" and the two GitHub links, splitting one thought
about who made this and where it lives. Below the links they read as the
tail of that block instead of an interruption in it, and the paragraph that
invites a bug report still comes last, where a reader who has just found a
wrong date is looking.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
Events routinely end within a day of each other, and at the old close
end of the ladder their bar ends were a few pixels apart — the board
being asked the one question it exists to answer and telling the reader
to squint. Seventy-two and 108 px/day separate them, and give a one-day
event a bar wide enough to read and to tap rather than the 34px every
short bar collapses to.
The clamp test now reads the ladder's last rung instead of naming 48, so
extending it again does not need the test rewritten.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Second name on the list, and the first from somewhere other than a fork —
so the doc comment stops calling this a list of forks, and says what the
`handle` field now carries: it renders verbatim, a bare handle meaning
GitHub and `u/` meaning Reddit, rather than a `platform` field nobody would
keep consistent across two entries.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A public fork of this project worked out several things worth having — a
theme toggle, an archive for finished events — and ideas are not code:
nothing here carries a licence obligation, so this is thanks rather than a
term, and it belongs in the credits rather than in NOTICE with the terms.
Credited by GitHub handle and fork URL, not by name. The commits behind that
fork carry a third name again, and a handle is the one identity that is
verifiable and stable.
Hardcoded, unlike the source and studio credits above it — nothing in the
feed knows a fork exists, so there is nothing to derive it from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Thirteen px/day opened the board on about a quarter of calendar, which
is more time than these schedules are written in: a patch is six weeks,
so most bars were short enough that their length stopped reading as a
duration and their title no longer fit inside the bar it named. Thirty-
two opens on roughly one patch cycle on a laptop.
Only new readers move. `prefs` is written on every load, so anyone who
has opened the app has a timelineDayWidth of their own and keeps it. A
phone now shows about twelve days rather than thirty, which is the cost;
the longer view is one press of − away and that answer is remembered.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The board's header said "One lane per game" and left it at that; now it
offers the other stacking too, in the same place and the same pill shape
the list's sort control uses — you reach for it while looking at the
board, not in settings.
Merged, there is no lane heading to say whose event a bar is, and hue
alone cannot answer that once every game shares one stack. So each bar
carries its game's short name, and its tooltip carries the full one. A
bar under 96px has no room for a tag and a title both, and a chopped
game name reads as a broken word — those keep the title and say the game
in the tooltip.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Lanes keep a game's events adjacent, which is what makes the board
readable for someone playing four of them — but a reader with four games
has one queue of deadlines, and lanes scatter it: the thing ending
tonight sits three lanes below the thing ending next month, and no
amount of scrolling puts them side by side. So the stacking becomes a
choice, and the choice is remembered like the scale and the view are.
The merged mode sorts with endingSoonestFirst rather than a bare end
date, so the timeline and the list cannot mean different things by the
same words — and an unannounced end keeps its place behind every dated
one instead of pretending to a deadline.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
In a 20rem column the scrolling strip shows four chips of a thirteen-game
reader's set behind a hairline scrollbar, and a game you cannot see is a
game you will not focus. Past `lg` they wrap instead: the rail is short and
the list beside it is long, so the extra rows cost that column nothing it
was using. On a phone the strip is right and stays.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Past `lg` the page is a pinned rail of instructions beside the lists, and
the focus bar was still a full-width strip above both columns: it spent
the whole width of a wide screen on a row of chips and pushed "next to
expire" — the thing the reader opened the page for — down behind it, while
sitting nowhere near the panels it narrows.
The top of the rail is still above everything it affects, and now it pins
with the deadlines and dailies it filters. On a phone, and on the
timeline, which has no rail, nothing moves: the same rule puts it back at
the top of the page. One element, rendered once per view.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The detail sheet formatted the stored ISO, so a day-precision end rendered
as 00:00Z — the previous day for every reader west of UTC, and now a
different day from the timer sitting beside it. Both dates come off the
clock instead, which resolves them the same way.
The note under them said the end was "accurate to the day only" and left
it there. It can say where the count actually lands, and should: a reader
deciding whether to log in tonight wants the reset, not a shrug. It still
says this is a reading rather than a time the source printed.
REGION_LABEL moves next to the region table it names, which is also how
the note gets "Europe" rather than the raw id.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A source that prints "August 19, 2026" and no time is stored at 00:00Z,
which is a placeholder for "somewhere in this day" rather than a claim
about when the day opens. The countdown read it literally, which turns it
into exactly that claim — and the UTC day is nobody's. Wuthering Waves
events dated the 19th were still running three hours after we had retired
them, because a European player's day opens at 04:00 on a UTC+1 server;
Asia was four hours out the other way and America nine. Sixty-two of the
eighty-six published events were affected, across ten of thirteen games,
so the reader was reading a wrong number far more often than a right one.
A day-precision boundary now resolves to the reset that opens that
game-day on the reader's server — the same grid daily.ts keys every tick
by, and the only clock we hold for a game. That is a reading of the date
the source printed, not a time invented for it: nothing stored moves, no
event ID moves, and the parsers still refuse to guess.
Two boundaries are never re-anchored. A regionEnds value is already the
instant a source stated per server, so anchoring it would throw a fact
away. And an event the reader typed in was resolved by readerInstant in
their own timezone, which is a stated time too — theirs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ticks are about to stop being its only caller: the countdown needs the same
grid, and daily.ts already imports time.ts, so asking for it the other way
would close a cycle. time.ts is where it belongs anyway — it has held
REGION_RESET_UTC_OFFSET all along, and serverOffsetUtc is that table's
accessor. Nothing changes shape; `shift` is exported as `resetShiftMs`
because dayKey now reads it across a module boundary.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Daily Quests is what the game calls the thing, not "daily missions".
The offset is the part worth recording. The English server is a single
worldwide machine on US Pacific — every duration on Event_List_(US)
says PDT — which normally earns a resetOffsets entry the way holodori's
JST did. It cannot have one: Pacific observes daylight saving and the
page itself alternates PDT and PST, while resetOffsets holds one fixed
number per region. Both -7 and -8 are wrong for half the year, and
either would re-label day keys twice a year for readers who logged
ticks under the other. So fgo takes the default and the gap is a
comment rather than a guess.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every game names its gacha something else, and inferType only knew the
five words the game8 sources use. Fate/Grand Order calls its banners
"Summoning Campaign" — the whole of that page's banner vocabulary — so
without this every FGO pull lands as "other" and the type filter stops
meaning anything for that lane.
Nothing on the current English page carries the word, so no checked-in
expected output moves; the unit case is what pins the behaviour until
the next refresh brings a banner in.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The source landed pointed at Event_List, whose first sentence is "This
page lists all Events in Fate/Grand Order Japan". Every date it
published was a JP date on a calendar whose other twelve games are
global — the akwiki CN column again, and the failure this product
exists to prevent. Event_List_(US) is the English schedule and is what
the adapter reads now; a test asserts the URL so it cannot drift back.
The page is a different template from the Reverse: 1999 one, so the
parser branches, the way game8 branches over seven shapes. Its sections
are fenced by banner images with the label drawn in a positioned div —
no heading, no id — and only ONGOING EVENTS is read. FUTURE EVENTS
states a month with no day, and PAST EVENTS states no year at all, so
neither can be dated and neither is guessed at. canParse asserts both
dividers and the Duration label, which turns a redesign into a stalled
source rather than a lane that quietly empties.
The rewrite drops what the JP branch needed and this page does not: the
hand-rolled month/year rollover (parseFullRange already reads both
stated years), the any-typed locals, and a heading-to-duration regex
whose nested lazy quantifiers hung for minutes on a 620KB body the
moment one anchor stopped matching. Durations name a zone but no clock,
so the stated calendar day is kept rather than shifted into UTC —
there is nothing to anchor a conversion to, and the start's day is half
the event ID.
Also reverts two things done to quiet tests rather than answer them.
The parser no longer normalises NFKC: that was pre-empting the
sanitizer so "real fixtures need no repair at all" would pass, and the
seam is where repairs belong. And renderedHtml no longer reaches into
a legacy {"*": …} envelope, which every source here rules out by
pinning formatversion=2.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.
A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.
Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.
The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adding a source is our decision, not the reader's. Eleven lanes became
fourteen this week, and for someone who plays two that is not a feature
arriving — it is their calendar filling with events they will never open,
which is the thing the first-run picker exists to prevent.
`knownGames` records every lane a reader has been offered; a lane missing from
it is new to them, so it is recorded and hidden on sight. The games chips in
settings list every lane, on or off, which is where they take one up.
The case that had to be right is the reader who installed before any of this
existed: they have no `knownGames` at all, and reading that as "has been
offered nothing" would switch off every game they already read. Absent means
unrecorded — the first pass records what is already on their screen and
changes nothing else. Lanes they invented are recorded but never hidden;
typing a game in is asking for it.
The decision is a pure function so this is provable rather than watched for.
One real cost, stated in the PRD rather than hidden: a reader whose game
finally arrives is not told, which makes the colophon roadmap matter more.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Ending soon" described the sort, not the job — and it sat one line above a
sort control whose own option reads "Ending soonest", so the tab and the
ordering looked like the same control said twice. Checklist is what a reader
with four games is doing with it.
The stored id stays "soon". That value is in `prefs.view` on real devices, and
a label is copy: renaming one must never move a reader to the other view.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two changes to how the board reads.
The window was drawn from the earliest start, so one long-running event — a
standing login campaign can have been going half a year — bought months of
empty calendar that nobody scrolls back through and that pushed every other
bar off to the right. It now floors at two months, a patch cycle and a half:
far enough that a running event's start is usually still on the board, past
the point where the answer changes what anyone does today. A bar older than
the board keeps its faded left edge rather than being redrawn as though it
started there.
The bars themselves were 28px with 4px between them, which read as a stack of
hairlines rather than a schedule. They are 36px now, with more padding, more
air between events, and more between lanes.
`boardWindow` comes out of the component while it is being changed: it
decides what a reader can and cannot see, which is worth a test rather than a
rendering.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New holodoriwiki parser plus the holodori-holodoriwiki-events source,
giving the game its two live and upcoming events at exact precision.
holodori.wiki is Miraheze, so this is the same call bawiki makes and for
the same reason: robots.txt disallows /w/ and /*?action=, which closes the
API route and leaves the rendered /wiki/Events page as the surface * is
allowed. It serves our own User-Agent a 200, sets no Content-Signal and no
Crawl-delay for us, and the wiki is CC BY-SA 4.0.
What makes this source unusually good is that it states its timezone on
every cell — 08/17/2026 8:00PM (JST) — so it is the only wiki source here
publishing exact precision on both boundaries without a per-region timer.
parseSlashClockZone requires that zone rather than defaulting to UTC: a row
that ever loses it drops out instead of silently landing nine hours off,
and an abbreviation absent from the table is refused rather than guessed,
which is why the table holds only JST and not the plausible-looking
candidates that are not fixed offsets. The 12-hour clock gets its own
handling and its own tests, because 12PM and 12AM are the two readings a
naive parser gets wrong while still producing a valid-looking instant.
Inclusion is fenced by the Current Events <h2> and bounded by the next one.
That matters more than it looks: Past Events sits directly below with
identical columns, so a reader taking every wikitable would put the back
catalogue on the calendar with nothing to mark it. Rows are still checked
against ctx.now on top of the heading, because Current is maintained by
hand and goes stale before anyone moves a row down.
Two rows are deliberately missing. Beginner Mission runs Game Launch →
Unknown, and no start means no event ID — a permanent tutorial chore is not
what a calendar of deadlines is for. An Unknown end is kept as endsAt null,
and unlike bawiki this parser does not drop a started-but-undated row: the
heading has already said the event is running, which is the fact bawiki
lacks when reading an archive.
Every event title is still a red link — the wiki has no article for any of
them yet — so each points at ?action=edit&redlink=1, a create-page form on
a path this robots.txt disallows. Refusing a href with a query keeps those
out and falls back to the events page; the articles get linked when they
exist, with no change here.
holodori gets resetOffsets of UTC+9 across all three regions, evidenced
rather than assumed the way Arknights' is: the game launched worldwide
simultaneously on one service, every boundary is stated in JST, and
Training Support Missions ends at 3:59AM JST, one minute before a 04:00
local reset. Only the offset is overridden; the hour is the default.
Setting it now is free, because no reader has a day key for a game the app
has never shipped — the same override a year from now would re-label ticks
already logged.
Both boundaries and the exclusions are asserted against an independent
extraction off the fixture, computed through a timezone library rather than
by the parser's own arithmetic.
Several counts in AGENTS.md and PRD.md were already stale before this — the
Fate/Grand Order source did not update them — and are corrected to what the
tree now holds rather than to what it held yesterday.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reader who was asked what good looks like named paimon.moe's timeline,
and the thing ours was missing is what that view never loses: on a wide
window we drew more calendar and nothing that said which day, whose game, or
which event was on screen. The date axis, the lane names and the event names
all scrolled away together — and a six-week bar starts weeks off the left
edge, so its name went with its start date and left a coloured rectangle.
So it is a pane that scrolls in both directions, with the axis stuck to the
top and every name stuck to the left. A name is sticky inside its own bar, so
it can never wander onto an event it does not belong to. The axis gained the
week the schedules are actually written in: a dated tick every Monday and a
brighter rule at each month.
The frozen label column that started this went away again — it stood on the
calendar and ate the names it was meant to keep. Lane names sit on their own
line instead, and "jump to today" sits in the board's header rather than
floating over the dates it sends you back to.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every screen got the same 672px column, so a desktop reader read a phone page
with two thirds of the window empty — and the one design criticism the release
thread produced was formed from a screenshot.
Past lg the page splits along the line this codebase already draws: what it
tells the reader to do — the next deadlines, tonight's dailies — pins to a
rail on the left and stays there while the lists it shows them scroll beside
it. The footer and the settings panel become columns rather than a long fall
of small grey text. Below that breakpoint nothing moves: the same split puts
the instructions first in one column.
The rail's rule sits on the panel rather than the column. The panel is short
and the list beside it is long, so a full-height divider would spend most of
its length walling off a gap.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reader who reported this had two games switched on, twenty-one live
events, and said the list stopped being usable — and every game added doubles
down on that. Each section now shows six rows with an explicit "show all N".
It truncates the view and nothing else. The slice comes off the front of a
list already in the order the reader chose, so the hidden rows keep their
place, stay counted in the header, and stay on the timeline. Expanding is
per-visit state: it is something you do while reading one list, not a
statement about how the app should work.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`view` was component state, so a reader who preferred the timeline was put
back on the list by every reload — and which view opened at all had been
decided for them twice over: the PRD said calendar, the app shipped the list.
Neither was the reader's answer.
So the first run asks, and the tabs write to `prefs` from then on. The
question ships pre-answered with the list, because a reader cannot choose
between two layouts they have not seen and that is the one that answers "what
expires next" in a look — and each option is drawn rather than described, for
the same reason. The screen says where to change it afterwards, since the tabs
are small text in a corner.
Adds `view` to the prefs key space. Additive and defaulted, so an existing
reader's stored prefs open exactly where they did before.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A reader with two games switched on said the list lost its point at
twenty-one rows and asked for the three closest deadlines up front. One row
was also fragile on its own terms: ticking off the headline event left the
panel pointing at something the reader had no context for.
Three equal panels would be a stat grid, and a reader arrives with one
question — so the shape is one answer at full size and two follow-ups under
it, with no meter, summary or badges to turn the panel into a second copy of
the list below it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The headline panel asked for one row and got one row, so nothing else could
ask this question. `nextToExpire` answers it for any count and `firstToExpire`
is now that function asked for one — one definition, so a big countdown and the
lines under it can never disagree about which deadline is next.
Unannounced ends still sort behind every dated one however long they have been
running: a panel of deadlines that leads with "unknown" is not a panel of
deadlines. It sorts a copy, because the array it is handed is the one the list
on screen is rendering from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New bawiki parser plus the ba-bawiki-events source, giving the game its
two live and upcoming events at day precision.
It reads the rendered /wiki/Events page, which is the opposite call to the
Fandom source next door and for the same kind of reason: bluearchive.wiki
is Miraheze, whose robots.txt disallows /w/ and /*?action=, so there the
API is the closed route and the page is the surface * is allowed. It
serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay
for us. The Fandom wiki was declined earlier as a JP archive yielding
nothing live; this is the live source that assessment pointed at.
Three page facts shape the parser, each a way to publish a confidently
wrong date. The schedule is a JP/Global tabber and the Japanese version
runs four to nine months ahead, so only Global is published — the akwiki
hazard. The Global tab's nav button carries the id
tabber-Global_version-label and precedes both panels, so slicing from the
first matching id reads the Japanese schedule while believing it read
ours; the first version of this parser did exactly that and published a
JP-only event. And three tabs on the page are named Global, the schedule
plus Mini-Event and Joint Firing Drill, so the schedule is found by its
Name (EN) header rather than by position, with canParse asserting the same
lookup — a renamed tab or column fails the run instead of emptying the
lane.
The page states no time of day and no timezone anywhere, which is why the
dates are day precision and why ba gets no resetOffsets. It also settles
the five other schedule tables, which do carry a wall clock but name no
zone and mostly do not say which server: reading those as UTC would invent
the fact that matters most, and rounding to a day would not save it,
because a 04:00 local boundary falls either side of UTC midnight depending
on the offset assumed and the start's day is part of the event ID. They
are left unparsed deliberately.
The two-event count is asserted against an independent extraction off the
fixture, and both starts corroborate against the per-event infoboxes
elsewhere on the page — GL 2026-08-04 and GL 2026-09-01, where the JP
reruns those tabs would have given are 2026-04-01 and 2026-05-06.
Three comment counts were already stale before this and are corrected to
what the tree now holds rather than to what it held yesterday.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
bluearchive.wiki gives each boundary its own column and writes it as a
plain 2026-08-04, so unlike every reader here there is no range to split
and no field order to infer. Day precision, because the page states no
time of day.
Anchored at both ends, which matters more for this shape than for any
other in the module: it is the least distinctive one, and unanchored it
would happily find a date inside an article slug or a version string.
Also names the two readers the paragraph below the table was describing.
"The last two" stopped being true when rows were appended after them, and
the examples belong to parseAdjacentFullRange either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nothing a reader marks, types or ticks ever leaves their browser, so there is no
account and no server-side record to look anything up in after a report arrives.
Whatever the form captured is all there will ever be — which is the argument for
structured forms over a blank box, and for the bug form carrying the footer's
"event data last refreshed" line as a prefill. A stale calendar and a genuinely
wrong parse are indistinguishable from outside, eight sources cannot be fetched
from CI at all, and that line is the only thing that separates the two cases.
The feature form states the three constraints up front, because "sync across my
devices" and "have a model read the page" both have settled answers and a reader
deserves them before typing rather than after. It also points source requests at
the assessed-and-declined table: the loudest feedback on the first release was
that the game list is too short, so that is the request that arrives most, and a
wiki that forbids automated access or only archives finished events cannot be
used no matter how often it is asked for.
Blank issues stay enabled. A form that turns someone away loses the report, not
the noise.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The footer only spoke up past the two-day threshold, and then only as a
count of stale sources. A page silent about its own age reads as current,
so it now says when the data last refreshed on every load — absolute date
plus relative age, next to where the warning appears. This is the half of
PRD F7 that was never built.
Where the number comes from is the whole point. `freshness()` in
src/shared/feed.ts takes the newest lastSuccessAt and never generatedAt:
the feed is rebuilt on every deploy whether or not anything was refetched,
so a build stamp would report a calendar as minutes old while its events
came from a fixture captured months ago. A game is also only as fresh as
its oldest source, or Endfield's live wiki would vouch for its stalled
Game8 page. That matters concretely here — eight sources cannot be fetched
from CI at all, so this notice is what stands between a reader and a
confidently stale calendar.
The stale warning now names the lagging games and how far behind each is,
because a count is not something a reader can act on while a name tells
them which source page to check. Two exceptions found by rendering it
against the real feed: past four games it summarises the remainder, and
when every game is behind — what a refresh that stopped running looks like
— it collapses to one sentence rather than ten names repeating one age.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New `fandom` parser plus the r1999-fandom-events source, giving the game
six live and upcoming events at exact precision.
It fetches api.php?action=parse, not /wiki/Events. The rendered page
answers a non-browser client with a Cloudflare challenge, and getting past
that would be defeating an access control — the reason uma.moe was
declined. The wiki's robots.txt instead allows /api.php?action= for *, and
that endpoint serves our real User-Agent a 200, so this reads the
sanctioned surface with our own headers and no impersonation. The body is
therefore JSON, which is what canParse checks first: a challenge page or
an error payload must fail loudly, not parse to zero events.
Two page facts shape the parser. Titles come from each row's <b>, because
a missing banner image renders as a red link reading "File:<Event>
Banner.png" that a cell-text reader would publish as the event name. And
the page is an archive of 154 rows since v1.1 with no ongoing section to
gate on, so inclusion is decided against ctx.now — the six-event count is
asserted against an independent extraction off the fixture, per
docs/INGESTION.md § Testing.
Because robots.txt is unreadable from a challenged address, the gate fails
closed in CI and the source is skipped there — a warning, not a broken
build. Refreshing it means running `bun run refresh` from an address
Fandom serves.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the r1999 GameId and its metadata, plus GameMeta.resetHourLocal for
the fact that makes it unlike every other game here: it rolls its day at
05:00 server time rather than 04:00, on a single global UTC-5 server, so
its day-key boundary is 10:00 UTC.
resetOffsets could not carry that. Landing 10:00 UTC through the offset
would mean claiming a UTC-6 server, and serverOffsetUtc answers more
questions than this one. The new field overrides RESET_HOUR_LOCAL per game
and is absent everywhere else, so no existing reader's logged day moved —
a test pins that for the games that already had readers.
Both facts are read off the source, not assumed: all 154 rows of the
wiki's event list state (UTC-5) and run 05:00 to 04:59, an event ending
one minute before the reset the next one begins on.
No adapter yet, so the game has no events until one lands.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds parseOrdinalDateTimeRange for "November 9th, 05:00 - December 4th,
2023, 04:59 (UTC-5)". Ordinal days anchor the pattern at both ends so it
cannot claim text the looser readers should get, and the year rolls back
across New Year as parseMonthDayRange does.
The offset is required rather than defaulted. parseSlashDateTimeRange has
to read its wall-clock times as UTC because its source states no zone;
here the zone is part of the format, so a cell without one returns null
instead of being silently read five hours early. Calendar validation runs
on the stated local fields, before the shift, or "February 30th, 23:00
(UTC-5)" would convert into a real instant in March.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The guidance in here is not Claude-specific — it is what this project is,
what it refuses to do, and the rules that are invisible from the code. Any
agent working here needs it, and AGENTS.md is the name they all look for.
CLAUDE.md stays as a pointer so Claude Code still finds it, and holds no
guidance of its own; two copies would drift and the wrong one would be read.
Every cross-reference in the source comments, docs, workflow and skills now
names AGENTS.md, and the image ignores both.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The tab has always rendered <Timeline>; only its label disagreed. Naming
one thing two ways costs a reader nothing on the screen and costs the next
person reading the code a lookup.
The view is component state, not a stored preference, so no localStorage
key moves. Prose uses of "calendar" elsewhere mean a schedule rather than
this tab and are left alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The footer thanks every wiki and every studio the feed touches, but never
said who assembled it. The byline sits with the source-code link, since
that is where someone goes when they want the person rather than the page.
Both personal links carry rel="me" so the site and the profile can verify
each other, and the GitHub mark is now a component because two links use it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The shell is served cache-first, which is what makes the app work on a
train and also what makes a deploy invisible: a reader with the tab open
— the reader this app is built for — keeps running the bundle they first
loaded, so a new game or a corrected date reaches their device and sits
there with nothing saying why the page looks unchanged. An old app shown
as current is the same failure as old events shown as current.
So the worker now installs quietly and waits instead of calling
skipWaiting(), the page notices it waiting and says so, and the reader's
tap sends the skip-waiting message and reloads on controllerchange. The
app never reloads itself: someone may be mid-way through typing in one of
their own events, and the notice says what a reload costs (their place on
the page) and what it does not (marks and notes live in localStorage).
Detection is derived rather than remembered. build:static grew into a
script that stamps sw.js with a hash of the built shell, because the
browser only offers a worker whose bytes differ, and the predecessor —
a hand-bumped CACHE_VERSION — had already been forgotten once. The feed
is deliberately not part of that hash: it changes twice a day, needs no
reload, and announcing it would teach readers to dismiss the notice
unread. The cache name stays put for the same reason a per-build one
would be wrong — it holds the feed an offline reader is reading.
A first install is not an update and stays silent.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The shell is served cache-first and CACHE_VERSION had sat at v1 through four
commits that changed main.js, so a returning reader kept the old bundle: none of
the new UI reached them and the page looked simply unchanged. sw.js says to bump
it on any shell change and I did not.
Also tells the reader why the game list holds only tracked games when they have
none of their own yet — the list is nine games we cover with no hint that a game
of theirs has to exist before an event can be filed under it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Someone filling this form in by hand is usually doing it because the game isn't
tracked, so making them scroll past nine that are gets the common case
backwards. The default selection follows the top of the list rather than staying
on whatever the feed happened to return first.
The sort groups and does not reshuffle: tracked games keep their feed order
behind the reader's own.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A file being imported is not necessarily one this reader wrote. Extracts the
record validator so both the store read and the import path share it, and covers
what it has to guarantee: a partly-corrupt file costs the reader only the broken
records, a hue that is not a hex colour never reaches a style attribute, an
export written before F13 is a file with nothing of its own rather than an
error, and an event whose dates contradict themselves does not land.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The interface for PRD F13. A game (name and lane colour) and events against it,
or against a tracked game when a source missed one, managed from the settings
panel and from the event's own detail sheet.
Two things the forms are careful about:
- "I don't know when it ends" is an offered answer, not a blank field. A
mandatory end date would push the reader into inventing one, which is the
failure the parsers are forbidden from committing — it would just be the
reader committing it instead. An unknown end behaves as it does for a scraped
event: no countdown, no checklist.
- A hand-entered date is never dressed up as a source's. The row carries a
"yours" chip, the sheet says so under the title, and the Source link is absent
rather than dead.
Deleting a game is refused while it still holds events, and says how many.
Deleting an event leaves the marks and ticks attached to it alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the two localStorage stores behind PRD F13 and joins their events to the
feed's in App, so they sort, filter, focus, expire and tick through exactly the
same code paths rather than a parallel set.
Three decisions worth naming:
- Export carries customGames and customEvents. These exist in one browser and
nowhere else — not in the feed, not on a server — so an export without them
would be a backup that loses the half the reader typed. Import merges by id
like every other set and never removes.
- A date is read in the reader's timezone, and a bare end date means the end of
that day. Someone who types 20 Aug means the 20th where they are; the feed's
00:00Z day boundaries are a parser declining to guess a time the source never
printed, which is a different situation from being told directly.
- An impossible date is refused rather than rolled over, because Date.parse
turns 30 February into 2 March and a silently shifted date is the failure this
product exists to prevent.
Deleting a game is refused while it still holds events, and deleting an event
leaves its marks and logged days alone — reaching into three stores on one tap
is how a misclick costs somebody a streak.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The data layer for PRD F13, with no UI and no behaviour change yet.
src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:
- A reader's event id is random, not derived from their title. They can type a
scraped event's exact name and date, which under ${game}:${slug}:${date} is a
byte-identical key — one completion mark and one streak silently shared by two
events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
attributed to a source, and claims no region split, because they entered one
instant and inventing three would fabricate two of them.
The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The most-named game in the release thread, and the cheapest one to add: the
GameId, hue and dailyTasks have been sitting in games.ts since launch with no
source behind them.
arknights.wiki.gg is a better source than Game8 for this game, but it is not the
mp-event shape wikigg.ts reads, so it gets its own parser. Two properties of the
page drive the code:
- Every row lists CN and Global, about five months apart. Only Global is
published; a row without one yields no event rather than borrowing the CN
date. A CN date on a Global calendar is a confidently wrong date.
- Only the next boundary carries a machine-readable timer — the end while an
event runs, the start while it is upcoming — so precision differs per side and
flips when the event goes live. An exact instant is accepted only when it
falls on the same UTC day as the date beside it, because startsAt.slice(0, 10)
is part of the event ID: a start that moved a day would orphan every
completion mark on the morning the event began.
Also sets resetOffsets for all three regions to UTC-7. Not a blanket per-game
offset — Arknights genuinely runs one Global server for every region we model,
and the page evidences the clock: every ending event ends at 10:59:59Z, which is
03:59:59 at UTC-7, one second before a 04:00 reset.
Six events, counted independently against the page before and after parsing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the game and its Game8 events page, the second of the games named in
the release thread. `parseAdjacentFullRange` reads the shape this page
uses: two full dates divided by an `<hr>` rather than a dash, so a
tag-stripping reader sees only whitespace between them. It is anchored at
both ends and needs a year on each half, because without that "August 12,
2026 Day 3 rewards" reads "Day 3" as an end.
Three of the four live rows publish. "Take Your Heart" ends 30 days after
each player makes an account, so it has no calendar date and no honest
place on a calendar. "Login Campaigns" names two candidate ends
("July 16/30, 2026") — it keeps its real start and takes no end, and the
leftover is not shown as a summary either, since a date the parser
refused to trust must not reappear dressed as information.
The page lists each live event twice: once in a bare Event|Duration table
and again under its own heading with Start Date / End Date rows and a
paragraph of prose. The second copy corroborates the dates, so deduping
now fills a missing summary from the copy it drops. Dates are still taken
wholesale from the better-dated copy and never blended.
Verified by re-extracting the page independently of the parser: 4 rows,
3 events, the fourth correctly skipped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds the game and its Game8 events page. One `SOURCES` entry against the
existing game8 parser, plus one date shape that page needs:
`parseLabelledStartEnd` reads a duration cell holding `Start: <date>` and
`End: <date>` split by a `<br>`, which a tag-stripping reader flattens
into one run of text.
Four of the seven events say `End: Permanent`. That is the source telling
us there is no deadline, so they publish with `endsAt: null` and
`endPrecision: "unknown"` rather than a plausible date. The labelled cell
is also structure end to end, so it never becomes a summary — stripping
the leading half would leave "End: Permanent" standing where a
description belongs.
Verified by re-extracting the page with a throwaway script independent of
the parser: 7 rows in, 7 events out, every date matching.
Note the source itself is stale — its "Current Events" table still lists
January-May 2025 and "Upcoming Events" says there are none. The adapter is
right; the page looks abandoned, and Nikki coverage worth relying on needs
a second source at a higher priority.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>