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]>
14 KiB
Architecture
Shape
One Bun process serves the static React build, exposes a read-only JSON API, and runs the ingestion scheduler on a timer. SQLite is the only datastore. There is no auth layer because there are no users.
┌──────────────────────────────────────────────┐
game wikis ───► │ Bun process │
news pages │ │
│ scheduler (every 6h) │
│ │ │
│ ▼ │
│ ingest pipeline │
│ fetch → parse → validate │
│ → reconcile → gate → publish │
│ │ │ │
│ │ └──► quarantine │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ SQLite │◄────────────────┘ │
│ └─────────────┘ /review (127.0.0.1) │
│ │ │
│ ▼ │
│ GET /api/events static React build │
└────────┬─────────────────────────┬───────────┘
│ │
▼ ▼
browser fetch browser render
│
▼
localStorage: completions,
filters, region ← never leaves the device
The pipeline makes no third-party API calls beyond fetching source pages. There is no inference anywhere, at ingest time or in a request path.
Layout
src/
server/
index.ts Bun.serve entry: static + API + scheduler bootstrap
routes/
events.ts GET /api/events, /api/events.json
games.ts GET /api/games
review.ts /review UI + approve/reject (localhost-bound)
health.ts GET /api/health
db/
client.ts bun:sqlite handle, WAL, pragmas
migrations/ NNN-name.sql, applied in order at boot
queries.ts all SQL lives here — no SQL in route handlers
ingest/
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 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
index.ts SOURCES registry, parseGame() ✓ built
shared/
schema.ts zod schemas — the contract, both sides ✓ built
time.ts clocks, urgency, region resets, captions ✓ built
games.ts per-game name, hue, and reset clock ✓ 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)
ProgressControls status, effort, note (F12)
Dailies.tsx today's strip, per-game reset clocks
DailyChecklist one repeating event's whole run
Fireworks.tsx the burst when the last daily lands
GameFocus.tsx one game at a time (F4a)
Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8)
Toast.tsx undo an ignore
UpdateNotice a newer app is installed and waiting (F14)
Colophon.tsx credit, disclaimer, repo link
state/
storage.ts namespaced, versioned localStorage
useMarkSet.ts ignores (and the superseded completions shape)
useProgress.ts status, effort, note, daily override (F12)
useDailyLog.ts which game-days are ticked off
usePrefs.ts region, filters, focus, onboarding flags
sort.ts deadline order, or what you're partway through
lens.ts who sees which rows — focus, outstanding, next-to-expire
useAppUpdate.ts is a newer build waiting, and taking it (F14)
serve.ts static server + /api/health ✓ built
scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built
build-static.ts shell + worker into public/, build-stamped ✓ built
parse-fixture.ts run one adapter offline ✓ built
fixtures/<game>/ checked-in raw HTML + expected parse output
Request paths
| Route | Purpose | Notes |
|---|---|---|
GET / + assets |
React SPA | Served from the bun build output |
GET /api/events?from&to&game |
Filtered feed | ETag + Cache-Control: public, max-age=300 |
GET /api/events.json |
Whole published feed | Cheap; the client mostly uses this and filters locally |
GET /api/games |
Game metadata: id, name, color, lastUpdatedAt | Drives the freshness badges (F7) |
GET /api/health |
Per-source last-success, quarantine depth | For an operator, not the UI |
GET /review |
Quarantine review UI | Bound to 127.0.0.1 only |
POST /api/review/:id/approve | /reject |
Promote or discard a quarantined event | Same binding |
Why /review needs no auth
Bun.serve runs two listeners: the public one on 0.0.0.0:PORT with the SPA and /api/*, and a
second on 127.0.0.1:ADMIN_PORT with /review and /api/review/*. The review routes are not
registered on the public listener at all — they are unreachable from off-box, so there is nothing
to authenticate. This is the mechanism that satisfies "no logins" without leaving an open admin
endpoint on the internet.
This is load-bearing. If someone later puts a reverse proxy in front of the admin port, or merges the two listeners "to simplify", the review UI becomes a public write endpoint. Any change in that area needs an explicit auth story first.
Data flow, concretely
- Scheduler wakes every 6h (± jitter). For each source not fetched within its
minIntervalMs, it acquires a per-source lock row and enqueues a run. - Pipeline executes the seven stages in
docs/INGESTION.md. Every stage writes toingest_runsso a failure is diagnosable after the fact without re-running. - Publish upserts into
eventsby stable ID, bumpingversionandupdatedAtwhen any field changed. Events that vanish from a source are not deleted — they are markedstatus = 'delisted'so a source outage cannot silently empty the calendar. - Client fetches the feed, merges the completion set from
localStorageby event ID, and renders. Merge is a client-side join; the server never learns what the user completed.
Concurrency and failure
- One in-flight run per source, enforced by a lock row with a stale-lock timeout of 15 minutes.
- A source that fails keeps its previously published events. A failed run never deletes or blanks data — worst case, the game's lane goes stale and gets a warning badge (F7).
- Three consecutive failures for one source raises its
healthtofailingin/api/health. It does not stop the schedule; a wiki being down for a day is normal. - Raw snapshots are cached by content hash, so a parser change is always evaluated offline against stored pages rather than by re-fetching.
Deployment
Single process, single SQLite file, no external services at all.
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 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.
Shipping a new version to an open page
A cache-first shell is what makes the offline story work and what makes a deploy invisible: the reader this app is built for leaves the tab open for days, so a new game, a repaired parser or a corrected date reaches their device and then sits there unused. Presenting an old app as current is the same failure as presenting old events as current, so it is disclosed the same way.
build:static ──► sw.js stamped with a hash of the built shell
│
browser byte-compares sw.js on navigation, hourly, and when the tab is
revealed (registration.update)
│
bytes differ ──► new worker installs, precaches, and WAITS
│
registration.waiting ≠ null and a controller exists
│
UpdateNotice: "A new version of Event Clock is ready." [Reload] [×]
│
Reload ──► postMessage {type:"skip-waiting"} ──► worker activates
──► controllerchange ──► location.reload()
Four properties this depends on, each of them load-bearing:
- The worker never calls
skipWaiting()on install. Claiming an open page unasked leaves the running bundle and the cached shell on two different builds, with nothing on screen saying so. It activates only on the message the reader's tap sends. A first install has no worker to wait for and activates immediately regardless — and is deliberately not announced, since nothing is being replaced. - The build id is derived, not remembered.
scripts/build-static.tshashes the built shell (index.html,main.js,styles.css,sw.jssource) and substitutes it for__BUILD__in the worker, so any shell change alters the worker's bytes and is therefore offered. The predecessor was a hand-bumpedCACHE_VERSION, which had already been forgotten once. The substitution throws if the placeholder is gone, because the failure mode is silent. - The feed is not part of the id. It is rewritten twice a day and served network-first, so new events reach an open page without a reload. Calling that a new version would teach readers to dismiss the notice unread.
- The cache name does not move with the build. Everything in it is refetched (
cache: "reload", since none of these URLs are fingerprinted) on install, so a per-build bucket would buy nothing and would discard the stored feed — the copy an offline reader is reading.
src/client/state/useAppUpdate.ts holds the client half; sw.js and the hook cannot import each
other, so test/update.test.tsx pins both ends of the skip-waiting handshake and the placeholder.
Deliberate non-choices
- No ORM.
bun:sqliteplus hand-written SQL inqueries.ts. The schema is six tables. - No Redis / job queue. The scheduler is a timer and a lock row. Restarting the process resumes cleanly because state is in SQLite.
- No server-side rendering. The feed is small and cacheable; a static SPA is enough.
- No websockets. Events change on a scale of hours; a 5-minute cache is more than adequate.