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
+5 -1
View File
@@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health
test/ 517 tests
test/ 536 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README
```
@@ -433,6 +433,10 @@ to an open page). Four things hold it up:
- Keep old fixtures when a source changes shape — the old one is the regression test proving the
parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the
current page and gets overwritten. Do not conflate them.
- **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
forgetting. The stored answer is theirs; do not add a heuristic that overrides it.
- **A list row is one target.** The event row opens the event and does nothing else — status,
effort, notes and the daily checklist all live in the detail sheet. A second control inside a
full-bleed row target is a mis-tap waiting to happen, and a decorative chevron says "this opens"
+2 -2
View File
@@ -108,7 +108,7 @@ src/
Fireworks.tsx the burst when the last daily lands
GameFocus.tsx one game at a time (F4a)
Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8)
Welcome.tsx first-run games and view (F8)
Toast.tsx undo an ignore
UpdateNotice a newer app is installed and waiting (F14)
YourOwn.tsx the reader's own games, in settings (F13)
@@ -119,7 +119,7 @@ src/
useMarkSet.ts ignores (and the superseded completions shape)
useProgress.ts status, effort, note, daily override (F12)
useDailyLog.ts which game-days are ticked off
usePrefs.ts region, filters, focus, onboarding flags
usePrefs.ts region, filters, focus, view, onboarding flags
useCustom.ts the reader's own games and events (F13)
gameMeta.tsx lane id → name, label, hue; resolves custom lanes too
sort.ts deadline order, or what you're partway through
+1 -1
View File
@@ -200,7 +200,7 @@ 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, detectDaily,
"gacha-tracker:v1:prefs" // { region, hiddenGames[], focusGame, sort, view, detectDaily,
// showCompleted, showIgnored, regionConfirmed, onboarded }
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
```
+1 -1
View File
@@ -303,7 +303,7 @@ diagnosis in each item still holds — what changed is whether it has been acted
| P1a Arknights | **Done.** `arknights-akwiki-events`, via the new `akwiki` parser |
| P1b `NextUp` → three | **Done** (2026-08-18). One headline and two behind it, off `nextToExpire` |
| P1b cap the long list | **Not done.** No "show all N" expander |
| P1b persist `view` | **Not done.** Still `useState` in `App.tsx` |
| P1b persist `view` | **Done** (2026-08-18). `prefs.view`, and the first run now asks which one to open on (PRD F8) |
| P1b Calendar → Timeline | **Done.** The tab reads "Timeline" |
| P1c more games | **Done, four of them.** Infinity Nikki, Persona 5: The Phantom X, Reverse: 1999, Blue Archive — one commit each. Of the games named in the thread, Azur Lane and Umamusume are **declined on conduct** rather than pending, and the declined and cleared-but-unbuilt candidates are recorded in `AGENTS.md` § Scraping conduct so they are not re-litigated |
| P1c roadmap in the colophon | **Not done** |
+15 -4
View File
@@ -70,8 +70,13 @@ unknown" — it must be visually distinct from an event that ends far in the fut
**F2 — Ends-soonest list.**
A flat list of all *currently running* events sorted ascending by end date, with a relative
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
view that justifies the app; it should be reachable in one tap from the calendar and is the better
default on narrow screens.
view that justifies the app, and it is one tap from the timeline.
**Which view opens is the reader's answer, not ours.** This spec said "calendar (default)" and the
app shipped opening on the list; both were a decision made on the reader's behalf and then forgotten
on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is
what the question ships pre-answered with — a reader cannot choose between two layouts they have not
seen, and it is the view that answers "what expires next" in one look.
**F3 — Mark completed.**
A toggle on every event, in both views. State is written to `localStorage` immediately and
@@ -149,13 +154,19 @@ Four constraints, each protecting something that already exists:
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.**
Before any events are shown, the reader picks which games they play. A calendar full of games they
**F8 — First-run setup.**
Before any events are shown, the reader picks which games they play, and how they want to read
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.
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,
it arrives already answered, and the screen says where to change it afterwards: the tabs are small
text in a corner, which is the one control a first-time reader will not find on their own.
**F9 — Ignore an event.**
Distinct from completing one. "Done" keeps an event visible and counted; "not interested" removes it
from both views entirely. Ignored events stay recoverable: a count and a reveal toggle appear in
+8 -6
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 } from "./state/usePrefs.ts";
import { 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 {
@@ -38,8 +38,6 @@ import {
} from "../shared/custom.ts";
import { metaFor } from "../shared/games.ts";
type View = "soon" | "timeline";
/**
* How many deadlines the headline carries.
*
@@ -83,7 +81,6 @@ function useNow(intervalMs = 1000): number {
export function App() {
const [state, setState] = useState<FeedState>({ status: "loading" });
const [view, setView] = useState<View>("soon");
const [openId, setOpenId] = useState<string | null>(null);
// The event most recently ignored, so it can be put back without hunting for
// a row that just disappeared.
@@ -91,6 +88,10 @@ export function App() {
const now = useNow();
const online = useOnline();
const { prefs, update, toggleGame } = usePrefs();
// Their answer from the first run, or their last tap on the tabs. Reading it
// from `prefs` is what stops a reload putting a timeline reader back on the
// list they did not choose.
const view = prefs.view;
const ignored = useMarkSet(KEYS.ignored);
const prog = useProgress();
const daily = useDailyLog();
@@ -292,10 +293,11 @@ export function App() {
<Shell>
<Welcome
available={games}
onConfirm={(chosen) =>
onConfirm={(chosen, chosenView) =>
update({
onboarded: true,
hiddenGames: games.filter((g) => !chosen.includes(g)),
view: chosenView,
})
}
/>
@@ -338,7 +340,7 @@ export function App() {
key={id}
role="tab"
aria-selected={view === id}
onClick={() => setView(id)}
onClick={() => update({ view: id })}
className={`rounded-[6px] px-2.5 py-1.5 text-xs font-medium transition-colors ${
view === id ? "bg-raised text-ink" : "text-faint hover:text-muted"
}`}
+171 -8
View File
@@ -1,25 +1,36 @@
import { useState } from "react";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
import type { View } from "../state/usePrefs.ts";
/**
* First run: pick your games.
* First run: pick your games, and how you want to read them.
*
* Asked once, before any events are shown, because a calendar full of games you
* don't play is worse than an empty one — it buries the thing you came for.
*
* Nothing is preselected. An empty state with a disabled button is clearer than
* guessing on the reader's behalf and hoping they notice.
* Nothing is preselected among the games. An empty state with a disabled button
* is clearer than guessing on the reader's behalf and hoping they notice.
*/
export function Welcome({
available,
onConfirm,
}: {
available: LaneId[];
onConfirm: (chosen: LaneId[]) => void;
onConfirm: (chosen: LaneId[], view: View) => void;
}) {
const gameMeta = useGameMeta();
const [chosen, setChosen] = useState<LaneId[]>([]);
/**
* The view is the one thing here that arrives already answered.
*
* A reader cannot be asked to choose between two layouts they have not seen,
* so the question ships with the answer this app is built around — the next
* deadline, in one look — and the alternative sitting next to it with a
* picture of what it is. Games stay unanswered because only the reader knows
* which ones they play; this one has a defensible default.
*/
const [view, setView] = useState<View>("soon");
const toggle = (id: LaneId) =>
setChosen((prev) =>
@@ -27,7 +38,7 @@ export function Welcome({
);
return (
<div className="flex min-h-full flex-col px-5 py-12">
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col px-5 py-12">
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]">
EVENT<span className="text-near">CLOCK</span>
</p>
@@ -40,7 +51,10 @@ export function Welcome({
anywhere but this browser.
</p>
<div className="mt-8 flex flex-col gap-2">
{/* Two columns once there is room for them: a dozen games in one column
pushes the view question and the way in off the bottom of the screen,
on the one screen where both need to be seen. */}
<div className="mt-8 grid gap-2 sm:grid-cols-2">
{available.map((id) => {
const game = gameMeta(id);
const on = chosen.includes(id);
@@ -90,11 +104,21 @@ export function Welcome({
})}
</div>
{/* The lanes in the timeline sketch are drawn in the hues of the first
games on this very screen, because that is what a lane is: a game.
Borrowing the urgency ramp for them would teach the wrong colour rule
before the reader has seen a single event. */}
<ViewChoice
value={view}
onChange={setView}
hues={available.slice(0, 3).map((id) => gameMeta(id).hue)}
/>
<div className="mt-8 flex flex-col gap-3">
<button
type="button"
disabled={chosen.length === 0}
onClick={() => onConfirm(chosen)}
onClick={() => onConfirm(chosen, view)}
className="rounded-xl bg-ink px-5 py-3 text-sm font-semibold text-ground transition-colors duration-150 hover:bg-white disabled:cursor-not-allowed disabled:bg-raised disabled:text-faint"
>
{chosen.length === 0
@@ -103,7 +127,7 @@ export function Welcome({
</button>
<button
type="button"
onClick={() => onConfirm(available)}
onClick={() => onConfirm(available, view)}
className="text-xs text-faint transition-colors duration-150 hover:text-muted"
>
Show everything instead
@@ -117,3 +141,142 @@ export function Welcome({
</div>
);
}
/**
* Which of the two views to open on.
*
* The views answer different questions — "what expires next" and "when is
* everything" — and which one a reader wants is not derivable from anything we
* know about them. It was previously decided for them and then forgotten on
* every reload; now it is asked once and remembered (`prefs.view`).
*
* Each option carries a drawing of itself rather than a description alone: the
* words "list" and "timeline" mean nothing until you have seen this app's
* version of them, and the miniature is honest about which one is denser.
*/
function ViewChoice({
value,
onChange,
hues,
}: {
value: View;
onChange: (view: View) => void;
/** Lane colours for the timeline sketch — real games, in their own hues. */
hues: string[];
}) {
const options: Array<{ id: View; label: string; hint: string }> = [
{ id: "soon", label: "Ending soon", hint: "A list, closest deadline first." },
{ id: "timeline", label: "Timeline", hint: "Bars per game, today pinned." },
];
return (
<div className="mt-10">
<h2 className="font-display text-lg font-semibold tracking-tight">
How do you want to see them?
</h2>
<div role="radiogroup" aria-label="Opening view" className="mt-3 grid gap-2 sm:grid-cols-2">
{options.map((option) => {
const on = value === option.id;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={on}
onClick={() => onChange(option.id)}
className={`rounded-xl border px-4 py-3.5 text-left transition-colors duration-150 ${
on
? "border-near bg-near/10"
: "border-hairline hover:border-faint"
}`}
>
{option.id === "soon" ? <ListSketch /> : <TimelineSketch hues={hues} />}
<p
className={`mt-3 text-[0.9375rem] font-medium ${
on ? "text-ink" : "text-muted"
}`}
>
{option.label}
</p>
<p className="mt-0.5 text-xs leading-snug text-faint">{option.hint}</p>
</button>
);
})}
</div>
{/* Where the control is, in the reader's own terms — the tabs are 12px
text in a corner, which is exactly the thing a first-time reader does
not find on their own. */}
<p className="mt-3 text-xs leading-relaxed text-faint">
You can switch between them any time, from the tabs in the top right.
</p>
</div>
);
}
/** Three rows and their meters, at a twelfth of the size. */
function ListSketch() {
const rows = [
{ width: "72%", ticks: 3, color: "var(--color-critical)" },
{ width: "58%", ticks: 6, color: "var(--color-soon)" },
{ width: "66%", ticks: 9, color: "var(--color-near)" },
];
return (
<span aria-hidden className="flex h-12 flex-col justify-center gap-2">
{rows.map((row) => (
<span key={row.width} className="flex flex-col gap-1">
<span
className="h-1 rounded-full bg-hairline"
style={{ width: row.width }}
/>
<span className="flex gap-[2px]">
{Array.from({ length: 12 }, (_, i) => (
<span
key={i}
className="h-1 w-1 rounded-[1px]"
style={{
background:
i < row.ticks ? row.color : "var(--color-hairline)",
}}
/>
))}
</span>
</span>
))}
</span>
);
}
/** Three lanes of bars against the now-rule, at the same size. */
function TimelineSketch({ hues }: { hues: string[] }) {
const geometry = [
{ left: "4%", width: "44%" },
{ left: "28%", width: "52%" },
{ left: "12%", width: "70%" },
];
const bars = geometry.map((bar, i) => ({
...bar,
// Falls back to the neutral tone rather than to a heat colour: a lane is an
// identity, and this sketch must never look like it is showing urgency.
hue: hues[i] ?? "var(--color-calm)",
}));
return (
<span aria-hidden className="relative flex h-12 flex-col justify-center gap-1.5">
<span className="absolute inset-y-0 left-[38%] w-px bg-critical/70" />
{bars.map((bar) => (
<span key={bar.left} className="relative block h-2.5">
<span
className="absolute h-full rounded-[2px]"
style={{
left: bar.left,
width: bar.width,
background: `color-mix(in srgb, ${bar.hue} 30%, var(--color-raised))`,
borderLeft: `2px solid ${bar.hue}`,
}}
/>
</span>
))}
</span>
);
}
+18
View File
@@ -5,6 +5,16 @@ import { guessRegion } from "../../shared/time.ts";
import type { SortMode } from "./sort.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
/**
* Which of the two views the reader is looking at.
*
* Lives here rather than in `App` because it is the reader's answer to "how do
* I read this?", and component state loses it on every reload — a reader who
* prefers the timeline was being put back on the list each time they opened the
* page, with nothing to blame but the app forgetting.
*/
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. */
@@ -20,6 +30,13 @@ export interface Prefs {
focusGame: LaneId | null;
/** How the list is ordered. Deadline order is the default and the fallback. */
sort: SortMode;
/**
* The view they were last reading. The list is the default: the page's whole
* claim is answering "what expires next" in one look, and the timeline
* answers "when is everything" — a slower question. One tap moves between
* them and the choice is remembered from then on.
*/
view: View;
/**
* Whether to guess which events repeat daily from what the source printed.
* Off leaves only the ones the reader marked themselves; it never discards a
@@ -45,6 +62,7 @@ function defaults(): Prefs {
hiddenGames: [],
focusGame: null,
sort: "ending",
view: "soon",
detectDaily: false,
showCompleted: true,
showIgnored: false,
+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"');
});
});