Commit Graph
137 Commits
Author SHA1 Message Date
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
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
Lucas WintherandClaude Opus 5 32a0683824 docs(agents): say that an agent commits only its own work
The working tree is shared. An agent that reaches for `git add -A` sweeps up
whatever the user or another agent left there and signs it, which puts work in
the log nobody reviewed and breaks the one-coherent-change rule right below it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 23:05:07 +02:00
Lucas WintherandClaude Opus 5 83ec0312d6 docs(feedback): record where the thread's work list stands
AGENTS.md now sends agents here to decide what to build next, which makes
a list where half the items have shipped actively misleading — P1a, three
of the games, the Timeline rename and custom events are all done, and
nothing here said so.

The thread reading stays fixed at the date it was made; only a status
table moves. Each item keeps its diagnosis, because those still hold —
what changed is whether anyone acted. Recording the not-dones is the point
as much as the dones: NextUp is still one row, the list still has no
expander, view is still component state, and those were the cheapest items
on the list.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:58:32 +02:00
Lucas WintherandClaude Opus 5 3d03775e26 feat(client): state in the footer when event data last refreshed
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]>
2026-08-17 22:58:32 +02:00
Lucas WintherandClaude Opus 5 0d81c728ba docs(architecture): catch the layout and the pipeline up to the code
This file was written before sanitize, the refresh runner, the daily
checklists and F13 landed, and had not been revisited since. Its tree
omitted eleven files that exist — akwiki, fandom, sanitize, robots,
snapshots, daily, effort, custom, useCustom, gameMeta and the two custom
components — plus refresh-sources.ts, the only code here that touches the
network. A layout doc that silently omits the trust boundary is worse than
no layout doc.

Three counts contradicted the docs they pointed at: seven stages against
INGESTION's six, six SQLite tables against DATA-MODEL's five, six date
formats against ten. The diagram dropped merge and labelled localStorage
"completions", which DATA-MODEL marks superseded.

The larger repair is that it presented the in-process 6h scheduler as
what runs. It is not built; refresh-sources.ts on an Actions cron is,
writing snapshots to disk with the feed built from files rather than
SQLite. Rather than rewrite the design as though it had always been this,
the diagram keeps it and a note says which half is real — the stages and
the review gate are unchanged either way, and only what wakes them up and
where they land differ. The broken tier is recorded in the same place,
since three failures raising health to failing and the runner reporting
broken are one fact described from two ends.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:56:13 +02:00
Lucas WintherandClaude Opus 5 823556f2c5 docs: correct the counts three docs had drifted past
Each of these was true when written and stopped being true when a source
landed. dates.ts holds ten formats, not nine; INGESTION says Game8 uses
"at least four page templates" directly above a list of seven; its testing
note still credits "both current adapters" when there are eleven; PRD
lists the seven launch games against ten in GameId. AGENTS also missed
fandom from the parsers-are-keyed-by-site bullet and build-static.ts from
the tree, which matters because another section leans on the latter.

The PRD table stays as launch scope rather than becoming a roster to keep
in sync — GameId is named as the live answer instead, which is the same
move that keeps this drift from recurring. Its no-schema-change claim is
sharpened to name GachaEvent, because Reverse: 1999 did add a field:
resetHourLocal, to game metadata, which moves no stored key. The claim as
written read as false against a commit that plainly added one.

Also draws sanitize into the stage diagram as 2.5. It runs on every source
without the pipeline arranging it, which is exactly why it kept being
absent from the picture.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:55:45 +02:00
Lucas WintherandClaude Opus 5 414dc840fd docs(agents): commits are succinct as well as self-contained
Self-containment was a clause on the end of the commit-straight-to-main
bullet and covered only half of what it should: one coherent change,
green on its own. Nothing said how to split a change that spans layers,
and nothing at all said what the message should contain.

Both halves are now stated. Splitting goes model → store → UI → docs with
each step green by itself, even where that means widening a type in the
model commit that only the UI commit uses — and docs/FEEDBACK.md already
makes the same argument for adapters, so it is cited rather than repeated.
Succinct is about the message: a one-line subject and a body saying why,
which a diff cannot show, rather than a list of files it can. The
no-stray-reformatting line is there because a formatter this project does
not run once buried a 100-line change in a 550-line diff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:55:17 +02:00
Lucas WintherandClaude Opus 5 5bbc9a2759 docs(agents): send agents to docs/ before they change what it describes
AGENTS.md cited three of the five docs in passing and never said to read
one. An agent could therefore repair a rule it had just rediscovered the
expensive way, which is how ARCHITECTURE.md came to describe a tree eleven
files out of date without anyone noticing.

