diff --git a/AGENTS.md b/AGENTS.md index 539cf3c..a8e7b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 58cbf23..6db589c 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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 ``` diff --git a/docs/PRD.md b/docs/PRD.md index e1fa6bb..bb8dbef 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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, diff --git a/src/client/App.tsx b/src/client/App.tsx index f9b9ae3..3aedb86 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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 ( diff --git a/src/client/components/Welcome.tsx b/src/client/components/Welcome.tsx index b20babf..74c6350 100644 --- a/src/client/components/Welcome.tsx +++ b/src/client/components/Welcome.tsx @@ -135,8 +135,9 @@ export function Welcome({

- 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.

); diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 2284848..aa8ff06 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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 | 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(() => ({ ...defaults(), diff --git a/test/prefs.test.ts b/test/prefs.test.ts new file mode 100644 index 0000000..0c65223 --- /dev/null +++ b/test/prefs.test.ts @@ -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"]); + }); +});