Commit Graph
100 Commits
Author SHA1 Message Date
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 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
Lucas Winther 059bf02130 Move analytics to bottom of page for quicker load of main app. 2026-08-17 19:00:50 +02:00
Lucas Winther 793dca4a79 Add privacy friendly analytics :) 2026-08-17 18:55:53 +02:00
Lucas WintherandClaude Opus 5 a4ab5aa36c feat(sw): offer a reload when a newer version is ready
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]>
2026-08-17 18:55:08 +02:00
Lucas WintherandClaude Opus 5 7130a94b21 fix(sw): bump the cache version for the F13 bundle
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]>
2026-08-17 18:34:34 +02:00
Lucas WintherandClaude Opus 5 0a25cce9ce fix(custom): list your own games first when adding an event
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]>
2026-08-17 18:31:18 +02:00
Lucas WintherandClaude Opus 5 adaa9eaada docs: record which sources were assessed and declined
Five games were surveyed for adapters before the scope changed. Three of those
sources are off-limits and the reasons are not visible from the code: Azur
Lane's wiki declines AI input outright, uma.moe gates its API behind a Turnstile
proof, and Fandom's robots.txt 403s — which fails closed, since a permission we
could not read is not a permission we have.

Also notes that four of the games named in the release thread have no wiki.gg
wiki at all, so nobody repeats that search.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:26:21 +02:00
Lucas WintherandClaude Opus 5 6de1434115 test(custom): pin the import gate
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]>
2026-08-17 18:25:41 +02:00
Lucas WintherandClaude Opus 5 c87ea4c602 feat(custom): let readers add their own games and events
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]>
2026-08-17 18:24:09 +02:00
Lucas WintherandClaude Opus 5 d046758671 feat(custom): store the reader's games and events, and back them up
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]>
2026-08-17 18:19:59 +02:00
Lucas WintherandClaude Opus 5 3702ee7a4c feat(custom): model reader-authored games and events
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]>
2026-08-17 18:16:21 +02:00
Lucas WintherandClaude Opus 5 4029885833 docs: bring reader-authored games and events into scope
The release thread's only feature request from the reader with the most games,
asked twice and asked for nothing else. No adapter roadmap reaches a ten-game
juggler, so this is the part of the product that serves readers we will never
scrape for.

Records the decision in the PRD as F13 rather than letting the code drift from a
spec that still lists user-submitted events as out of scope, and specifies the
key spaces in DATA-MODEL before any of it is built — a reader's event must never
be minted as ${game}:${slug}:${date}, because they can type a title identical to
a scraped one and the collision would silently share one completion mark, note
and streak between two events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:10:41 +02:00
Lucas WintherandClaude Opus 5 1dd58d3f52 feat(arknights): track Arknights from its own wiki
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]>
2026-08-17 18:05:01 +02:00
Lucas WintherandClaude Opus 5 8c2ec19c22 docs: record the two new sources and the shapes they added
Nine sources across eight games now, two more Game8 page shapes, two more
date formats, and the h4 rule that neither game's page can be read
without.

Also notes what the two new games do *not* have: no `resetOffsets` entry,
so both take the regional default. Neither source states a server map, and
`resetOffsets` feeds `dayKey`, which is a localStorage key — so that is
recorded as an assumption to confirm rather than left to look like a
verified fact.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 17:21:19 +02:00
Lucas WintherandClaude Opus 5 d8b539bce1 feat(p5x): track Persona 5: The Phantom X
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]>
2026-08-17 17:21:10 +02:00
Lucas WintherandClaude Opus 5 67951e81b4 feat(nikki): track Infinity Nikki
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]>
2026-08-17 17:20:54 +02:00
Lucas WintherandClaude Opus 5 243c7d70dd fix(game8): let an h4 fence a section off without naming one
Game8 uses h4 for two opposite jobs. Persona 5 hides its entire finished
back catalogue behind nothing but an `<h4>Finished Events</h4>` inside a
collapsed accordion, so a reader blind to h4 sees one uninterrupted run of
tables and would publish fifty dead events. Genshin uses h4 the other way
round, for sub-headings *within* a single event ("Availability Period",
"Characters & Rewards for this Test Run").

