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