So: a table of what each doc settles and what to read it before, plus the
two rules that follow. The docs are part of the change, because they hold
the only record of *why* and a false sentence there costs the next reader
the whole argument rather than a detail. And a disagreement between this
file and a doc is a bug to report — this file summarises, the doc holds the
reasoning, so neither silently wins and the wrong one gets fixed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:55:04 +02:00
Lucas WintherandClaude Opus 5 d5cdca8d62 docs: record the Blue Archive source assessment
bluearchive.fandom.com/wiki/Event/Event_List fetches and parses cleanly,
so the reason to decline it is not obvious from trying it: every one of its
88 rows is JP-server history, the newest ending 2026-02-18, so it yields
zero live or upcoming events. That reads as an empty lane plus a
permanently rejected snapshot, since the runner rejects a body parsing to
nothing — a failure mode worth naming in the assessment table, because
"clean table, no error" invites writing the adapter first.

bluearchive.wiki is the live alternative and stays unbuilt for now: it is
Miraheze, its robots closes the API route we used for Fandom, and it lists
JP and Global in separate tables — the akwiki hazard, which wants settling
before a line is written rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:47:14 +02:00
Lucas WintherandClaude Opus 5 3377fcb211 feat(ingest): add the Reverse: 1999 event source via Fandom's API
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]>
2026-08-17 22:46:47 +02:00
Lucas WintherandClaude Opus 5 1a65a1f06c feat(games): track Reverse: 1999, which resets at 05:00
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]>
2026-08-17 22:45:40 +02:00
Lucas WintherandClaude Opus 5 7926a70850 feat(dates): read ordinal ranges with a stated UTC offset
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]>
2026-08-17 22:44:38 +02:00
Lucas Winther 9d24116c12 chore(data): refresh source snapshots 2026-08-17 22:35:18 +02:00
Lucas WintherandClaude Opus 5 8d4a28b254 docs(license): split the data carve-out into NOTICE
The carve-out was 180 words appended to LICENSE, which pushes the file below
the similarity threshold licence detectors match against — a repository whose
licence reads "other" is less inviting to a forker than one that reads MIT,
and the carve-out was never part of MIT's text anyway.

LICENSE is verbatim MIT again, plus three lines binding NOTICE into the terms
so the carve-out travels with any copy rather than being optional reading.
NOTICE carries the substance. README points at both.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:33:29 +02:00
Lucas WintherandClaude Opus 5 50fb7b8efb docs(readme): the licence section names MIT and its limit
The section still read "not yet chosen" after the LICENSE file landed, which
is the one wrong answer worse than no answer for someone deciding whether
they may fork. Says MIT, and says in the same breath that the schedules are
not MIT's to give.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:31:02 +02:00
Lucas WintherandClaude Opus 5 23a394dbe8 docs(license): say what MIT here covers, and what it cannot
The code is mine to license; the schedules under fixtures/ and snapshots/
were compiled by wiki editors and describe events run by the publishers, so
relicensing them under MIT would be claiming something I never held. A reader
who forks this needs to know the data does not come with it.

Kept as a note under the unmodified MIT text rather than woven into it, so
the license itself still reads verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:20:46 +02:00
Lucas WintherandClaude Opus 5 025605aa09 docs: move the working agreement to AGENTS.md
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]>
2026-08-17 22:16:40 +02:00
Lucas WintherandClaude Opus 5 c2740760bd docs(claude): commit to main, no branch per change
The assistant's default is to cut a branch whenever it is asked to commit
on a default branch. That default is for shared repos; this one is solo and
its history is a single line, so a branch per change is a merge to clean up
after and nothing gained. CLAUDE.md overrides the default, so it says so here.

Restates the self-contained-commit rule alongside it, since that is the part
worth keeping.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:14:25 +02:00
Lucas Winther f2c8dc38cd Merge remote-tracking branch 'origin/main' 2026-08-17 22:13:17 +02:00
Lucas Winther 2609fd5c54 chore: add basic license. 2026-08-17 22:12:21 +02:00
Lucas WintherandClaude Opus 5 39c633ea66 refactor(app): rename the Calendar view to Timeline
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]>
2026-08-17 22:09:52 +02:00
Lucas WintherandClaude Opus 5 d7b2f306ae feat(colophon): credit the author, and link out to site and GitHub
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]>
2026-08-17 22:09:26 +02:00
Lucas WintherandClaude Opus 5 54052d50c4 fix(refresh): only 200 is a page, so a 202 challenge names itself
The health check earned its keep on the first run: six sources broken, four
cycles each, "last confirmed never" — and a last status of **202**. That is
game8.co's edge answering a GitHub runner with a bot-management body instead of
the wiki.