So h4 now gates sections but never names one: an unrecognised h4 leaves
the current event title alone, where an unrecognised h2 or h3 still sets
it. Letting one claim the title renames "Character Test Runs" to the
label sitting above its own date table, which the Genshin fixture catches.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 17:20:31 +02:00
Lucas WintherandClaude Opus 5 2b0aa6bd0f docs(feedback): record the first public release thread
Analysis of the 26-comment r/gachagaming thread from two days after the
first Pages deploy, with every claim checked against the tree at 6fc3c0b.

Two criticisms recur and only two: the game list is too short to be
anyone's tracker, and the design reads as unfinished. Nobody questioned a
single date, which is the thing most of this codebase's rules exist to
protect.

Recording it because the priority list it ends on is what the next
commits act on — P1c, more games, best-source-first, one commit each. The
saved thread it quotes is left untracked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 17:20:14 +02:00
Lucas WintherandClaude Opus 5 6fc3c0b256 docs(readme): link the live Pages site
The app is deployed and reachable; the README only explained how to run
it locally.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:57:26 +02:00
Lucas WintherandClaude Opus 5 3434ac02cf feat(daily): ship daily detection off, and say it's experimental
Dailiness is guessed from the source's wording, which is a weak signal
and wrong in both directions. Guessing on a reader's behalf and hoping
they notice is the wrong default for a checklist they may build a streak
on, so `detectDaily` now starts false and the control carries an
Experimental tag saying what it does and doesn't know.

The default applies to new readers only — a stored `prefs` keeps its
value, because switching it off under someone who has been ticking
auto-detected checklists would pull those chips out of the strip with no
explanation. Nothing is deleted either way.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:57:26 +02:00
Lucas WintherandClaude Opus 5 3a70672d22 style(daily): fade an untouched chip's game colour further
The hue on an unticked chip was competing with the tick for attention.
It only needs to say which game the job belongs to; how far you've got is
the tick's answer, and ticking one should read as a jump rather than a
nudge.

Border 34% → 20%, wash 5% → 3%, check 0.35 → 0.25. Done is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:48:24 +02:00
Lucas WintherandClaude Opus 5 d70720e2af feat(filter): focus one game at a time
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]>
2026-08-16 20:42:42 +02:00
Lucas WintherandClaude Opus 5 2a7a117705 fix(sheet): make "Mark done" mark it done
The button advanced one step round the untouched → doing → done cycle,
so pressing "Mark done" on a fresh event produced "doing it" and needed
a second press. Worse, the press after "done" cleared the status
outright rather than undoing anything.

Three states need three targets — that is the control directly above it.
This one is the commit, so it sets done and back. cycleStatus had no
other caller and is gone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:41:30 +02:00
Lucas WintherandClaude Opus 5 edc6f3e4bb fix(soon): stop pointing at events you've finished or ignored
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]>
2026-08-16 20:40:46 +02:00
Lucas WintherandClaude Opus 5 c3e9b9064d feat(daily): a small burst when the last daily lands
The one moment in this app that is unambiguously good news, and it read
the same as any other tick.

Fires only on a tick that completes the set — the list also shortens when
the reader filters, and a burst there would be congratulating them for
filtering. Nothing on mount either, so arriving at an already-finished
day is not treated as having just finished it.

Decorative, aria-hidden and under a second; the "All done" text beside it
is what a screen reader gets. Fully off under prefers-reduced-motion
rather than merely reduced — nothing here carries meaning, so the honest
reduced version is nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:39:09 +02:00
Lucas WintherandClaude Opus 5 a0d0faf231 feat(daily): keep each game's colour on its chip, done or not
The hue only appeared on completion, so the half of the strip a reader
actually scans — the unfinished half — was a row of identical grey pills
with nothing to say which game each belonged to.

Soft border and wash while outstanding, full strength once ticked. Done
is still legible without the colour: the tick, the stronger border and
the deeper wash all carry it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:38:14 +02:00
Lucas WintherandClaude Opus 5 e6e4f7085a fix(daily): reset Endfield's European day on the server it's on
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]>
2026-08-16 20:37:47 +02:00
Lucas WintherandClaude Opus 5 6c5546ba53 docs(architecture): list the client files that already exist
The tree had drifted: progress controls, the daily strip and checklist,
the undo toast, and three state modules have all been in the code for a
while without appearing here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 20:36:11 +02:00
Lucas WintherandClaude Opus 5 41f0330e02 feat: let the reader switch off daily detection
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]>
2026-08-15 22:26:16 +02:00
Lucas WintherandClaude Opus 5 7f384eb9db feat: let the reader mark an event as repeating daily
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]>
2026-08-15 22:14:37 +02:00
Lucas WintherandClaude Opus 5 6177633abc fix: give the image build the files its tests read
The suite gained three tests that assert on .github/workflows — the
freshness-cache restore, the per-attempt cache key and the rebase-and-
retry push, all defects that live in YAML and fail silently. But
.dockerignore excluded .github, so those three tests failed inside the
image build and took the whole build down with them.

