docs: record the game order and the catch-up window
The rules a future change could undo without noticing, written where whoever touches that area next will read them. Three of them are traps rather than descriptions. An absent `gameOrder` means the reader has never placed a game, not an empty order — reading it the other way hands every existing install a blank list, which is the `knownGames` mistake again. `games` is left in feed order deliberately, because the code that hides a reader's games diffs it. And the catch-up window bounds display and never storage: a fortnight's streak exists on one device and nowhere else, so nothing prunes the log against a window. The spec that produced the work goes in alongside, including the helper it specified and the implementation dropped, so the next reader sees why rather than wondering. Also corrects the test count in AGENTS.md, which said 772 and was already stale before this. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
74c1f74e87
commit
5ae559de97
@@ -112,6 +112,7 @@ src/ingest/ html.ts, dates.ts (sixteen formats), merge.ts, sanitize.ts, ro
|
|||||||
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||||
src/client/ React app, service worker, manifest
|
src/client/ React app, service worker, manifest
|
||||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||||
|
gameOrder.ts — the reader's game order, and the A–Z fallback; pure
|
||||||
useCustom.ts — the reader's own games and events (PRD F13)
|
useCustom.ts — the reader's own games and events (PRD F13)
|
||||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||||
zoom.ts — the timeline's scale ladder; pure
|
zoom.ts — the timeline's scale ladder; pure
|
||||||
@@ -119,7 +120,7 @@ src/client/ React app, service worker, manifest
|
|||||||
theme.ts — dark or light, and what a game hue reads as on each
|
theme.ts — dark or light, and what a game hue reads as on each
|
||||||
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 772 tests
|
test/ 831 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -878,6 +879,40 @@ to an open page). Four things hold it up:
|
|||||||
and treating that as "has been offered nothing" switches off every game they already read. Seeding
|
and treating that as "has been offered nothing" switches off every game they already read. Seeding
|
||||||
records what is on their screen and changes nothing else. Lanes they invented (`mygame:`) are
|
records what is on their screen and changes nothing else. Lanes they invented (`mygame:`) are
|
||||||
recorded but never hidden.
|
recorded but never hidden.
|
||||||
|
- **The order games appear in is the reader's, and absent means unplaced.** Nothing used to decide
|
||||||
|
it: the focus bar, the settings list, the timeline's lanes and the dailies strip all rendered
|
||||||
|
`App`'s `games`, which is whichever game happened to hold the first event row. `orderGames`
|
||||||
|
(`src/client/state/gameOrder.ts`) is now the one rule, and every surface goes through it — sorted
|
||||||
|
on the **name** and never the `LaneId`, because the id is not what is printed, and through
|
||||||
|
`localeCompare`, because `<` files hololive Dreams after every capitalised game. An absent
|
||||||
|
`prefs.gameOrder` means *the reader has never placed a game*, not an empty order, so they get the
|
||||||
|
alphabetical rule — the `knownGames` distinction again, and the same trap. Two properties are
|
||||||
|
load-bearing rather than incidental. The result is **always a permutation of the lanes it was
|
||||||
|
given**: a game dropped there is indistinguishable on screen from one the reader switched off, and
|
||||||
|
switching it back on would not bring it back. And a lane it does not name **trails** the ones it
|
||||||
|
does, which is what keeps a game we add later out of the middle of a hand-made order, and what
|
||||||
|
keeps a retired source's slot warm rather than pruning it. `games` itself is left in feed order on
|
||||||
|
purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it, so ordering it at source
|
||||||
|
would let a display preference reach the code that hides a reader's games.
|
||||||
|
- **Reordering is a settings-only affordance.** The focus bar and the dailies strip are the fastest
|
||||||
|
tap targets in the app, and a drag target on top of a tick target costs somebody a streak the first
|
||||||
|
time it misfires — so the live surfaces are never draggable. Both a handle and ↑↓ buttons ship,
|
||||||
|
because touch fires no drag events at all: the arrows are the mechanism and the drag is the pointer
|
||||||
|
fast path, and being ordinary buttons is what makes the whole thing reachable by keyboard and
|
||||||
|
screen reader without a second implementation of the same interaction.
|
||||||
|
- **A game's dailies stay together, and grouping never re-sorts inside a group.** `dailyGroups`
|
||||||
|
(`Dailies.tsx`) emits a game's standing chore followed by that game's repeating events, in the
|
||||||
|
order those arrived; it replaced `[...chores, ...repeating]`, which put a game's chore and its own
|
||||||
|
login event at opposite ends of the strip. Collapsed, the grouping is **adjacency only** — no
|
||||||
|
per-game headings, because that strip is the part of the page answerable in ten seconds and a
|
||||||
|
heading per game makes it the tallest block on it, pushing "next to expire" down the page.
|
||||||
|
- **A day already gone is still tickable, and the window bounds display and never data.** People play
|
||||||
|
at midnight and tick at breakfast, so `catchUpDays` (`src/shared/daily.ts`) offers the last
|
||||||
|
`CATCH_UP_DAYS` game-days for a standing chore and for an event whose end was never announced —
|
||||||
|
the two cases `dailyDays` cannot answer. It never returns a day past today: a tick claims you did
|
||||||
|
it, and a future pip is a control for a claim that cannot be true, so it is absent rather than
|
||||||
|
disabled. A tick older than the window stays logged and keeps counting toward the streak — nothing
|
||||||
|
prunes the log against a window, here or anywhere else.
|
||||||
- **Which view opens is the reader's answer.** `prefs.view` is asked once on the first run (PRD F8)
|
- **Which view opens is the reader's answer.** `prefs.view` is asked once on the first run (PRD F8)
|
||||||
and written by the tabs from then on. It was component state, which meant a reader who preferred
|
and written by the tabs from then on. It was component state, which meant a reader who preferred
|
||||||
the timeline was put back on the list by every reload, with nothing to blame but the app
|
the timeline was put back on the list by every reload, with nothing to blame but the app
|
||||||
|
|||||||
+28
-1
@@ -217,7 +217,8 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
|||||||
"gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, at } }
|
"gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, at } }
|
||||||
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
|
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
|
||||||
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
|
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
|
||||||
"gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, focusGame, sort, view,
|
"gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, gameOrder[]?,
|
||||||
|
// focusGame, sort, view,
|
||||||
// timelineDayWidth, timelineGroup, showUpcoming,
|
// timelineDayWidth, timelineGroup, showUpcoming,
|
||||||
// timelineSplitUpcoming, detectDaily, showCompleted,
|
// timelineSplitUpcoming, detectDaily, showCompleted,
|
||||||
// showIgnored, theme, regionConfirmed, onboarded }
|
// showIgnored, theme, regionConfirmed, onboarded }
|
||||||
@@ -250,6 +251,19 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
|||||||
// knownGames is every lane the reader has been offered. Absent
|
// knownGames is every lane the reader has been offered. Absent
|
||||||
// means unrecorded, not "offered nothing" — see PRD F8; a lane
|
// means unrecorded, not "offered nothing" — see PRD F8; a lane
|
||||||
// missing from it is new to them and arrives switched off.
|
// 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
|
// theme is "dark" | "light" | "system", defaulting to dark — see
|
||||||
// PRD F15. It is read by the app *and* by a pre-paint script in
|
// 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
|
// index.html, which is the only thing outside the client bundle
|
||||||
@@ -296,6 +310,19 @@ server time, so a player finishing at 02:00 is still on the previous day's daili
|
|||||||
computed against the reader's chosen region (`RESET_HOUR_LOCAL`, `dayKey`). Keys sort
|
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.
|
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.
|
||||||
|
|
||||||
**Not every game has a server per region.** `GameMeta.resetOffsets` records the regions where a
|
**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
|
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
|
has two server groups rather than three, and Europe is served off the Americas machine on a fixed
|
||||||
|
|||||||
+44
@@ -248,6 +248,26 @@ 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.
|
||||||
|
|
||||||
|
**A day you did but never ticked can be recorded.** People play at midnight and tick at breakfast, or
|
||||||
|
forget for a week and come back — a log that only accepts today is a log that goes wrong on its first
|
||||||
|
bad evening and is never trusted again. An event whose end was announced already allowed this, since
|
||||||
|
its checklist draws every day of the run and past days are clickable. The two that did not are the
|
||||||
|
ones this closes: a game's **standing chore**, which had no history surface at all and is the
|
||||||
|
most-missed thing in these games, and an event with **`endsAt: null`**, whose checklist could draw no
|
||||||
|
run and so offered only today. Both now show the last fortnight as a strip of days, on the dailies
|
||||||
|
section behind a "Catch up" disclosure and in the detail sheet respectively.
|
||||||
|
|
||||||
|
Three rules hold it honest:
|
||||||
|
|
||||||
|
- **Never a day later than today.** A tick is a claim that you did it, and tomorrow is not something
|
||||||
|
anyone can have done, so a future day is absent rather than shown and disabled.
|
||||||
|
- **An event's strip starts when the event did**, capped at the same fortnight. A standing login
|
||||||
|
campaign that opened in March gets fourteen days, not a hundred and eighty — and never a day before
|
||||||
|
it was claimable.
|
||||||
|
- **The window bounds what is shown and never what is stored.** A tick from five weeks ago stays
|
||||||
|
logged, keeps counting toward the streak and the totals, and is simply off-screen. Nothing removes a
|
||||||
|
day the reader did not remove themselves.
|
||||||
|
|
||||||
**F13 — Your own games and your own events.**
|
**F13 — Your own games and your own events.**
|
||||||
No feasible adapter set covers everyone. Fourteen games were named in the first release thread and
|
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
|
the reader with the largest collection asked for exactly one thing — *"can you add a custom game
|
||||||
@@ -375,6 +395,30 @@ Switching is instant, costs nothing and saves nothing: no reload, and nothing ma
|
|||||||
ticked is touched. And it survives the load it is chosen on — the shell sets the theme before first
|
ticked is touched. And it survives the load it is chosen on — the shell sets the theme before first
|
||||||
paint, so a reader on light is never shown a dark page while the bundle downloads.
|
paint, so a reader on light is never shown a dark page while the bundle downloads.
|
||||||
|
|
||||||
|
**F16 — Your own game order.**
|
||||||
|
Nothing used to decide the order games appear in. The focus bar, the settings list, the timeline's
|
||||||
|
lanes and the dailies strip all rendered whichever game happened to hold the first event row — which
|
||||||
|
is arbitrary, and shifts as events come and go. Two answers, and the reader gets both:
|
||||||
|
|
||||||
|
- **A rule, for everybody.** Absent any choice of theirs, games are alphabetical by name, which is
|
||||||
|
what the first-run picker does (F8). Absent means *they have never placed a game*, not that they
|
||||||
|
have no order — so every existing reader gets the rule rather than an empty list, the same
|
||||||
|
distinction `knownGames` draws.
|
||||||
|
- **An order, for anyone who wants one.** Games can be dragged, or nudged with arrows, into any order
|
||||||
|
in settings. Every surface that lists a game follows it, so the game they actually play is first
|
||||||
|
everywhere at once.
|
||||||
|
|
||||||
|
**Reordering lives in settings and nowhere else.** The focus bar and the dailies strip are the fastest
|
||||||
|
tap targets in the app — the strip is the part of the page answerable in ten seconds — and a drag
|
||||||
|
target on top of a tick target costs somebody a streak the first time it misfires. Both a drag handle
|
||||||
|
and arrow buttons ship, because touch fires no drag events at all and a drag-only list is unreachable
|
||||||
|
on a phone and by keyboard alike.
|
||||||
|
|
||||||
|
A game added later is not slotted into an order the reader made by hand: it trails everything they
|
||||||
|
placed. It also arrives switched off (F8), so settings is where they meet it, which is where they
|
||||||
|
would move it anyway. **A game's dailies stay together** — a repeating event the reader marked sits
|
||||||
|
with the standing chore of the game it came from, because the reader thinks in games and not in kinds.
|
||||||
|
|
||||||
## Out of scope for v1
|
## Out of scope for v1
|
||||||
|
|
||||||
Accounts and sync; push notifications; in-game resource or pull tracking; native mobile apps (the
|
Accounts and sync; push notifications; in-game resource or pull tracking; native mobile apps (the
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Game order, grouped dailies, and catching up a missed day
|
||||||
|
|
||||||
|
**Date:** 2026-08-20
|
||||||
|
**Status:** implemented, 2026-08-20
|
||||||
|
|
||||||
|
Three changes that share one primitive. Read `AGENTS.md` first — every rule cited below is already
|
||||||
|
written down there, and the citations are load-bearing rather than decorative.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Three complaints, one root:
|
||||||
|
|
||||||
|
1. **Nothing decides the order games appear in.** The focus bar, the settings chips and the timeline
|
||||||
|
lanes all render `games` from `App.tsx`, which is
|
||||||
|
`[...new Set([...allRows.map(r => r.event.game), ...custom.lanes])]` — *whichever game happened to
|
||||||
|
hold the first event row*. That is arbitrary, it shifts as events come and go, and it is the same
|
||||||
|
problem already fixed on the first-run picker (`docs/PRD.md` F8).
|
||||||
|
2. **A game's dailies are scattered.** `Dailies.tsx` builds `[...chores, ...repeating]`, so Genshin's
|
||||||
|
standing chore and Genshin's login event are separated by every other game on the page. The reader
|
||||||
|
thinks in games; the strip thinks in kinds.
|
||||||
|
3. **A day you did but did not tick cannot be recorded.** Three surfaces, and only one of them works:
|
||||||
|
|
||||||
|
| Surface | Backfill today? |
|
||||||
|
|---|---|
|
||||||
|
| Event with an announced end, in the detail sheet | **Yes** — every day in `dailyDays` is a pip button, only future days disabled |
|
||||||
|
| Event with `endsAt: null` | **No** — `days === null` renders no pips at all, so only today is reachable |
|
||||||
|
| A game's standing chore (`dailies:<game>`) | **No, and there is no history UI at all** — one `TickChip` wired to today |
|
||||||
|
|
||||||
|
The third is the one the code itself calls "the most-missed thing in every one of these games".
|
||||||
|
|
||||||
|
The store is already capable: `toggleDay(id, day)` takes any day key and `streakOf` reads whatever is
|
||||||
|
logged. This is UI reach, not a data-model change — **nothing here touches an event ID, a `dailies:`
|
||||||
|
key or a `dayKey` format.**
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Whose order is it? | The reader's, stored in `prefs.gameOrder` |
|
||||||
|
| Where is it edited? | Settings only. The live surfaces are never draggable |
|
||||||
|
| How? | A drag handle **and** ↑↓ buttons, both always visible |
|
||||||
|
| Default before they touch it | Alphabetical by display name |
|
||||||
|
| A game added later | Lands after everything hand-placed; not auto-appended to the order |
|
||||||
|
| Which surfaces obey it | Focus bar, Today's dailies, settings games list, timeline lanes |
|
||||||
|
| Chore catch-up window | 14 days |
|
||||||
|
| Undated-event catch-up window | Its start day → today, capped at 14 days back |
|
||||||
|
|
||||||
|
The live surfaces stay drag-free because the dailies strip is the fastest tap target on the page —
|
||||||
|
the code calls it the part "answerable in ten seconds" — and a drag target on top of a tick target
|
||||||
|
costs a streak when it misfires. `AGENTS.md` § Conventions ("a list row is one target") is the same
|
||||||
|
worry.
|
||||||
|
|
||||||
|
## 1. `src/client/state/gameOrder.ts` — new, pure, no React
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function orderGames(
|
||||||
|
lanes: readonly LaneId[],
|
||||||
|
stored: readonly LaneId[] | undefined,
|
||||||
|
nameOf: (id: LaneId) => string,
|
||||||
|
): LaneId[]
|
||||||
|
|
||||||
|
/** Move one entry. Out-of-range indices are a no-op. */
|
||||||
|
export function moveGame(order: readonly LaneId[], from: number, to: number): LaneId[]
|
||||||
|
```
|
||||||
|
|
||||||
|
`orderGames`, in priority order:
|
||||||
|
|
||||||
|
1. **`stored` absent → every lane alphabetical by `nameOf`, via `localeCompare`.** Absent means *the
|
||||||
|
reader has never placed a game*, exactly as an absent `knownGames` means unrecorded rather than
|
||||||
|
"offered nothing" (`usePrefs.ts` § `adoptNewLanes`). Every existing install is in this state on
|
||||||
|
day one, and this default is what the first-run picker already does.
|
||||||
|
2. **`stored` present → the lanes it names, in its order, filtered to lanes that exist; then every
|
||||||
|
unnamed lane, alphabetical, appended.**
|
||||||
|
3. **The result is always a permutation of `lanes`.** Never drops one, never invents one, never
|
||||||
|
duplicates one even if `stored` does. This is the safety property: a game missing from the focus
|
||||||
|
bar or from settings is indistinguishable from a game the reader switched off, and their fix for
|
||||||
|
that — switch it back on — would do nothing.
|
||||||
|
4. **Total.** An unknown lane still gets a name (`metaFor` answers "Unknown game"), so a `mygame:`
|
||||||
|
lane, or an import carrying an event whose game did not come with it, sorts rather than throws.
|
||||||
|
5. **A retired game keeps its slot.** Filter on output; never prune `stored`. A source that goes away
|
||||||
|
and comes back returns to where the reader put it — `AGENTS.md` § "Retiring a game, a source or a
|
||||||
|
page must never cost the reader a row they typed", applied to a preference.
|
||||||
|
|
||||||
|
`localeCompare`, not `<`: `hololive Dreams` is the one lowercase name in `games.ts` and a code-point
|
||||||
|
sort files it after every capitalised game. Sort on the **name**, never the `LaneId` — `hsr` is
|
||||||
|
Honkai: Star Rail, `nikke` is Goddess of Victory: Nikke.
|
||||||
|
|
||||||
|
**A reorder is always applied to the list as displayed, and the whole result is stored.** Both halves
|
||||||
|
matter, and an earlier draft of this spec had a separate `seedOrder` helper for the second one before
|
||||||
|
noticing the first: the indices a drag or an arrow produces are positions *on screen*, so applying
|
||||||
|
them to a stored order naming only some lanes would move the wrong game — and storing only the moved
|
||||||
|
id would leave rule 2 reading "that game, then everything else alphabetically", which is not what
|
||||||
|
dragging one row one notch means. Writing back what the reader is looking at gets both right, and
|
||||||
|
needs no helper.
|
||||||
|
|
||||||
|
### `prefs.gameOrder`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** The reader's own game order. Absent means they have never placed one — see orderGames. */
|
||||||
|
gameOrder?: LaneId[];
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional and **absent from `defaults()`**, deliberately: a stored `[]` would mean "an order that
|
||||||
|
names nothing", which is indistinguishable from absent under rule 2 but invites a future reader of
|
||||||
|
the code to treat it as meaningful. "Reset to A–Z" writes `gameOrder: undefined`, which
|
||||||
|
`JSON.stringify` drops.
|
||||||
|
|
||||||
|
No new key space and no migration — one more field in the single `prefs` blob, like `timelineGroup`.
|
||||||
|
It rides the export for free (`exportProgress` already writes `prefs`).
|
||||||
|
|
||||||
|
**Known asymmetry, not fixed here:** `importProgress` restores progress, daily, ignored and custom
|
||||||
|
but **never prefs**, so an imported file does not restore a game order. That is pre-existing and
|
||||||
|
applies equally to region, theme and view. Do not fix it as a side effect of this work; it deserves
|
||||||
|
its own decision about merge semantics.
|
||||||
|
|
||||||
|
## 2. The surfaces follow the order
|
||||||
|
|
||||||
|
`App.tsx` computes it once, next to `games`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ordered = useMemo(
|
||||||
|
() => orderGames(games, prefs.gameOrder, (id) => gameMeta(id).name),
|
||||||
|
[games, prefs.gameOrder, gameMeta],
|
||||||
|
);
|
||||||
|
const enabled = useMemo(
|
||||||
|
() => ordered.filter((g) => !prefs.hiddenGames.includes(g)),
|
||||||
|
[ordered, prefs.hiddenGames],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`games` itself does not change.** It stays the lane-identity list that `adoptNewLanes` diffs and
|
||||||
|
`knownGames` is seeded from. Reordering it at source would entangle a display preference with the
|
||||||
|
logic that decides which of a reader's games get switched off — a reordering bug would become a
|
||||||
|
*game silently hidden* bug.
|
||||||
|
|
||||||
|
Then: focus bar gets `enabled` (already does — it just becomes ordered), `Dailies` gets ordered games,
|
||||||
|
`Controls` gets `ordered`, and `timelineLanes` gains lane ordering:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
timelineLanes(rows, mode, split, order?: readonly LaneId[])
|
||||||
|
```
|
||||||
|
|
||||||
|
Applied to the **lanes**, never to the rows inside one — `lanes.ts` already states that grouping is
|
||||||
|
not a licence to re-sort within a group. A lane whose game is absent from `order` sorts after the
|
||||||
|
placed ones, keeping the function total. The merged `"ending"` mode is untouched: it is deliberately
|
||||||
|
not per-game.
|
||||||
|
|
||||||
|
`Welcome.tsx` drops its local comparator and calls `orderGames(available, undefined, nameOf)` — the
|
||||||
|
picker precedes any stored order, so it always gets the alphabetical rule. One rule, not a fifth copy.
|
||||||
|
|
||||||
|
## 3. The settings reorder editor
|
||||||
|
|
||||||
|
The Games chip row in `Controls.tsx` becomes one row per game:
|
||||||
|
|
||||||
|
```
|
||||||
|
∷ ● Genshin Impact 3 of 14 ↑ ↓ [on]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Drag handle (`draggable` + `dragover`/`drop`, no dependency — the only runtime dep is `zod`) **and**
|
||||||
|
↑↓ buttons, both always visible. **Touch does not fire drag events**, so ↑↓ is the real mechanism
|
||||||
|
and drag is the desktop fast path. Say so in the UI copy.
|
||||||
|
- ↑↓ are ordinary buttons, so keyboard and screen-reader support come free:
|
||||||
|
`aria-label="Move Genshin Impact up"`, and the row states its position so it is audible.
|
||||||
|
- A reorder writes `moveGame(games, from, to)` — the list as displayed — as the whole new
|
||||||
|
`gameOrder`.
|
||||||
|
- "Reset to A–Z" writes `gameOrder: undefined`.
|
||||||
|
- **The row is not itself a target.** The handle, two arrows and the on/off toggle are four explicit
|
||||||
|
controls and nothing else is clickable, which is why this does not breach "a list row is one
|
||||||
|
target" — that rule exists because a full-bleed row target plus an inner control is a mis-tap.
|
||||||
|
|
||||||
|
## 4. Dailies: grouping, and catching up
|
||||||
|
|
||||||
|
### Grouping
|
||||||
|
|
||||||
|
Replace `[...chores, ...repeating]` with a pure exported function in the component module — the
|
||||||
|
pattern `Timeline.tsx` already uses for `boardWindow` / `splitAt` / `startMarkers`, so it is tested
|
||||||
|
directly with no rendering:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function dailyGroups(
|
||||||
|
games: readonly LaneId[], // already in the reader's order
|
||||||
|
events: readonly DisplayEvent[],
|
||||||
|
now: number,
|
||||||
|
region: Region,
|
||||||
|
): DailyGroup[] // { game, chore | null, events }
|
||||||
|
```
|
||||||
|
|
||||||
|
For each game in order: its standing chore first, then that game's repeating events **in the order
|
||||||
|
they arrived**. Grouping is not a licence to re-sort inside a group. A `mygame:` lane contributes
|
||||||
|
events but no chore (`isCustomGameId` — there is no routine we could name on a reader's behalf).
|
||||||
|
|
||||||
|
**Collapsed, grouping is adjacency only — no per-game headings.** Chips stay one wrapping row; a
|
||||||
|
game's chore and its events simply become neighbours, and they already share a hue. Headings would
|
||||||
|
make the ten-second strip the tallest block on the page, above "next to expire", which is the answer
|
||||||
|
the reader came for. Headings appear only in the expanded state, where there is vertical room.
|
||||||
|
|
||||||
|
The `2/4` counter, `soonest` and the mixed-reset line are computed over the same items and are
|
||||||
|
unaffected: grouping only reorders. Per-game reset clocks stay per-game — Endfield's European day can
|
||||||
|
still be yesterday's (`Dailies.tsx:46-48`).
|
||||||
|
|
||||||
|
### `catchUpDays` — `src/shared/daily.ts`, beside `dailyDays`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const CATCH_UP_DAYS = 14;
|
||||||
|
|
||||||
|
export function catchUpDays(
|
||||||
|
now: number,
|
||||||
|
region: Region,
|
||||||
|
game: LaneId | undefined,
|
||||||
|
notBefore: number | null,
|
||||||
|
): string[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Day keys oldest → today inclusive: at most `CATCH_UP_DAYS`, never earlier than `notBefore`'s day,
|
||||||
|
**never past today**. Pure, clock as an argument, like everything else in that module.
|
||||||
|
|
||||||
|
- A **chore** passes `notBefore: null` — no start exists — and gets the last 14 days.
|
||||||
|
- An **undated event** passes its `startsMs`, so the strip begins at its real start, capped at 14 back.
|
||||||
|
That cap is what stops a login campaign that opened in March from rendering 180 pips.
|
||||||
|
|
||||||
|
### The two strips
|
||||||
|
|
||||||
|
- `Dailies` gains a `Catch up ▾` disclosure: `useState(false)`, **per-visit, never stored** — the same
|
||||||
|
argument the repo already makes for "show all N" (`AGENTS.md` § "Truncating a list is not
|
||||||
|
re-sorting it"): it is something a reader does while reading, not a statement about how the app
|
||||||
|
should work. Expanded, each group renders its game heading and a pip row per item.
|
||||||
|
- `DailyChecklist`'s `days === null` branch gains the same strip, replacing a sentence that currently
|
||||||
|
offers nothing to click. Dated events keep the full `dailyDays` strip they already have.
|
||||||
|
- `DayPip` moves out of `DailyChecklist.tsx` into `src/client/components/DayPip.tsx` so both surfaces
|
||||||
|
share one pip instead of growing two that drift.
|
||||||
|
|
||||||
|
### Four guard rails
|
||||||
|
|
||||||
|
1. **Future days are never rendered** in a catch-up strip — not rendered-and-disabled. A tick claims
|
||||||
|
you did it. The dated checklist keeps its dimmed future pips: there the run's length is published
|
||||||
|
and the strip is a forecast, whereas catch-up is explicitly about the past.
|
||||||
|
2. **The window bounds display, never data.** A tick from five weeks ago stays logged, keeps counting
|
||||||
|
in `streakOf` and in the `logged` total, and is simply off-screen. `toggleDay` only ever touches the
|
||||||
|
exact day tapped — `AGENTS.md`: "a tick is never removed except by the reader".
|
||||||
|
3. **Every call passes `game`.** Anything reading or writing a tick must, or it writes under one clock
|
||||||
|
and reads under another. Chores pass their game id, events pass `event.game`. Endfield's European
|
||||||
|
reset is 09:00 UTC, so this is not theoretical.
|
||||||
|
4. **Catch-up cannot resurrect a finished event.** Expanded rows come from the same items the collapsed
|
||||||
|
strip uses, which `App` already filters through `outstanding` — an event the reader marked done has
|
||||||
|
no line left to tick. True by construction; pinned by a test anyway.
|
||||||
|
|
||||||
|
The completion **fireworks** fire on `complete > was.complete` at equal `total`. Backfilling yesterday
|
||||||
|
leaves today's count alone, so no burst — correct. Ticking today from inside the expanded strip does
|
||||||
|
burst, also correct.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| File | Pins |
|
||||||
|
|---|---|
|
||||||
|
| `test/game-order.test.ts` | `orderGames` is a permutation in every case (absent / present / retired / unknown / duplicate-bearing `stored`); alphabetical by name and not by id; `localeCompare` and the lowercase name; unplaced lanes trail placed ones; `moveGame` records the whole displayed list, never loses an entry, and is a no-op off either end |
|
||||||
|
| `test/daily.test.ts` | `catchUpDays`: the 14-day cap, the `notBefore` clip, never past today, a per-game reset offset (Endfield europe UTC-5) |
|
||||||
|
| `test/daily.test.ts` | A tick older than the window survives and still counts toward the streak |
|
||||||
|
| `test/views.test.tsx` | `dailyGroups` keeps a chore adjacent to its game's events, preserves within-game order, and yields no row for a done event; `Welcome` still orders alphabetically through the shared rule |
|
||||||
|
| `test/controls.test.tsx` | The reorder rows render both affordances; ↑ on the first row is inert |
|
||||||
|
| `test/prefs.test.ts` | `gameOrder` absent survives a load; reset writes it away |
|
||||||
|
|
||||||
|
Tests must not need build output, and must run offline.
|
||||||
|
|
||||||
|
## Docs to update in the same change
|
||||||
|
|
||||||
|
- `docs/PRD.md` — the game order as reader-facing behaviour (F4/F8 neighbourhood), and catch-up under
|
||||||
|
the dailies feature.
|
||||||
|
- `docs/DATA-MODEL.md` — `prefs.gameOrder`, and that absent means unplaced.
|
||||||
|
- `AGENTS.md` § Conventions — the ordering rule, and that catch-up bounds display and never data.
|
||||||
|
|
||||||
|
## Commit split
|
||||||
|
|
||||||
|
Four commits, each typechecking and passing tests on its own, per `AGENTS.md` § Conventions:
|
||||||
|
|
||||||
|
1. `gameOrder` module + `prefs.gameOrder` + tests. Nothing renders differently.
|
||||||
|
2. The four surfaces follow the order; `Welcome.tsx` converted to the shared rule.
|
||||||
|
3. The settings reorder editor.
|
||||||
|
4. Dailies grouping + `catchUpDays` + `DayPip` extraction + both strips.
|
||||||
Reference in New Issue
Block a user