Files
gacha-event-tracker/docs/DATA-MODEL.md
T
Lucas WintherandClaude Opus 5.5 5297b8ead9 feat(prefs): show events that haven't started to new readers
A new reader should see what's coming before deciding they don't want
it. It's set at first-run setup rather than in the defaults, so an
existing install whose saved prefs predate the field keeps its answer
instead of having it switched on underneath it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:42:05 +02:00

43 KiB
Raw Blame History

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

import { z } from "zod";

export const GameId = z.enum([
  "genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999",
  "ba", "fgo", "holodori", "gfl2", "stellasora", "czn", "uma", "nikke", "hi3",
]);

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", "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.

startPrecision / endPrecision, and what 00:00Z means. A source that prints a calendar date and no time of day gets "day" precision, and the instant stored alongside it is that date at 00:00Z. That timestamp is a placeholder for "somewhere in this day", not a claim that the day begins at UTC midnight — the parser has declined to invent a time, exactly as it declines to invent a date. Nothing downstream may read it as an instant: clockFor in src/shared/time.ts resolves a day-precision boundary to the reset that opens that game-day on the reader's server (dayStartMs), which is the same clock daily.ts keys every tick by, and the countdown and the detail sheet both run off that. Read literally instead, the stored value expires an event up to nine hours early — the whole of Asia and Europe — which is how a Wuthering Waves event dated "August 19" was called over three hours before the game ended it.

Two boundaries are exempt, for the same reason in both directions. A regionEnds value is taken verbatim: that map only exists because a source published a timer per server, so it is already the instant, and re-anchoring it would throw a stated fact away. And an event the reader typed in (extractionMethod: "manual") is taken verbatim too: readerInstant resolved it to the instant they meant, in their own timezone, when they entered it.

confidence is assigned by the parser and adjusted during merge and reconciliation — see docs/INGESTION.md § Scoring. It records how firmly the sources pinned the event down.

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

-- 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,      -- 'parser' | 'manual'
  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,   -- '<game>-<site>-<page>', e.g. 'genshin-game8-events'
  game             TEXT NOT NULL,
  url              TEXT NOT NULL,
  parser_id        TEXT NOT NULL,      -- parser template id, e.g. 'game8'
  priority         INTEGER NOT NULL DEFAULT 0,
  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
);

-- Cached raw snapshots so re-parsing 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.

