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:
co-authored by
Claude Opus 5
parent
41046899f9
commit
7804a08863
@@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest
|
|||||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
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)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 517 tests
|
test/ 536 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
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
|
- 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
|
parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the
|
||||||
current page and gets overwritten. Do not conflate them.
|
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,
|
- **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
|
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"
|
full-bleed row target is a mis-tap waiting to happen, and a decorative chevron says "this opens"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ src/
|
|||||||
Fireworks.tsx the burst when the last daily lands
|
Fireworks.tsx the burst when the last daily lands
|
||||||
GameFocus.tsx one game at a time (F4a)
|
GameFocus.tsx one game at a time (F4a)
|
||||||
Controls.tsx games, region, export/import(F4, F5, F6)
|
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
|
Toast.tsx undo an ignore
|
||||||
UpdateNotice a newer app is installed and waiting (F14)
|
UpdateNotice a newer app is installed and waiting (F14)
|
||||||
YourOwn.tsx the reader's own games, in settings (F13)
|
YourOwn.tsx the reader's own games, in settings (F13)
|
||||||
@@ -119,7 +119,7 @@ src/
|
|||||||
useMarkSet.ts ignores (and the superseded completions shape)
|
useMarkSet.ts ignores (and the superseded completions shape)
|
||||||
useProgress.ts status, effort, note, daily override (F12)
|
useProgress.ts status, effort, note, daily override (F12)
|
||||||
useDailyLog.ts which game-days are ticked off
|
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)
|
useCustom.ts the reader's own games and events (F13)
|
||||||
gameMeta.tsx lane id → name, label, hue; resolves custom lanes too
|
gameMeta.tsx lane id → name, label, hue; resolves custom lanes too
|
||||||
sort.ts deadline order, or what you're partway through
|
sort.ts deadline order, or what you're partway through
|
||||||
|
|||||||
+1
-1
@@ -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:progress" // { [eventId]: { status?, effort?, note?, at } }
|
||||||
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
|
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
|
||||||
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
|
"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 }
|
// showCompleted, showIgnored, regionConfirmed, onboarded }
|
||||||
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
|
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| 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 `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 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" |
|
| 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 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** |
|
| P1c roadmap in the colophon | **Not done** |
|
||||||
|
|||||||
+15
-4
@@ -70,8 +70,13 @@ unknown" — it must be visually distinct from an event that ends far in the fut
|
|||||||
**F2 — Ends-soonest list.**
|
**F2 — Ends-soonest list.**
|
||||||
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
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
|
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
|
view that justifies the app, and it is one tap from the timeline.
|
||||||
default on narrow screens.
|
|
||||||
|
**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.**
|
**F3 — Mark completed.**
|
||||||
A toggle on every event, in both views. State is written to `localStorage` immediately and
|
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
|
which is the same argument the code already makes for streaks. This is the *only* copy — there is
|
||||||
no server to restore from.
|
no server to restore from.
|
||||||
|
|
||||||
**F8 — First-run game picker.**
|
**F8 — First-run setup.**
|
||||||
Before any events are shown, the reader picks which games they play. A calendar full of games they
|
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
|
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
|
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
|
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.
|
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.**
|
**F9 — Ignore an event.**
|
||||||
Distinct from completing one. "Done" keeps an event visible and counted; "not interested" removes it
|
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
|
from both views entirely. Ignored events stay recoverable: a count and a reveal toggle appear in
|
||||||
|
|||||||
+8
-6
@@ -17,7 +17,7 @@ import { useAppUpdate } from "./state/useAppUpdate.ts";
|
|||||||
import { useMarkSet } from "./state/useMarkSet.ts";
|
import { useMarkSet } from "./state/useMarkSet.ts";
|
||||||
import { useProgress } from "./state/useProgress.ts";
|
import { useProgress } from "./state/useProgress.ts";
|
||||||
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.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 { useCustom } from "./state/useCustom.ts";
|
||||||
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
||||||
import {
|
import {
|
||||||
@@ -38,8 +38,6 @@ import {
|
|||||||
} from "../shared/custom.ts";
|
} from "../shared/custom.ts";
|
||||||
import { metaFor } from "../shared/games.ts";
|
import { metaFor } from "../shared/games.ts";
|
||||||
|
|
||||||
type View = "soon" | "timeline";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many deadlines the headline carries.
|
* How many deadlines the headline carries.
|
||||||
*
|
*
|
||||||
@@ -83,7 +81,6 @@ function useNow(intervalMs = 1000): number {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [state, setState] = useState<FeedState>({ status: "loading" });
|
const [state, setState] = useState<FeedState>({ status: "loading" });
|
||||||
const [view, setView] = useState<View>("soon");
|
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
// The event most recently ignored, so it can be put back without hunting for
|
// The event most recently ignored, so it can be put back without hunting for
|
||||||
// a row that just disappeared.
|
// a row that just disappeared.
|
||||||
@@ -91,6 +88,10 @@ export function App() {
|
|||||||
const now = useNow();
|
const now = useNow();
|
||||||
const online = useOnline();
|
const online = useOnline();
|
||||||
const { prefs, update, toggleGame } = usePrefs();
|
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 ignored = useMarkSet(KEYS.ignored);
|
||||||
const prog = useProgress();
|
const prog = useProgress();
|
||||||
const daily = useDailyLog();
|
const daily = useDailyLog();
|
||||||
@@ -292,10 +293,11 @@ export function App() {
|
|||||||
<Shell>
|
<Shell>
|
||||||
<Welcome
|
<Welcome
|
||||||
available={games}
|
available={games}
|
||||||
onConfirm={(chosen) =>
|
onConfirm={(chosen, chosenView) =>
|
||||||
update({
|
update({
|
||||||
onboarded: true,
|
onboarded: true,
|
||||||
hiddenGames: games.filter((g) => !chosen.includes(g)),
|
hiddenGames: games.filter((g) => !chosen.includes(g)),
|
||||||
|
view: chosenView,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -338,7 +340,7 @@ export function App() {
|
|||||||
key={id}
|
key={id}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={view === id}
|
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 ${
|
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"
|
view === id ? "bg-raised text-ink" : "text-faint hover:text-muted"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
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
|
* 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.
|
* 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
|
* Nothing is preselected among the games. An empty state with a disabled button
|
||||||
* guessing on the reader's behalf and hoping they notice.
|
* is clearer than guessing on the reader's behalf and hoping they notice.
|
||||||
*/
|
*/
|
||||||
export function Welcome({
|
export function Welcome({
|
||||||
available,
|
available,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
available: LaneId[];
|
available: LaneId[];
|
||||||
onConfirm: (chosen: LaneId[]) => void;
|
onConfirm: (chosen: LaneId[], view: View) => void;
|
||||||
}) {
|
}) {
|
||||||
const gameMeta = useGameMeta();
|
const gameMeta = useGameMeta();
|
||||||
const [chosen, setChosen] = useState<LaneId[]>([]);
|
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) =>
|
const toggle = (id: LaneId) =>
|
||||||
setChosen((prev) =>
|
setChosen((prev) =>
|
||||||
@@ -27,7 +38,7 @@ export function Welcome({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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]">
|
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]">
|
||||||
EVENT<span className="text-near">CLOCK</span>
|
EVENT<span className="text-near">CLOCK</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -40,7 +51,10 @@ export function Welcome({
|
|||||||
anywhere but this browser.
|
anywhere but this browser.
|
||||||
</p>
|
</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) => {
|
{available.map((id) => {
|
||||||
const game = gameMeta(id);
|
const game = gameMeta(id);
|
||||||
const on = chosen.includes(id);
|
const on = chosen.includes(id);
|
||||||
@@ -90,11 +104,21 @@ export function Welcome({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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">
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={chosen.length === 0}
|
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"
|
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
|
{chosen.length === 0
|
||||||
@@ -103,7 +127,7 @@ export function Welcome({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onConfirm(available)}
|
onClick={() => onConfirm(available, view)}
|
||||||
className="text-xs text-faint transition-colors duration-150 hover:text-muted"
|
className="text-xs text-faint transition-colors duration-150 hover:text-muted"
|
||||||
>
|
>
|
||||||
Show everything instead
|
Show everything instead
|
||||||
@@ -117,3 +141,142 @@ export function Welcome({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { guessRegion } from "../../shared/time.ts";
|
|||||||
import type { SortMode } from "./sort.ts";
|
import type { SortMode } from "./sort.ts";
|
||||||
import { KEYS, readJson, writeJson } from "./storage.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 {
|
export interface Prefs {
|
||||||
region: Region;
|
region: Region;
|
||||||
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
|
/** 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;
|
focusGame: LaneId | null;
|
||||||
/** How the list is ordered. Deadline order is the default and the fallback. */
|
/** How the list is ordered. Deadline order is the default and the fallback. */
|
||||||
sort: SortMode;
|
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.
|
* 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
|
* Off leaves only the ones the reader marked themselves; it never discards a
|
||||||
@@ -45,6 +62,7 @@ function defaults(): Prefs {
|
|||||||
hiddenGames: [],
|
hiddenGames: [],
|
||||||
focusGame: null,
|
focusGame: null,
|
||||||
sort: "ending",
|
sort: "ending",
|
||||||
|
view: "soon",
|
||||||
detectDaily: false,
|
detectDaily: false,
|
||||||
showCompleted: true,
|
showCompleted: true,
|
||||||
showIgnored: false,
|
showIgnored: false,
|
||||||
|
|||||||
+29
-4
@@ -1,17 +1,18 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
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 { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
import { metaFor } from "../src/shared/games.ts";
|
import { metaFor } from "../src/shared/games.ts";
|
||||||
import { clockFor } from "../src/shared/time.ts";
|
import { clockFor } from "../src/shared/time.ts";
|
||||||
import { GachaEvent, type GameId } from "../src/shared/schema.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
|
* Not a substitute for using the thing, but they pin the claims each one makes:
|
||||||
* leads with the closest deadline, it carries the ones behind it, and it never
|
* the headline carries the deadlines behind the closest one, and the first run
|
||||||
* dresses an unannounced end up as a countdown.
|
* asks how the reader wants to read the app rather than deciding for them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||||
@@ -95,3 +96,27 @@ describe("NextUp", () => {
|
|||||||
expect(html).toContain("no end date");
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user