From 5964f4b2fa088ea996838a998a817f763750442b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:28:17 +0200 Subject: [PATCH] feat: build a static event feed from fixtures Emits exactly the shape GET /api/events.json will serve, so the client can be developed against real parsed data before the server and database land. Reads fixtures only, never the network. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 ++ scripts/build-feed.ts | 90 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 scripts/build-feed.ts diff --git a/.gitignore b/.gitignore index 72cfbad..4871830 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ dist # runtime data data/ + +# build output +public/ diff --git a/scripts/build-feed.ts b/scripts/build-feed.ts new file mode 100644 index 0000000..90d1036 --- /dev/null +++ b/scripts/build-feed.ts @@ -0,0 +1,90 @@ +/** + * Build the static event feed from checked-in fixtures. + * + * Offline: this reads fixtures, never the network. It exists so the client can + * be developed and demoed against real parsed data before the server and + * database land, and it emits exactly the shape `GET /api/events.json` will. + * + * bun run build:feed + */ +import { ADAPTERS } from "../src/ingest/adapters/index.ts"; +import { mergeEvents } from "../src/ingest/merge.ts"; +import { EventFeed, SCHEMA_VERSION, type SourceHealth } from "../src/shared/feed.ts"; +import type { GachaEvent, GameId } from "../src/shared/schema.ts"; + +const OUT = "public/data/events.v1.json"; + +/** Newest fixture per adapter. */ +async function latestFixture(adapterId: string, game: GameId) { + const glob = new Bun.Glob(`fixtures/${game}/*.html`); + const files = [...glob.scanSync(".")].sort(); + const file = files.at(-1); + if (file === undefined) { + throw new Error(`no fixture found for ${adapterId} (fixtures/${game}/*.html)`); + } + return { file, html: await Bun.file(file).text() }; +} + +const now = new Date().toISOString(); +const byGame = new Map(); +const sources: SourceHealth[] = []; + +for (const adapter of ADAPTERS) { + const { file, html } = await latestFixture(adapter.id, adapter.game); + const events = adapter.parse(html, { + now, + sourceUrl: adapter.url, + sourceId: adapter.id, + game: adapter.game, + }); + + const groups = byGame.get(adapter.game) ?? []; + groups.push(events); + byGame.set(adapter.game, groups); + + sources.push({ + sourceId: adapter.id, + game: adapter.game, + url: adapter.url, + // Fixture capture date stands in for a real fetch timestamp until the + // scheduler exists. The UI's staleness badge reads this, so it must not + // claim to be fresher than the data actually is. + lastSuccessAt: fixtureDate(file), + eventCount: events.length, + }); + + console.log(` ${adapter.id.padEnd(24)} ${String(events.length).padStart(3)} events ← ${file}`); +} + +const events: GachaEvent[] = []; +let conflictCount = 0; +for (const [, groups] of byGame) { + const merged = mergeEvents(groups); + events.push(...merged.events); + conflictCount += merged.conflicts.length; + for (const c of merged.conflicts) { + console.warn( + ` ! conflict: "${c.kept.title}" ${c.field} differs by ${c.deltaHours}h between sources`, + ); + } +} + +events.sort((a, b) => a.startsAt.localeCompare(b.startsAt) || a.id.localeCompare(b.id)); + +const feed = EventFeed.parse({ + schemaVersion: SCHEMA_VERSION, + generatedAt: now, + events, + sources, +}); + +await Bun.write(OUT, `${JSON.stringify(feed, null, 2)}\n`); +console.log( + `\n${OUT}: ${events.length} events across ${byGame.size} games, ${conflictCount} conflicts`, +); + +/** "game8-events-2026-08-14.html" → ISO timestamp. */ +function fixtureDate(path: string): string | null { + const m = /(\d{4}-\d{2}-\d{2})\.html$/.exec(path); + return m?.[1] ? `${m[1]}T00:00:00.000Z` : null; +}