"gacha-tracker:v1:progress"     // { [eventId]: { status?, effort?, daily?, note?, goal?, at } }
"gacha-tracker:v1:daily"        // { [id]: { days: ["2026-08-15", ...], at } }
"gacha-tracker:v1:ignored"      // { [eventId]: { at } }  — "stop showing me this"
"gacha-tracker:v1:prefs"        // { region, hiddenGames[], knownGames[]?, gameOrder[]?,
                                //   focusGame, sort, view,
                                //   timelineDayWidth, timelineGroup, showUpcoming,
                                //   timelineSplitUpcoming, detectDaily, showChores,
                                //   showCompleted, showIgnored, theme, regionConfirmed,
                                //   onboarded, routineGames?, routinesAsked? }
                                // routineGames is the games whose built-in weekly tasks
                                // the reader turned on; routinesAsked the games already
                                // asked about. Both absent means none — built-ins are
                                // opt-in for every install. PRD F17.
                                // changelogSeen is the newest changelog entry id the
                                // reader dismissed. Absent means unrecorded: first-run
                                // setup sets it, so absent is an install from before the
                                // changelog, shown the last 14 days once. PRD F18.
                                // timelineDayWidth is px per day on the board, stored as the
                                // measurement rather than a step number and read through
                                // snapDayWidth — so a value from an older ladder still opens
                                // on something renderable.
                                // timelineGroup is "game" (a lane each) or "ending" (every game
                                // in one deadline queue). Defaults to "game", so shipping it
                                // moved nobody's board; see PRD F1.
                                // showUpcoming shows events that have not started yet, in
                                // BOTH views — the board plots them, the checklist keeps its
                                // "Not started yet" section. Defaults to false, but
                                // first-run setup (firstRunPrefs) sets it true, so new
                                // readers start with it on and older installs keep their
                                // answer. Set from settings alongside showCompleted /
                                // showIgnored, which is the same question. See PRD F1.
                                // It was named timelineUpcoming while it governed the board
                                // alone. `adoptRenamed` in usePrefs.ts carries a stored old
                                // value across on load — nothing is lost by dropping it, since
                                // this is one blob under one key rather than a key space, but
                                // resetting a reader's answer for them is not nothing either.
                                // A stored new name always wins, so it cannot overwrite a
                                // fresher answer with a stale one.
                                // timelineSplitUpcoming keeps those events in their own block
                                // under a "Not started yet" heading (true, the default) or puts
                                // them in one deadline order with the running ones (false).
                                // The board only: the checklist splits them into a section of
                                // its own either way. Read only when showUpcoming is on, but
                                // stored regardless, so switching that back on restores the
                                // answer given. PRD F1.
                                // knownGames is every lane the reader has been offered. Absent
                                // means unrecorded, not "offered nothing" — see PRD F8; a lane
                                // missing from it is new to them and arrives switched off.
                                // gameOrder is the order the reader put their games in, and it
                                // governs every surface that lists a game: the focus bar, the
                                // dailies strip, the settings list and the timeline's lanes.
                                // Absent means they have never placed one — NOT an empty order
                                // — and `orderGames` then sorts alphabetically by name, which
                                // is what the first-run picker does. The same distinction
                                // knownGames draws, for the same reason: every install
                                // predating the field is in that state. A lane it does not
                                // name trails the ones it does, so a game we add later never
                                // lands in the middle of a hand-made order, and a lane that
                                // has left the feed keeps its slot rather than being pruned —
                                // a source that comes back returns where the reader put it.
                                // Reset writes the field away rather than storing []. PRD F16.
                                // theme is "dark" | "light" | "system", defaulting to dark — see
                                // PRD F15. It is read by the app *and* by a pre-paint script in
                                // index.html, which is the only thing outside the client bundle
                                // that touches a key in this space.
"gacha-tracker:v1:completions"  // SUPERSEDED — read once to migrate, never written

progress is everything the reader says about an event themselves:

Field Values Meaning
status "doing" | "done" | absent Where they are with it
effort "quick" | "short" | "long" | "grind" | absent How much work they reckon it is
note free text Anything worth remembering
daily true | false | absent Whether it repeats daily, overruling detection
goal { period: "week", measure: "days", required: 1–7, weekStartsOn: 1–7 } | { period: "event", measure: "days", required } | absent A check-in goal: N distinct game-days per week or across the event (PRD F12)

An entry with none of these set is deleted rather than kept, so the store stays a set of things the reader actually said something about. isEmpty in useProgress.ts must name every field: one left out is a record marked only with that field, silently dropped on the next write.

goal is read where it is used and never stripped. It is stored typed unknown and read through readGoal (src/shared/goal.ts), which returns undefined for anything that does not parse. Nothing removes an unreadable goal from the record — the likeliest one is a newer shape (weekly tasks add measure: "amount") met by an older cached bundle, and dropping it on load would delete it on the next write. A goal makes the event a daily regardless of daily and detection; setting one clears a stored daily: false, and answering "this isn't a daily event" clears the goal, so the two never contradict in storage. No other control removes a goal — not "it repeats daily", and not pressing Off when no goal is readable — because either could be standing on one this build cannot see. The "not daily" control says it removes the goal whenever anything is stored there, readable or not.

effort is load-bearing, not decorative. Combined with the time remaining it answers "can I still finish this?" — the same two days is comfortable for a quick event and hopeless for a grind. See src/shared/effort.ts; the runway heuristic assumes about an hour of play a day, is stated as a guess in the UI, and never hides or reorders anything.

An event with no recorded effort never gets a warning. Inferring an estimate in order to warn about it would be fabricating the reader's own input.

Ignores stay in a separate store because they mean something different: a done event is dimmed and still counted, an ignored one disappears from both views.

Daily checklists

Some events are not one job with a deadline but twenty small jobs on twenty separate deadlines, and a missed day is gone whatever you do afterwards. daily records which game-days the reader ticked off, keyed by:

  • an event ID, for a repeating event in the feed (src/shared/daily.ts § isDaily), or
  • dailies:<game>, the standing per-game chore — commissions, sanity, daily training. No source publishes these, so they are a fixed client-side list, never feed data. The two-segment shape cannot collide with an event ID, which is always game:slug:date.

