From 7804a08863242a5b117cb82e21292d90083640fd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 18 Aug 2026 03:28:03 +0200 Subject: [PATCH] feat(prefs): ask which view to open on, and remember the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- AGENTS.md | 6 +- docs/ARCHITECTURE.md | 4 +- docs/DATA-MODEL.md | 2 +- docs/FEEDBACK.md | 2 +- docs/PRD.md | 19 +++- src/client/App.tsx | 14 ++- src/client/components/Welcome.tsx | 179 ++++++++++++++++++++++++++++-- src/client/state/usePrefs.ts | 18 +++ test/views.test.tsx | 33 +++++- 9 files changed, 250 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3278d4b..4adbc39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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// 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" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70ac8ab..19d5917 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index e9ac42f..d858370 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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 ``` diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index d3d9bae..0e5e4e3 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -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** | diff --git a/docs/PRD.md b/docs/PRD.md index 88c372a..d456b7b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 diff --git a/src/client/App.tsx b/src/client/App.tsx index 37cfd60..82062eb 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 } 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({ status: "loading" }); - const [view, setView] = useState("soon"); const [openId, setOpenId] = useState(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() { + 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" }`} diff --git a/src/client/components/Welcome.tsx b/src/client/components/Welcome.tsx index fbbe3b1..38f12cf 100644 --- a/src/client/components/Welcome.tsx +++ b/src/client/components/Welcome.tsx @@ -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([]); + /** + * 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("soon"); const toggle = (id: LaneId) => setChosen((prev) => @@ -27,7 +38,7 @@ export function Welcome({ ); return ( -
+

EVENTCLOCK

@@ -40,7 +51,10 @@ export function Welcome({ anywhere but this browser.

-
+ {/* 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. */} +
{available.map((id) => { const game = gameMeta(id); const on = chosen.includes(id); @@ -90,11 +104,21 @@ export function Welcome({ })}
+ {/* 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. */} + gameMeta(id).hue)} + /> +
); } + +/** + * 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 ( +
+

+ How do you want to see them? +

+ +
+ {options.map((option) => { + const on = value === option.id; + return ( + + ); + })} +
+ + {/* 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. */} +

+ You can switch between them any time, from the tabs in the top right. +

+
+ ); +} + +/** 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 ( + + {rows.map((row) => ( + + + + {Array.from({ length: 12 }, (_, i) => ( + + ))} + + + ))} + + ); +} + +/** 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 ( + + + {bars.map((bar) => ( + + + + ))} + + ); +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index b184653..2284848 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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, diff --git a/test/views.test.tsx b/test/views.test.tsx index 933f36f..5d0989d 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -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( {}} />); + + 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"'); + }); +});