855 lines
56 KiB
Markdown
855 lines
56 KiB
Markdown
# 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", "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(),
|
||
|
||
// Other sources' copies merge folded into this one; the client moves marks
|
||
// off them. Optional — see § ID stability.
|
||
mergedIds: z.array(z.string()).optional(),
|
||
});
|
||
|
||
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.
|
||
|
||
**A parser fix can move IDs too, and gets the same treatment.** `src/client/state/retiredIds.ts`
|
||
holds an explicit table of IDs the feed stopped publishing and what each became — first used on
|
||
2026-10-14, when Game8's Genshin parser stopped keeping `Battle Pass -` in front of a pass's name
|
||
(the label had put every pass on the calendar twice beside Fandom's bare name). On every load and
|
||
every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one.
|
||
|
||
**A merge that starts fusing two published IDs retires one of them too — and the feed says so.**
|
||
`GachaEvent.mergedIds` lists every other source's copy merge folded into an event. When a feed
|
||
arrives, `aliasesFrom` turns those into the same kind of move, skipping any id the feed still
|
||
publishes as its own row (a pair split again keeps each row's marks), so no table is edited by hand.
|
||
`RETIRED_IDS` stays only for parser changes, whose old ids no feed will ever name again. The field is
|
||
optional: an older cached feed lacks it and validates, and an older bundle's non-strict parse drops
|
||
it unread. Both paths follow the same rules:
|
||
|
||
- **A move inside the same storage key, not a copy, and not a deleted key.** The mark survives under
|
||
the ID the feed now uses. A copy left under the old ID would be carried forward again on each load
|
||
and bring back a mark the reader had since cleared.
|
||
- **When both IDs hold an entry**, each store's import merge decides — days are unioned, the earlier
|
||
ignore stands — except that progress keeps the later record's fields *and* any field only the other
|
||
held, because both rows were the reader's own.
|
||
- **An unreadable entry is left where it is.** These run in `useState` initialisers, where a throw is
|
||
a blank screen.
|
||
- **A table, never a pattern**, and kept until at least 2027-10-14 — for `RETIRED_IDS`; the feed's
|
||
table is rebuilt from every feed.
|
||
- **A wrong fusion takes the tick with it, and nothing undoes that.** A move keeps no record of
|
||
where a mark began, so if merge ever fuses two different events, the absorbed row's tick lands on
|
||
the survivor and stays there if a later rule splits them again (reviewed 2026-10-14, accepted as
|
||
the price of not resurrecting cleared marks). The only defence is that merge rules stay strict —
|
||
which is why dates alone never pair two events (`docs/INGESTION.md` § Stage 3). An import is
|
||
carried through the same two tables on its way in.
|
||
|
||
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, -- '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.
|
||
|
||
## Legacy client-side storage (v1 compatibility)
|
||
|
||
Production uses the [active v2 reader envelope](#active-reader-state-v2-2026-10-16). The keys and
|
||
merge rules in this section describe migration input and retained compatibility helpers.
|
||
|
||
Namespaced, versioned, and small. Nothing here ever goes to the server.
|
||
|
||
```ts
|
||
"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,
|
||
// choresOn?, choresOff?,
|
||
// showWeeklyTasks, showMonthlyTasks, showCycleTasks, showCompleted, showIgnored, theme, regionConfirmed,
|
||
// onboarded, showRoutines?, routineGames?, routinesOff?,
|
||
// routinesAsked?, routinesHidden?, standardPulls? }
|
||
// Chores and built-in weekly tasks are each one global
|
||
// answer plus the games that differ (src/shared/perGame.ts):
|
||
// an on-list wins, then an off-list, then the global answer.
|
||
// showChores is the chores' global answer (default true);
|
||
// choresOn / choresOff the exceptions. showRoutines is the
|
||
// built-ins' — absent means never answered, read as off,
|
||
// and while absent the strip may ask about one game.
|
||
// routineGames is the games whose built-ins are on (the
|
||
// on-list, unchanged from before showRoutines existed),
|
||
// routinesOff the off-list, routinesAsked the games already
|
||
// asked about. routinesHidden is routine ids stopped one by
|
||
// one: it narrows the game's answer and never widens it.
|
||
// standardPulls says whether a shop's standard-banner
|
||
// pulls are offered beside its premium ones; absent is
|
||
// false, premium only.
|
||
// Every list absent means none — built-ins are opt-in for
|
||
// every install. PRD F17.
|
||
// showWeeklyTasks / showMonthlyTasks / showCycleTasks say
|
||
// whether tasks of that period sit in the strip's row under
|
||
// the dailies. All default true, so an install from before
|
||
// them keeps its tasks on screen (an absent showCycleTasks
|
||
// reads as true); off hides and never deletes.
|
||
// 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.
|
||
A task's other periods are cut the same way: a month from its chosen day, and a **cycle** of N days
|
||
as `from + k·N` for the whole number `k` that puts today inside it — before `from` as well as after,
|
||
so `from` may be any day a cycle began. A built-in may name a server `clock` (code only, never
|
||
stored): its day keys are then cut on that server's reset for every reader, for a cycle that ends at
|
||
one instant everywhere (NTE's Beyond the Rails, on Asia's).
|
||
|
||
A built-in may also **reset with its game's version** (`{ unit: "version" }`, 2026-10-22). That
|
||
period is code only — it is in `RoutinePeriodSchema`, never `PeriodSchema`, so no stored task, export
|
||
or older bundle holds one — and it is cut on instants, not day keys: `versionWindow`
|
||
(`src/shared/versions.ts`) finds the version holding `now` in the hand-kept `VERSIONS` table, whose
|
||
`end` is the moment the next version's stock appears on every server. Only a recorded `end` rolls the
|
||
period; past the newest one the window is the version that began there, open until its end is
|
||
recorded, and `VERSION_DAYS` gives an estimate that is printed and never acted on. Its numbers sit in
|
||
`amounts` under the version's start date in UTC (`2026-09-30` for WuWa 3.7), a key that adding the
|
||
next entry never moves. It groups, tallies and switches with the cycles (`cadenceOf`).
|
||
|
||
**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+2** | 21:00 / 09:00 / 03:00 | 05:00 / 04: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](https://github.com/cicerakes/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.
|
||
|
||
**The game outranks Game-Time-Master when the two disagree.** Neverness to Everness's Europe server
|
||
is UTC+2, not the UTC+1 Game-Time-Master lists: on 2026-10-16 the owner read two in-game timers that
|
||
both ran out at 03:00 UTC — one on 26 Oct, after Europe's clocks change, which rules out a server
|
||
following daylight saving — and Realm of Greed's own text, "resets every Monday at 5:00". Moving it
|
||
re-labelled the one hour 03:00–04:00 UTC of every European reader's NTE day.
|
||
|
||
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.
|
||
|
||
```ts
|
||
"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 }
|
||
// | { unit: "cycle", days: 2–366, from: "YYYY-MM-DD" },
|
||
// 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. **A cycle task is the newest shape** (2026-10-16): a build from before
|
||
it fails `TaskSchema` on one, skips it on display and keeps it on disk, so it reappears when the
|
||
bundle updates; its numbers sit under the cycle's start key like any period's.
|
||
|
||
**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. Six prefs govern them: `showRoutines` (the answer
|
||
for every game; **absent means never answered**, read as off), `routineGames` and `routinesOff`
|
||
(games that differ from it, on and off), `routinesAsked` (games the strip already asked about), and
|
||
`routinesHidden` (single built-ins the reader stopped tracking, by id), and `standardPulls` (whether a
|
||
shop's standard-banner line, `pulls: "standard"`, is offered at all; absent is no). A built-in is shown
|
||
when its game is on, it is offered **and** it is not hidden, so hiding one never changes the game's answer and a built-in
|
||
shipped later still arrives. Tracking one of a game that is off turns the game on with that one
|
||
alone — its siblings join `routinesHidden` (`trackRoutinePatch`, `usePrefs.ts`).
|
||
The lists are **absent meaning none**, so built-ins are opt-in for every install. `routineGames`
|
||
predates `showRoutines` and kept its meaning when it became the on-list — nothing was migrated, and
|
||
a tab still running an older shell reads it the same way. *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)
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
## Active reader state (v2, 2026-10-16)
|
||
|
||
The localStorage/export descriptions above document legacy v1 compatibility. Production mounts
|
||
only `ReaderApp` and persists `gacha-tracker:v2:reader`, validated by state/reader/schema.ts.
|
||
One stable `account:<id>` represents one tracked game/server; it is browser-local, not a login.
|
||
Original event/task/daily IDs remain unchanged inside account data. Account data contains progress,
|
||
ignored, and daily/amount maps nested by clock revision. A revision freezes effectiveAt, region,
|
||
reset override and automaticBase (offset/hour/minute). Changes append revisions; imported clocks
|
||
can be historicalOnly and never replace the local active reset.
|
||
|
||
The envelope includes accounts, data, archive, seenEvents, prefs, customGames/customEvents/tasks,
|
||
codeSeen (per game), codeRedeemed (per account), and eventSeen (per account). Archive/seen keys are
|
||
JSON.stringify([accountId,eventId]); archive snapshots retain event, outcome/time, resolved end,
|
||
clockRevision, progress, days and amounts. Account/revision cross-references are validated before
|
||
restore. Field timestamps and operation IDs rebase writes and preserve deletion tombstones. Unknown
|
||
fields pass through exports; progress imports retain opaque root/account extensions in
|
||
importedExtensions as well as mergeable known fields. Existing same-server settings remain local.
|
||
|
||
Migration preserves raw v1 keys in place and in migration recovery, creates one account per game at
|
||
the saved region and maps old follows/pins/logs. Newly offered games remain off. Startup compares
|
||
legacyRaw with surviving v1 keys to carry older-tab deltas into the original account without
|
||
resurrecting newer v2 deletions. Startup (finishing a pending restore, then migrating or reconciling)
|
||
runs under the same reader lock as every save (`state/reader/open.ts`), so another tab's save
|
||
cannot land between its read and its write. Parser aliases carry progress, ticks, amounts and archive/seen IDs.
|
||
|
||
Keys outside the envelope:
|
||
|
||
- gacha-tracker:v2:migration-recovery — raw pre-migration bytes.
|
||
- gacha-tracker:v2:backups — at most 7 daily + 4 weekly + 6 monthly UTC bucket snapshots, and at
|
||
most `AUTO_BACKUP_BYTES` (1.5M UTF-16 units) together: past it the oldest are dropped, never the
|
||
newest. Back up now copies (`v2:manual-backups`) have their own 20-copy, 1.5M cap.
|
||
- gacha-tracker:v2:restore-recovery — the complete pre-restore export.
|
||
- gacha-tracker:v2:restore-pending — prepared replacement, resumed before mounting on startup.
|
||
|
||
Exports use format gacha-tracker-export, version 2, exportedAt, reader and legacy. Progress exports
|
||
mark scope progress; full imports preview replacement, progress imports preview a merge. Backups
|
||
are never recursively included. Original v1 export envelopes remain convertible.
|
||
|
||
Prefs add interface (structured/default or relaxed), density (standard/default or compact), scheme
|
||
(slate/warm/plum/custom), separate light/dark customPalette, per-game gameColors, timezone
|
||
(null/Automatic or IANA), checklist daily placement/expansion and redesignTourSeen. Main view remains
|
||
soon or timeline; utility navigation is transient. Future/unreadable records remain exportable even
|
||
when this build cannot offer editing controls.
|
||
|
||
`prefs.redesignTourSeen` (2026-10-18) records where the reader is with the tour. Its values are
|
||
`"2026-10-tour-2"` (done), `"new"` or `"returning"` (the tour is forced; the value picks the opening
|
||
line), and `"replay"` (optional). Any other value, including the earlier tour's `"2026-10-redesign"`,
|
||
means the reader has not taken this tour: the app moves them to `interface: "relaxed"` and stores
|
||
`"returning"`. A later tour version changes the done value, so every reader takes it once more. See
|
||
`tourMode` in `ReturningTour.tsx`.
|
||
|
||
|
||
`prefs.celebrations` (2026-10-18) is the Settings → Appearance switch for fireworks. Absent or
|
||
`true` means on; only `false` turns every celebration off, big and small. See `celebrationsOn` in
|
||
`Fireworks.tsx`.
|
||
|
||
`prefs.shine` (2026-10-20) is its neighbour for the sweep across a code just redeemed or an event
|
||
just completed: absent or `true` is on, only `false` is off (`shineOn` in `Fireworks.tsx`).
|
||
`prefs.reduceMotion` (2026-10-20) is Appearance → Motion & celebrations → Reduce motion: only `true`
|
||
reduces motion, and it adds to the device's `prefers-reduced-motion` rather than replacing it
|
||
(`motionReduced` in `state/appearance.ts`). Neither changes an ID or a key; an older build ignores
|
||
both.
|
||
|
||
The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`)
|
||
for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the
|
||
interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved.
|