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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0abca649fa
commit
d8ce442cff
11 files changed
+836
-541
No files matched your search
@@ -266,14 +266,7 @@ note only says where to start.
|
|||||||
readers could reach the ~5 MB quota. Failures are reported, not silent.
|
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.
|
- 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.
|
It's stranded, not lost.
|
||||||
- [ ] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18).
|
- [x] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). Done
|
||||||
- Relaxed vs Structured, and the Timeline's new vs classic look. Each is one switch in
|
2026-10-18. The tour is now forced once for everyone, including new readers and anyone who saw the
|
||||||
Settings → Appearance today, which most readers will never open.
|
earlier tour. Its "Pick a layout" and Timeline steps switch the page behind in place, and the
|
||||||
- Show the difference live, on the reader's own events: a "try it" that switches the page in place,
|
selected look is kept. Returning readers start on Relaxed. See `docs/DESIGN.md` → "The tour".
|
||||||
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.
|
|
||||||
@@ -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
|
soon or timeline; utility navigation is transient. Future/unreadable records remain exportable even
|
||||||
when this build cannot offer editing controls.
|
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`)
|
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
|
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,
|
(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
|
checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and
|
||||||
per-game Setup / Codes / About.
|
per-game Setup / Codes / About.
|
||||||
- **Returning-user tour.** Routines → Checklist → Timeline → Redeem codes → Archive → Settings.
|
- **The tour (2026-10-18, owner).** Every reader takes it once before using the app: new readers
|
||||||
Continue stays in a fixed place, Back sits beside it, and Skip is apart. "Replay the introduction"
|
straight after setup, returning readers on their first visit, and readers who saw the earlier tour
|
||||||
starts it at step 1.
|
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(
|
let s = JSON.parse(
|
||||||
localStorage.getItem("gacha-tracker:v2:reader"),
|
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.interface = variant;
|
||||||
s.prefs.theme = theme;
|
s.prefs.theme = theme;
|
||||||
s.prefs.changelogSeen = "9999-12-31";
|
s.prefs.changelogSeen = "9999-12-31";
|
||||||
|
|||||||
@@ -938,3 +938,15 @@ redesign commit.
|
|||||||
page. See docs/SOURCES.md § Redeem codes for eight more games.
|
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
|
- **Owner's TODO:** let readers try Relaxed/Structured and the new/classic Timeline live, including
|
||||||
returning readers who've done the tour.
|
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 { RoutinesPage } from "./components/redesign/RoutinesPage.tsx";
|
||||||
import { DailyPanel } from "./components/redesign/DailyPanel.tsx";
|
import { DailyPanel } from "./components/redesign/DailyPanel.tsx";
|
||||||
import { RoutineGroup } from "./components/redesign/RoutineGroup.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 { focusBarShown } from "./components/redesign/FocusPicker.tsx";
|
||||||
import { ArchiveDetail } from "./components/redesign/ArchiveDetail.tsx";
|
import { ArchiveDetail } from "./components/redesign/ArchiveDetail.tsx";
|
||||||
import { ArchivePage } from "./components/redesign/ArchivePage.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 { DataSourcesPage } from "./components/redesign/DataSourcesPage.tsx";
|
||||||
import { FeedFooter } from "./components/redesign/FeedFooter.tsx";
|
import { FeedFooter } from "./components/redesign/FeedFooter.tsx";
|
||||||
import { SetupWizard } from "./components/redesign/SetupWizard.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". */
|
/** The upcoming section's sub-line: "3 events in 2 games". */
|
||||||
function upcomingSummary(rows: readonly { event: { game: string } }[]): string {
|
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
|
// 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
|
// 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.
|
// 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);
|
const firstPage = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firstPage.current) {
|
if (firstPage.current) {
|
||||||
@@ -268,6 +273,21 @@ function ReaderContent({
|
|||||||
}
|
}
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, [page]);
|
}, [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 [gameTab, setGameTab] = useState<"setup" | "codes">("setup");
|
||||||
const [codesGame, setCodesGame] = useState<string | undefined>(
|
const [codesGame, setCodesGame] = useState<string | undefined>(
|
||||||
startRoute?.game,
|
startRoute?.game,
|
||||||
@@ -669,461 +689,490 @@ function ReaderContent({
|
|||||||
return (
|
return (
|
||||||
<DisplayTimeContext value={zone}>
|
<DisplayTimeContext value={zone}>
|
||||||
<GameMetaProvider value={meta}>
|
<GameMetaProvider value={meta}>
|
||||||
<main className="app-shell reader-ui">
|
<main
|
||||||
<header className="top">
|
className="app-shell reader-ui"
|
||||||
{prefs.onboarded ? (
|
data-touring={touring || undefined}
|
||||||
<button
|
>
|
||||||
type="button"
|
{/* While the tour is forced the page behind shows each stop but
|
||||||
className="mark"
|
takes no clicks or focus. `display: contents` keeps the layout
|
||||||
aria-label="Event Clock — go to your default view"
|
as if this wrapper were not here. */}
|
||||||
onClick={() => changePage(prefs.view)}
|
<div className="tour-behind" inert={tourForced}>
|
||||||
>
|
<header className="top">
|
||||||
Event<span>Clock</span>
|
{prefs.onboarded ? (
|
||||||
</button>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<p className="mark">
|
className="mark"
|
||||||
Event<span>Clock</span>
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{prefs.onboarded && (
|
{backupError && (
|
||||||
<AppNavigation
|
<p role="status" className="help">
|
||||||
page={page}
|
{backupError}
|
||||||
onPage={changePage}
|
</p>
|
||||||
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>
|
{!prefs.onboarded ? (
|
||||||
{saveError && !saveError.ok && (
|
<SetupWizard
|
||||||
<p role="alert" className="save-error">
|
state={state}
|
||||||
Your last change could not be saved ({saveError.reason}). Your
|
meta={meta}
|
||||||
previous data remains available. Try again or export it in Backup.
|
onFinish={async (draft) => {
|
||||||
</p>
|
const next = setupReader(
|
||||||
)}
|
draft,
|
||||||
{backupError && (
|
state,
|
||||||
<p role="status" className="help">
|
new Date().toISOString(),
|
||||||
{backupError}
|
mintAccountId,
|
||||||
</p>
|
);
|
||||||
)}
|
for (const account of Object.values(next.accounts)) {
|
||||||
{!prefs.onboarded ? (
|
const result = await dispatch({
|
||||||
<SetupWizard
|
type: "account",
|
||||||
state={state}
|
account,
|
||||||
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" },
|
|
||||||
...actionMeta(),
|
...actionMeta(),
|
||||||
});
|
});
|
||||||
setPage(prefs.view);
|
if (!result.ok) return;
|
||||||
}}
|
}
|
||||||
/>
|
// No navigation here: the tour opens next and leads the way,
|
||||||
)}
|
// ending on the chosen view.
|
||||||
{page === "soon" && (
|
await dispatch({
|
||||||
<div className="checklist-layout">
|
type: "prefs",
|
||||||
<aside className="checklist-rail">
|
patch: next.prefs,
|
||||||
<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,
|
|
||||||
...actionMeta(),
|
...actionMeta(),
|
||||||
});
|
});
|
||||||
setTransition(null);
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
Undo
|
) : (
|
||||||
</button>
|
<>
|
||||||
</aside>
|
{page === "soon" && (
|
||||||
)}
|
<div className="checklist-layout">
|
||||||
{archivedOpen && (
|
<aside className="checklist-rail">
|
||||||
<ArchiveDetail
|
<div className="rail">
|
||||||
record={archivedOpen}
|
{focusBarShown(state.prefs, "soon") && focusBar}
|
||||||
state={state}
|
<NextDeadline
|
||||||
onAction={dispatch}
|
row={scoped.find((r) => r.clock.live)}
|
||||||
onClose={() => setArchivedOpen(null)}
|
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
|
||||||
/>
|
servers={(game) => manyServers(state, game)}
|
||||||
)}
|
onOpen={setOpen}
|
||||||
{open && (
|
/>
|
||||||
<ReaderDetail
|
{separate && (
|
||||||
sources={feed.sources}
|
<DailyPanel
|
||||||
onSources={() => {
|
onManage={() => changePage("routines")}
|
||||||
setOpen(null);
|
rows={pins.filter((r) => r.cadence === "day")}
|
||||||
showSettings("sources");
|
state={state}
|
||||||
}}
|
onAction={dispatch}
|
||||||
row={open}
|
now={now}
|
||||||
state={state}
|
/>
|
||||||
now={now}
|
)}
|
||||||
timezone={zone}
|
<section className="pinned-routines">
|
||||||
onAction={dispatch}
|
<div className="pinned-heading">
|
||||||
onOutcome={(kind) => void outcome(open, kind)}
|
<div>
|
||||||
onClose={() => setOpen(null)}
|
<h2>Routines</h2>
|
||||||
ownProps={ownProps}
|
<p className="sub">Showing only pinned</p>
|
||||||
/>
|
</div>
|
||||||
)}
|
<button onClick={() => changePage("routines")}>
|
||||||
{appUpdate.available && (
|
Edit pins
|
||||||
<UpdateNotice
|
</button>
|
||||||
applying={appUpdate.applying}
|
</div>
|
||||||
onApply={appUpdate.apply}
|
{accounts
|
||||||
onDismiss={appUpdate.dismiss}
|
.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>
|
</main>
|
||||||
</GameMetaProvider>
|
</GameMetaProvider>
|
||||||
|
|||||||
@@ -249,7 +249,9 @@ export function AppearancePage({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null })
|
onReplayTour
|
||||||
|
? onReplayTour()
|
||||||
|
: patch({ redesignTourSeen: "replay" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Replay the introduction
|
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. */
|
/** Where a step takes the reader; ReaderApp opens the matching page. */
|
||||||
export type TourStop =
|
export type TourStop =
|
||||||
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
|
"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 {
|
export const TOUR_STEPS: readonly {
|
||||||
stop: TourStop;
|
stop: TourStop;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
choice?: TourChoice;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
stop: "routines",
|
stop: "routines",
|
||||||
@@ -19,10 +23,17 @@ export const TOUR_STEPS: readonly {
|
|||||||
title: "Your Checklist",
|
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.",
|
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",
|
stop: "timeline",
|
||||||
title: "The 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",
|
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
|
* `prefs.redesignTourSeen` holds where the reader is with the tour:
|
||||||
* first, in the same place, so clicking through never lands on Back; Back
|
*
|
||||||
* follows it, and Skip stands apart at the end.
|
* - `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({
|
export function ReturningTour({
|
||||||
|
intro,
|
||||||
|
closable,
|
||||||
|
looks,
|
||||||
|
onLook,
|
||||||
onStep,
|
onStep,
|
||||||
onFinish,
|
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;
|
onStep: (stop: TourStop) => void;
|
||||||
onFinish: () => 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) => {
|
const go = (next: number) => {
|
||||||
setStep(next);
|
setStep(next);
|
||||||
onStep(TOUR_STEPS[next]!.stop);
|
onStep(TOUR_STEPS[next]!.stop);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
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;
|
const last = step === TOUR_STEPS.length - 1;
|
||||||
return (
|
return (
|
||||||
<aside className="returning-tour" aria-label="Introduction to the new look">
|
<aside
|
||||||
{step >= 0 && (
|
ref={card}
|
||||||
<p className="tour-step">
|
tabIndex={-1}
|
||||||
{step + 1} of {TOUR_STEPS.length}
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<h2>
|
<p className="tour-step">
|
||||||
{step < 0
|
{step + 1} of {TOUR_STEPS.length}
|
||||||
? "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>
|
</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">
|
<div className="tour-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="commit"
|
className="commit"
|
||||||
onClick={() => (last ? onFinish() : go(step + 1))}
|
onClick={() => (last ? onFinish() : go(step + 1))}
|
||||||
>
|
>
|
||||||
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
|
{last ? "Finish tour" : "Continue"}
|
||||||
</button>
|
</button>
|
||||||
{step > 0 && (
|
{step > 0 && (
|
||||||
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="tour-skip" onClick={onFinish}>
|
{closable && (
|
||||||
{step < 0 ? "Keep browsing" : "Skip tour"}
|
<button type="button" className="tour-skip" onClick={onFinish}>
|
||||||
</button>
|
Close tour
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+96
-38
@@ -1158,23 +1158,6 @@ textarea {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
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 {
|
.empty-state {
|
||||||
padding: 32px 28px;
|
padding: 32px 28px;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
@@ -2011,27 +1994,6 @@ textarea {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 13px;
|
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 {
|
:root[data-interface="relaxed"] .event-detail-sheet {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@@ -5388,3 +5350,99 @@ button.mark {
|
|||||||
.redeem-where p span {
|
.redeem-where p span {
|
||||||
color: var(--color-muted);
|
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 }
|
? { timelineLook: draft.timelineLook }
|
||||||
: {}),
|
: {}),
|
||||||
onboarded: true,
|
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)
|
hiddenGames: Object.values(accounts)
|
||||||
.map((a) => a.game)
|
.map((a) => a.game)
|
||||||
.filter((g) => !draft.games.includes(g)),
|
.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).toContain(label);
|
||||||
expect(html).not.toContain("Show events that haven't started");
|
expect(html).not.toContain("Show events that haven't started");
|
||||||
});
|
});
|
||||||
test("returning readers can browse immediately without restarting setup", () => {
|
import {
|
||||||
const html = render(<ReturningTour onStep={() => {}} onFinish={() => {}} />);
|
TOUR_DONE,
|
||||||
expect(html).toContain("Your games, routines and progress are all still here");
|
TOUR_STEPS,
|
||||||
expect(html).toContain("Keep browsing");
|
tourMode,
|
||||||
expect(html).toContain("Take the quick tour");
|
} 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", () => {
|
test("the tour introduces the Timeline right after the Checklist, and codes", () => {
|
||||||
const stops = TOUR_STEPS.map((s) => s.stop);
|
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");
|
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