docs: bring reader-authored games and events into scope
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1dd58d3f52
commit
4029885833
@@ -319,6 +319,63 @@ would stop a later parser improvement from ever reaching that event. This is the
|
|||||||
`progress` that changes what the app *shows* rather than recording what the reader did, which is
|
`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.
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
Three first segments are now reserved and **none of them may ever become a `GameId`**: `dailies`,
|
||||||
|
`mygame`, `myevent`. 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 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
**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`
|
### Migration from `completions`
|
||||||
|
|
||||||
`completions` used membership to mean "done", which cannot express "started". `progress` replaces it
|
`completions` used membership to mean "done", which cannot express "started". `progress` replaces it
|
||||||
@@ -353,10 +410,26 @@ old key.
|
|||||||
"dailies:genshin": { "days": ["2026-08-14", "2026-08-15"], "at": "..." }
|
"dailies:genshin": { "days": ["2026-08-14", "2026-08-15"], "at": "..." }
|
||||||
},
|
},
|
||||||
"ignored": { "zzz:some-event-i-skip:2026-08-19": { "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 }
|
"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 **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
|
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. An export written before daily checklists existed
|
recorded is a day the reader actually played. An export written before daily checklists existed
|
||||||
|
|||||||
+33
-3
@@ -114,6 +114,32 @@ The heuristic assumes about an hour of play a day and says so. It never hides or
|
|||||||
it adds a flag the reader can ignore. **An event with no recorded effort never gets a warning**,
|
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.
|
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.**
|
**F8 — First-run game picker.**
|
||||||
Before any events are shown, the reader picks which games they play. A calendar full of games they
|
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
|
don't play is worse than an empty one — it buries the thing they came for. Nothing is preselected
|
||||||
@@ -144,9 +170,13 @@ proposition is trust in the dates.
|
|||||||
|
|
||||||
## Out of scope for v1
|
## Out of scope for v1
|
||||||
|
|
||||||
Accounts and sync; push notifications; per-event checklists or progress tracking; in-game resource
|
Accounts and sync; push notifications; in-game resource or pull tracking; native mobile apps (the
|
||||||
or pull tracking; user-submitted events; native mobile apps (the web app installs to a home screen,
|
web app installs to a home screen, which is enough); localization beyond English.
|
||||||
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
|
## Success criteria
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user