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
+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"
}`}