`response.ok` admitted it. A 202 therefore passed the status gate as a
document, reached the parser, yielded nothing, and was reported as "kept
previous snapshot; new body yielded 0 events" — an accurate description of the
symptom that never mentioned the status explaining it, which is why this looked
like a parser problem for days. Require 200: 202 means the request was accepted
for processing, 204 has no body, 206 is a fragment, and none of them is a page.
The rejection note already names the Server header, so the next cycle reads
`HTTP 202 (AkamaiGHost)` instead.

Recorded in CLAUDE.md § Scraping conduct, including that this must not be worked
around. The per-host spacing added alongside does not help and was never going
to — a 202 on a cycle's first request is address reputation, not rate. Browser
-shaped headers or a residential egress would be defeating an access control,
which is the ground uma.moe was declined on, and game8's robots.txt is
permissive, which makes doing it there worse rather than better.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:05:05 +02:00
Lucas Winther 988f72a56f Merge remote-tracking branch 'origin/main' 2026-08-17 21:58:15 +02:00
Lucas WintherandClaude Opus 5 367e1877a3 chore(data): refresh the Genshin snapshot
One real cycle against game8.co while verifying the runner: 200, nine events,
stored. Genshin was the last game reading from a three-day-old fixture that had
a live source available.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 21:56:34 +02:00
Lucas WintherandClaude Opus 5 2a0ea1a796 fix(refresh): make a source that stopped answering turn the run red
The only snapshot the scheduled refresh has ever committed is
endfield-wikigg-events. Seven sources existed at that run; the six game8.co
ones yielded nothing, and no cycle since has committed anything. All of them
fetch fine from a laptop, so whatever is happening happens on the runner —
meanwhile eight of ten games were served from checked-in fixtures for three
days behind a green tick. Nine failures out of ten was exit 0 with warnings
buried in a log nobody opens.

`consecutiveFailures` was already tracked and never read. A source that has
failed BROKEN_AFTER_FAILURES (3, so ~36h at two cycles a day) is now reported
as `broken`: a GitHub annotation, a job-summary row carrying its status code,
and a `broken` step output. The runner still exits 0 on it and `refresh.yml`
fails on that output in a final step, after the commit and the CI dispatch —
exiting non-zero from the runner would skip the commit and throw away the pages
that did arrive, which is the opposite of what "one wiki down never blanks a
calendar" is for. The streak is read from the store rather than from this
cycle's outcome, so a source dead for days that happens to be inside its
six-hour window has not recovered.

A non-ok response now records what turned us away — the Server header, whether
a CF-Ray was present, any Retry-After — because a bare `HTTP 403` reads
identically whether the page moved behind a login or a CDN decided the runner
is a bot farm, and that is the open question here. Values are trimmed and
capped: the note lands in a workflow command and a markdown cell, and it came
from a host we do not control.

Also space requests to a host already asked this cycle, honouring its
Crawl-delay and defaulting to 2s. Eight sources share game8.co, so the
per-source floor alone still permitted one cycle to arrive as eight
back-to-back requests to a single site — which is what a burst looks like from
the far end regardless of our intent, and is plausibly self-inflicted here. The
wait is taken after the interval and robots gates, so a source we then skip
costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 21:56:01 +02:00
Lucas WintherandClaude Opus 5 d2615c606c fix(ci): fail the build when a single source yields no events
The feed sanity check only had a floor on the total, which is blind to the
failure it exists to catch: nine healthy sources hold the number comfortably
over twenty while the tenth has gone to zero and that game shows an empty
calendar. Check per source, and print the per-source counts so a drop is
legible in the log before it is a failure.

Zero is unambiguous here. A live snapshot that parsed to nothing is refused by
the refresh runner and never stored, so a source at zero in the feed means the
checked-in fixture stopped parsing — a regression in our code, not a quiet week
for that game.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 21:55:22 +02:00
Lucas WintherandClaude Opus 5 286aadefc4 chore: ignore half-written snapshots
Every snapshot is written to a sibling `.tmp-*` and renamed into place, so a
run killed mid-write leaves one behind. `refresh.yml` commits the whole
snapshots/ directory, which would pin that truncated page in git forever — and
the point of the atomic write was that a crash leaves nothing a reader can
mistake for a page.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 21:55:03 +02:00
github-actions[bot] 954d8c7601 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32052097532.
2026-08-17 17:50:22 +00:00