Files
gacha-event-tracker/CLAUDE.md
T
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

7.5 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A web app that aggregates live and upcoming events across popular gacha games, plots them on a calendar, sorts them by end date, and lets a user mark events completed.

Status: first vertical slice. The schema, the Game8 parser, and two working adapters (Genshin Impact, Neverness to Everness) exist and are tested. The server, database, and UI do not exist yet — docs/ specifies them.

Three constraints that shape everything

  1. No accounts, no logins, no user records. Completion state lives in the browser's localStorage, keyed by event ID. There is no user table and no session. Any request implying "sync across devices" is solved with export/import JSON, not a server-side user.
  2. No LLM in the pipeline. Event data is extracted by deterministic code-based parsers only. There is no Anthropic dependency, no API key, and no per-run inference cost. A source that cannot be parsed deterministically does not get an adapter — see docs/INGESTION.md § No LLM.
  3. A server is allowed (Bun) and owns fetching, parsing, and SQLite. The client only ever calls this app's own /api/*.

Stack

Layer Choice
Runtime / server / bundler / test runner Bun 1.3 (Bun.serve, bun:sqlite, bun test, bun build)
UI React 19 + TypeScript (strict) + Tailwind
Storage SQLite via bun:sqlite (gitignored — *.sqlite)
Validation Zod — one schema module shared by server and client

The only runtime dependency is zod. Do not add a bundler, test runner, HTTP client, or HTML parsing library — Bun covers all four. tsconfig.json runs strict plus noUncheckedIndexedAccess and exactOptionalPropertyTypes.

Commands

bun install
bun test                      # full suite, offline, no network
bun run typecheck             # tsc --noEmit

# Run an adapter against a checked-in fixture (offline, free)
bun run parse genshin-game8-events fixtures/genshin/game8-events-2026-08-14.html
bun run parse nte-game8-events     fixtures/nte/game8-events-2026-08-14.html --json

# Single test file / single test
bun test test/dates.test.ts
bun test --test-name-pattern "year-less"

bun run parse ... --json is also how .expected.json fixtures are regenerated after an intentional parser change. Regenerating them makes the test self-consistent, not correct — always re-verify a sample against the live page afterward.

Current state of the code

src/shared/schema.ts          Zod GachaEvent, GameId, slugify, eventId   ← the contract
src/ingest/html.ts            flat-table HTML reader (no dependency)
src/ingest/dates.ts           three date formats, null rather than guess
src/ingest/adapters/
  types.ts                    Adapter interface, ParseContext
  game8.ts                    shared Game8 parser — handles 2 table shapes
  index.ts                    adapter registry
scripts/parse-fixture.ts      offline adapter runner
test/                         37 tests
fixtures/<game>/              raw HTML + .expected.json per source

Not yet built: src/server/**, src/client/**, the SQLite layer, the scheduler, the review UI.

Domain rules that are not obvious from the code

These come from how gacha games actually schedule things, and they cause most bugs here:

  • Store every timestamp as UTC ISO 8601. Sources publish in a mix of UTC+8, server-local, and "after maintenance".
  • Banner ends are usually global and simultaneous; event ends are usually per-region. Character banners end at one instant worldwide; story/login events end at each region's daily reset (Asia / America / Europe differ by hours). regionScoped and regionEnds exist for this — do not collapse them into one timestamp.
  • endsAt: null is a correct, expected value. An event whose end is genuinely unannounced gets endsAt: null and endPrecision: "unknown". Never invent a plausible date to satisfy a non-null type. This is the worst failure mode this codebase has, because the user's entire reason for visiting is trusting the end date.
  • Patch cycles are ~6 weeks. Any event over 180 days is a parse error, not a long event. The validator and the tests both reject it.

Working on parsers

  • Parsers are pure. No network, no Date.now(), no randomness — time arrives as ctx.now. This is what makes fixture tests meaningful; a parser that reads the clock cannot be tested.
  • Skip, never guess. Every function in dates.ts returns null rather than inferring a missing year, month, or end. readColumnTable drops a row it cannot date. An omitted event is a recoverable disappointment; a confidently wrong date is the failure this product exists to prevent.
  • Game8 has no single template. Three shapes are known so far, and a page may mix them:
    1. Label/value detail tables (Event Start / Event End) — Genshin.
    2. Column tables (Event | Duration | Event Details | Rewards) — NTE.
    3. Image-grid schedules with a bare MM/DD and no end — unsupportable, yields nothing by design.
    4. Combined cells that fold label, range and blurb together (Period: 08/09/26 - 08/30/26 During the event...) — Arknights: Endfield. Before assuming a new Game8 page will work, dump its structure and check which shape it uses.
  • Silent drops are the dangerous failure. A date format the parser does not recognise makes events vanish with no error. Abbreviated months (Apr. 29 - May 13, 2026) are supported for exactly this reason. When adding a source, compare the parser's event count against an independent count of the page.

Event IDs are localStorage keys

`${game}:${slugify(title)}:${startsAt.slice(0, 10)}`
→ "genshin:mutual-aid-in-bloom-into-the-frostlands:2026-08-12"

Changing slugify or eventId in src/shared/schema.ts — including seemingly cosmetic changes to the slug rules — silently orphans every completion mark every user has, with no server-side recovery, because the server never had the data. If it must change, ship a client-side migration that remaps old keys and keep it for at least a year. Use the schema-guardian agent on any such change.

Scraping conduct

Sources are community wikis. Treat them as a guest would:

  • Honor robots.txt; set a descriptive User-Agent with a contact URL.
  • One request per source per refresh cycle, minimum 6 hours apart.
  • Send If-None-Match / If-Modified-Since; treat 304 as "skip, unchanged".
  • Cache raw snapshots so re-parsing never re-fetches. Iterate against fixtures, not the network.
  • Record sourceUrl on every event and surface attribution in the UI.

Note that game8.co disallows GPTBot and Google-Extended in robots.txt — it has opted out of AI-training crawlers. Our use is a low-rate personal aggregator with attribution and no model training, and no User-agent: * rule applies to our paths. Keep it that way: do not raise the fetch rate, and do not add an LLM that consumes page content.

A source whose ToS forbids automated access does not get an adapter. Flag it and ask.

Conventions

  • Zod schemas are the single source of truth for types. Derive with z.infer<>; never hand-write an interface that duplicates a schema.
  • Every adapter ships a fixture in fixtures/<game>/ and a test asserting parsed output. This is how a source silently changing shape gets caught.
  • Keep old fixtures when a source changes shape — the old one is the regression test proving the parser still handles the previous format.