Put .github in the build context. It reaches the build stage only; the
runtime stage still copies nothing but public/ and serve.ts, and the
image is unchanged (verified: /app holds public and serve.ts, nothing
else).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 22:03:41 +02:00
Lucas WintherandClaude Opus 5 831fcbe6c2 fix(ci): keep the freshness signal alive and the push resilient
`lastConfirmedAt` lives in gitignored bookkeeping that only refresh.yml
restored, so the workflow that actually builds and deploys never saw it:
every source reported its last *content change* as its last success, and
the UI flagged anything whose bytes had not moved in two days as stale —
which is most wiki pages most of the time. ci.yml now restores the same
cache read-only before building the feed.

The refresh push was a bare `git push`, so a human push landing in
between made it non-fast-forward: the job failed and threw away pages it
had just fetched, while the bookkeeping had already been saved, so those
sources would not be re-asked for six hours. Rebase and retry instead —
never force.

The cache save key used run_id, which is stable across re-runs, so a
re-run saved nothing and the run after it restored stale bookkeeping.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 21:53:44 +02:00
Lucas WintherandClaude Opus 5 c85ec0b2d1 fix: harden the fetch pipeline for a real server
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]>
2026-08-15 21:53:34 +02:00
Lucas WintherandClaude Opus 5 99786f6fc9 fix: label a daily pip in game-day space, not the reader's zone
A day key is a game-day, not an instant, but the pip's title and
aria-label were formatted in the browser's timezone. Every reader west
of UTC saw a pip reading "12" whose screen-reader label said "Aug 11" —
including the whole `america` region these checklists are scoped to.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 21:42:09 +02:00
Lucas WintherandClaude Opus 5 1bc7b0057c fix: keep sanitised text stable when it is truncated
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]>
2026-08-15 21:42:00 +02:00
Lucas WintherandClaude Opus 5 02863ed008 docs: cover the refresh pipeline, sanitisation and dailies
The status sections claimed the feed was generated from fixtures and the
scheduler unbuilt, which stopped being true. Also documents the
sanitisation stage and the two new key spaces — `dailies:<game>` and
game-day keys — beside the existing warning about event IDs, since they
carry the same "no server-side recovery" property.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 21:19:08 +02:00
Lucas WintherandClaude Opus 5 8e42afdd7a feat: sort by what you're partway through
"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]>
2026-08-15 21:18:35 +02:00
Lucas WintherandClaude Opus 5 ccc5d369bd feat: track events that repeat daily, and each game's dailies
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]>
2026-08-15 21:18:22 +02:00
Lucas WintherandClaude Opus 5 ad3647bee2 fix: dim only finished events in the timeline
The bar tested for an entry in the progress store, but an entry appears
there the moment a reader records an effort or a note. Recording "this
looks like a grind" greyed the event out as though it were already done.

Ask whether it is done instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 21:16:48 +02:00
Lucas WintherandClaude Opus 5 b1df7b2581 refactor(ui): make an event row a single open target
The row already opened the event; the tick sat on top of it, so the two
smallest targets on the page did different things a few pixels apart.
"Done" was never the only thing a reader wants to say about an event
either — status, effort and notes all live in the detail sheet.

The tick becomes a chevron: decorative, aria-hidden, and leaning towards
where the row is about to take you on hover. The full-bleed button is
the only control, so there is no second stop for anyone tabbing or using
a screen reader. Undo on a revealed ignored row stays a real button —
it is a real action with nowhere better to sit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 21:16:25 +02:00
Lucas WintherandClaude Opus 5 b085087b05 feat: refresh sources on a schedule
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]>
2026-08-15 21:15:25 +02:00
Lucas WintherandClaude Opus 5 18b9652aed feat: sanitise every string a source publishes
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]>
2026-08-15 21:15:13 +02:00
Lucas WintherandClaude Opus 5 2b9338a8b0 docs: cover progress, effort and the completions migration
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:49:53 +02:00
Lucas WintherandClaude Opus 5 89df140c00 feat: let readers record where they are and how much work it is
The done checkbox now cycles untouched → doing → done, in the order people
actually move through. Effort, status and a note are set from the detail
sheet.

