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:
co-authored by
Claude Opus 5.5
parent
0abca649fa
commit
d8ce442cff
@@ -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".
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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'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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user