feat(prefs): ask which view to open on, and remember the answer

`view` was component state, so a reader who preferred the timeline was put
back on the list by every reload — and which view opened at all had been
decided for them twice over: the PRD said calendar, the app shipped the list.
Neither was the reader's answer.

So the first run asks, and the tabs write to `prefs` from then on. The
question ships pre-answered with the list, because a reader cannot choose
between two layouts they have not seen and that is the one that answers "what
expires next" in a look — and each option is drawn rather than described, for
the same reason. The screen says where to change it afterwards, since the tabs
are small text in a corner.

Adds `view` to the prefs key space. Additive and defaulted, so an existing
reader's stored prefs open exactly where they did before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 03:28:03 +02:00
co-authored by Claude Opus 5
parent 41046899f9
commit 7804a08863
9 changed files with 250 additions and 27 deletions
+29 -4
View File
@@ -1,17 +1,18 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { NextUp } from "../src/client/components/NextUp.tsx";
import { Welcome } from "../src/client/components/Welcome.tsx";
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
import { metaFor } from "../src/shared/games.ts";
import { clockFor } from "../src/shared/time.ts";
import { GachaEvent, type GameId } from "../src/shared/schema.ts";
/**
* Static-render checks on the headline panel.
* Static-render checks on the two surfaces a reader meets first.
*
* Not a substitute for using the thing, but they pin the claims it makes: it
* leads with the closest deadline, it carries the ones behind it, and it never
* dresses an unannounced end up as a countdown.
* Not a substitute for using the thing, but they pin the claims each one makes:
* the headline carries the deadlines behind the closest one, and the first run
* asks how the reader wants to read the app rather than deciding for them.
*/
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
@@ -95,3 +96,27 @@ describe("NextUp", () => {
expect(html).toContain("no end date");
});
});
describe("Welcome (first run)", () => {
const html = () =>
render(<Welcome available={["genshin", "hsr"]} onConfirm={() => {}} />);
test("asks how the reader wants to see their events", () => {
expect(html()).toContain("How do you want to see them?");
expect(html()).toContain("Ending soon");
expect(html()).toContain("Timeline");
});
test("says where the choice lives afterwards", () => {
// The tabs are small text in a corner — the one control a first-time reader
// will not find on their own, so the screen that sets it says where it is.
expect(html()).toContain("top right");
});
test("opens on the list, with the choice already answered", () => {
// Games stay unanswered because only the reader knows which ones they play.
// This one has a defensible default, and a reader cannot choose between two
// layouts they have not seen yet.
expect(html()).toContain('aria-checked="true"');
});
});