The release thread's only feature request from the reader with the most games,
asked twice and asked for nothing else. No adapter roadmap reaches a ten-game
juggler, so this is the part of the product that serves readers we will never
scrape for.
Records the decision in the PRD as F13 rather than letting the code drift from a
spec that still lists user-submitted events as out of scope, and specifies the
key spaces in DATA-MODEL before any of it is built — a reader's event must never
be minted as ${game}:${slug}:${date}, because they can type a title identical to
a scraped one and the collision would silently share one completion mark, note
and streak between two events.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# Gacha Event Tracker — Product Spec
|
||
|
||
## The problem
|
||
|
||
A player of three or four gacha games is tracking a dozen concurrent, overlapping, time-boxed
|
||
events across as many different in-game calendars. The information exists — on wikis, in patch
|
||
notes, in-game — but never in one place and never sorted by the thing that actually matters:
|
||
**what expires next.** The failure mode is missing a limited event by a day.
|
||
|
||
## What this app is
|
||
|
||
A single-page web app that answers three questions:
|
||
|
||
1. What is running right now, across all my games?
|
||
2. What ends soonest?
|
||
3. Which of these have I already finished?
|
||
|
||
## What this app is not
|
||
|
||
- Not an account system. There is no login, no profile, no cloud sync.
|
||
- Not a wiki. It does not explain how to complete an event, only that it exists and when it ends.
|
||
- Not a notification service. No push, no email, no background alerts. (A browser-local reminder
|
||
is a plausible v2; it is out of scope for v1.)
|
||
- Not a damage calculator, build planner, or pull tracker.
|
||
|
||
## Users
|
||
|
||
One persona: a player of 2–5 gacha games who checks in a few times a week, most often on mobile.
|
||
They care about accuracy of end dates above everything else — a wrong date is worse than a missing
|
||
event, because a missing event sends them to a wiki while a wrong one makes them miss content.
|
||
|
||
## Scope — v1
|
||
|
||
### Games at launch
|
||
|
||
| Game | ID |
|
||
|---|---|
|
||
| Genshin Impact | `genshin` |
|
||
| Honkai: Star Rail | `hsr` |
|
||
| Zenless Zone Zero | `zzz` |
|
||
| Wuthering Waves | `wuwa` |
|
||
| Arknights | `arknights` |
|
||
| Arknights: Endfield | `endfield` |
|
||
| Neverness to Everness | `nte` |
|
||
|
||
Adding a game must require no schema change — only a `GameId` entry and a source registration.
|
||
That is the test of whether the data model is right. A game may have several sources; see
|
||
`docs/INGESTION.md` § Three layers.
|
||
|
||
### Features
|
||
|
||
**F1 — Calendar view (default).**
|
||
A horizontal timeline, one lane per game, spanning a scrollable date range with "today" pinned as a
|
||
vertical marker. Each event is a bar from `startsAt` to `endsAt`. Bars are colored by game, and
|
||
completed events render at reduced opacity with a check. Clicking a bar opens a detail panel with
|
||
title, type, exact start/end in the user's local timezone, source link, and a completion toggle.
|
||
|
||
An event with `endsAt: null` renders as a bar with a frayed right edge and the label "end date
|
||
unknown" — it must be visually distinct from an event that ends far in the future.
|
||
|
||
**F2 — Ends-soonest list.**
|
||
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
||
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
|
||
view that justifies the app; it should be reachable in one tap from the calendar and is the better
|
||
default on narrow screens.
|
||
|
||
**F3 — Mark completed.**
|
||
A toggle on every event, in both views. State is written to `localStorage` immediately and
|
||
optimistically — there is no server round trip and no failure case. Completed events stay visible
|
||
but de-emphasized; a filter toggles them out entirely.
|
||
|
||
**F4 — Filters.**
|
||
Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views.
|
||
Preferences persist in `localStorage`.
|
||
|
||
**F4a — Focus one game at a time.**
|
||
Switching games on and off says *which games the reader plays*, and is set once. It is the wrong
|
||
tool for the thing a player of four games actually does while reading: clear one game, move to the
|
||
next. Doing that with the on/off switches costs two taps per game and leaves the settings panel no
|
||
longer describing what they play.
|
||
|
||
So focus is a **lens over the filter, not a second filter**: a bar at the top of the page, above
|
||
everything it affects, narrowing every view — headline, dailies, lists, calendar and counts — to one
|
||
game, with a "next game" control that steps through them and ends by returning to all. It never
|
||
changes `hiddenGames`, "All" is always one tap away, and a focus on a game that is switched off or
|
||
has left the feed is ignored rather than obeyed, so it can never strand the reader on a blank page
|
||
whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing
|
||
waiting says so before it is visited.
|
||
|
||
**F5 — Region selection.**
|
||
A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed
|
||
end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a
|
||
guess from the browser timezone, shown as a dismissible "showing America server times — change".
|
||
|
||
**F6 — Export / import.**
|
||
Because there are no accounts, moving between devices is manual: download a JSON file of completed
|
||
IDs and preferences, upload it elsewhere. Import merges rather than replaces, and never removes a
|
||
completion the user already has.
|
||
|
||
**F12 — Record your own progress and effort.**
|
||
Three states, not two: untouched, doing it, done. Plus an optional effort estimate — quick, short,
|
||
long, grind — and a free-text note.
|
||
|
||
Three states need three targets. A single control cycling untouched → doing → done makes a button
|
||
labelled "Mark done" produce "doing it", which is the control lying about itself; the detail sheet
|
||
has an explicit control per state, and its primary action goes straight to done and back.
|
||
|
||
Effort is not decoration. Combined with the time remaining it answers the question the calendar
|
||
can't: *can I still finish this?* The same two days is comfortable for a quick event and hopeless
|
||
for a grind, so an event carrying an effort estimate gets a "tight" or "running out of time" flag
|
||
when the remaining time no longer covers it.
|
||
|
||
The heuristic assumes about an hour of play a day and says so. It never hides or reorders anything —
|
||
it adds a flag the reader can ignore. **An event with no recorded effort never gets a warning**,
|
||
because inferring an estimate in order to warn about it would be fabricating their input.
|
||
|
||
**F13 — Your own games and your own events.**
|
||
No feasible adapter set covers everyone. Fourteen games were named in the first release thread and
|
||
the reader with the largest collection asked for exactly one thing — *"can you add a custom game
|
||
option, we can input our own event description and time frames?"* — and, separately, said they would
|
||
wait until "more are added **or we are able to customise it**." That is the tail no source list
|
||
reaches, and it needs no scraping, no ToS question and no server.
|
||
|
||
So a reader can define a game (a name and a lane colour) and enter events against it, or against a
|
||
game the app already tracks when a source missed something. Their events sit in the same lists,
|
||
timeline, sort and filters as scraped ones, and everything they can do to a scraped event — done,
|
||
doing, effort, note, ignore, daily checklist — works identically.
|
||
|
||
Four constraints, each protecting something that already exists:
|
||
|
||
- **Their events are visibly theirs.** A hand-entered date is never attributed to a source and never
|
||
carries a source link. The reader must be able to tell, at a glance, which dates the app went and
|
||
found and which ones they typed.
|
||
- **Their events never touch the ingest pipeline.** `sanitize.ts` and `merge.ts` exist for pages we
|
||
do not control; a reader's own typing is neither untrusted markup nor a second opinion to
|
||
reconcile. Nothing they enter is fetched, parsed, merged, scored or quarantined.
|
||
- **Their IDs live in their own key space.** Never `${game}:${slug}:${date}` — see
|
||
`docs/DATA-MODEL.md` § Reader-authored key spaces.
|
||
- **They are in the backup.** An export that omitted hand-entered events would be a lossy backup,
|
||
which is the same argument the code already makes for streaks. This is the *only* copy — there is
|
||
no server to restore from.
|
||
|
||
**F8 — First-run game picker.**
|
||
Before any events are shown, the reader picks which games they play. A calendar full of games they
|
||
don't play is worse than an empty one — it buries the thing they came for. Nothing is preselected
|
||
and the button stays disabled until something is chosen; guessing on their behalf and hoping they
|
||
notice is worse than asking. The choice is stored as *hidden* games, the inverse, so a game added
|
||
later appears by default rather than staying invisible forever.
|
||
|
||
**F9 — Ignore an event.**
|
||
Distinct from completing one. "Done" keeps an event visible and counted; "not interested" removes it
|
||
from both views entirely. Ignored events stay recoverable: a count and a reveal toggle appear in
|
||
settings once there is something to reveal.
|
||
|
||
**F10 — Works offline.**
|
||
The reader's question is answered entirely by data already on the device, and countdowns run off the
|
||
local clock, so losing signal should not lose the app. A service worker caches the shell and serves
|
||
the last feed it downloaded. Offline is disclosed in the header and above the footer — see F7; stale
|
||
data must never be presented as current.
|
||
|
||
**F11 — Credit and disclaimer.**
|
||
The sources that compile these calendars, and the studios that make the games, are named on the same
|
||
screen as the data rather than one navigation step away. The page states plainly that it is
|
||
unofficial and unaffiliated, and that the source page is the authority when the two disagree.
|
||
|
||
**F7 — Freshness disclosure.**
|
||
The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours
|
||
stale, its lane carries a warning badge. Never present stale data as current — the whole value
|
||
proposition is trust in the dates.
|
||
|
||
## Out of scope for v1
|
||
|
||
Accounts and sync; push notifications; in-game resource or pull tracking; native mobile apps (the
|
||
web app installs to a home screen, which is enough); localization beyond English.
|
||
|
||
Two entries left this list after v1 shipped and readers used it. Per-event checklists became F12 and
|
||
the daily strip; **user-submitted events became F13**, on the strength of the release thread — the
|
||
reader juggling ten games asked for it twice and asked for nothing else, and no adapter roadmap
|
||
answers them. The decision is recorded here rather than left implicit in the code.
|
||
|
||
## Success criteria
|
||
|
||
- A user can identify their next expiring event within **5 seconds** of load, on mobile.
|
||
- Published end dates are correct for **99%+** of events. This is a data-quality target, and it is
|
||
what the review gate in `docs/INGESTION.md` exists to protect. Prefer publishing nothing to
|
||
publishing a guess.
|
||
- Adding a new game is an adapter plus a fixture plus a test — no schema migration, no client
|
||
change.
|
||
|
||
## Quality bar for dates — the core product rule
|
||
|
||
The app's entire value is that the dates are right. Therefore:
|
||
|
||
- An event with an uncertain end date is published with `endsAt: null`, **not** with a plausible
|
||
guess.
|
||
- An event whose confidence is below threshold, or whose sources disagree, is not published at all
|
||
until a human approves it.
|
||
- Every event links to its source so a skeptical user can verify in one click.
|
||
|
||
An empty calendar is a recoverable disappointment. A confidently wrong end date is the failure this
|
||
product exists to prevent.
|
||
|
||
## Open questions
|
||
|
||
- Does the calendar need a month/grid view, or is the timeline enough? (Assumption: timeline is
|
||
enough for v1; revisit after use.)
|
||
- Should an ignored event still count toward the "N live" header total? (Assumption: no — ignoring
|
||
means gone.)
|
||
- Should events the user has hidden by game filter still count toward "ends soonest"?
|
||
(Assumption: no — the filter is global.)
|
||
- Is 6 hours the right refresh cadence? (Assumption: yes; events are announced days ahead, so
|
||
sub-hourly refresh buys nothing and is rude to the sources.)
|