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]>
This commit is contained in:
Lucas Winther
2026-08-15 01:27:56 +02:00
co-authored by Claude Opus 5
parent f2cf9ba0cb
commit 692b5a83d5
9 changed files with 211 additions and 67 deletions
+62 -16
View File
@@ -57,29 +57,48 @@ src/
scheduler.ts timer + jitter + per-source lock [not built]
pipeline.ts the 6 stages, orchestration only [not built]
html.ts flat-table HTML reader (no dependency) ✓ built
dates.ts deterministic date parsing ✓ built
dates.ts six deterministic date formats ✓ built
merge.ts cross-source dedupe, corroboration ✓ built
validate.ts zod parse + calendar sanity rules [not built]
reconcile.ts diff vs published, confidence, conflicts [not built]
parsers/
types.ts SourceParser interface ✓ built
game8.ts game8.co article calendars ✓ built
wikigg.ts wiki.gg mp-event templates ✓ built
index.ts parser registry ✓ built
adapters/
types.ts Adapter interface, ParseContext ✓ built
game8.ts shared Game8 parser (2 table shapes) ✓ built
index.ts registry: adapter id → Adapter ✓ built
index.ts SOURCES registry, parseGame() ✓ built
shared/
schema.ts zod schemas — the contract, both sides ✓ built
time.ts region reset math, duration formatting [not built]
client/
main.tsx
App.tsx
views/
Timeline.tsx F1
EndingSoon.tsx F2
EventDetail.tsx
time.ts clocks, urgency, region resets, captions ✓ built
games.ts per-game name and hue ✓ built
feed.ts the /api/events.json wire contract ✓ built
client/ ✓ all built
main.tsx render + service worker registration
App.tsx shell, views, filters, onboarding gate
api.ts typed feed fetch, schemaVersion refusal
sw.js offline: shell cache, feed fallback
manifest.webmanifest, icon.svg
components/
NextUp.tsx the hero countdown (PRD F1)
EventRow.tsx row + meter + caption (F2, F3)
Meter.tsx the depletion meter
Legend.tsx what the bars and colours mean
Timeline.tsx calendar lanes (F1)
EventDetail.tsx detail sheet, ignore action (F9)
Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8)
Colophon.tsx credit, disclaimer, repo link
state/
completions.ts localStorage read/write + export/import
prefs.ts region, filters
api.ts typed fetch of /api/events
storage.ts namespaced, versioned localStorage
useMarkSet.ts completions and ignores (same shape)
usePrefs.ts region, filters, onboarding flags
serve.ts static server + /api/health ✓ built
scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built
parse-fixture.ts run one adapter offline ✓ built
fixtures/<game>/ checked-in raw HTML + expected parse output
docs/
```
## Request paths
@@ -137,13 +156,40 @@ PORT=3000
ADMIN_PORT=3001 # bound to 127.0.0.1
DATABASE_PATH=./data/events.sqlite
INGEST_INTERVAL_MS=21600000
INGEST_ENABLED=true # false for local UI work — never hits the network or the API
INGEST_ENABLED=true # false for local UI work — never touches the network
CONFIDENCE_THRESHOLD=0.8
BASE_PATH=/ # trailing slash; set when hosting under a subpath
```
`INGEST_ENABLED=false` is the default for local development. Frontend work should run against a
seeded SQLite file and cost nothing.
### Today
`serve.ts` serves `public/` plus `/api/health`, and the feed is generated offline from fixtures by
`bun run build:feed`. It emits exactly the shape `/api/events.json` will, so the real server slots in
without the client changing. Reads are confined to `public/` by resolving the path and checking it
stays inside the root — string-matching `..` is not enough, because encodings and URL normalisation
both change what the string looks like.
`Dockerfile` builds and serves this; the image runs typecheck and tests during build, ships no source
or toolchain, and runs unprivileged. `.github/workflows/ci.yml` and `.gitlab-ci.yml` run the same
gates and publish it.
### Hosting under a subpath
Assets resolve against a `<base href>` substituted at build time, the feed URL resolves against
`document.baseURI` so deep links work, and the service worker derives its paths from its own
registration scope. `BASE_PATH=/gacha-event-tracker/ bun run build` for GitHub Pages; without it a
subpath deploy 404s on every asset.
### Offline
The service worker caches the shell and webfonts (cache-first) and the feed (network-first, falling
back to the last copy seen). Countdowns run off the device clock, so the app stays useful with no
network. Offline state is surfaced in the header and above the footer — stale data must never be
presented as current.
## Deliberate non-choices
- **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is six tables.
+18 -8
View File
@@ -196,11 +196,19 @@ malformed row surfaces at the boundary rather than deep in the UI.
Namespaced, versioned, and small. Nothing here ever goes to the server.
```ts
"gacha-tracker:v1:completions" // { [eventId]: { completedAt: string } }
"gacha-tracker:v1:prefs" // { region, hiddenGames[], hiddenTypes[], showCompleted }
"gacha-tracker:v1:feedCache" // { fetchedAt, events } — offline fallback
"gacha-tracker:v1:completions" // { [eventId]: { at: string } } — "I finished this"
"gacha-tracker:v1:ignored" // { [eventId]: { at: string } } — "stop showing me this"
"gacha-tracker:v1:prefs" // { region, hiddenGames[], showCompleted, showIgnored,
// regionConfirmed, onboarded }
```
Completions and ignores are the same shape and share one implementation
(`useMarkSet`), but stay in separate stores because they mean different things: a completed event is
dimmed and still counted, an ignored one disappears from both views.
Offline caching is the service worker's job, not localStorage's — it caches the feed response
itself, so there is no second copy of the events to keep in sync.
The `v1` segment is the migration hook. On boot, the client checks for keys at older versions and
migrates them forward before reading. **Never delete an old-version key until the migration has
shipped and run** — a user who has not opened the app in six months still has their data under the
@@ -213,14 +221,16 @@ old key.
"format": "gacha-tracker-export",
"version": 1,
"exportedAt": "2026-08-14T12:00:00.000Z",
"completions": { "genshin:windblume-festival:2026-03-14": { "completedAt": "..." } },
"prefs": { "region": "europe", "hiddenGames": [] }
"completions": { "genshin:windblume-festival:2026-03-14": { "at": "..." } },
"ignored": { "zzz:some-event-i-skip:2026-08-19": { "at": "..." } },
"prefs": { "region": "europe", "hiddenGames": [], "onboarded": true }
}
```
Import **merges**: a completion present in either the file or the current device stays completed.
Import never removes a completion. Losing a user's marks to a bad import is unrecoverable, so the
merge is deliberately one-directional.
Import **merges** both sets: a mark present in either the file or the current device survives, and
import never removes one. Losing a user's marks to a bad import is unrecoverable, so the merge is
deliberately one-directional. A file whose `format` is unrecognised is refused outright rather than
partly applied.
## Schema versioning
+31 -2
View File
@@ -41,6 +41,33 @@ Consequences worth internalising:
- A game may have any number of sources. `parseGame(game, documents, now)` runs them all and
merges.
### Parsers in the tree
| Parser | Site | Sources using it |
|---|---|---|
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE |
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
`wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server
region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a
prose wiki when both exist, and give it a higher `priority`.
### Date formats understood
All live in `src/ingest/dates.ts`, each returning null rather than inferring anything:
| Function | Shape | Seen on |
|---|---|---|
| `parseMonthDayYear` | `August 12, 2026` | Genshin detail rows |
| `parseMonthDayRange` | `August 12 - September 21, 2026` (year on the end only) | Genshin, NTE |
| `parseFullRange` | `Aug. 14, 2026 - Aug. 24, 2026` (a year each side) | Star Rail, Wuthering Waves |
| `parseShortSlashRange` | `08/09/26 - 08/30/26` | Endfield |
| `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events |
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
`parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date
and reports no end.
### The parser interface
```ts
@@ -87,7 +114,7 @@ produced it.
| Dates without a year, or no end date at all | **Unsupportable** — yields nothing rather than guessing |
| Free-form prose with no table structure | Find a different source |
Game8 uses at least three page templates and a game's page may use any of them:
Game8 uses at least four page templates and a game's page may use any of them:
1. **Label/value detail tables**`Event Start` / `Event End` rows under a per-event `h3`, full
dates with year. *(Genshin Impact)*
@@ -96,8 +123,10 @@ Game8 uses at least three page templates and a game's page may use any of them:
3. **Image-grid schedules** — a bare `MM/DD`, no year, no end date. **Unsupportable.**
4. **Combined cells** — one cell holding label, range and blurb
(`Period: 08/09/26 - 08/30/26 During the event...`). *(Arknights: Endfield)*
5. **Rowspan Start/End pairs** — the event name spans two rows, so a flat cell reader sees
`[title, "Start", date]` then `["End", date]`. *(Zenless Zone Zero)*
Shapes 1, 2 and 4 are handled. Before assuming a new Game8 page will work, dump its heading/table
Shapes 1, 2, 4 and 5 are handled. Before assuming a new Game8 page will work, dump its heading/table
structure and check which shape it uses — and check **every** table, not just the obvious one.
Endfield was written off as undatable on a first pass that only inspected its `Duration` rows; its
two real events were in a table further down.
+27 -1
View File
@@ -83,6 +83,29 @@ Because there are no accounts, moving between devices is manual: download a JSON
IDs and preferences, upload it elsewhere. Import merges rather than replaces, and never removes a
completion the user already has.
**F8 — First-run game picker.**
Before any events are shown, the reader picks which games they play. A calendar full of games they
don't play is worse than an empty one — it buries the thing they came for. Nothing is preselected
and the button stays disabled until something is chosen; guessing on their behalf and hoping they
notice is worse than asking. The choice is stored as *hidden* games, the inverse, so a game added
later appears by default rather than staying invisible forever.
**F9 — Ignore an event.**
Distinct from completing one. "Done" keeps an event visible and counted; "not interested" removes it
from both views entirely. Ignored events stay recoverable: a count and a reveal toggle appear in
settings once there is something to reveal.
**F10 — Works 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. A service worker caches the shell and serves
the last feed it downloaded. Offline is disclosed in the header and above the footer — see F7; stale
data must never be presented as current.
**F11 — Credit and disclaimer.**
The sources that compile these calendars, and the studios that make the games, are named on the same
screen as the data rather than one navigation step away. The page states plainly that it is
unofficial and unaffiliated, and that the source page is the authority when the two disagree.
**F7 — Freshness disclosure.**
The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours
stale, its lane carries a warning badge. Never present stale data as current — the whole value
@@ -91,7 +114,8 @@ proposition is trust in the dates.
## Out of scope for v1
Accounts and sync; push notifications; per-event checklists or progress tracking; in-game resource
or pull tracking; user-submitted events; mobile apps; localization beyond English.
or pull tracking; user-submitted events; native mobile apps (the web app installs to a home screen,
which is enough); localization beyond English.
## Success criteria
@@ -119,6 +143,8 @@ product exists to prevent.
- Does the calendar need a month/grid view, or is the timeline enough? (Assumption: timeline is
enough for v1; revisit after use.)
- Should an ignored event still count toward the "N live" header total? (Assumption: no — ignoring
means gone.)
- Should events the user has hidden by game filter still count toward "ends soonest"?
(Assumption: no — the filter is global.)
- Is 6 hours the right refresh cadence? (Assumption: yes; events are announced days ahead, so