Rows carry chips for what the reader said, and a "tight" or "running out of
time" flag when the remaining time no longer covers the effort they recorded.
The detail sheet shows the arithmetic behind that rather than just asserting
it, and calls it a rough guide.

Imports from before this change are mapped forward rather than dropped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:49:53 +02:00
Lucas WintherandClaude Opus 5 2f7e5bdf79 feat: replace the completions store with per-event progress
Membership in a set can only say "done", so "started" was inexpressible.
Progress carries a status instead, plus an optional effort estimate and a
note.

The old completions key is read once to seed status: "done", and is never
written to or deleted. Someone who last opened the app six months ago still
has their marks under it, these live only in the browser, and nothing else
holds a copy to restore from.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:49:53 +02:00
Lucas WintherandClaude Opus 5 ea719730a7 feat: add an effort scale with time semantics
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]>
2026-08-15 01:49:53 +02:00
Lucas WintherandClaude Opus 5 25c32dbe62 fix: derive the colophon's credits from the feed
It still thanked only Game8 and hardcoded the studio list, so wiki.gg went
uncredited and unmentioned in the disclaimer after being added as a source.

Sources and studios are now read from the feed and the game registry, so
adding either credits the right people automatically. A hardcoded credit goes
stale the moment someone adds a source, and out of date is the one thing a
credit must not be.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:43:47 +02:00
Lucas WintherandClaude Opus 5 6fad016f66 feat: make ignoring an event undoable
Ignoring made an event vanish with no way back except knowing the reveal
toggle existed — and the row you would click to undo is the row that just
disappeared.

Now: an undo appears the moment you ignore something, and revealed ignored
rows are labelled and carry a restore control where the done checkbox
normally sits, which is the most direct place for it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:43:47 +02:00
Lucas WintherandClaude Opus 5 5ea5d8292f fix: let the calendar scroll back
Clamping the window to now made the past unreachable — you could not see when
a running event began. Rendering from the earliest start instead buried today
off-screen, which is what the clamp was fixing.

The window now covers both and the container opens scrolled to today, so the
default view is unchanged and history is a scroll away. It reaches a week
past the oldest running event, so the range stays bounded by what is actually
live. Nothing is clipped at the left edge any more.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:43:47 +02:00
Lucas WintherandClaude Opus 5 0efcfc52f0 fix: tell Dark Reader this page is already dark
Dark Reader inverts an already-dark palette, which wrecks the interface: the
colours here carry meaning — the urgency ramp is how you read how soon
something ends — so having them re-mapped is not a cosmetic problem.

darkreader-lock is its documented opt-out. color-scheme: dark is the standard
signal alongside it, so browser chrome and form controls render dark too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:43:47 +02:00
Lucas WintherandClaude Opus 5 3bab95acd5 fix: copy serve.ts into the Docker build stage
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]>
2026-08-15 01:35:25 +02:00
Lucas WintherandClaude Opus 5 fd1111017f fix: let the Pages job enable Pages itself
configure-pages fails with a 404 when Pages has never been switched on for
the repository. It can enable it given the pages: write permission the job
already holds, which beats requiring a trip through settings before CI can
go green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:30:23 +02:00
Lucas WintherandClaude Opus 5 abaecc13c7 fix: lowercase the GHCR image name
github.repository preserves the owner's casing and GHCR rejects any
uppercase in a repository name, so the image job failed on tag validation
before it built anything.

Also drops the review-quarantine skill's references to evidence spans and the
extraction-evaluator agent, both of which went away with the LLM layer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:28:44 +02:00
Lucas WintherandClaude Opus 5 692b5a83d5 docs: bring every markdown file up to date with the code
The docs had drifted in ways that would mislead: DATA-MODEL documented a
localStorage shape the code stopped using (completedAt, no ignored store),
INGESTION claimed three Game8 templates when five are known, ARCHITECTURE
still listed the whole client and time.ts as unbuilt, and the review-quarantine
skill described a pipeline that does not exist yet without saying so.