Day keys are YYYY-MM-DD in game-day space, not UTC: gacha servers roll the day at 04:00 local server time, so a player finishing at 02:00 is still on the previous day's dailies, and the key is computed against the reader's chosen region (RESET_HOUR_LOCAL, dayKey). Keys sort lexicographically, which is what "how many days are left" and streak counting rely on.

A day already gone is still writable, and catchUpDays decides how far back. People play at midnight and tick at breakfast; a log that only accepts today goes wrong on its first bad evening. dailyDays already answers this for an event whose end was published, because the whole run is known — the two cases it cannot answer are a dailies:<game> chore, which has no start or end because it is a routine, and an event with endsAt: null, where it returns null. catchUpDays answers both with the last CATCH_UP_DAYS (14) game-days, clipped at an event's start where there is one and never extending past today: a tick claims you did it, and nobody can have done tomorrow.

That window bounds display and never storage. A tick older than it stays in daily, keeps counting toward streakOf and dailySummary, and is simply not drawn — the same rule as a tick outside the window the feed now claims. Nothing prunes the log against either window, because a fortnight's streak exists on that device and nowhere else.

A check-in goal counts days in this same log; it stores no days of its own. goalStatus recomputes from daily on every read, so a backfill, a removed tick or a week rolling over needs no bookkeeping and clears nothing. Weeks are cut in day-key space by periodOf (src/shared/period.ts): the seven keys from the chosen weekday, so a week rolls exactly where the game's day does, per region and per reset override. A day key later than today never counts.

Not every game has a server per region. GameMeta.resetOffsets records the regions where a game's server clock differs from REGION_RESET_UTC_OFFSET. Endfield is the case this exists for: it has two server groups rather than three, and Europe is served off the Americas machine on a fixed UTC-5, so a European player's reset is 09:00 UTC — six hours after the HoYoverse/Kuro pattern. Every day-key function takes an optional game for this reason, and anything reading or writing a tick must pass it: a write under one clock and a read under another puts the tick on a day the reader cannot see.

