From 3b2feadf5dfbd89679a3bc782b480b0458e371ed Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:11:08 +0200 Subject: [PATCH] docs: rewrite CLAUDE.md for the current codebase Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 199 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 104 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ae9d90..7e21078 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,135 +4,144 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A web app that aggregates live and upcoming events across popular gacha games (Genshin Impact, -Honkai: Star Rail, Zenless Zone Zero, Wuthering Waves, Arknights, Arknights: Endfield), plots them -on a calendar, sorts them by end date, and lets a user mark events completed. +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: specification only.** No application code exists yet. `docs/` is the source of truth for -what to build; everything below describes the intended system, not an existing one. When you write -the first code, follow `docs/ARCHITECTURE.md` and update this file's Commands section with the real -commands. +**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. -## Two constraints that shape everything +## 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 feature request - that implies "sync across devices" must be solved with export/import JSON, not a server-side - user. -2. **A server is allowed, and is where all secrets live.** The Bun server owns scraping, LLM - extraction, and the SQLite database. `ANTHROPIC_API_KEY` never reaches the browser. The client - only ever calls this app's own `/api/*`. + `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 (single dependency — `Bun.serve`, `bun:sqlite`, `bun test`, `bun build`) | +| 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` (file is gitignored — `*.sqlite`) | +| Storage | SQLite via `bun:sqlite` (gitignored — `*.sqlite`) | | Validation | Zod — one schema module shared by server and client | -| LLM | Anthropic TypeScript SDK (`@anthropic-ai/sdk`), model `claude-opus-5` | -TypeScript runs `strict: true` **and** `noUncheckedIndexedAccess`. Do not add a bundler, test -runner, or process manager — Bun covers all three. +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`. -## Architecture in one paragraph +## Commands -A scheduled job inside the Bun process runs one **adapter** per game. Each adapter fetches a source -page, cleans it, and hands it to a **deterministic parser** when the source has a stable shape, or -to **Claude structured extraction** when it doesn't. Results are validated with Zod plus calendar -sanity rules, then either published to the `events` table or held in `events_quarantine` for human -review at an unauthenticated `/review` route bound to `127.0.0.1`. The React client fetches -`/api/events`, renders a calendar and an ends-soonest list, and stores completion ticks in -`localStorage`. Full detail: `docs/ARCHITECTURE.md`. +```bash +bun install +bun test # full suite, offline, no network +bun run typecheck # tsc --noEmit -The important consequence: **the LLM runs at ingestion time, never in a request path.** A page load -must never trigger an API call to Anthropic. If you find yourself adding one, the design is wrong. +# 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 -## Reading order for a new task +# Single test file / single test +bun test test/dates.test.ts +bun test --test-name-pattern "year-less" +``` -| Task | Read | -|---|---| -| Anything at all | `docs/ARCHITECTURE.md` | -| Adding/changing an event field | `docs/DATA-MODEL.md` — the schema is versioned and the client depends on it | -| Adding a game, fixing a broken adapter | `docs/INGESTION.md`, then invoke the `add-game-source` skill | -| Touching prompts, extraction, or cost | `docs/LLM-EXTRACTION.md` | -| Product questions (what does the calendar show?) | `docs/PRD.md` | +`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// 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 are the source of most bugs in -this kind of app: +These come from how gacha games actually schedule things, and they cause most bugs here: -- **Store every timestamp as UTC ISO 8601. Never store a local wall-clock time.** 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.** Genshin - and HSR character banners end at the same instant worldwide, while story/login events end at each - region's daily reset (Asia / America / Europe are offset by hours). The `regionScoped` flag and - the optional `regionEnds` map exist for exactly this — do not collapse them into one timestamp. -- **"Ends after maintenance" and "TBD" are real values.** An event whose end is genuinely unknown - gets `endsAt: null` and `endPrecision: "unknown"`. Never invent a plausible date to satisfy a - non-null type — that is the single worst failure mode for this app, because the user's whole +- **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. -- **Version 1.x patch cycles are ~6 weeks (42 days), split into two banner phases.** Any extracted - event with a duration over 180 days is almost certainly a parse error, not a long event. The - validator rejects it. +- **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 with the LLM extraction layer +## Working on parsers -Read `docs/LLM-EXTRACTION.md` before editing any prompt or request. The rules that will actually -bite you: +- **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 — Arknights: Endfield. **Unsupportable**; + yields nothing by design. + 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. -- **Model is `claude-opus-5`.** That is the exact, complete ID — never append a date suffix. -- **Use structured outputs, not prompt-and-parse.** `client.messages.parse()` with - `zodOutputFormat(EventExtractionSchema)` from `@anthropic-ai/sdk/helpers/zod`. Read - `response.parsed_output`. Do not write a JSON-repair or regex-extraction fallback — if the schema - is right, there is nothing to repair. -- **Never set `temperature`, `top_p`, or `top_k`.** They are removed on `claude-opus-5` and return - a 400. Steer with the prompt. -- **Never set `thinking: {type: "enabled", budget_tokens: N}`.** Removed — returns 400. Thinking is - on by default; control depth with `output_config.effort`. -- **Deterministic parsers come first.** The LLM is for sources whose markup is unstable. A source - with a clean JSON API or a stable table must not go through the model. -- **Skip unchanged sources by content hash.** This is the main cost lever — most refresh cycles - should make zero API calls. +## Event IDs are localStorage keys -## Cost discipline +``` +`${game}:${slugify(title)}:${startsAt.slice(0, 10)}` +→ "genshin:mutual-aid-in-bloom-into-the-frostlands:2026-08-12" +``` -Every ingestion run should be able to answer "why did this cost anything?" Extraction is billed at -`claude-opus-5` rates ($5/MTok input, $25/MTok output). Three levers, in order of impact: - -1. **Content-hash skip** — unchanged source, no call at all. -2. **HTML pre-cleaning** — strip `