Adds the parser roster and the six date formats as tables, documents the
subpath/base-href and offline behaviour, and records the new product surface
(first-run picker, ignore, offline, credit) as PRD features.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:27:56 +02:00
Lucas WintherandClaude Opus 5 f2cf9ba0cb fix: make the server tests hermetic
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]>
2026-08-15 01:26:01 +02:00
Lucas WintherandClaude Opus 5 b86794a62d feat: add GitHub Actions CI and support hosting under a subpath
Mirrors the GitLab pipeline — typecheck, tests, feed sanity — and adds a
Pages deploy.

Pages serves from /<repo>/, and the app used absolute asset paths, so that
job would have shipped a site that 404s on everything. Assets now resolve
against a <base href> the build substitutes, the feed URL resolves against
document.baseURI so deep links work too, and the service worker derives its
paths from its own registration scope. Root-hosted builds are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:22:22 +02:00
Lucas WintherandClaude Opus 5 517066dc65 feat: add Docker image, static server and GitLab CI
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]>
2026-08-15 01:20:42 +02:00
Lucas WintherandClaude Opus 5 71bff72ee9 feat: let readers ignore events they don't care about
Ignoring is not completing. "Done" keeps an event visible and counted;
"not interested" removes it from both views, which is the entire point.

Completions and ignores are the same shape and want the same guarantees, so
they now share one mark-set implementation — union merge on import, never a
removal, since nothing else holds a copy. Both ride in the export file.

Ignored events stay recoverable: the count and a reveal toggle appear in
settings once there is something to reveal.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:18:11 +02:00
Lucas WintherandClaude Opus 5 dd596b5a43 feat: add wiki.gg as a second Endfield source
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]>
2026-08-15 01:17:17 +02:00
Lucas WintherandClaude Opus 5 f4b133274a fix: say what the bars and colours mean
The meter encoded two things at once and neither was guessable from looking
at it — obvious to whoever built it, opaque to everyone else.

Every row now carries a plain caption under the bar ("Aug 10 – Aug 17 · 3 of
7 days left", "started Apr 22 · no end date announced"), which explains the
bar and doubles as the exact dates people wanted anyway. A legend above the
list explains the colour ramp, which no per-row text can — shown once,
because a legend repeated fifty times is noise.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:09:54 +02:00
Lucas WintherandClaude Opus 5 ad45b52c84 feat: add Arknights: Endfield, and stop passing off requirements as summaries
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]>
2026-08-15 01:07:39 +02:00
Lucas WintherandClaude Opus 5 65f1660718 fix: open the calendar at the current time
The window ran from the earliest event start, which is months back, so today
sat far off-screen and every visit began with a scroll to find yourself.

It now opens at now. Events already running are clipped at the left edge and
faded there, the same way an unannounced end frays on the right — both say
the truth extends past what is drawn. The left edge also carries a month
label, since the window opens mid-month and the first real boundary can be
weeks away.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:00:56 +02:00
Lucas WintherandClaude Opus 5 395d8b8ca8 docs: cover the five sources and offline support in the README
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:53:11 +02:00
Lucas WintherandClaude Opus 5 c633d0510d feat: work offline
The reader's question is answered entirely by data already on the device and
countdowns run off the local clock, so losing signal should not lose the app.

The shell is cache-first; the feed is network-first falling back to the last
copy seen, because stale events beat a blank screen. Webfonts are cached too
— without them an offline load silently drops to system faces and the whole
thing changes character.

Offline is surfaced in the header and above the footer rather than hidden:
stale data must never be presented as current.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:52:41 +02:00
Lucas WintherandClaude Opus 5 1a04e9472b feat: credit the sources, disclaim affiliation, link the repo
Game8's editors compile the calendars this depends on, and the studios make
the games — both belong on the same screen as the data rather than one
navigation step away.

States plainly that this is an unofficial fan-made tool with no connection
to Game8, HoYoverse, Kuro Games, Hypergryph or Hotta Studio, and that the
source page is the authority when the two disagree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:52:41 +02:00
Lucas WintherandClaude Opus 5 817ad41dcd feat: add Star Rail, Wuthering Waves and Zenless Zone Zero
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]>
2026-08-15 00:47:50 +02:00
Lucas WintherandClaude Opus 5 90be312942 feat: parse both-year ranges and open-ended events
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]>
2026-08-15 00:47:50 +02:00
Lucas WintherandClaude Opus 5 107179acfa feat: ask which games you play on first run
A calendar full of games you don't play is worse than an empty one — it
buries the thing you came for.