Game Reset (server local) Server offset Reset (UTC) Copenhagen, summer / winter
Genshin, Star Rail, ZZZ, Wuwa, Honkai Impact 3rd 04:00 region (EU = UTC+1) 03:00 05:00 / 04:00
Fate/Grand Order 04:00 region (see below — the real one shifts) 03:00 05:00 / 04:00
Infinity Nikki, Asia / America / Europe 04:00 UTC+8 / UTC-7 / UTC+2 20:00 / 11:00 / 02:00 04:00 / 03:00 in Europe
Neverness to Everness, Asia / America / Europe 05:00 UTC+8 / UTC-4 / UTC+1 21:00 / 09:00 / 04:00 06:00 / 05:00 in Europe
Arknights, all regions 04:00 UTC-7 (one Global server) 11:00 13:00 / 12:00
Endfield, Europe 04:00 UTC-5 (on the Americas server) 09:00 11:00 / 10:00
Endfield, Asia / Americas 04:00 regional default 20:00 / 09:00 —
Reverse: 1999, America / Europe 05:00 UTC-5 (Global server) 10:00 12:00 / 11:00
Reverse: 1999, Asia 05:00 UTC+8 (Asia server) 21:00 —
Girls' Frontline 2, Asia 05:00 UTC+8 (Haoplay Asia) 21:00 —
Girls' Frontline 2, America / Europe 21:00 UTC-8 (Haoplay Global) 05:00 07:00 / 06:00
hololive Dreams, Nikke, all regions 05:00 UTC+9 (one worldwide server) 20:00 22:00 / 21:00
Blue Archive, all regions 04:00 UTC+9 (one worldwide server) 19:00 21:00 / 20:00
Umamusume, all regions 15:00 UTC (midnight JST, on the source's UTC calendar) 15:00 17:00 / 16:00
Chaos Zero Nightmare, all regions 18:00 UTC (one worldwide server) 18:00 20:00 / 19:00
P5X, all regions 20:00 UTC (the shared EN/JP server) 20:00 22:00 / 21:00
Punishing: Gray Raven, all regions 05:00 UTC (Global server) 05:00 07:00 / 06:00
Stella Sora, all regions 13:00 UTC-7, fixed (one worldwide server) 20:00 22:00 / 21:00

Where these come from. Arknights, Reverse: 1999's Global server and Nikke were read off their own sources; Endfield's off its server map. Everything else in the table — and the hours that corrected hololive Dreams (05:00, where one mission's 3:59AM JST end had been read as a 04:00 reset) — comes from Game-Time-Master, which tracks each game's servers with a zone and a reset hour apiece and is credited in the colophon. Until 2026-10-13 Infinity Nikki, P5X, Blue Archive, Chaos Zero Nightmare, Umamusume, Girls' Frontline 2, Stella Sora, Punishing: Gray Raven and Neverness to Everness took the regional default as an assumption no source had checked, and every one of them was wrong for at least one region. Infinity Nikki's was the report that found it: its American readers' day rolled two hours early. Correcting them re-labelled ticks readers had already logged, which an override that arrives with the game never does — so check Game-Time-Master when a game is added, not after it has readers.

Arknights' offset is read off the source rather than assumed — every ending event on arknights.wiki.gg carries an exact end of 10:59:59Z, which is 03:59:59 at UTC-7, one second before a 04:00 reset. Nikke's is read the same way, and earns an hour too: every schedule column on its wiki is headed Start(UTC+9) / End(UTC+9), and a story event ends at 04:59:59 while the pickup banner replacing it starts at 05:00:00. hololive Dreams' zone is the source's ((JST) on every boundary); its hour is Game-Time-Master's.

An override covering all three regions is not the blanket per-game offset this section warns about below: each of those games genuinely runs one worldwide server for every region we model. Where a game runs more than one — Infinity Nikki, Neverness to Everness, Reverse: 1999, Girls' Frontline 2, Endfield — only the regions that differ are listed.

The offset is chosen so the day key names the source's calendar day. A day-precision boundary resolves to the reset that opens that day key (§ above), so the same instant written in two zones is not the same entry. Umamusume resets at midnight JST, but its source heads its dates Availability (UTC); stored as UTC+9 at 00:00, a bare end date of the 14th would resolve to 15:00 UTC on the 13th — a day early. Stored as UTC at 15:00, it resolves to the 14th. Where the source prints no zone at all, the zone Game-Time-Master states is taken as written.

Note also that nikke and nikki are one letter apart and are different games — Goddess of Victory: Nikke and Infinity Nikki. Both are the first segment of every completion key their game will ever have, so a typo in either direction is silent data loss for real readers.

A game that rolls on a different hour needs its own field rather than a bent offset. Reverse: 1999's Global server runs UTC-5 and resets at 05:00, so its day rolls at 10:00 UTC. resetOffsets alone cannot say that: landing 10:00 UTC through the offset would mean claiming a UTC-6 server, which is a lie every other reader of serverOffsetUtc would inherit. So GameMeta.resetHourLocal overrides RESET_HOUR_LOCAL per region, like resetOffsets — Girls' Frontline 2's Asia server rolls at 05:00 and its Global server at 21:00, so one number per game would lie about one of them — and is absent, meaning 04:00, for everything else. That is why introducing it moved no existing day key.

These server offsets are fixed and do not observe DST, so the reader's local reset time moves by an hour across the European clock change while the UTC instant stays put.

The override is deliberately per region, not per game. A blanket per-game offset is the wrong shape: it drags the regions that do have their own server onto somebody else's clock, moving day keys for readers who never had the bug. List only the regions that actually differ.

Changing a value in that table is a data change, not a constant change: it re-labels the game-day some already-logged ticks fall in, for readers in that region. Two consequences to check before shipping one, both of which are invisible at runtime:

  • A tick logged inside the shifted window reads as the adjacent day, which can show as a one-day break in a streak. Recoverable — past days stay editable.
  • If the shift moves a window's boundary, a day can drop out of dailyDays entirely. A day that is not in dailyDays renders no pip, so a tick on it is unreachable: not deleted, but with no UI path back to it and nothing server-side to recover from. Check the real fixture windows for the affected game and region before changing an offset.

Three rules this store keeps, for the same reason the rest of the client does — nothing else holds a copy:

  • A tick is never removed except by the reader. Ticks that fall outside the window the feed now claims still count; a source quietly moving a date must not erase a fortnight's streak.
  • An unannounced end yields no checklist, not a checklist of guessed length. dailyDays returns null when endsAt is null, and the UI says how many days are ticked instead of how many are left.
  • Past days stay editable. People log in and tick up later, and a checklist that cannot be corrected stops being trusted after the first mistake.

Dailiness is derived from the published event — type: "login", or wording like "daily", "check-in", "7-day" in the title or summary — and never from a game's habits or an event's length. It adds no schema field, so nothing about the feed contract or the event ID changes.

The reader overrules detection, per event and globally. progress.daily records their answer for one event and wins outright (resolveDaily); absent means they have not said, so detection stands. prefs.detectDaily switches the guessing off altogether, leaving only events they marked themselves — it silences detection rather than deleting anything, so every mark and every logged day survives and switching it back on restores exactly what was there.

Detection is off by default and labelled experimental in the UI. Wording is a weak signal and it is wrong in both directions, so a new reader starts with only the standing dailies:<game> chores and whatever they mark themselves; opting in is one checkbox. The default applies to new readers only — a stored prefs keeps whatever value it has, because turning it off under a reader who has been ticking auto-detected checklists would pull those chips out of the strip with no explanation. An override that merely agrees with detection is not stored (dailyOverride) — freezing today's guess into their data would stop a later parser improvement from ever reaching that event. This is the only field in progress that changes what the app shows rather than recording what the reader did, which is why it lives beside their other notes rather than in the feed.

Reader-authored key spaces

PRD F13 lets a reader define their own game and enter their own events. Those records get their own ID spaces, and the reason is the one this document keeps making:

mygame:<slug>        a game the reader defined      "mygame:limbus-company"
myevent:<random>     an event the reader entered    "myevent:k3f9qa2m"
mytask:<random>      a weekly or monthly task       "mytask:p0q8n3x2ab"
routine:<game>:<slug>  a built-in weekly task      "routine:zzz:ridu-weekly"

A reader's event is never minted as ${game}:${slug}:${date}. That space is derived from source titles, and a reader can type a title identical to a scraped one — same game, same start date, same slug, same key. Two records under one key means one completion mark, one note and one streak silently belong to both, and the collision is invisible until someone notices their progress moving on its own. A random suffix makes that impossible rather than unlikely.

The randomness buys a second property worth keeping: renaming your own event does not move its ID. A feed event's ID follows its source title, so a wiki renaming an event mints a new ID and reconciliation has to recognise the near-match to preserve marks. A reader's event has no source to follow, so its ID is assigned once at creation and never derived from anything they can edit.

Five first segments are now reserved and none of them may ever become a GameId: dailies, mygame, myevent, mytask, routine. A test asserts this against GameId.options, because the day that stops being true is the day two key spaces merge silently.

"gacha-tracker:v1:customGames"   // { [id]: { id, name, hue, at } }
"gacha-tracker:v1:customEvents"  // { [id]: { id, game, title, type, summary,
                                 //           startsAt, startPrecision,
                                 //           endsAt, endPrecision, at, updatedAt } }
"gacha-tracker:v1:tasks"         // { [mytask id]: { id, game, title,
                                 //   period: { unit: "week", startsOn: 1–7 } | { unit: "month", startsOn: 1–31 },
                                 //   measure: { kind: "days", required } | { kind: "amount", target, unit? },
                                 //   from?, at, updatedAt } }
"gacha-tracker:v1:amounts"       // { [itemKey]: { [periodStartKey]: { value, at } } }

Tasks (PRD F17) are kept as stored and validated where they are shown. Unlike customEvents, whose load filter drops a record that fails its schema and so deletes it on the next write, the task store keeps every record and visibleTasks filters for display: the likeliest unreadable task is one a newer version wrote, and opening an older tab must not erase it. amounts is the same — readableAmounts skips a malformed entry when reading and leaves it on disk. A task's ticks live in daily under its id; its numbers live in amounts under its id and the start key of the period (periodOf, src/shared/period.ts), so a new week starts with no value and old weeks are never pruned. An event's points goal stores its numbers the same way, under the event id. Deleting a task removes the task record only.

Built-in tasks (src/shared/routines.ts) are not stored: they ship in the bundle, each with a source URL and the asOf date that page gives, and their ticks and numbers are stored under their routine: id exactly as a reader's task's are. Two prefs govern them: routineGames (games whose built-ins are on) and routinesAsked (games already asked), both absent meaning none so they are opt-in for every install. Make it mine adds a mytask: with from: <routine id> and copies the routine's day log and amounts to it — additively, so deleting the copy shows the original with its own numbers again.

Reader-authored events reuse progress, daily and ignored unchanged, keyed by their myevent: id — everything the reader can say about a scraped event they can say about their own, with no second set of stores to keep in sync.

Field rules mirror the feed's, because the reader deserves the same guarantees the parsers are held to:

Rule Why
endsAt null pairs with endPrecision: "unknown" The same invariant GachaEvent enforces. "I don't know when this ends" is a supported answer for a reader too, and a required one — otherwise entering an unannounced event forces them to invent a date
A date with no time is "day" precision, a date with one is "exact" So the UI's existing "accurate to the day only" note is honest about their input as well. Their day-precision boundary is still their instant, though — readerInstant puts a start at 00:00 and an end at 23:59:59 in their own timezone, so unlike a parser's placeholder it is never re-anchored to a server reset
endsAt must be after startsAt A backwards interval is a typo whoever made it
hue must match #rrggbb It reaches a style attribute, and an imported file is not necessarily one the reader wrote
regionScoped is always false They entered one instant, not a per-region map. Claiming otherwise would fabricate three timestamps out of one

There is deliberately no dailyTasks for a custom game, so it contributes no standing dailies:<game> chore. That list is the routine every player of a tracked game recognises; asking a reader to invent one is a form to fill in for a reminder they already have. Their events can still be marked as repeating, per event, like any other.

Occurrence ids

A reader's event may carry a repeat rule. The rule is what is stored; its occurrences are derived on read and never written.

myevent:<token>              the rule, as stored
myevent:<token>#YYYY-MM-DD   one occurrence, derived

The suffix is the occurrence's own start day, read in the reader's local timezone — the same reading readerInstant wrote it with and fields() shows back. # is outside [a-z0-9] and therefore outside CustomEventId, so an occurrence id cannot be written into customEvents and cannot survive an import; validRecords drops it. That is the guardrail, and test/recurrence.test.ts pins it in both directions.

Marks, ignores, progress and daily ticks key off the whole string, so each occurrence carries its own completion and its own streak.

Rescheduling a rule strands its marks. Moving the anchor start or the interval re-keys every occurrence; the marks under the old ids stay in localStorage and stop being reachable. This is not migrated, for the reason removeEvent does not cascade and useMarkSet.merge never removes — nothing else holds a copy, so a silent rewrite is unrecoverable. The edit form counts what will be stranded and says so first. Renaming a rule costs nothing: the token is random.

Deleting. Removing your own event leaves any marks and logged days behind rather than reaching into three other stores on a single tap; they are inert, and the alternative is a misclick that deletes a streak. Removing a game that still has events is refused and says how many, rather than cascading.

Migration from completions

completions used membership to mean "done", which cannot express "started". progress replaces it and is seeded from it once, on first load, mapping each entry to status: "done".

The old key is never written to and never deleted. Someone who last opened the app six months ago still has their marks under it, these live only in the browser, and nothing else holds a copy to restore from. Exports produced before the change are still accepted on import and mapped forward the same way.

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 old key.

Export format (PRD F6)

{
  "format": "gacha-tracker-export",
  "version": 1,
  "exportedAt": "2026-08-14T12:00:00.000Z",
  "progress": {
    "genshin:windblume-festival:2026-03-14": { "status": "done", "at": "..." },
    "hsr:garden-of-plenty:2026-08-14": { "status": "doing", "effort": "grind", "at": "..." }
  },
  "daily": {
    "endfield:bedazzling-dawnstar:2026-08-12": { "days": ["2026-08-13", "2026-08-14"], "at": "..." },
    "dailies:genshin": { "days": ["2026-08-14", "2026-08-15"], "at": "..." }
  },
  "ignored": { "zzz:some-event-i-skip:2026-08-19": { "at": "..." } },
  "customGames": {
    "mygame:limbus-company": { "id": "mygame:limbus-company", "name": "Limbus Company",
                               "hue": "#c74b50", "at": "..." }
  },
  "customEvents": {
    "myevent:k3f9qa2m": { "id": "myevent:k3f9qa2m", "game": "mygame:limbus-company",
                          "title": "Walpurgisnacht", "type": "banner", "summary": null,
                          "startsAt": "2026-08-20T00:00:00.000Z", "startPrecision": "day",
                          "endsAt": "2026-09-03T00:00:00.000Z", "endPrecision": "day",
                          "at": "...", "updatedAt": "..." }
  },
  "prefs": { "region": "europe", "hiddenGames": [], "sort": "ending", "onboarded": true }
}

customGames and customEvents are additive keys, so a v1 export written before F13 simply lacks them — not an error, just a file from a device that had none. They merge by id like everything else, and because an event and the game it belongs to travel in the same file, an import never lands an event whose lane is missing.

Import merges every set: a mark present in either the file or the current device survives, and import never removes one. daily merges as a union of days per ID — every day either side recorded is a day the reader actually played.

Which copy wins a conflict differs by store, and the difference is the shape of the data. For ignored (and the legacy completions) the earlier mark wins: at records when the reader made it, membership in the set is the whole fact, and the oldest timestamp is the truest answer to when they said it. For progress the later record wins, because there the record is the data — a status, an effort, a note, whether it repeats — and at is when one of those last changed. Keeping the earlier copy there discards every edit made after it, in both directions an import happens in: restoring a backup taken before an evening's work would roll that evening back, and importing an old file onto a device with newer progress would roll the device back. Taking the maximum of the two timestamps keeps the merge order-independent and idempotent either way, and removes nothing. mergeProgress is pure and test/progress.test.ts pins it. Check-in goals ride progress, so they need no export key of their own and merge with the record they belong to. tasks and amounts are additive export keys like customGames: a file without them is from a device that had none. Tasks merge by id with the later updatedAt winning (mergeTasks); an incoming record is let in on its identity alone (a mytask: id and an updatedAt) and stored exactly as it came, so a newer version's task — or a field this build does not know — survives a trip through an older build, and an edit keeps such fields too; amounts merge per item and per period with the later at winning (mergeAmounts), so restoring an old backup cannot roll this week's number back, and a week only one side has survives. An export written before daily checklists existed simply has no daily key, which is not an error. 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.

A standard Export produces a progress backup containing progress, daily checklists, ignored events, and custom games/events without prefs, so that importing progress onto another device leaves that device's region, theme, and game order intact. Export all includes prefs alongside progress in the export JSON. Standard Import merges progress and custom content while ignoring prefs. Import all merges progress and custom content and also restores all preferences (region, hiddenGames, gameOrder, theme, view, etc.) via restorePrefsValue, falling back to defaults for any fields omitted by older exports — designed specifically for moving hosts.

Schema versioning

/api/events responses carry { schemaVersion: 1, generatedAt, events: [...], sources: [...] }. 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.

sources is a SourceHealth per registered source (src/shared/feed.ts), and it is what the footer's freshness line and CI's build gate both read. Three of its fields describe one source's last document and only make sense together: eventCount is what it contributed after expired events were dropped, parsedCount is what the same bytes yield when parsed as of their own capture date, and statesNoEvents says the page itself declared it currently lists none. That triple is what separates a parser that has stopped reading a redesigned page (a fault) from one whose events have all since ended, and from a game between versions (neither) — see docs/INGESTION.md § Stage 1.

Every field added here is defaulted, and that is a rule rather than a habit. The service worker serves the last feed it downloaded, so a required field fails validation on every cached feed and takes the offline promise down with it. A default must also be the strict reading of silence: parsedCount defaults to null — an older feed never recorded the figure, which is an absence of information and not evidence of health — and statesNoEvents to false, because a feed built before the field existed never asked the page and cannot be read as having got an answer.

Two timestamp fields distinguish crawler activity from site updates: lastConfirmedAt records when the crawler last successfully contacted and verified the source (HTTP 200 or 304 Not Modified), and contentChangedAt records when the served bytes last changed. Both default to null so feeds built before their introduction continue to validate.