feat(prefs): a game we add arrives switched off

Adding a source is our decision, not the reader's. Eleven lanes became
fourteen this week, and for someone who plays two that is not a feature
arriving — it is their calendar filling with events they will never open,
which is the thing the first-run picker exists to prevent.

`knownGames` records every lane a reader has been offered; a lane missing from
it is new to them, so it is recorded and hidden on sight. The games chips in
settings list every lane, on or off, which is where they take one up.

The case that had to be right is the reader who installed before any of this
existed: they have no `knownGames` at all, and reading that as "has been
offered nothing" would switch off every game they already read. Absent means
unrecorded — the first pass records what is already on their screen and
changes nothing else. Lanes they invented are recorded but never hidden;
typing a game in is asking for it.

The decision is a pure function so this is provable rather than watched for.
One real cost, stated in the PRD rather than hidden: a reader whose game
finally arrives is not told, which makes the colophon roadmap matter more.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 04:05:23 +02:00
co-authored by Claude Opus 5
parent b3aea00cbb
commit d5dbe72e17
7 changed files with 188 additions and 11 deletions
+7
View File
@@ -470,6 +470,13 @@ to an open page). Four things hold it up:
on the timeline — so the *sorting groups, it never reorders* rule below holds for what is hidden
exactly as it does for what is shown. Expanding is per-visit state, not a stored preference: it is
something a reader does while reading one list, not a statement about how the app should work.
- **A game we add arrives switched off, and `knownGames` absent means *unrecorded*.** Adding a
source is our decision; a reader who plays two games did not ask for the other twelve. So a lane
missing from `prefs.knownGames` is new *to them* and is hidden on sight (`adoptNewLanes`, PRD F8).
The trap is the other reading: every install from before this existed has no `knownGames` at all,
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
recorded but never hidden.
- **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
the timeline was put back on the list by every reload, with nothing to blame but the app
+6 -2
View File
@@ -200,8 +200,12 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
"gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, 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[], focusGame, sort, view, detectDaily,
// showCompleted, showIgnored, regionConfirmed, onboarded }
"gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, focusGame, sort, view,
// detectDaily, showCompleted, showIgnored, regionConfirmed,
// onboarded }
// 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.
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
```
+16 -2
View File
@@ -187,8 +187,22 @@ Before any events are shown, the reader picks which games they play, and how the
them. 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.
notice is worse than asking. The choice is stored as *hidden* games, the inverse — which is a storage
shape, not a policy: what happens to a game added later is decided separately, below.
**A game added later arrives switched off.** Adding a source is our decision, not the reader's, and
someone who plays two games did not ask for the other twelve; a calendar that fills itself up is the
thing this screen exists to prevent. `prefs.knownGames` records every lane a reader has been offered,
and a lane missing from it is recorded and hidden on sight. The games chips in settings list every
lane, on or off, which is where they take a new one up — and the cost of this is real and worth
stating: a reader whose game finally arrives is not told, so the roadmap line in the colophon
(`docs/FEEDBACK.md` P1c) matters more, not less.
An absent `knownGames` means *unrecorded*, never *offered nothing* — every reader who installed
before it existed is in that state, and reading it the other way would switch off every game they
already read. The first pass records what is already on their screen and changes nothing else.
Lanes the reader invented (`mygame:`) are recorded but never hidden: typing a game in is asking for
it.
The view question (F2) sits under it, with each option drawn rather than only described — the words
"list" and "timeline" mean nothing until you have seen this app's version of them. Unlike the games,
+23 -3
View File
@@ -17,7 +17,7 @@ import { useAppUpdate } from "./state/useAppUpdate.ts";
import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { usePrefs, type View } from "./state/usePrefs.ts";
import { adoptNewLanes, usePrefs, type View } from "./state/usePrefs.ts";
import { useCustom } from "./state/useCustom.ts";
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
import {
@@ -206,6 +206,26 @@ export function App() {
[allRows, custom.lanes],
);
/**
* A lane the reader has never been offered starts switched off.
*
* Adding a source is our decision, not theirs, and a reader who plays two
* games did not ask for the other twelve. So a lane that is new to *them*
* is recorded and hidden, and the games chips in settings are where they
* take it up — the one place that lists every lane, on or off.
*
* The seeding branch is the whole reason this is safe: an existing reader
* has no `knownGames` at all, and treating that as "has been offered
* nothing" would switch off every game they already read. Absent means
* unrecorded, so the first pass records what is already on their screen and
* changes nothing else.
*/
useEffect(() => {
if (state.status !== "ready") return;
const patch = adoptNewLanes(games, prefs.knownGames, prefs.hiddenGames);
if (patch !== null) update(patch);
}, [state.status, games, prefs.knownGames, prefs.hiddenGames, update]);
/** Games the reader plays, in feed order. The focus bar rotates through these. */
const enabled = useMemo(
() => games.filter((g) => !prefs.hiddenGames.includes(g)),
@@ -311,8 +331,8 @@ export function App() {
}
// First run: ask which games before showing a calendar full of ones they
// don't play. Stored as hiddenGames (the inverse) so a game added later shows
// up by default rather than staying invisible.
// don't play. Stored as hiddenGames (the inverse); what happens to a game
// added *later* is decided by `knownGames` above, not by this shape.
if (!prefs.onboarded) {
return (
<GameMetaProvider value={gameMeta}>
+3 -2
View File
@@ -135,8 +135,9 @@ export function Welcome({
</div>
<p className="mt-auto pt-10 text-xs leading-relaxed text-faint">
More games are coming as sources are added. Anything you switch on later
shows up automatically.
More games are coming as sources are added. A new one stays switched off
until you ask for it — you'll find it in settings, at the bottom of the
page.
</p>
</div>
);
+65 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { LaneId } from "../../shared/custom.ts";
import { isCustomGameId, type LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { guessRegion } from "../../shared/time.ts";
import type { SortMode } from "./sort.ts";
@@ -17,8 +17,33 @@ export type View = "soon" | "timeline";
export interface Prefs {
region: Region;
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
/**
* Games the reader has switched off.
*
* Still stored as the inverse, but no longer because a new game should
* appear by default — see `knownGames`, which is what decides that now. It
* stays the inverse because it is what every existing device has written
* down, and rewriting a live key space to say the same thing differently
* costs a migration and buys nothing.
*/
hiddenGames: LaneId[];
/**
* Every lane this reader has been offered.
*
* A game we add is a game they never asked for. Turning eleven lanes into
* fourteen under someone who plays two is not a feature arriving, it is
* their calendar filling with events they will never open — so a lane that
* is new *to them* arrives switched off, and the games chips in settings are
* where they take it up.
*
* Absent means "never recorded", which is not the same as "has been offered
* nothing": every existing reader is in that state, and seeding it from
* what is on screen is what stops this from switching their games off the
* first time they load a build that has it. Their own games (`mygame:`) are
* recorded here too but never auto-hidden — they asked for those by typing
* them in.
*/
knownGames?: LaneId[];
/**
* One game to look at right now, or null for all of them.
*
@@ -71,6 +96,44 @@ function defaults(): Prefs {
};
}
/**
* What to record and what to switch off when the set of lanes changes.
*
* Pure and separate from the hook because it decides whether a reader's games
* get switched off, which is the kind of thing that should be provable rather
* than watched for. Returns `null` when there is nothing to do, so the caller
* writes to storage only when something actually changed.
*
* Two cases it must not get wrong:
*
* - **`known` absent.** Every reader who installed before this existed is in
* that state, and it means "unrecorded", not "has been offered nothing".
* Seeding records what is already on their screen and switches nothing off.
* - **A lane they invented.** `mygame:` lanes are the reader asking for a game
* by typing it in, so they are recorded but never hidden. Only a lane that
* arrived because we added a source turns up switched off.
*/
export function adoptNewLanes(
lanes: readonly LaneId[],
known: readonly LaneId[] | undefined,
hidden: readonly LaneId[],
): Partial<Prefs> | null {
// An empty list is a feed that has not arrived, not a reader with no games.
if (lanes.length === 0) return null;
if (known === undefined) return { knownGames: [...lanes] };
const fresh = lanes.filter((lane) => !known.includes(lane));
if (fresh.length === 0) return null;
const unasked = fresh.filter(
(lane) => !isCustomGameId(lane) && !hidden.includes(lane),
);
return {
knownGames: [...known, ...fresh],
hiddenGames: [...hidden, ...unasked],
};
}
export function usePrefs() {
const [prefs, setPrefs] = useState<Prefs>(() => ({
...defaults(),
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { adoptNewLanes } from "../src/client/state/usePrefs.ts";
import type { LaneId } from "../src/shared/custom.ts";
/**
* What happens to a reader's games when we add a source.
*
* Adding one is our decision, not theirs: a reader who plays two games did not
* ask for the other twelve, and a calendar that fills up on its own is the
* thing the first-run picker exists to prevent. So a lane that is new to them
* arrives switched off — and the one case that must never misfire is the
* reader who installed before any of this was recorded.
*/
const TRACKED: LaneId[] = ["genshin", "hsr", "zzz"];
describe("adoptNewLanes", () => {
test("an unrecorded reader has everything on their screen recorded, and nothing switched off", () => {
// Every existing install is in this state. Reading "no record" as "has been
// offered nothing" would switch off every game they already read.
expect(adoptNewLanes(TRACKED, undefined, [])).toEqual({
knownGames: TRACKED,
});
});
test("a lane they were never offered arrives switched off", () => {
const patch = adoptNewLanes([...TRACKED, "holodori"], TRACKED, []);
expect(patch).toEqual({
knownGames: [...TRACKED, "holodori"],
hiddenGames: ["holodori"],
});
});
test("their own game is recorded but never hidden", () => {
// They asked for it by typing it in. Hiding it would be the app arguing
// with the reader about a game they just created.
const patch = adoptNewLanes(
[...TRACKED, "mygame:limbus-company"],
TRACKED,
[],
);
expect(patch).toEqual({
knownGames: [...TRACKED, "mygame:limbus-company"],
hiddenGames: [],
});
});
test("nothing new is nothing to write", () => {
expect(adoptNewLanes(TRACKED, TRACKED, ["zzz"])).toBeNull();
});
test("an empty list is a feed that has not arrived, not a reader with no games", () => {
// Seeding from it would record nothing and then treat every real game as
// new the moment the feed lands.
expect(adoptNewLanes([], undefined, [])).toBeNull();
expect(adoptNewLanes([], TRACKED, [])).toBeNull();
});
test("a game they had already switched off is not listed twice", () => {
const patch = adoptNewLanes([...TRACKED, "wuwa"], TRACKED, ["wuwa"]);
expect(patch?.hiddenGames).toEqual(["wuwa"]);
});
test("their existing choices are left exactly as they were", () => {
const patch = adoptNewLanes([...TRACKED, "fgo"], TRACKED, ["hsr"]);
expect(patch?.hiddenGames).toEqual(["hsr", "fgo"]);
});
});