Nothing is preselected; the button stays disabled until a game is picked,
which is clearer than guessing and hoping the reader notices. The choice is
stored as hiddenGames, the inverse, so a game added later shows up by
default rather than staying invisible.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:39:42 +02:00
Lucas WintherandClaude Opus 5 e0ba53f369 feat: show an excerpt and make rows respond to hover and press
The whole row is now one click target, which gives a generous tap area and
a single unambiguous hover region.

Hover transitions are fast and eased out (110-160ms) so a row responds on
the way in and settles on the way out — a slow symmetric fade is what makes
an interface feel laggy even at full framerate. The meter is the element
that visibly wakes up, since it is the signature. Press feedback lands on
touch as well as mouse, and every hover rule sits behind `hover: hover` so a
tap never leaves a row stuck looking hovered.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:39:42 +02:00
Lucas WintherandClaude Opus 5 6fffd283a5 feat: extract event descriptions from Game8 detail tables
Column tables already carried a description column, but label/value detail
tables did not — Genshin events had no blurb at all. The page follows each
detail table with a sentence of prose, so take that, skipping the
call-to-action paragraphs, which are navigation rather than description.

Summary coverage goes from 13/22 events to 22/22.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:39:42 +02:00
Lucas WintherandClaude Opus 5 62f673161b docs: rewrite the README for the working app
Covers what's built versus specified, how to run it, the parser/adapter/
merge layering, and why Arknights: Endfield has no source.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:30:40 +02:00
Lucas WintherandClaude Opus 5 b1d18eaf59 feat: add the Event Clock interface
Opens on the single event closest to expiring, at a size nothing else
competes with — the reader arrives with one question. Below it, live events
ordered by what ends soonest, then a quiet timeline view with one lane per
game.

Countdowns use tabular figures so ticking never reflows the row.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:28:45 +02:00
Lucas WintherandClaude Opus 5 ec8cc97c1a feat: add the depletion meter
A discrete tick-strip rather than a smooth progress bar, borrowed from the
stamina meters these games all use — ticks read as a finite resource visibly
draining, which is the right metaphor for a limited-time event.

An event with no announced end gets a hatched strip, never a full meter.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:28:45 +02:00
Lucas WintherandClaude Opus 5 ac165f0df7 feat: add local-only completion and preference state
Completions and prefs live in localStorage and never reach the server —
there is no account and nothing to log into. Reads never throw, so a corrupt
value costs a preference rather than the whole screen.

Import merges and never removes: a completion present on either side stays
completed, because nothing else holds a copy to restore from.

The feed client refuses a schemaVersion it does not know rather than
guessing at unfamiliar fields.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:28:45 +02:00
Lucas WintherandClaude Opus 5 49d52e3587 chore: add React, Tailwind and the client build
Tailwind v4 @theme carries the two colour axes as tokens: a deep blue-black
ground, per-game hues for identity, and a heat ramp for urgency that is
monotonic in salience (dim, blue, amber, red).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:28:17 +02:00
Lucas WintherandClaude Opus 5 5964f4b2fa feat: build a static event feed from fixtures
Emits exactly the shape GET /api/events.json will serve, so the client can
be developed against real parsed data before the server and database land.
Reads fixtures only, never the network.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:28:17 +02:00
Lucas WintherandClaude Opus 5 45ad5b39da feat: add shared time, games and feed contracts
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]>
2026-08-15 00:28:17 +02:00
Lucas WintherandClaude Opus 5 3b2feadf5d docs: rewrite CLAUDE.md for the current codebase
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:11:08 +02:00
Lucas WintherandClaude Opus 5 e9f69ea8dc chore: update agents and skills for the parser-only pipeline
Removes the extraction-evaluator agent and reframes adapter-author around
reusing an existing site parser before writing a new one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:11:08 +02:00
Lucas WintherandClaude Opus 5 3ea7286f56 docs: drop the LLM extraction layer, document multi-source ingestion
Event data is parsed deterministically; there is no model call, API key or
per-run cost anywhere in the pipeline. A source that cannot be parsed
deterministically gets no adapter rather than an inference fallback.

Documents the parser/adapter/merge split, records that Game8 uses three
page templates, and adds Neverness to Everness.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:11:08 +02:00
Lucas WintherandClaude Opus 5 493fc9f22a feat: merge events across multiple sources per game
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]>
2026-08-15 00:11:08 +02:00
Lucas WintherandClaude Opus 5 489ce92ef5 feat: add Game8 parser with Genshin and NTE adapters
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]>
2026-08-15 00:10:53 +02:00