feat(tour): everyone takes the tour once, and tries the layouts in it

The tour now floats over the page it describes and must be finished
before the app can be used: new readers straight after setup, returning
readers on their next visit, and anyone who saw the earlier tour. The
page behind stays visible and scrollable but inert.

"Pick a layout" (on the Checklist) and the Timeline step switch Relaxed /
Structured and New / Classic in place; the selected look is kept.
Returning readers start on Relaxed. A replay from Settings stays
optional and closable.

redesignTourSeen now records new / returning / replay / 2026-10-tour-2;
any other value means not yet taken.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-18 04:45:02 +02:00
co-authored by Claude Opus 5.5
parent 0abca649fa
commit d8ce442cff
11 changed files with 836 additions and 541 deletions
+4 -11
View File
@@ -266,14 +266,7 @@ note only says where to start.
readers could reach the ~5 MB quota. Failures are reported, not silent.
- After a rollback, anything done in the new build lives only in v2 and isn't shown by the old app.
It's stranded, not lost.
- [ ] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18).
- Relaxed vs Structured, and the Timeline's new vs classic look. Each is one switch in
Settings → Appearance today, which most readers will never open.
- Show the difference live, on the reader's own events: a "try it" that switches the page in place,
with an easy way back, rather than a description or a static picture.
- Aim this especially at returning readers, who arrive on the default (Structured, new Timeline)
without having chosen it.
- Offer it even to readers who have already done or skipped the returning-user tour (the tour
records `redesignTourSeen`; this needs its own "seen" so it isn't swallowed by that).
- Open questions: where it appears (a one-time card on the Checklist and Timeline? a tour step?), and
whether trying a look saves it or only previews it until confirmed.
- [x] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). Done
2026-10-18. The tour is now forced once for everyone, including new readers and anyone who saw the
earlier tour. Its "Pick a layout" and Timeline steps switch the page behind in place, and the
selected look is kept. Returning readers start on Relaxed. See `docs/DESIGN.md` → "The tour".
+7
View File
@@ -816,6 +816,13 @@ Prefs add interface (structured/default or relaxed), density (standard/default o
soon or timeline; utility navigation is transient. Future/unreadable records remain exportable even
when this build cannot offer editing controls.
`prefs.redesignTourSeen` (2026-10-18) records where the reader is with the tour. Its values are
`"2026-10-tour-2"` (done), `"new"` or `"returning"` (the tour is forced; the value picks the opening
line), and `"replay"` (optional). Any other value, including the earlier tour's `"2026-10-redesign"`,
means the reader has not taken this tour: the app moves them to `interface: "relaxed"` and stores
`"returning"`. A later tour version changes the done value, so every reader takes it once more. See
`tourMode` in `ReturningTour.tsx`.
The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`)
for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the
+14 -3
View File
@@ -631,6 +631,17 @@ legend on the same baseline as "Next: …".
(interface, theme, scheme, urgency colours, default view, game order, timeline design, focus bar,
checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and
per-game Setup / Codes / About.
- **Returning-user tour.** Routines → Checklist → Timeline → Redeem codes → Archive → Settings.
Continue stays in a fixed place, Back sits beside it, and Skip is apart. "Replay the introduction"
starts it at step 1.
- **The tour (2026-10-18, owner).** Every reader takes it once before using the app: new readers
straight after setup, returning readers on their first visit, and readers who saw the earlier tour
once more. Routines → Checklist → Pick a layout → Timeline → Redeem codes → Archive → Settings.
- It floats over the page it describes. While forced, the page behind is `inert` under a light
scrim. It can be scrolled and seen, but not clicked. There is no Skip, and a reload restarts at
step 1.
- "Pick a layout" (on the Checklist) and the Timeline step each hold a two-way switch, Relaxed /
Structured and New / Classic. The switch changes the page behind in place, and the selected one
is kept. Readers are shown the difference rather than told about it, because most never open
Appearance.
- Returning readers are moved to Relaxed as the tour opens. New readers keep the look they chose in
setup.
- Continue stays in a fixed place and Back sits beside it. Only a replay ("Replay the
introduction" in Appearance) has a way out, "Close tour", with no scrim.
+1 -1
View File
@@ -103,7 +103,7 @@ for (const variant of ["structured", "relaxed"])
let s = JSON.parse(
localStorage.getItem("gacha-tracker:v2:reader"),
);
s.prefs.redesignTourSeen = "2026-10-redesign";
s.prefs.redesignTourSeen = "2026-10-tour-2";
s.prefs.interface = variant;
s.prefs.theme = theme;
s.prefs.changelogSeen = "9999-12-31";
@@ -938,3 +938,15 @@ redesign commit.
page. See docs/SOURCES.md § Redeem codes for eight more games.
- **Owner's TODO:** let readers try Relaxed/Structured and the new/classic Timeline live, including
returning readers who've done the tour.
## Round: the forced tour and trying the looks (2026-10-18)
The owner asked for every reader, new and returning, to be put through the tour before they can use
the app, and for returning readers to start on Relaxed. "Pick a layout" (Checklist) and the Timeline
step switch the page behind in place, and the selected look is kept. See `docs/DESIGN.md` → "The tour"
and `docs/DATA-MODEL.md` for the `redesignTourSeen` values.
Schema review: no data-loss or stuck-state path. Accepted, by the owner's rule: a returning reader who
had picked Structured on purpose is moved to Relaxed once, and the previous value isn't kept. The tour's
layout step puts it back in one click. Left as is (low): a stale second tab can re-force the tour
once, and a save-error banner shown during a forced tour can be read but not clicked.
+500 -451
View File
@@ -91,7 +91,10 @@ import { GameSettings } from "./components/redesign/GameSettings.tsx";
import { RoutinesPage } from "./components/redesign/RoutinesPage.tsx";
import { DailyPanel } from "./components/redesign/DailyPanel.tsx";
import { RoutineGroup } from "./components/redesign/RoutineGroup.tsx";
import { TimelinePage } from "./components/redesign/TimelinePage.tsx";
import {
TimelinePage,
timelineLook,
} from "./components/redesign/TimelinePage.tsx";
import { focusBarShown } from "./components/redesign/FocusPicker.tsx";
import { ArchiveDetail } from "./components/redesign/ArchiveDetail.tsx";
import { ArchivePage } from "./components/redesign/ArchivePage.tsx";
@@ -102,7 +105,11 @@ import { UpdatesPanel } from "./components/redesign/UpdatesPanel.tsx";
import { DataSourcesPage } from "./components/redesign/DataSourcesPage.tsx";
import { FeedFooter } from "./components/redesign/FeedFooter.tsx";
import { SetupWizard } from "./components/redesign/SetupWizard.tsx";
import { ReturningTour } from "./components/redesign/ReturningTour.tsx";
import {
ReturningTour,
TOUR_DONE,
tourMode,
} from "./components/redesign/ReturningTour.tsx";
/** The upcoming section's sub-line: "3 events in 2 games". */
function upcomingSummary(rows: readonly { event: { game: string } }[]): string {
@@ -258,8 +265,6 @@ function ReaderContent({
// A new tab starts at its top. Without this the window kept the last page's
// scroll, so leaving a scrolled Settings page opened the Checklist part-way
// down. The first render is skipped so a reload's restored scroll stands.
// Set by Replay the introduction, so the tour opens on its first step.
const [tourAutoStart, setTourAutoStart] = useState(false);
const firstPage = useRef(true);
useEffect(() => {
if (firstPage.current) {
@@ -268,6 +273,21 @@ function ReaderContent({
}
window.scrollTo(0, 0);
}, [page]);
// Everyone takes the tour once before using the app; a replay from Settings
// is optional. A returning reader who has not taken it starts on Relaxed.
const tour = prefs.onboarded
? tourMode(state.prefs.redesignTourSeen)
: "done";
useEffect(() => {
if (tour !== "unseen") return;
void dispatch({
type: "prefs",
patch: { interface: "relaxed", redesignTourSeen: "returning" },
...actionMeta(),
});
}, [tour]);
const touring = tour === "new" || tour === "returning" || tour === "replay";
const tourForced = tour === "new" || tour === "returning";
const [gameTab, setGameTab] = useState<"setup" | "codes">("setup");
const [codesGame, setCodesGame] = useState<string | undefined>(
startRoute?.game,
@@ -669,461 +689,490 @@ function ReaderContent({
return (
<DisplayTimeContext value={zone}>
<GameMetaProvider value={meta}>
<main className="app-shell reader-ui">
<header className="top">
{prefs.onboarded ? (
<button
type="button"
className="mark"
aria-label="Event Clock — go to your default view"
onClick={() => changePage(prefs.view)}
>
Event<span>Clock</span>
</button>
) : (
<p className="mark">
Event<span>Clock</span>
<main
className="app-shell reader-ui"
data-touring={touring || undefined}
>
{/* While the tour is forced the page behind shows each stop but
takes no clicks or focus. `display: contents` keeps the layout
as if this wrapper were not here. */}
<div className="tour-behind" inert={tourForced}>
<header className="top">
{prefs.onboarded ? (
<button
type="button"
className="mark"
aria-label="Event Clock — go to your default view"
onClick={() => changePage(prefs.view)}
>
Event<span>Clock</span>
</button>
) : (
<p className="mark">
Event<span>Clock</span>
</p>
)}
{prefs.onboarded && (
<AppNavigation
page={page}
onPage={changePage}
counts={{
soon: outstanding.filter((r) => r.clock.live).length,
routines: accounts
.flatMap((a) => routineRows(state, a.id, now))
.filter((r) => r.placement.followed && !r.status.met)
.length,
archive: Object.keys(state.archive).length,
}}
/>
)}
</header>
{saveError && !saveError.ok && (
<p role="alert" className="save-error">
Your last change could not be saved ({saveError.reason}). Your
previous data remains available. Try again or export it in
Backup.
</p>
)}
{prefs.onboarded && (
<AppNavigation
page={page}
onPage={changePage}
counts={{
soon: outstanding.filter((r) => r.clock.live).length,
routines: accounts
.flatMap((a) => routineRows(state, a.id, now))
.filter((r) => r.placement.followed && !r.status.met)
.length,
archive: Object.keys(state.archive).length,
}}
/>
{backupError && (
<p role="status" className="help">
{backupError}
</p>
)}
</header>
{saveError && !saveError.ok && (
<p role="alert" className="save-error">
Your last change could not be saved ({saveError.reason}). Your
previous data remains available. Try again or export it in Backup.
</p>
)}
{backupError && (
<p role="status" className="help">
{backupError}
</p>
)}
{!prefs.onboarded ? (
<SetupWizard
state={state}
meta={meta}
onFinish={async (draft) => {
const next = setupReader(
draft,
state,
new Date().toISOString(),
mintAccountId,
);
for (const account of Object.values(next.accounts)) {
const result = await dispatch({
type: "account",
account,
...actionMeta(),
});
if (!result.ok) return;
}
const result = await dispatch({
type: "prefs",
patch: next.prefs,
...actionMeta(),
});
if (result.ok) setPage(draft.view);
}}
/>
) : (
<>
{state.prefs.redesignTourSeen !== "2026-10-redesign" && (
<ReturningTour
autoStart={tourAutoStart}
onStep={(stop) => {
if (stop !== "codes") {
if (stop === "settings") showSettings("appearance");
else setPage(stop);
return;
}
// The Codes tab of a tracked game that has codes, else the
// Checklist, where new codes are announced.
const game = accounts
.map((a) => a.game)
.find((g) => codes?.codes.some((c) => c.game === g));
if (!game) {
setPage("soon");
return;
}
setGameTab("codes");
setCodesGame(game);
showSettings("games");
}}
onFinish={() => {
setTourAutoStart(false);
void dispatch({
type: "prefs",
patch: { redesignTourSeen: "2026-10-redesign" },
{!prefs.onboarded ? (
<SetupWizard
state={state}
meta={meta}
onFinish={async (draft) => {
const next = setupReader(
draft,
state,
new Date().toISOString(),
mintAccountId,
);
for (const account of Object.values(next.accounts)) {
const result = await dispatch({
type: "account",
account,
...actionMeta(),
});
setPage(prefs.view);
}}
/>
)}
{page === "soon" && (
<div className="checklist-layout">
<aside className="checklist-rail">
<div className="rail">
{focusBarShown(state.prefs, "soon") && focusBar}
<NextDeadline
row={scoped.find((r) => r.clock.live)}
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
servers={(game) => manyServers(state, game)}
onOpen={setOpen}
/>
{separate && (
<DailyPanel
onManage={() => changePage("routines")}
rows={pins.filter((r) => r.cadence === "day")}
state={state}
onAction={dispatch}
now={now}
/>
)}
<section className="pinned-routines">
<div className="pinned-heading">
<div>
<h2>Routines</h2>
<p className="sub">Showing only pinned</p>
</div>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
</div>
{accounts
.filter((a) => focus === null || a.game === focus)
.map((a) => {
const rows = pins.filter(
(r) =>
r.accountId === a.id &&
(!separate || r.cadence !== "day"),
);
return rows.length ? (
<RoutineGroup
key={
a.id +
focus +
String(state.prefs.routineDisclosure)
}
title={`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
hue={meta(a.game).hue}
done={rows.filter((r) => r.status.met).length}
total={rows.length}
summary={
rows.every((r) => r.status.met)
? `All ${rows.length} done`
: `${rows.filter((r) => r.status.met).length} of ${rows.length} completed`
}
policy={
state.prefs.routineDisclosure === "always"
? "always"
: state.prefs.routineDisclosure ===
"expanded"
? "expanded"
: "collapsed"
}
focused={focus !== null}
>
{rows.map((row) => (
<PinnedRoutine
key={row.id}
row={row}
hue={meta(a.game).hue}
onAction={dispatch}
now={now}
/>
))}
</RoutineGroup>
) : null;
})}
<button
type="button"
className="quiet"
onClick={() => changePage("routines")}
>
Manage routines
</button>
</section>
</div>
</aside>
<div className="checklist-main">
{mainUpdates}
<div className="list-heading running-heading">
<h1>Running now</h1>
<div className="list-tools">
<div className="segmented">
{["standard", "compact"].map((d) => (
<button
type="button"
key={d}
aria-pressed={prefs.density === d}
onClick={() =>
void dispatch({
type: "prefs",
patch: { density: d },
...actionMeta(),
})
}
>
{d === "standard" ? "Standard" : "Compact"}
</button>
))}
</div>
<div className="segmented" aria-label="Sort events">
{(["ending", "doing"] as const).map((sort) => (
<button
key={sort}
aria-pressed={prefs.sort === sort}
onClick={() =>
void dispatch({
type: "prefs",
patch: { sort },
...actionMeta(),
})
}
>
{sort === "ending"
? "Ending soonest"
: "Doing first"}
</button>
))}
</div>
</div>
{/* One line under the title and the buttons: the count
on the left, live's legend on the right. */}
<div className="heading-meta">
<p className="sub">
{visible.filter((r) => r.clock.live).length} events in{" "}
{new Set(visible.map((r) => r.event.game)).size} games
· sorted by what ends first
{!online ? " · offline" : ""}
</p>
<UrgencyLegend />
</div>
</div>
{visible.length ? (
<>
{prefs.density === "compact" && <LedgerColumns />}
<ul className="event-list">
{visible
.filter((r) => !r.clock.upcoming)
.map(renderRow)}
</ul>
{visible.some((r) => r.clock.upcoming) && (
<section className="upcoming-events">
<div className="list-heading">
<div>
<p className="eyebrow">Coming up</p>
<h2>Not started yet</h2>
<p className="sub">
{upcomingSummary(
visible.filter((r) => r.clock.upcoming),
)}
</p>
</div>
</div>
{prefs.density === "compact" && (
<LedgerColumns upcoming />
)}
<ul className="event-list">
{visible
.filter((r) => r.clock.upcoming)
.map(renderRow)}
</ul>
</section>
)}
</>
) : (
<p className="empty-state">
No events to show. Choose another game or change
tracking in Settings → Your games.
</p>
)}
<p className="list-foot">
Done or ignored events leave this list and wait in the{" "}
<button onClick={() => changePage("archive")}>
Archive
</button>
.
</p>
</div>
</div>
)}
{page === "timeline" && (
<>
{mainUpdates}
<TimelinePage
state={state}
focus={focus}
focusBar={
focusBarShown(state.prefs, "timeline") ? focusBar : null
}
rows={visible}
now={now}
onAction={dispatch}
onOpen={setOpen}
/>
</>
)}
{page === "routines" && (
<RoutinesPage state={state} onAction={dispatch} now={now} />
)}
{page === "archive" && (
<ArchivePage
state={state}
onAction={dispatch}
now={now}
onOpen={setArchivedOpen}
/>
)}
{page === "settings" && (
<SettingsLayout
state={state}
game={codesGame}
page={settingsPage}
onPage={(p) => {
setCodesGame(undefined);
setSettingsPage(p);
}}
onGame={(g) => {
setCodesGame(g);
setGameTab("setup");
setSettingsPage("games");
}}
>
{settingsPage === "appearance" ||
(settingsPage === "games" && !codesGame) ? (
<AppearancePage
state={state}
onAction={dispatch}
onReplayTour={() => {
setTourAutoStart(true);
void dispatch({
type: "prefs",
patch: { redesignTourSeen: null },
...actionMeta(),
});
}}
/>
) : settingsPage === "games" && codesGame ? (
<GameSettings
key={`${codesGame}-${gameTab}`}
initialTab={gameTab}
codes={codes}
initialGame={codesGame}
state={state}
sources={feed.sources}
onAction={dispatch}
/>
) : settingsPage === "time" ? (
<TimeSettings state={state} now={now} onAction={dispatch} />
) : settingsPage === "sources" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
trackedGames(state).has(s.game),
)}
now={now}
timezone={zone}
online={online}
onRefresh={refreshFeed}
showUpcoming={prefs.showUpcoming}
detectDaily={prefs.detectDaily}
standardPulls={state.prefs.standardPulls === true}
onPrefs={(patch) =>
void dispatch({ type: "prefs", patch, ...actionMeta() })
}
/>
) : settingsPage === "backup" ? (
<BackupPage state={state} />
) : settingsPage === "changelog" ? (
<ChangelogPage notes={NOTES} entries={ENTRIES} />
) : settingsPage === "about" ? (
<AboutPage sources={feed.sources} />
) : settingsPage === "own" ? (
<section>
<h1>Your own events and games</h1>
<p className="help">
Only what you added yourself. Routines live on the
Routines tab.
</p>
<YourOwn {...ownProps} />
</section>
) : null}
</SettingsLayout>
)}
<FeedFooter
sources={feed.sources}
now={now}
online={online}
timezone={zone}
onAbout={() => showSettings("about")}
onRefresh={refreshFeed}
/>
</>
)}
{transition && (
<aside className="archive-toast" role="status">
{transition.label} “{transition.title}” · Moved to Archive{" "}
<button
type="button"
onClick={() => {
void dispatch({
type: "restore",
accountId: transition.accountId,
eventId: transition.eventId,
if (!result.ok) return;
}
// No navigation here: the tour opens next and leads the way,
// ending on the chosen view.
await dispatch({
type: "prefs",
patch: next.prefs,
...actionMeta(),
});
setTransition(null);
}}
>
Undo
</button>
</aside>
)}
{archivedOpen && (
<ArchiveDetail
record={archivedOpen}
state={state}
onAction={dispatch}
onClose={() => setArchivedOpen(null)}
/>
)}
{open && (
<ReaderDetail
sources={feed.sources}
onSources={() => {
setOpen(null);
showSettings("sources");
}}
row={open}
state={state}
now={now}
timezone={zone}
onAction={dispatch}
onOutcome={(kind) => void outcome(open, kind)}
onClose={() => setOpen(null)}
ownProps={ownProps}
/>
)}
{appUpdate.available && (
<UpdateNotice
applying={appUpdate.applying}
onApply={appUpdate.apply}
onDismiss={appUpdate.dismiss}
/>
/>
) : (
<>
{page === "soon" && (
<div className="checklist-layout">
<aside className="checklist-rail">
<div className="rail">
{focusBarShown(state.prefs, "soon") && focusBar}
<NextDeadline
row={scoped.find((r) => r.clock.live)}
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
servers={(game) => manyServers(state, game)}
onOpen={setOpen}
/>
{separate && (
<DailyPanel
onManage={() => changePage("routines")}
rows={pins.filter((r) => r.cadence === "day")}
state={state}
onAction={dispatch}
now={now}
/>
)}
<section className="pinned-routines">
<div className="pinned-heading">
<div>
<h2>Routines</h2>
<p className="sub">Showing only pinned</p>
</div>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
</div>
{accounts
.filter((a) => focus === null || a.game === focus)
.map((a) => {
const rows = pins.filter(
(r) =>
r.accountId === a.id &&
(!separate || r.cadence !== "day"),
);
return rows.length ? (
<RoutineGroup
key={
a.id +
focus +
String(state.prefs.routineDisclosure)
}
title={`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
hue={meta(a.game).hue}
done={rows.filter((r) => r.status.met).length}
total={rows.length}
summary={
rows.every((r) => r.status.met)
? `All ${rows.length} done`
: `${rows.filter((r) => r.status.met).length} of ${rows.length} completed`
}
policy={
state.prefs.routineDisclosure === "always"
? "always"
: state.prefs.routineDisclosure ===
"expanded"
? "expanded"
: "collapsed"
}
focused={focus !== null}
>
{rows.map((row) => (
<PinnedRoutine
key={row.id}
row={row}
hue={meta(a.game).hue}
onAction={dispatch}
now={now}
/>
))}
</RoutineGroup>
) : null;
})}
<button
type="button"
className="quiet"
onClick={() => changePage("routines")}
>
Manage routines
</button>
</section>
</div>
</aside>
<div className="checklist-main">
{mainUpdates}
<div className="list-heading running-heading">
<h1>Running now</h1>
<div className="list-tools">
<div className="segmented">
{["standard", "compact"].map((d) => (
<button
type="button"
key={d}
aria-pressed={prefs.density === d}
onClick={() =>
void dispatch({
type: "prefs",
patch: { density: d },
...actionMeta(),
})
}
>
{d === "standard" ? "Standard" : "Compact"}
</button>
))}
</div>
<div className="segmented" aria-label="Sort events">
{(["ending", "doing"] as const).map((sort) => (
<button
key={sort}
aria-pressed={prefs.sort === sort}
onClick={() =>
void dispatch({
type: "prefs",
patch: { sort },
...actionMeta(),
})
}
>
{sort === "ending"
? "Ending soonest"
: "Doing first"}
</button>
))}
</div>
</div>
{/* One line under the title and the buttons: the count
on the left, live's legend on the right. */}
<div className="heading-meta">
<p className="sub">
{visible.filter((r) => r.clock.live).length} events
in {new Set(visible.map((r) => r.event.game)).size}{" "}
games · sorted by what ends first
{!online ? " · offline" : ""}
</p>
<UrgencyLegend />
</div>
</div>
{visible.length ? (
<>
{prefs.density === "compact" && <LedgerColumns />}
<ul className="event-list">
{visible
.filter((r) => !r.clock.upcoming)
.map(renderRow)}
</ul>
{visible.some((r) => r.clock.upcoming) && (
<section className="upcoming-events">
<div className="list-heading">
<div>
<p className="eyebrow">Coming up</p>
<h2>Not started yet</h2>
<p className="sub">
{upcomingSummary(
visible.filter((r) => r.clock.upcoming),
)}
</p>
</div>
</div>
{prefs.density === "compact" && (
<LedgerColumns upcoming />
)}
<ul className="event-list">
{visible
.filter((r) => r.clock.upcoming)
.map(renderRow)}
</ul>
</section>
)}
</>
) : (
<p className="empty-state">
No events to show. Choose another game or change
tracking in Settings → Your games.
</p>
)}
<p className="list-foot">
Done or ignored events leave this list and wait in the{" "}
<button onClick={() => changePage("archive")}>
Archive
</button>
.
</p>
</div>
</div>
)}
{page === "timeline" && (
<>
{mainUpdates}
<TimelinePage
state={state}
focus={focus}
focusBar={
focusBarShown(state.prefs, "timeline") ? focusBar : null
}
rows={visible}
now={now}
onAction={dispatch}
onOpen={setOpen}
/>
</>
)}
{page === "routines" && (
<RoutinesPage state={state} onAction={dispatch} now={now} />
)}
{page === "archive" && (
<ArchivePage
state={state}
onAction={dispatch}
now={now}
onOpen={setArchivedOpen}
/>
)}
{page === "settings" && (
<SettingsLayout
state={state}
game={codesGame}
page={settingsPage}
onPage={(p) => {
setCodesGame(undefined);
setSettingsPage(p);
}}
onGame={(g) => {
setCodesGame(g);
setGameTab("setup");
setSettingsPage("games");
}}
>
{settingsPage === "appearance" ||
(settingsPage === "games" && !codesGame) ? (
<AppearancePage
state={state}
onAction={dispatch}
onReplayTour={() => {
void dispatch({
type: "prefs",
patch: { redesignTourSeen: "replay" },
...actionMeta(),
});
}}
/>
) : settingsPage === "games" && codesGame ? (
<GameSettings
key={`${codesGame}-${gameTab}`}
initialTab={gameTab}
codes={codes}
initialGame={codesGame}
state={state}
sources={feed.sources}
onAction={dispatch}
/>
) : settingsPage === "time" ? (
<TimeSettings
state={state}
now={now}
onAction={dispatch}
/>
) : settingsPage === "sources" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
trackedGames(state).has(s.game),
)}
now={now}
timezone={zone}
online={online}
onRefresh={refreshFeed}
showUpcoming={prefs.showUpcoming}
detectDaily={prefs.detectDaily}
standardPulls={state.prefs.standardPulls === true}
onPrefs={(patch) =>
void dispatch({
type: "prefs",
patch,
...actionMeta(),
})
}
/>
) : settingsPage === "backup" ? (
<BackupPage state={state} />
) : settingsPage === "changelog" ? (
<ChangelogPage notes={NOTES} entries={ENTRIES} />
) : settingsPage === "about" ? (
<AboutPage sources={feed.sources} />
) : settingsPage === "own" ? (
<section>
<h1>Your own events and games</h1>
<p className="help">
Only what you added yourself. Routines live on the
Routines tab.
</p>
<YourOwn {...ownProps} />
</section>
) : null}
</SettingsLayout>
)}
<FeedFooter
sources={feed.sources}
now={now}
online={online}
timezone={zone}
onAbout={() => showSettings("about")}
onRefresh={refreshFeed}
/>
</>
)}
{transition && (
<aside className="archive-toast" role="status">
{transition.label} “{transition.title}” · Moved to Archive{" "}
<button
type="button"
onClick={() => {
void dispatch({
type: "restore",
accountId: transition.accountId,
eventId: transition.eventId,
...actionMeta(),
});
setTransition(null);
}}
>
Undo
</button>
</aside>
)}
{archivedOpen && (
<ArchiveDetail
record={archivedOpen}
state={state}
onAction={dispatch}
onClose={() => setArchivedOpen(null)}
/>
)}
{open && (
<ReaderDetail
sources={feed.sources}
onSources={() => {
setOpen(null);
showSettings("sources");
}}
row={open}
state={state}
now={now}
timezone={zone}
onAction={dispatch}
onOutcome={(kind) => void outcome(open, kind)}
onClose={() => setOpen(null)}
ownProps={ownProps}
/>
)}
{appUpdate.available && (
<UpdateNotice
applying={appUpdate.applying}
onApply={appUpdate.apply}
onDismiss={appUpdate.dismiss}
/>
)}
</div>
{touring && (
<>
{tourForced && <div className="tour-scrim" aria-hidden="true" />}
<ReturningTour
intro={tour === "replay" ? undefined : tour}
closable={!tourForced}
looks={{
interface: prefs.interface ?? "structured",
timelineLook: timelineLook(state.prefs),
}}
onLook={(patch) =>
void dispatch({ type: "prefs", patch, ...actionMeta() })
}
onStep={(stop) => {
setOpen(null);
setArchivedOpen(null);
if (stop !== "codes") {
if (stop === "settings") showSettings("appearance");
else setPage(stop);
return;
}
// The Codes tab of a tracked game that has codes, else the
// Checklist, where new codes are announced.
const game = accounts
.map((a) => a.game)
.find((g) => codes?.codes.some((c) => c.game === g));
if (!game) {
setPage("soon");
return;
}
setGameTab("codes");
setCodesGame(game);
showSettings("games");
}}
onFinish={() => {
void dispatch({
type: "prefs",
patch: { redesignTourSeen: TOUR_DONE },
...actionMeta(),
});
setPage(prefs.view);
}}
/>
</>
)}
</main>
</GameMetaProvider>
@@ -249,7 +249,9 @@ export function AppearancePage({
type="button"
className="btn"
onClick={() =>
onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null })
onReplayTour
? onReplayTour()
: patch({ redesignTourSeen: "replay" })
}
>
Replay the introduction
+125 -27
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
/** Where a step takes the reader; ReaderApp opens the matching page. */
export type TourStop =
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
/** A look the reader tries from inside a step, switching the page behind. */
type TourChoice = "interface" | "timelineLook";
export const TOUR_STEPS: readonly {
stop: TourStop;
title: string;
body: string;
choice?: TourChoice;
}[] = [
{
stop: "routines",
@@ -19,10 +23,17 @@ export const TOUR_STEPS: readonly {
title: "Your Checklist",
body: "What ends first, at the top. Focus on a game, tick today's dailies and pinned routines beside the list, and switch between Standard and Compact rows.",
},
{
stop: "soon",
title: "Pick a layout",
body: "Try both on your own Checklist: the page behind changes as you switch. Whichever is selected when you continue is the one you keep.",
choice: "interface",
},
{
stop: "timeline",
title: "The Timeline",
body: "Every event as a bar across the calendar, with now marked. Show time left or end dates, and choose its look in Settings → Appearance.",
body: "Every event as a bar across the calendar, with now marked. Try the new and classic looks here; whichever is selected when you continue stays.",
choice: "timelineLook",
},
{
stop: "codes",
@@ -41,63 +52,150 @@ export const TOUR_STEPS: readonly {
},
];
const CHOICES: Record<
TourChoice,
readonly { value: string; label: string; note: string }[]
> = {
interface: [
{
value: "relaxed",
label: "Relaxed",
note: "Separated panels with more breathing room.",
},
{
value: "structured",
label: "Structured",
note: "A compact, ruled planner.",
},
],
timelineLook: [
{
value: "new",
label: "New look",
note: "A name column beside each bar.",
},
{
value: "classic",
label: "Classic look",
note: "Filled bars with the title inside, under each game.",
},
],
};
/**
* The returning reader's introduction to the new look. Continue always sits
* first, in the same place, so clicking through never lands on Back; Back
* follows it, and Skip stands apart at the end.
* `prefs.redesignTourSeen` holds where the reader is with the tour:
*
* - `TOUR_DONE`: finished. Nothing shows.
* - `"new"` / `"returning"`: the tour must be finished before the app can be
* used, and the reader's look is settled: chosen in setup, or moved to
* Relaxed when the tour first opened. Which one sets the opening line, so
* a reload part-way through still greets the reader the same way.
* - `"replay"`: started from Settings. Optional, and closable at any step.
* - anything else (absent, null, or an earlier tour's marker): a returning
* reader who has not taken this tour. ReaderApp moves them to Relaxed and
* stores `"returning"`.
*
* A new tour version changes `TOUR_DONE`, so everyone takes it once more.
*/
export const TOUR_DONE = "2026-10-tour-2";
export type TourMode = "done" | "new" | "returning" | "replay" | "unseen";
export function tourMode(seen: unknown): TourMode {
if (seen === TOUR_DONE) return "done";
if (seen === "new" || seen === "returning" || seen === "replay") return seen;
return "unseen";
}
/**
* The introduction to Event Clock. Continue always sits first, in the same
* place, so clicking through never lands on Back; Back follows it. Only a
* replay has a way out before the end.
*/
export function ReturningTour({
intro,
closable,
looks,
onLook,
onStep,
onFinish,
autoStart = false,
}: {
/** The opening line on the first step; none on a replay. */
intro?: "new" | "returning" | undefined;
/** A replay can be closed at any step. */
closable: boolean;
looks: Record<TourChoice, string>;
onLook: (patch: Partial<Record<TourChoice, string>>) => void;
onStep: (stop: TourStop) => void;
onFinish: () => void;
/** Replayed from Settings: skip the welcome and open the first step. */
autoStart?: boolean;
}) {
const [step, setStep] = useState(autoStart ? 0 : -1);
const [step, setStep] = useState(0);
const card = useRef<HTMLElement>(null);
const go = (next: number) => {
setStep(next);
onStep(TOUR_STEPS[next]!.stop);
};
useEffect(() => {
if (autoStart) onStep(TOUR_STEPS[0]!.stop);
onStep(TOUR_STEPS[0]!.stop);
card.current?.focus();
}, []);
const current = TOUR_STEPS[step]!;
const last = step === TOUR_STEPS.length - 1;
return (
<aside className="returning-tour" aria-label="Introduction to the new look">
{step >= 0 && (
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
<aside
ref={card}
tabIndex={-1}
className="returning-tour"
aria-label="Introduction to Event Clock"
>
{step === 0 && intro && (
<p className="tour-intro">
{intro === "returning"
? "Welcome back. A little has moved, so here's a short tour before you carry on. Your games, routines and progress are all still here."
: "Welcome to Event Clock. Here's a short tour before you start."}
</p>
)}
<h2>
{step < 0
? "Welcome back. A little has moved."
: TOUR_STEPS[step]!.title}
</h2>
<p>
{step < 0
? "Your games, routines and progress are all still here. Take a short tour of the new layout, or keep browsing."
: TOUR_STEPS[step]!.body}
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
</p>
<h2>{current.title}</h2>
<p>{current.body}</p>
{current.choice && (
<div className="tour-choice" role="group" aria-label={current.title}>
{CHOICES[current.choice].map((option) => (
<button
key={option.value}
type="button"
aria-pressed={looks[current.choice!] === option.value}
onClick={() => onLook({ [current.choice!]: option.value })}
>
<strong>{option.label}</strong>
<small>{option.note}</small>
</button>
))}
</div>
)}
{current.choice && (
<p className="tour-later">
You can change this later in Settings → Appearance.
</p>
)}
<div className="tour-actions">
<button
type="button"
className="commit"
onClick={() => (last ? onFinish() : go(step + 1))}
>
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
{last ? "Finish tour" : "Continue"}
</button>
{step > 0 && (
<button type="button" className="btn" onClick={() => go(step - 1)}>
Back
</button>
)}
<button type="button" className="tour-skip" onClick={onFinish}>
{step < 0 ? "Keep browsing" : "Skip tour"}
</button>
{closable && (
<button type="button" className="tour-skip" onClick={onFinish}>
Close tour
</button>
)}
</div>
</aside>
);
+96 -38
View File
@@ -1158,23 +1158,6 @@ textarea {
flex-direction: row;
align-items: center;
}
.returning-tour {
padding: 20px 24px;
border-radius: 12px;
background: var(--color-raised);
margin-bottom: 24px;
}
.returning-tour h2 {
margin: 0 0 8px;
}
.returning-tour div {
display: flex;
gap: 20px;
margin-top: 14px;
}
.returning-tour button {
color: var(--color-near);
}
.empty-state {
padding: 32px 28px;
color: var(--color-muted);
@@ -2011,27 +1994,6 @@ textarea {
color: var(--color-muted);
font-size: 13px;
}
.returning-tour {
border-radius: 0;
margin: 0;
padding: 20px 24px;
border-bottom: 1px solid var(--color-hairline);
background: var(--color-surface);
}
.returning-tour h2 {
font: 600 21px var(--font-display);
}
.returning-tour p {
font-size: 13px;
line-height: 1.65;
max-width: 75ch;
}
:root[data-interface="relaxed"] .returning-tour {
border-radius: 18px;
margin-bottom: 24px;
padding: 24px 28px;
border: 0;
}
:root[data-interface="relaxed"] .event-detail-sheet {
border-radius: 14px;
}
@@ -5388,3 +5350,99 @@ button.mark {
.redeem-where p span {
color: var(--color-muted);
}
/* The tour floats over the page it describes, so the page stays visible and
scrollable behind it. While the tour is forced the page is inert and a light
scrim says so; a replay has no scrim. The page gets room at its foot so its
last rows can scroll clear of the card. */
.tour-behind {
display: contents;
}
.reader-ui[data-touring] {
padding-bottom: 300px;
}
.tour-scrim {
position: fixed;
inset: 0;
z-index: 80;
pointer-events: none;
background: color-mix(in srgb, var(--color-ground) 30%, transparent);
}
.returning-tour {
position: fixed;
z-index: 90;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
width: min(620px, calc(100vw - 2 * var(--edge)));
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 22px 26px 20px;
border: 1px solid var(--color-hairline);
border-radius: 14px;
background: var(--color-raised);
box-shadow: 0 18px 48px rgb(0 0 0 / 0.35);
}
.returning-tour:focus {
outline: none;
}
.returning-tour h2 {
margin: 0 0 8px;
font: 600 21px var(--font-display);
}
.returning-tour p {
margin: 0;
font-size: 13px;
line-height: 1.65;
}
.returning-tour .tour-intro {
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-hairline);
color: var(--color-ink);
font-size: 14px;
}
.returning-tour .tour-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.returning-tour .tour-choice button {
display: grid;
gap: 2px;
padding: 11px 14px;
text-align: left;
border: 1px solid var(--color-hairline);
border-radius: 10px;
background: var(--color-surface);
color: var(--color-ink);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.returning-tour .tour-choice button:hover {
border-color: var(--color-muted);
}
.returning-tour .tour-choice button[aria-pressed="true"] {
border-color: var(--color-near);
background: color-mix(in srgb, var(--color-near) 14%, var(--color-surface));
}
.returning-tour .tour-choice small {
color: var(--color-muted);
font-size: 12px;
}
.returning-tour .tour-later {
margin-top: 8px;
color: var(--color-muted);
font-size: 12px;
}
@media (max-width: 700px) {
.returning-tour {
bottom: var(--edge);
padding: 18px 18px 16px;
}
.returning-tour .tour-choice {
grid-template-columns: 1fr;
}
}
+3 -1
View File
@@ -84,7 +84,9 @@ export function setupReader(
? { timelineLook: draft.timelineLook }
: {}),
onboarded: true,
redesignTourSeen: "2026-10-redesign",
// Straight into the tour, which a new reader must finish; setup has
// already settled their look.
redesignTourSeen: "new",
hiddenGames: Object.values(accounts)
.map((a) => a.game)
.filter((g) => !draft.games.includes(g)),
+71 -8
View File
@@ -23,16 +23,79 @@ test("initial preferences follow the Round 6 setup and defer upcoming events to
expect(html).toContain(label);
expect(html).not.toContain("Show events that haven&#x27;t started");
});
test("returning readers can browse immediately without restarting setup", () => {
const html = render(<ReturningTour onStep={() => {}} onFinish={() => {}} />);
expect(html).toContain("Your games, routines and progress are all still here");
expect(html).toContain("Keep browsing");
expect(html).toContain("Take the quick tour");
});
import {
TOUR_DONE,
TOUR_STEPS,
tourMode,
} from "../src/client/components/redesign/ReturningTour.tsx";
import { setupReader } from "../src/client/state/reader/onboarding.ts";
import { TOUR_STEPS } from "../src/client/components/redesign/ReturningTour.tsx";
const tourProps = {
looks: { interface: "relaxed", timelineLook: "new" },
onLook: () => {},
onStep: () => {},
onFinish: () => {},
};
test("the tour opens on its first step, with no way to skip it when forced", () => {
const html = render(
<ReturningTour {...tourProps} intro="returning" closable={false} />,
);
expect(html).toContain("Welcome back. A little has moved");
expect(html).toContain("1 of " + TOUR_STEPS.length);
expect(html).toContain(TOUR_STEPS[0]!.title);
expect(html).not.toContain("Skip");
expect(html).not.toContain("Close tour");
expect(html).not.toContain("Keep browsing");
});
test("a new reader is welcomed, and a replay can be closed", () => {
expect(
render(<ReturningTour {...tourProps} intro="new" closable={false} />),
).toContain("Welcome to Event Clock");
const replay = render(<ReturningTour {...tourProps} closable />);
expect(replay).toContain("Close tour");
expect(replay).not.toContain("Welcome");
});
test("the tour lets the reader try both layouts and both Timeline looks", () => {
const choices = TOUR_STEPS.filter((s) => s.choice).map((s) => [
s.stop,
s.choice,
]);
expect(choices).toEqual([
["soon", "interface"],
["timeline", "timelineLook"],
]);
});
test("the tour introduces the Timeline right after the Checklist, and codes", () => {
const stops = TOUR_STEPS.map((s) => s.stop);
expect(stops.indexOf("timeline")).toBe(stops.indexOf("soon") + 1);
expect(stops.indexOf("timeline")).toBe(stops.lastIndexOf("soon") + 1);
expect(stops).toContain("codes");
});
test("everyone takes this tour once, including readers who saw the last one", () => {
expect(tourMode(TOUR_DONE)).toBe("done");
for (const seen of [undefined, null, "2026-10-redesign", 7])
expect(tourMode(seen)).toBe("unseen");
expect(tourMode("new")).toBe("new");
expect(tourMode("returning")).toBe("returning");
expect(tourMode("replay")).toBe("replay");
});
test("finishing setup sends a new reader into the forced tour", () => {
const s = migrateLegacy({}, [], "2026-10-16T12:00:00.000Z", () => "a");
const next = setupReader(
{
region: "europe",
interface: "structured",
theme: "dark",
scheme: "default",
timezone: null,
showUpcoming: true,
view: "soon",
games: [],
tracking: {},
},
s,
"2026-10-16T12:00:00.000Z",
() => "account:b",
);
expect(tourMode(next.prefs.redesignTourSeen)).toBe("new");
expect(next.prefs.interface).toBe("structured");
});