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]>
Switching games on and off says which games you play, and is set once.
It's the wrong tool for what a player of four games does while reading —
clear one game, move to the next — which cost two taps per game and left
the settings panel no longer describing what they play.
Focus is a lens over that filter, not a second filter: a bar at the top,
above everything it narrows, with a "next game" control that steps
through and ends by returning to all. It never touches hiddenGames, and
a focus on a game since switched off is ignored rather than obeyed, so
it can't strand you on a blank page whose cause is elsewhere. Each chip
carries that game's outstanding count, so a game with nothing waiting
says so before you visit it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two symptoms of one bug. The "next to expire" headline counted events
the reader had marked done or ignored, and the dailies strip kept a
tickable chip for a repeating event they had already finished.
showCompleted and showIgnored decide what a reader can *look at*. The
headline and the strip are *instructions*, so they answer a different
question — what is still on your plate — and both now go through one
`outstanding` lens.
Also fixes a second bug in the same line: `next` took the head of the
list, which under "doing first" sorting is whatever you're partway
through, not the soonest deadline. It reads the minimum now.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Endfield has two server groups, not three: Europe is served off the
Americas machine on a fixed UTC-5, so a European player's day rolls at
09:00 UTC. We were resetting it six hours early, at 03:00, which ticked
the wrong box every morning between those two instants.
Adds GameMeta.resetOffsets, a sparse per-region override, and threads an
optional `game` through every day-key function. Per region rather than
per game on purpose: a blanket offset would drag Asia — which does have
its own Endfield server — onto the Americas clock, moving day keys for
readers who never had the bug. A regression test pins Asia's output as
identical to before.
Day keys are localStorage keys, so this re-labels ticks logged between
03:00 and 09:00 UTC by European Endfield players, one day backward. No
tick is deleted and past days stay editable, but a streak can read as
broken for a day. That is the cost of correcting a wrong reset; leaving
it wrong is worse.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Guessing from a source's wording is right most of the time, and someone
who finds it wrong often enough should be able to stop it rather than
dismiss the same false positive every patch.
Off, only events the reader marked themselves get a checklist. It
silences the guess rather than deleting anything: overrides, ticks and
streaks all survive, so switching it back on restores exactly what was
there. With it off, marking an event stores an explicit yes, since there
is no longer a detection for an override to agree with.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dailiness was read off the source's wording alone, which is wrong in both
directions: a grind that resets every day but whose page never prints
"daily" got no checklist, and a banner whose blurb mentions "daily login
rewards" got one nobody could dismiss.
The reader's answer now wins. The control sits exactly where the
checklist goes — the one place the answer visibly matters — so marking an
event and ticking today off are the same gesture in the same place.
An override is stored only when it disagrees with detection. Recording
agreement would freeze today's guess into the reader's own data, so a
later parser improvement could never reach that event.
Marked events also join today's dailies at the top of the page, beside
the per-game chores: at 23:50 a login campaign and a commission run are
the same job, and ticking one should not mean opening a sheet to find its
checklist.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A review of code that has never made a live request. Each of these was
reproduced before it was fixed.
robots.txt matched a group by testing whether our whole User-Agent
contained the group's name. Our contact URL carries "StereotypicalCat",
so a wiki writing `User-agent: cat` matched us — and because a named
group *replaces* the wildcard group, that silently discarded every rule
under `User-agent: *`. Match the RFC 9309 product token instead. A 200
carrying an HTML "not found" page also parsed to zero rules and read as
permission; it now fails closed, while a genuinely empty body still
means "no restrictions".
The response body was read outside the try that guarded the request, so
one truncated body aborted the whole cycle: later sources were never
fetched, and the failed source never recorded its check, meaning a
re-dispatch would ask that wiki again minutes later.
Bodies were decoded as UTF-8 unconditionally and stored re-encoded. A
page served in a legacy charset became replacement characters with the
original bytes gone — and mojibake in a title flows into slugify and
moves every localStorage key for that source. Decode by the declared
charset, keep the served bytes verbatim, and hash those.
Unchanged bytes skipped the metadata write, so once a server rotated its
ETag we sent a stale validator forever and it served full bodies instead
of 304s.
Also: snapshot writes go through a temp file and a rename, and `--only`
with no value is an argument error rather than "all sources".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The cut was marked with U+2026, which NFKC decomposes into three dots —
so re-sanitising a truncated string grew it by two characters and re-cut
it at a different word boundary. A title would quietly rewrite itself
every time the event was re-ingested, and the module promises
idempotency in its own docstring.
Append what normalisation would produce instead. The existing corpus
missed this because its only over-length entry has no space in its last
40%, so it happened to re-truncate to the identical string; the new test
uses prose.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"What runs out first" is the question this app exists for, but it is not
the only one a reader arrives with. The other is "what was I in the
middle of?", and until now the list could not answer it.
Two modes, toggled from the list's own header rather than from settings,
because ordering is something you reach for while looking at a list.
Ticking a day off a daily counts as doing it without the reader also
setting the status — the tick already said so.
Sorting only ever groups. Both modes fall back to endingSoonestFirst
inside a group and live still beats upcoming, so choosing an order can
never cost the reader the deadline order the product is for.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A login campaign is not one job with a deadline. It is twenty small jobs
on twenty separate deadlines, and a day you miss is gone whatever you do
afterwards — which a single "done" tick cannot express.
Repeating events now get a checklist: today's tick, a strip of every day
in the run showing what you got and what you missed, the streak, and how
many chances are left. Past days stay editable, because people tick up
later and a checklist you cannot correct stops being trusted after the
first mistake. Alongside it sits today's dailies — commissions, sanity,
daily training — one tick per game, keyed `dailies:<game>`, since no
source publishes those and they are the only thing on the page that
expires tonight rather than next patch.
Days roll at 04:00 server time per region, not midnight: finishing at
02:00 is still yesterday, and a naive UTC date would tick the wrong box
for four hours every night.
Dailiness is read off what the source published — a login event type, or
"daily"/"check-in"/"7-day" wording — never from a game's habits or an
event's length. That adds no schema field, so the feed contract and every
event ID are untouched. An unannounced end yields a tick count, not a
checklist of invented length, and a tick is never removed except by the
reader, including ticks outside the window the feed now claims: a source
quietly moving a date must not erase a fortnight's streak that exists
nowhere else.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The feed was generated from checked-in fixtures and only moved when
somebody captured a page by hand. This fetches.
bun run refresh caches each page raw under snapshots/ and rebuilds the
feed from what it cached; build-feed prefers a snapshot and falls back to
the fixture, so a clean checkout and the container build stay offline and
reproducible. The workflow runs it twice a day and commits only when a
page's bytes actually changed — a 304, an identical body or a rejected
parse all leave the tree clean — then dispatches ci.yml, which already
knows how to test, build and deploy.
Scraping conduct is enforced in code rather than left to good
intentions: one request per source per cycle, a six-hour floor checked
per source, conditional requests, a User-Agent with a contact URL, and
robots.txt honoured — failing closed, because a permission we could not
read is not a permission we have. No retries; a retry is a second
request.
A body that yields zero events is rejected and the previous snapshot
kept, so a redesigned wiki shows up as a stale timestamp rather than an
emptied calendar. One source down is a warning; all of them down fails
the run, so a cycle that learned nothing is never committed.
Tested entirely offline against an injected fetch and clock — no request
has ever been made to a live wiki from this code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Scraped text becomes React content, JSON on disk and eventually SQLite
rows, so it gets cleaned at one seam: the parse wrapper in toAdapter().
Every source passes through it, a source added tomorrow is covered
without its author doing anything, and no parser can opt out — parsers
stay pure readers of one site's markup.
Removes script/style/comment content and residual tags, decodes entities
to a fixed point so an encoded tag cannot resurrect in a later decoder,
NFKC-normalises, strips control, zero-width and bidi-override characters
(an RTL override visually spoofs a title), bounds each field to the cap
the schema already declares, and requires sourceUrl to be absolute
http(s).
Three things it will not do: touch a date, drop an event it could clean
instead, or repair in silence — every repair and drop is logged by
default. Event IDs are localStorage keys, so an ID is recomputed only
when a sanitised title actually changed and the ID was minted the
standard way; all seven fixtures pass through unrepaired and
byte-identical, which is the regression guard.
Also stops decodeEntities throwing RangeError on an out-of-range
numeric reference, which would have taken a whole source's events down.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Four buckets named the way a player would describe them — quick, short, long,
grind — each carrying rough working hours.
The hours exist so effort can answer the question the calendar cannot: can I
still finish this? The same two days is comfortable for a quick event and
hopeless for a grind. The runway heuristic assumes about an hour of play a
day, which is deliberately modest — a warning that only fires once something
is already impossible is useless.
An event with no recorded effort returns "fine" and never warns. Inferring an
estimate in order to warn about it would be fabricating the reader's input.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The image build ran the tests without serve.ts present, so the server the
tests spawn never started and the hook stalled until it timed out. The
runtime stage would have failed on the same missing file immediately after.
The test now bails the moment the process exits and reports its stderr, so
this shows up as "serve.ts exited with 1 before listening" in 53ms rather
than an unexplained hook timeout after five seconds.
serve.ts is also in tsconfig's include now — it was outside it, so a type
error in the file that serves the app would only have surfaced at runtime.
Adding it immediately caught one in the test.
Verified by replaying the build stage against exactly the copied file set.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
They read from public/, which does not exist on a clean checkout — CI runs
bun test before any build, so the four of them failed there while passing
locally against stale build output.
serve.ts now takes PUBLIC_DIR, and the tests serve a temporary tree they
create themselves. They exercise serve.ts rather than the build, which is
what they were always meant to do.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The server is a placeholder for the one in docs/ARCHITECTURE.md — it serves
public/ and a health endpoint, nothing more. Reads are confined to public/ by
resolving the path and checking it stays inside the root; string-matching
".." is not enough, since encodings and URL normalisation both change what
the string looks like and only the resolved path says which file would open.
The image runs typecheck and tests during build, ships no source or
toolchain, and runs unprivileged.
CI's feed job fails if the event count collapses. A source that quietly stops
yielding events is what a parser-only pipeline is most prone to, and nothing
else would surface it. Everything is offline, so a red pipeline always means
the code changed rather than a wiki being down.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
First non-Game8 parser, and the first source that states region-scoped ends:
wiki.gg emits ISO timestamps with one timer per server region, so Endfield
events now carry exact times and separate Asia / Americas-Europe ends. That
gap is up to 13 hours, which is what regionEnds was built for.
Two sources for one game also exercised merge for real. It caught both
overlaps — including "Bedazzling Dawnstar" against Game8's "Bedazzling
Dawnstar Sign-In" — and flagged that the two disagree on the end date by 70
hours rather than averaging them into a date neither states.
Matching an appended qualifier needed a prefix test, not a subset test: a
subset would also fuse "Gold Clash" with "Gold Rush Clash Royale".
Also fixes build-feed picking fixtures by game rather than by source, which
handed the wiki.gg page to the Game8 parser once Endfield had two.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Endfield was previously written off as undatable. That was wrong: a first
pass only inspected its Duration rows (all "Permanently Available") and its
year-less version grid, and missed an "Event | Schedule & Summary" table
whose cells read "Period: 08/09/26 - 08/30/26 During the event...". Two real
events, with a year.
Adds MM/DD/YY range parsing for that shape. Month-first ordering is not
assumed lightly — Endfield's own version grid reads 01/22, 04/17, 07/16 for
versions 1.0, 1.2 and 1.4, chronological only if the month comes first.
Also stops presenting unlock conditions as descriptions. Several templates
put "Reach Union Level 8" where a blurb would go; filling the summary slot
with text that never says what the event is is worse than leaving it empty.
Wuthering Waves correctly drops to zero summaries as a result.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three more Game8 templates. ZZZ spans each event name across two rows with a
rowspan, so a flat cell reader sees [title, "Start", date] then ["End",
date]; losing that pairing would silently halve the calendar.
Section matching also had to widen — these pages head their sections
"Featured Events", "List of Events and Schedule" and "Ongoing Events".
Verified independently of the parser: HSR's year-less summary table lists
six ongoing events and all six are captured via their detail rows, so
nothing is silently dropped.
28 events added across the three games.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Honkai: Star Rail and Wuthering Waves state a year on each side of a range
("Aug. 14, 2026 - Aug. 24, 2026"), which the year-on-the-end parser cannot
read.
Both also publish genuinely open-ended events — "Jul. 24, 2026 - End of
4.6", "July 10, 2026 - Permanent". Those keep their real start and report no
end, rather than being dropped or given a guessed one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Time is this product's subject, so the vocabulary lives in one module:
remaining, progress through a window, and urgency. Urgency is derived from
absolute time left, deliberately independent of proportion — a 90-day event
with three hours left is as urgent as a 3-day one.
An unannounced end is never urgent and has no progress: "we don't know" and
"loads of time" are different facts, and conflating them is what this
product exists to avoid.
Games carry a hue for identity only. Urgency is a separate axis, so one
glance answers both "whose event is this?" and "how long have I got?".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Collapses the same event seen by two sources, matching on ID or on title
similarity plus start-date proximity. Proximity is the real guard against
false positives, since a rerun reuses its name months later.
Agreement from an independent source raises confidence; the same row seen
twice in one document does not. Disagreement on an end date is recorded as
a conflict for the review gate rather than averaged — splitting the
difference would publish a date neither source asserts.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Parsers are keyed by site template, not game: one Game8 parser serves
every Game8 page, and adapters bind a URL and game to a parser. Adding a
source for a known site is one SOURCES entry.
Game8 uses at least three templates. This handles label/value detail
tables (Genshin, 9 events) and column tables (NTE, 13 events). Its
image-grid schedule has no year and no end date, so Arknights: Endfield
is not supportable from that source and gets no adapter.
canParse is a structural tripwire: a site redesign fails the run loudly
instead of publishing an empty calendar that reads as "no events".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every date function returns null rather than inferring a missing year,
month or end date. A source that does not state an end yields
endsAt: null, never a plausible guess — a confidently wrong end date is
the failure this product exists to prevent.
Handles the three formats seen on Game8: "August 12, 2026",
"August 12 - September 21, 2026" (year applies to both ends, rolling the
start back when the range crosses New Year), and a slash datetime range.
Abbreviated months parse too, so a source switching to "Apr. 29" does not
silently drop events.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>