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]>
208 lines
11 KiB
Markdown
208 lines
11 KiB
Markdown
# 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 of repeating jobs
|
|
DailyChecklist one repeating event's whole run
|
|
Fireworks.tsx the burst when the last daily lands
|
|
Controls.tsx games, region, export/import(F4, F5, F6)
|
|
Welcome.tsx first-run game picker (F8)
|
|
Toast.tsx undo an ignore
|
|
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, onboarding flags
|
|
sort.ts deadline order, or what you're partway through
|
|
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
|
|
```
|
|
|
|
## 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
|
|
|
|
1. **Scheduler** wakes every 6h (± jitter). For each source not fetched within its `minIntervalMs`,
|
|
it acquires a per-source lock row and enqueues a run.
|
|
2. **Pipeline** executes the seven stages in `docs/INGESTION.md`. Every stage writes to
|
|
`ingest_runs` so a failure is diagnosable after the fact without re-running.
|
|
3. **Publish** upserts into `events` by stable ID, bumping `version` and `updatedAt` when any field
|
|
changed. Events that vanish from a source are *not* deleted — they are marked
|
|
`status = 'delisted'` so a source outage cannot silently empty the calendar.
|
|
4. **Client** fetches the feed, merges the completion set from `localStorage` by 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 `health` to `failing` in `/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.
|
|
|
|
## Deliberate non-choices
|
|
|
|
- **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.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.
|