Initial agent auxilliary files.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# 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 → clean → parse|extract → 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
|
||||
```
|
||||
|
||||
Anthropic API calls happen only inside the ingest pipeline. **No request path — not `/api/events`,
|
||||
not a page load — ever calls the model.** If a feature seems to need live inference, it needs a
|
||||
precomputed field instead.
|
||||
|
||||
## 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
|
||||
pipeline.ts the 7 stages, orchestration only
|
||||
clean.ts HTML → text reduction before extraction
|
||||
extract.ts Anthropic client, prompts, batch submission
|
||||
validate.ts zod parse + calendar sanity rules
|
||||
reconcile.ts diff vs published, confidence, conflict detection
|
||||
adapters/
|
||||
index.ts registry: GameId → Adapter
|
||||
genshin.ts
|
||||
hsr.ts
|
||||
zzz.ts
|
||||
wuwa.ts
|
||||
arknights.ts
|
||||
endfield.ts
|
||||
shared/
|
||||
schema.ts zod schemas — the contract, imported by both sides
|
||||
types.ts z.infer types only
|
||||
time.ts region reset math, duration formatting
|
||||
client/
|
||||
main.tsx
|
||||
App.tsx
|
||||
views/
|
||||
Timeline.tsx F1
|
||||
EndingSoon.tsx F2
|
||||
EventDetail.tsx
|
||||
state/
|
||||
completions.ts localStorage read/write + export/import
|
||||
prefs.ts region, filters
|
||||
api.ts typed fetch of /api/events
|
||||
fixtures/<game>/ checked-in raw HTML + expected parse output
|
||||
docs/
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Extraction results are written to `extraction_log` with the input hash, so a prompt change can be
|
||||
evaluated against previously-seen inputs without re-fetching or re-paying.
|
||||
|
||||
## Deployment
|
||||
|
||||
Single process, single SQLite file, no external services beyond the Anthropic API.
|
||||
|
||||
```
|
||||
PORT=3000
|
||||
ADMIN_PORT=3001 # bound to 127.0.0.1
|
||||
DATABASE_PATH=./data/events.sqlite
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
INGEST_INTERVAL_MS=21600000
|
||||
INGEST_ENABLED=true # false for local UI work — never hits the network or the API
|
||||
EXTRACTION_MODE=batch # batch | sync
|
||||
CONFIDENCE_THRESHOLD=0.8
|
||||
```
|
||||
|
||||
`INGEST_ENABLED=false` is the default for local development. Frontend work should run against a
|
||||
seeded SQLite file and cost nothing.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Data Model
|
||||
|
||||
`src/shared/schema.ts` is the single source of truth. TypeScript types are derived with
|
||||
`z.infer<>` — never hand-write an interface that duplicates a schema.
|
||||
|
||||
## The Event
|
||||
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
|
||||
export const GameId = z.enum([
|
||||
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield",
|
||||
]);
|
||||
|
||||
export const EventType = z.enum([
|
||||
"banner", // limited character/weapon rate-up
|
||||
"story", // main or side story chapter, limited-time
|
||||
"rerun", // returning event
|
||||
"challenge", // combat/endgame cycle (Abyss, Memory of Chaos, ...)
|
||||
"login", // login rewards / check-in
|
||||
"shop", // limited shop or exchange window
|
||||
"maintenance", // server downtime
|
||||
"other",
|
||||
]);
|
||||
|
||||
export const Region = z.enum(["asia", "america", "europe"]);
|
||||
|
||||
/** How much we actually know about a boundary timestamp. */
|
||||
export const Precision = z.enum([
|
||||
"exact", // sourced to the minute
|
||||
"day", // date known, time-of-day inferred from the game's reset
|
||||
"unknown", // genuinely not announced — endsAt is null
|
||||
]);
|
||||
|
||||
export const GachaEvent = z.object({
|
||||
id: z.string(), // `${game}:${slug}:${YYYY-MM-DD}` — see Stability below
|
||||
game: GameId,
|
||||
title: z.string().min(1).max(200),
|
||||
type: EventType,
|
||||
summary: z.string().max(500).nullable(),
|
||||
|
||||
startsAt: z.string().datetime(), // UTC ISO 8601, always
|
||||
startPrecision: Precision,
|
||||
endsAt: z.string().datetime().nullable(),
|
||||
endPrecision: Precision,
|
||||
|
||||
/** True when the end time follows each region's daily reset rather than a global instant. */
|
||||
regionScoped: z.boolean(),
|
||||
/** Populated only when regionScoped; per-region resolved UTC instants. */
|
||||
regionEnds: z.record(Region, z.string().datetime()).nullable(),
|
||||
|
||||
sourceUrl: z.string().url(),
|
||||
sourceId: z.string(), // which adapter/source produced this
|
||||
|
||||
status: z.enum(["published", "delisted"]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
extractionMethod: z.enum(["parser", "llm", "manual"]),
|
||||
|
||||
version: z.number().int().positive(),
|
||||
firstSeenAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type GachaEvent = z.infer<typeof GachaEvent>;
|
||||
```
|
||||
|
||||
### Field notes that matter
|
||||
|
||||
**`endsAt: null` is a first-class state, not an error.** Many events are announced with "duration
|
||||
TBD" or "until the next version update". The correct representation is `endsAt: null` with
|
||||
`endPrecision: "unknown"`. The extractor is instructed to produce this and the UI renders it
|
||||
distinctly (PRD F1). Filling in a plausible date instead is the single worst bug this codebase can
|
||||
ship.
|
||||
|
||||
**`regionScoped` + `regionEnds`.** Character banners end at one global instant — `regionScoped:
|
||||
false`, `regionEnds: null`. Story and login events end at each region's daily reset — `regionScoped:
|
||||
true`, with `regionEnds` carrying the three resolved UTC instants. The client picks one using the
|
||||
user's stored region (PRD F5). Collapsing these into a single timestamp loses up to 13 hours of
|
||||
accuracy and will make the countdown wrong for two thirds of users.
|
||||
|
||||
**`confidence`** is assigned during reconciliation, not by the model's self-report. See
|
||||
`docs/LLM-EXTRACTION.md` § Scoring — a model asserting "I am 0.95 confident" is not evidence.
|
||||
|
||||
**`status: "delisted"`** means the event stopped appearing at its source. It is never deleted,
|
||||
because a source outage would otherwise silently empty the calendar. Delisted events are excluded
|
||||
from the API feed but retained for debugging and for the case where a source flickers.
|
||||
|
||||
### ID stability — read before changing
|
||||
|
||||
```
|
||||
`${game}:${slugify(title)}:${startsAt.slice(0, 10)}`
|
||||
→ "genshin:windblume-festival:2026-03-14"
|
||||
```
|
||||
|
||||
**Event IDs are the localStorage keys for completion state.** Changing the scheme orphans every
|
||||
completion mark every user has ever made, silently, with no error and no way to recover it
|
||||
server-side (the server never had the data). If the scheme must change, ship a client-side
|
||||
migration that reads the old keys and remaps them, and keep that migration for at least a year.
|
||||
|
||||
The date suffix disambiguates reruns of the same event. Title is slugified from the *source's*
|
||||
title, so a wiki renaming an event creates a new ID — reconciliation detects this as a near-match
|
||||
(same game, overlapping dates, high title similarity) and treats it as an update rather than a new
|
||||
event, preserving the original ID.
|
||||
|
||||
## SQLite schema
|
||||
|
||||
```sql
|
||||
-- Published feed. One row per event.
|
||||
CREATE TABLE events (
|
||||
id TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
starts_at TEXT NOT NULL,
|
||||
start_precision TEXT NOT NULL,
|
||||
ends_at TEXT,
|
||||
end_precision TEXT NOT NULL,
|
||||
region_scoped INTEGER NOT NULL DEFAULT 0,
|
||||
region_ends TEXT, -- JSON object or NULL
|
||||
source_url TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
confidence REAL NOT NULL,
|
||||
extraction_method TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_events_ends ON events (ends_at) WHERE status = 'published';
|
||||
CREATE INDEX idx_events_game ON events (game, starts_at);
|
||||
CREATE INDEX idx_events_window ON events (starts_at, ends_at);
|
||||
|
||||
-- Candidates held back by the review gate. Same shape plus why.
|
||||
CREATE TABLE events_quarantine (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL, -- full GachaEvent JSON
|
||||
reason TEXT NOT NULL, -- 'low_confidence' | 'date_conflict' | 'sanity_failed' | 'novel_shape'
|
||||
detail TEXT NOT NULL, -- human-readable explanation for the reviewer
|
||||
conflicts_with TEXT, -- events.id, when reason = 'date_conflict'
|
||||
run_id TEXT NOT NULL REFERENCES ingest_runs(id),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
resolution TEXT -- 'approved' | 'rejected' | NULL
|
||||
);
|
||||
CREATE INDEX idx_quarantine_open ON events_quarantine (created_at) WHERE resolved_at IS NULL;
|
||||
|
||||
-- One row per configured source.
|
||||
CREATE TABLE sources (
|
||||
id TEXT PRIMARY KEY, -- 'genshin-wiki-events'
|
||||
game TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
strategy TEXT NOT NULL, -- 'parser' | 'llm' | 'parser_then_llm'
|
||||
min_interval_ms INTEGER NOT NULL DEFAULT 21600000,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
content_hash TEXT, -- sha256 of cleaned content; the skip check
|
||||
last_success_at TEXT,
|
||||
last_attempt_at TEXT,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
health TEXT NOT NULL DEFAULT 'ok', -- 'ok' | 'degraded' | 'failing'
|
||||
lock_holder TEXT,
|
||||
lock_expires_at TEXT
|
||||
);
|
||||
|
||||
-- One row per pipeline execution. The audit trail.
|
||||
CREATE TABLE ingest_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES sources(id),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
outcome TEXT, -- 'published' | 'skipped_unchanged' | 'quarantined' | 'failed'
|
||||
stage_failed TEXT,
|
||||
error TEXT,
|
||||
events_seen INTEGER DEFAULT 0,
|
||||
events_changed INTEGER DEFAULT 0,
|
||||
events_held INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- One row per LLM call. Enables replaying prompt changes against past inputs.
|
||||
CREATE TABLE extraction_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL REFERENCES ingest_runs(id),
|
||||
source_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
input_hash TEXT NOT NULL, -- of the cleaned text sent to the model
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
stop_reason TEXT,
|
||||
refusal_category TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_extraction_input ON extraction_log (input_hash);
|
||||
|
||||
-- Cached raw + cleaned snapshots so re-extraction never re-fetches.
|
||||
CREATE TABLE snapshots (
|
||||
content_hash TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL,
|
||||
raw BLOB NOT NULL,
|
||||
cleaned TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
`region_ends` and `payload` hold JSON as TEXT; parse them through the Zod schema on read so a
|
||||
malformed row surfaces at the boundary rather than deep in the UI.
|
||||
|
||||
## Client-side storage
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
old key.
|
||||
|
||||
### Export format (PRD F6)
|
||||
|
||||
```json
|
||||
{
|
||||
"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": [] }
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Schema versioning
|
||||
|
||||
`/api/events` responses carry `{ schemaVersion: 1, generatedAt, events: [...] }`. The client
|
||||
refuses to render a `schemaVersion` it does not know and shows a "refresh the page" prompt instead
|
||||
of guessing at unfamiliar fields. Additive fields do not bump the version; removing or retyping a
|
||||
field does.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Ingestion Pipeline
|
||||
|
||||
Seven stages, run per source. Every stage writes its outcome to `ingest_runs` so a failure two days
|
||||
ago can be diagnosed without re-running or re-paying.
|
||||
|
||||
```
|
||||
fetch → clean → parse|extract → validate → reconcile → gate → publish
|
||||
│
|
||||
└──► quarantine
|
||||
```
|
||||
|
||||
## The adapter contract
|
||||
|
||||
An adapter is the only per-game code. Everything downstream of `parse` is shared.
|
||||
|
||||
```ts
|
||||
export interface Adapter {
|
||||
id: string; // 'genshin-wiki-events'
|
||||
game: GameId;
|
||||
url: string;
|
||||
strategy: "parser" | "llm" | "parser_then_llm";
|
||||
minIntervalMs?: number; // default 6h
|
||||
|
||||
/** Narrow the cleaned document to just the region containing event data. */
|
||||
select?(cleaned: string): string;
|
||||
|
||||
/**
|
||||
* Deterministic parse. Return null to fall through to LLM extraction
|
||||
* (only meaningful when strategy is 'parser_then_llm').
|
||||
* Pure over its input — no network, no clock, no randomness. This is what
|
||||
* makes fixture tests possible.
|
||||
*/
|
||||
parse?(cleaned: string, ctx: ParseContext): RawEvent[] | null;
|
||||
|
||||
/** Extra instructions appended to the shared extraction prompt. */
|
||||
extractionHints?: string;
|
||||
|
||||
/** Game-specific normalization: reset times, region offsets, patch cadence. */
|
||||
normalize(raw: RawEvent, ctx: ParseContext): GachaEvent;
|
||||
}
|
||||
|
||||
export interface ParseContext {
|
||||
now: string; // injected, never Date.now() — keeps parse pure and testable
|
||||
sourceUrl: string;
|
||||
sourceId: string;
|
||||
game: GameId;
|
||||
}
|
||||
```
|
||||
|
||||
**`parse` must not read the clock.** It takes `now` from `ctx`. This is what lets a fixture test
|
||||
assert exact output for a page captured last March.
|
||||
|
||||
### Choosing a strategy
|
||||
|
||||
| Source shape | Strategy |
|
||||
|---|---|
|
||||
| JSON API, or a stable HTML table with consistent headers | `parser` |
|
||||
| Free-form patch notes, announcement prose, inconsistent markup | `llm` |
|
||||
| Mostly-stable markup that occasionally changes | `parser_then_llm` |
|
||||
|
||||
Prefer `parser`. It is free, deterministic, and instantly testable. The LLM exists for sources that
|
||||
genuinely cannot be parsed reliably, not as the default. A source with a clean API that goes through
|
||||
the model is a bug.
|
||||
|
||||
## Stage 1 — fetch
|
||||
|
||||
- Send `If-None-Match` / `If-Modified-Since` from `sources.etag` / `last_modified`. A `304` ends
|
||||
the run as `skipped_unchanged` with zero further cost.
|
||||
- `User-Agent: gacha-event-tracker/1.0 (+https://github.com/<owner>/gacha-event-tracker)`.
|
||||
- Honor `robots.txt`. Cache the parsed robots per host for 24h.
|
||||
- 20s timeout; retry twice with exponential backoff on 5xx and network errors; never retry 4xx.
|
||||
- Store the raw bytes in `snapshots`.
|
||||
|
||||
On failure: increment `consecutive_failures`, leave published events untouched, end the run as
|
||||
`failed`. A source being down never mutates the feed.
|
||||
|
||||
## Stage 2 — clean
|
||||
|
||||
Reduce the document before it costs anything. This stage is the second-biggest cost lever after the
|
||||
content-hash skip.
|
||||
|
||||
- Drop `<script>`, `<style>`, `<svg>`, `<noscript>`, comments, nav, header, footer, and known
|
||||
wiki chrome (edit links, category boxes, reference lists).
|
||||
- Collapse whitespace; convert tables to pipe-delimited text; keep headings as `#` markers so
|
||||
section structure survives.
|
||||
- Apply `adapter.select()` if present to isolate the event region.
|
||||
- Hash the result (sha256) → `content_hash`.
|
||||
|
||||
**If `content_hash` matches `sources.content_hash`, end the run as `skipped_unchanged`.** This is
|
||||
the check that keeps a 6-hourly schedule from costing anything on a quiet week — most runs should
|
||||
end here.
|
||||
|
||||
A typical wiki page goes from ~15k tokens raw to ~5k cleaned. Verify with `messages.count_tokens`
|
||||
when tuning, not by guessing.
|
||||
|
||||
## Stage 3 — parse or extract
|
||||
|
||||
Per strategy. `parse` produces `RawEvent[]` directly. `extract` sends the cleaned text to
|
||||
`claude-opus-5` with a structured-output schema — see `docs/LLM-EXTRACTION.md` for the request
|
||||
shape, prompt, and cost rules.
|
||||
|
||||
`parser_then_llm` calls `parse` first and falls through to `extract` only when it returns `null`.
|
||||
When that fallthrough happens, log it loudly: it means the source changed shape and the parser needs
|
||||
updating. A `parser_then_llm` source that is silently always falling through is paying LLM prices
|
||||
for a parser that no longer works.
|
||||
|
||||
## Stage 4 — validate
|
||||
|
||||
Zod parse against `GachaEvent`, then calendar sanity rules. Anything failing a hard rule goes to
|
||||
quarantine with `reason: 'sanity_failed'` — never to the feed.
|
||||
|
||||
**Hard rules (reject):**
|
||||
|
||||
| Rule | Rationale |
|
||||
|---|---|
|
||||
| `endsAt > startsAt` when both present | A backwards interval is always a parse error |
|
||||
| Duration ≤ 180 days | Patch cycles are ~6 weeks; 180d means a year was misread as a range |
|
||||
| `startsAt` within [now − 2y, now + 1y] | Catches century typos and relative-date misreads |
|
||||
| `endsAt` null ⟺ `endPrecision === "unknown"` | The two fields must agree |
|
||||
| `regionEnds` non-null ⟺ `regionScoped` | Same |
|
||||
| All `regionEnds` values within 24h of each other | Region resets differ by hours, not days |
|
||||
| `title` non-empty, ≤ 200 chars, not a placeholder ("TBD", "Event", "Unknown") | Catches header rows scraped as events |
|
||||
|
||||
**Soft rules (reduce confidence, do not reject):**
|
||||
|
||||
- Duration under 1 hour or over 60 days → −0.2
|
||||
- `startPrecision` or `endPrecision` is `"day"` → −0.1
|
||||
- Title very similar to another event in the same batch → −0.15 (likely a duplicate row)
|
||||
|
||||
## Stage 5 — reconcile
|
||||
|
||||
Diff the validated candidates against currently published events for this source.
|
||||
|
||||
1. **Exact ID match** → compare fields. Unchanged: no-op. Changed: candidate update.
|
||||
2. **Near match** — same game, date windows overlap, title similarity ≥ 0.85 — → treat as an update
|
||||
to the existing event, **keeping the existing ID**. This is what survives a wiki renaming an
|
||||
event without orphaning every user's completion mark.
|
||||
3. **No match** → new event.
|
||||
4. **Published event absent from this run's candidates** → mark `status = 'delisted'`. Do not
|
||||
delete.
|
||||
|
||||
**Conflict detection.** A candidate that changes an already-published `endsAt` by more than 24 hours
|
||||
is a `date_conflict`. This is the case worth being paranoid about: the user may have already planned
|
||||
around the old date, and a silent shift is exactly the failure the product exists to prevent. Route
|
||||
it to quarantine regardless of confidence.
|
||||
|
||||
### Scoring
|
||||
|
||||
Confidence is computed here, from evidence — **not** taken from the model's self-report. A model
|
||||
saying "confidence: 0.95" is a token prediction, not a measurement.
|
||||
|
||||
```
|
||||
base parser → 0.95 llm → 0.70
|
||||
+0.15 the same event was extracted identically from a previous run
|
||||
+0.10 both timestamps have precision "exact"
|
||||
+0.10 a second source for the same game corroborates within 1 hour
|
||||
−0.20 any soft rule fired
|
||||
−0.30 this is a date_conflict against a published event
|
||||
```
|
||||
|
||||
Clamp to [0, 1]. `CONFIDENCE_THRESHOLD` (default 0.8) is the gate.
|
||||
|
||||
## Stage 6 — gate
|
||||
|
||||
| Condition | Destination |
|
||||
|---|---|
|
||||
| `confidence >= CONFIDENCE_THRESHOLD` and no conflict | publish |
|
||||
| `confidence < CONFIDENCE_THRESHOLD` | quarantine — `low_confidence` |
|
||||
| `date_conflict` | quarantine — `date_conflict`, with `conflicts_with` set |
|
||||
| failed a hard rule | quarantine — `sanity_failed` |
|
||||
| new event type or field the schema does not recognize | quarantine — `novel_shape` |
|
||||
|
||||
A quarantined event does not block its siblings. If eight events in a run pass and two are held, the
|
||||
eight publish.
|
||||
|
||||
## Stage 7 — publish
|
||||
|
||||
Upsert by ID inside a transaction. Bump `version` and `updatedAt` only when a field actually
|
||||
changed — an unchanged run must not churn `updatedAt`, or the freshness badge (PRD F7) becomes
|
||||
meaningless. Update `sources.content_hash`, `etag`, `last_success_at`, and reset
|
||||
`consecutive_failures`.
|
||||
|
||||
## The review gate
|
||||
|
||||
Quarantined events surface at `GET /review` on the admin listener (`127.0.0.1:ADMIN_PORT`). See
|
||||
`docs/ARCHITECTURE.md` § Why `/review` needs no auth — the routes are simply not registered on the
|
||||
public listener.
|
||||
|
||||
The review UI shows, per held event: the parsed fields, the reason and detail, the conflicting
|
||||
published event side-by-side when applicable, a link to the source, and the exact cleaned text
|
||||
excerpt the extraction came from. A reviewer needs to answer "is this date right?" without leaving
|
||||
the page.
|
||||
|
||||
- `POST /api/review/:id/approve` — writes to `events` with `extraction_method: 'manual'` and
|
||||
`confidence: 1.0`, and stamps `resolved_at` / `resolution`.
|
||||
- `POST /api/review/:id/reject` — stamps resolution only. The event is not published, and the same
|
||||
candidate will be re-held on the next run if the source has not changed.
|
||||
|
||||
Approving with edits is supported: the reviewer can correct a date before approving. That corrected
|
||||
value is the one that publishes.
|
||||
|
||||
**Quarantine depth is the health signal for the whole pipeline.** A growing queue means a source
|
||||
changed shape or the prompt regressed. `/api/health` exposes the count; watch it.
|
||||
|
||||
## Testing
|
||||
|
||||
Every adapter ships:
|
||||
|
||||
1. `fixtures/<game>/<source>-<YYYY-MM-DD>.html` — a real captured page.
|
||||
2. `fixtures/<game>/<source>-<YYYY-MM-DD>.expected.json` — the exact `GachaEvent[]` it should
|
||||
produce.
|
||||
3. A test running `parse` + `normalize` against the fixture with a pinned `ctx.now`, asserting
|
||||
deep equality.
|
||||
|
||||
`bun test` must pass with no network access. When a source changes shape, capture a new fixture
|
||||
alongside the old one and keep both — the old fixture is the regression test proving the parser
|
||||
still handles the previous format.
|
||||
|
||||
Validator rules get their own unit tests with deliberately broken inputs: backwards intervals,
|
||||
1000-year durations, `endsAt` set with `endPrecision: "unknown"`. These rules are the last line of
|
||||
defense before a wrong date reaches a user; test them like it.
|
||||
@@ -0,0 +1,291 @@
|
||||
# LLM Extraction
|
||||
|
||||
Stage 3 of the pipeline, for sources whose markup is too unstable to parse deterministically. Read
|
||||
`docs/INGESTION.md` first for where this sits.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **The model runs at ingestion time only.** No request path calls Anthropic. A page load must
|
||||
never trigger inference.
|
||||
2. **Deterministic parsers win.** If a source has a JSON API or a stable table, it gets a parser,
|
||||
not a prompt.
|
||||
3. **The model's job is transcription, not judgment.** It converts prose and tables into structured
|
||||
dates. It does not decide what is important, does not infer missing dates, and does not resolve
|
||||
contradictions — it reports them.
|
||||
4. **Confidence is computed from evidence in `reconcile`, not asserted by the model.** The output
|
||||
schema has no confidence field. A model claiming 0.95 confidence has predicted a token, not
|
||||
measured anything.
|
||||
|
||||
## Model and parameters
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---|---|---|
|
||||
| `model` | `claude-opus-5` | Exact, complete ID — never append a date suffix |
|
||||
| `max_tokens` | `16000` | Non-streaming; keeps the request under SDK HTTP timeouts |
|
||||
| `output_config.effort` | `"medium"` | Transcription, not reasoning. Sweep low/medium/high against fixtures before settling |
|
||||
| `output_config.format` | `zodOutputFormat(ExtractionResult)` | Schema-conformant output, validated by the SDK |
|
||||
| `thinking` | *omit* | On by default on `claude-opus-5`; the default is correct here |
|
||||
| `temperature` / `top_p` / `top_k` | **never set** | Removed on `claude-opus-5` — sending any of them returns 400 |
|
||||
|
||||
`thinking: {type: "enabled", budget_tokens: N}` is also removed and returns 400. If you want less
|
||||
thinking, lower `effort`.
|
||||
|
||||
Note that `max_tokens` caps thinking *and* output together. If extraction on a large page returns
|
||||
`stop_reason: "max_tokens"`, raise it rather than trimming the schema.
|
||||
|
||||
## The output schema
|
||||
|
||||
The model returns a list of candidate events plus explicit uncertainty. Note what is **absent**:
|
||||
no confidence score, no "importance" ranking, no summary of the page.
|
||||
|
||||
```ts
|
||||
// src/ingest/extract.ts
|
||||
import { z } from "zod";
|
||||
|
||||
const ExtractedEvent = z.object({
|
||||
title: z.string().describe("The event name exactly as written in the source, not paraphrased."),
|
||||
type: z.enum(["banner","story","rerun","challenge","login","shop","maintenance","other"]),
|
||||
summary: z.string().nullable()
|
||||
.describe("One sentence from the source describing the event. Null if the source gives none."),
|
||||
|
||||
startsAt: z.string().nullable()
|
||||
.describe("ISO 8601 UTC. Null only if the source truly does not state a start."),
|
||||
startPrecision: z.enum(["exact","day","unknown"])
|
||||
.describe("'exact' if a time of day is stated; 'day' if only a date; 'unknown' if neither."),
|
||||
|
||||
endsAt: z.string().nullable()
|
||||
.describe("ISO 8601 UTC. Null when the source says TBD, 'until further notice', or gives no end."),
|
||||
endPrecision: z.enum(["exact","day","unknown"]),
|
||||
|
||||
regionScoped: z.boolean()
|
||||
.describe("True if the end follows each server region's daily reset rather than one global instant."),
|
||||
|
||||
sourceTimezone: z.string().nullable()
|
||||
.describe("The timezone the source stated, e.g. 'UTC+8', 'server local'. Null if unstated."),
|
||||
evidence: z.string()
|
||||
.describe("The verbatim span from the input that gave you the dates. Must appear in the input."),
|
||||
});
|
||||
|
||||
const ExtractionResult = z.object({
|
||||
events: z.array(ExtractedEvent),
|
||||
ambiguities: z.array(z.object({
|
||||
title: z.string(),
|
||||
issue: z.string().describe("What is unclear or contradictory in the source."),
|
||||
})).describe("Events you could not confidently transcribe. These are held for human review."),
|
||||
});
|
||||
```
|
||||
|
||||
`evidence` is the load-bearing field. It is checked in `validate`: if the quoted span does not
|
||||
appear in the input text, the event is quarantined as `sanity_failed`. That check is what turns a
|
||||
fabricated date into a caught error instead of a shipped one.
|
||||
|
||||
## The system prompt
|
||||
|
||||
Kept in `src/ingest/prompts/extract-events.v1.md`, versioned in the filename, and logged as
|
||||
`prompt_version` in `extraction_log` so a regression can be traced to a specific revision.
|
||||
|
||||
It must stay **above 512 tokens** — that is the minimum cacheable prefix on `claude-opus-5`. Below
|
||||
it, prompt caching silently stops working with no error. After any prompt edit, check
|
||||
`usage.cache_read_input_tokens` is non-zero on the second call.
|
||||
|
||||
```markdown
|
||||
You extract scheduled in-game events from gacha game source pages into structured data.
|
||||
|
||||
Your output is consumed by a calendar that players rely on to avoid missing limited-time content.
|
||||
A wrong end date is worse than a missing event: a missing event sends someone to a wiki, a wrong
|
||||
one makes them miss content permanently. Transcribe what the source says; never supply what it
|
||||
omits.
|
||||
|
||||
## What counts as an event
|
||||
|
||||
Anything with a start and a bounded or open-ended run: character and weapon banners, story
|
||||
chapters, side events, login campaigns, limited shops, combat cycles, announced maintenance.
|
||||
|
||||
Not events: permanent features, general game descriptions, patch version numbers on their own,
|
||||
speculation or leaks, community posts, and anything phrased as expected, rumored, or datamined.
|
||||
|
||||
## Dates
|
||||
|
||||
- Emit UTC ISO 8601 with an explicit `Z`.
|
||||
- When the source states a timezone (commonly UTC+8 for Chinese-developed titles), convert to UTC
|
||||
and record what it stated in `sourceTimezone`.
|
||||
- When only a date is given, set the timestamp to 00:00:00Z and `precision: "day"`. Do not guess a
|
||||
time of day.
|
||||
- When the source says the end is TBD, "until further notice", "with the next version update", or
|
||||
gives no end at all: `endsAt: null` and `endPrecision: "unknown"`. This is a correct, expected
|
||||
answer. Do not compute a plausible date from a typical patch length.
|
||||
- `regionScoped` is true when the end is tied to each server's daily reset, false when the source
|
||||
gives one simultaneous global instant. Character banners are usually global; story and login
|
||||
events are usually region-scoped. Use what the source says over this heuristic when it says
|
||||
anything.
|
||||
|
||||
## Evidence
|
||||
|
||||
For every event, `evidence` must be a verbatim span copied from the input that contains the dates
|
||||
you reported. It is checked against the input automatically. If you cannot quote a span, the event
|
||||
belongs in `ambiguities` instead.
|
||||
|
||||
## Ambiguities
|
||||
|
||||
Put an entry in `ambiguities` — not in `events` — when the source contradicts itself, gives dates
|
||||
you cannot reconcile, or describes something that may not be a scheduled event. A human reviews
|
||||
these. Reporting uncertainty is a successful outcome, not a failure; guessing to avoid it is the
|
||||
one thing that breaks this system.
|
||||
|
||||
## Scope
|
||||
|
||||
Report every qualifying event on the page and nothing else. Do not rank them, do not summarize the
|
||||
page, do not comment on your process, and do not add fields the schema does not have. If the page
|
||||
contains no events, return empty arrays.
|
||||
```
|
||||
|
||||
### Why the prompt reads the way it does
|
||||
|
||||
`claude-opus-5` follows instructions literally and verifies its own work without being told, so the
|
||||
prompt states scope and boundaries plainly instead of adding emphasis or self-check scaffolding.
|
||||
Specifically: **do not add "double-check your answer" or "verify before responding"** here. On this
|
||||
model that produces over-verification with no accuracy gain. If extraction quality drops, change the
|
||||
schema descriptions or `effort` — not the volume of the prompt.
|
||||
|
||||
## Request shape
|
||||
|
||||
```ts
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
||||
|
||||
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
|
||||
|
||||
const response = await client.messages.parse({
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 16000,
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: EXTRACTION_SYSTEM_PROMPT, // stable across every source — cached
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
],
|
||||
output_config: {
|
||||
effort: "medium",
|
||||
format: zodOutputFormat(ExtractionResult),
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
`Game: ${adapter.game}`,
|
||||
`Source: ${adapter.url}`,
|
||||
`Today (UTC): ${ctx.now}`,
|
||||
adapter.extractionHints ?? "",
|
||||
"",
|
||||
"--- SOURCE TEXT ---",
|
||||
cleanedText,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// parsed_output is null if the model refused or hit max_tokens — check before use.
|
||||
const result = response.parsed_output;
|
||||
```
|
||||
|
||||
**Ordering matters for caching.** The system prompt is byte-identical across every source, so it
|
||||
sits first and stays cached. Everything volatile — game, URL, `now`, the page text — goes in the
|
||||
user turn, after the cache breakpoint. Interpolating `ctx.now` into the system prompt would
|
||||
invalidate the cache on every single call; it is in the user turn for exactly that reason.
|
||||
|
||||
## Batch mode
|
||||
|
||||
`EXTRACTION_MODE=batch` is the default for scheduled runs: 50% cheaper, results typically within an
|
||||
hour, which is irrelevant against a 6-hour cadence. The synchronous path above is for
|
||||
`refresh --now` and for local development.
|
||||
|
||||
```ts
|
||||
const batch = await client.messages.batches.create({
|
||||
requests: sourcesToExtract.map((s) => ({
|
||||
custom_id: s.runId, // key results by this — order is not guaranteed
|
||||
params: { /* same body as above */ },
|
||||
})),
|
||||
});
|
||||
|
||||
// Poll batches.retrieve(batch.id) until processing_status === "ended",
|
||||
// then stream batches.results(batch.id) and key each result by custom_id.
|
||||
```
|
||||
|
||||
Results arrive in **any order**. Key by `custom_id`, never by array position. The batch job's
|
||||
poll loop lives in `scheduler.ts` and persists `batch.id` so a process restart resumes rather than
|
||||
resubmitting.
|
||||
|
||||
## Handling non-success responses
|
||||
|
||||
Check `stop_reason` before touching `parsed_output`:
|
||||
|
||||
| `stop_reason` | Meaning | Action |
|
||||
|---|---|---|
|
||||
| `end_turn` | Normal | Proceed |
|
||||
| `max_tokens` | Output truncated; `parsed_output` unusable | Raise `max_tokens`, or split the page by section. Fail the run — never publish a partial list |
|
||||
| `refusal` | Safety classifier declined | Log `stop_details.category` to `extraction_log.refusal_category`, fail the run, alert. Vanishingly unlikely for game wiki content — if it fires, the input is probably not what you think it is |
|
||||
|
||||
`stop_details` can be `null` even on a refusal, so branch on `stop_reason` and treat `stop_details`
|
||||
as informational.
|
||||
|
||||
**Do not write a JSON-repair or regex-extraction fallback.** Structured outputs guarantee schema
|
||||
conformance; if parsing fails, the response was truncated or refused, and both cases are handled
|
||||
above. A repair path would silently paper over truncation and publish half a calendar.
|
||||
|
||||
## Cost
|
||||
|
||||
At `claude-opus-5` rates — $5/MTok input, $25/MTok output.
|
||||
|
||||
One extraction of a cleaned wiki page:
|
||||
|
||||
```
|
||||
input ~5,000 tok × $5/MTok = $0.025
|
||||
output ~1,500 tok × $25/MTok = $0.0375
|
||||
───────
|
||||
sync $0.063
|
||||
batch $0.031 (50% off)
|
||||
```
|
||||
|
||||
Six sources, four runs a day:
|
||||
|
||||
| Scenario | Extractions/day | Cost/day | Cost/month |
|
||||
|---|---|---|---|
|
||||
| Realistic — ~20% of runs see changed content | ~5 | $0.16 | **~$4.70** |
|
||||
| Worst case — every source changes every run | 24 | $0.74 | ~$22 |
|
||||
| No content-hash skip, no batch | 24 | $1.51 | ~$45 |
|
||||
|
||||
The gap between rows one and three is the whole argument for the skip check and batch mode. Track
|
||||
actuals by summing `extraction_log` token columns — do not rely on these estimates once there is
|
||||
real data.
|
||||
|
||||
Prompt caching contributes modestly (the ~1k-token system prompt at 0.1× on reads) but is free to
|
||||
keep. Its real value is that it makes the prompt cheap to grow if extraction quality needs more
|
||||
instruction.
|
||||
|
||||
## Evaluating a prompt change
|
||||
|
||||
`extraction_log` stores `input_hash` for every call, and `snapshots` stores the cleaned text keyed
|
||||
by the same hash. So a prompt revision is evaluated by replaying past inputs — no re-fetching, no
|
||||
new scraping load:
|
||||
|
||||
1. Pull the last N distinct `input_hash` values with known-correct expected output.
|
||||
2. Run the new prompt against each cleaned snapshot.
|
||||
3. Diff against `fixtures/*/*.expected.json`.
|
||||
4. Compare on three axes: dates correct, events missed, events hallucinated. **Hallucinated events
|
||||
and wrong dates are disqualifying; a missed event is a regression to weigh.** That asymmetry is
|
||||
the product rule from `docs/PRD.md` restated as an eval criterion.
|
||||
|
||||
Bump the prompt filename version and record it as `prompt_version` so the log distinguishes
|
||||
"extraction got worse" from "the source changed".
|
||||
|
||||
## Things not to do
|
||||
|
||||
- Do not ask the model to output a confidence score. Confidence is computed in `reconcile`.
|
||||
- Do not ask the model to resolve a contradiction between two sources. Route it to quarantine.
|
||||
- Do not send raw HTML. Always clean first — it is a 3× cost difference and it improves accuracy.
|
||||
- Do not add a second model call to "verify" the first. That is a scaffolding pattern this model
|
||||
does not need, and it doubles cost for no measured gain. The evidence-span check and the
|
||||
validator rules are the verification layer.
|
||||
- Do not lower `max_tokens` to save money. Output tokens scale with the number of events found;
|
||||
truncation costs a whole run.
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# Gacha Event Tracker — Product Spec
|
||||
|
||||
## The problem
|
||||
|
||||
A player of three or four gacha games is tracking a dozen concurrent, overlapping, time-boxed
|
||||
events across as many different in-game calendars. The information exists — on wikis, in patch
|
||||
notes, in-game — but never in one place and never sorted by the thing that actually matters:
|
||||
**what expires next.** The failure mode is missing a limited event by a day.
|
||||
|
||||
## What this app is
|
||||
|
||||
A single-page web app that answers three questions:
|
||||
|
||||
1. What is running right now, across all my games?
|
||||
2. What ends soonest?
|
||||
3. Which of these have I already finished?
|
||||
|
||||
## What this app is not
|
||||
|
||||
- Not an account system. There is no login, no profile, no cloud sync.
|
||||
- Not a wiki. It does not explain how to complete an event, only that it exists and when it ends.
|
||||
- Not a notification service. No push, no email, no background alerts. (A browser-local reminder
|
||||
is a plausible v2; it is out of scope for v1.)
|
||||
- Not a damage calculator, build planner, or pull tracker.
|
||||
|
||||
## Users
|
||||
|
||||
One persona: a player of 2–5 gacha games who checks in a few times a week, most often on mobile.
|
||||
They care about accuracy of end dates above everything else — a wrong date is worse than a missing
|
||||
event, because a missing event sends them to a wiki while a wrong one makes them miss content.
|
||||
|
||||
## Scope — v1
|
||||
|
||||
### Games at launch
|
||||
|
||||
| Game | ID |
|
||||
|---|---|
|
||||
| Genshin Impact | `genshin` |
|
||||
| Honkai: Star Rail | `hsr` |
|
||||
| Zenless Zone Zero | `zzz` |
|
||||
| Wuthering Waves | `wuwa` |
|
||||
| Arknights | `arknights` |
|
||||
| Arknights: Endfield | `endfield` |
|
||||
|
||||
Adding a seventh game must require no schema change — only a new adapter. That is the test of
|
||||
whether the data model is right.
|
||||
|
||||
### Features
|
||||
|
||||
**F1 — Calendar view (default).**
|
||||
A horizontal timeline, one lane per game, spanning a scrollable date range with "today" pinned as a
|
||||
vertical marker. Each event is a bar from `startsAt` to `endsAt`. Bars are colored by game, and
|
||||
completed events render at reduced opacity with a check. Clicking a bar opens a detail panel with
|
||||
title, type, exact start/end in the user's local timezone, source link, and a completion toggle.
|
||||
|
||||
An event with `endsAt: null` renders as a bar with a frayed right edge and the label "end date
|
||||
unknown" — it must be visually distinct from an event that ends far in the future.
|
||||
|
||||
**F2 — Ends-soonest list.**
|
||||
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
||||
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
|
||||
view that justifies the app; it should be reachable in one tap from the calendar and is the better
|
||||
default on narrow screens.
|
||||
|
||||
**F3 — Mark completed.**
|
||||
A toggle on every event, in both views. State is written to `localStorage` immediately and
|
||||
optimistically — there is no server round trip and no failure case. Completed events stay visible
|
||||
but de-emphasized; a filter toggles them out entirely.
|
||||
|
||||
**F4 — Filters.**
|
||||
Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views.
|
||||
Preferences persist in `localStorage`.
|
||||
|
||||
**F5 — Region selection.**
|
||||
A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed
|
||||
end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a
|
||||
guess from the browser timezone, shown as a dismissible "showing America server times — change".
|
||||
|
||||
**F6 — Export / import.**
|
||||
Because there are no accounts, moving between devices is manual: download a JSON file of completed
|
||||
IDs and preferences, upload it elsewhere. Import merges rather than replaces, and never removes a
|
||||
completion the user already has.
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A user can identify their next expiring event within **5 seconds** of load, on mobile.
|
||||
- Published end dates are correct for **99%+** of events. This is a data-quality target, and it is
|
||||
what the review gate in `docs/INGESTION.md` exists to protect. Prefer publishing nothing to
|
||||
publishing a guess.
|
||||
- Adding a new game is an adapter plus a fixture plus a test — no schema migration, no client
|
||||
change.
|
||||
|
||||
## Quality bar for dates — the core product rule
|
||||
|
||||
The app's entire value is that the dates are right. Therefore:
|
||||
|
||||
- An event with an uncertain end date is published with `endsAt: null`, **not** with a plausible
|
||||
guess.
|
||||
- An event whose extraction confidence is below threshold is not published at all until a human
|
||||
approves it.
|
||||
- Every event links to its source so a skeptical user can verify in one click.
|
||||
|
||||
An empty calendar is a recoverable disappointment. A confidently wrong end date is the failure this
|
||||
product exists to prevent.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does the calendar need a month/grid view, or is the timeline enough? (Assumption: timeline is
|
||||
enough for v1; revisit after use.)
|
||||
- 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
|
||||
sub-hourly refresh buys nothing and costs API spend.)
|
||||
Reference in New Issue
Block a user