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:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 04:45:02 +02:00
1 parent 0abca649fa
commit d8ce442cff
11 files changed
+836 -541

No files matched your search

@@ -249,7 +249,9 @@ export function AppearancePage({
type="button"
className="btn"
onClick={() =>
onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null })
onReplayTour
? onReplayTour()
: patch({ redesignTourSeen: "replay" })
}
>
Replay the introduction
+125 -27
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
/** Where a step takes the reader; ReaderApp opens the matching page. */
export type TourStop =
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
/** A look the reader tries from inside a step, switching the page behind. */
type TourChoice = "interface" | "timelineLook";
export const TOUR_STEPS: readonly {
stop: TourStop;
title: string;
body: string;
choice?: TourChoice;
}[] = [
{
stop: "routines",
@@ -19,10 +23,17 @@ export const TOUR_STEPS: readonly {
title: "Your Checklist",
body: "What ends first, at the top. Focus on a game, tick today's dailies and pinned routines beside the list, and switch between Standard and Compact rows.",
},
{
stop: "soon",
title: "Pick a layout",
body: "Try both on your own Checklist: the page behind changes as you switch. Whichever is selected when you continue is the one you keep.",
choice: "interface",
},
{
stop: "timeline",
title: "The Timeline",
body: "Every event as a bar across the calendar, with now marked. Show time left or end dates, and choose its look in Settings → Appearance.",
body: "Every event as a bar across the calendar, with now marked. Try the new and classic looks here; whichever is selected when you continue stays.",
choice: "timelineLook",
},
{
stop: "codes",
@@ -41,63 +52,150 @@ export const TOUR_STEPS: readonly {
},
];
const CHOICES: Record<
TourChoice,
readonly { value: string; label: string; note: string }[]
> = {
interface: [
{
value: "relaxed",
label: "Relaxed",
note: "Separated panels with more breathing room.",
},
{
value: "structured",
label: "Structured",
note: "A compact, ruled planner.",
},
],
timelineLook: [
{
value: "new",
label: "New look",
note: "A name column beside each bar.",
},
{
value: "classic",
label: "Classic look",
note: "Filled bars with the title inside, under each game.",
},
],
};
/**
* The returning reader's introduction to the new look. Continue always sits
* first, in the same place, so clicking through never lands on Back; Back
* follows it, and Skip stands apart at the end.
* `prefs.redesignTourSeen` holds where the reader is with the tour:
*
* - `TOUR_DONE`: finished. Nothing shows.
* - `"new"` / `"returning"`: the tour must be finished before the app can be
* used, and the reader's look is settled: chosen in setup, or moved to
* Relaxed when the tour first opened. Which one sets the opening line, so
* a reload part-way through still greets the reader the same way.
* - `"replay"`: started from Settings. Optional, and closable at any step.
* - anything else (absent, null, or an earlier tour's marker): a returning
* reader who has not taken this tour. ReaderApp moves them to Relaxed and
* stores `"returning"`.
*
* A new tour version changes `TOUR_DONE`, so everyone takes it once more.
*/
export const TOUR_DONE = "2026-10-tour-2";
export type TourMode = "done" | "new" | "returning" | "replay" | "unseen";
export function tourMode(seen: unknown): TourMode {
if (seen === TOUR_DONE) return "done";
if (seen === "new" || seen === "returning" || seen === "replay") return seen;
return "unseen";
}
/**
* The introduction to Event Clock. Continue always sits first, in the same
* place, so clicking through never lands on Back; Back follows it. Only a
* replay has a way out before the end.
*/
export function ReturningTour({
intro,
closable,
looks,
onLook,
onStep,
onFinish,
autoStart = false,
}: {
/** The opening line on the first step; none on a replay. */
intro?: "new" | "returning" | undefined;
/** A replay can be closed at any step. */
closable: boolean;
looks: Record<TourChoice, string>;
onLook: (patch: Partial<Record<TourChoice, string>>) => void;
onStep: (stop: TourStop) => void;
onFinish: () => void;
/** Replayed from Settings: skip the welcome and open the first step. */
autoStart?: boolean;
}) {
const [step, setStep] = useState(autoStart ? 0 : -1);
const [step, setStep] = useState(0);
const card = useRef<HTMLElement>(null);
const go = (next: number) => {
setStep(next);
onStep(TOUR_STEPS[next]!.stop);
};
useEffect(() => {
if (autoStart) onStep(TOUR_STEPS[0]!.stop);
onStep(TOUR_STEPS[0]!.stop);
card.current?.focus();
}, []);
const current = TOUR_STEPS[step]!;
const last = step === TOUR_STEPS.length - 1;
return (
<aside className="returning-tour" aria-label="Introduction to the new look">
{step >= 0 && (
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
<aside
ref={card}
tabIndex={-1}
className="returning-tour"
aria-label="Introduction to Event Clock"
>
{step === 0 && intro && (
<p className="tour-intro">
{intro === "returning"
? "Welcome back. A little has moved, so here's a short tour before you carry on. Your games, routines and progress are all still here."
: "Welcome to Event Clock. Here's a short tour before you start."}
</p>
)}
<h2>
{step < 0
? "Welcome back. A little has moved."
: TOUR_STEPS[step]!.title}
</h2>
<p>
{step < 0
? "Your games, routines and progress are all still here. Take a short tour of the new layout, or keep browsing."
: TOUR_STEPS[step]!.body}
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
</p>
<h2>{current.title}</h2>
<p>{current.body}</p>
{current.choice && (
<div className="tour-choice" role="group" aria-label={current.title}>
{CHOICES[current.choice].map((option) => (
<button
key={option.value}
type="button"
aria-pressed={looks[current.choice!] === option.value}
onClick={() => onLook({ [current.choice!]: option.value })}
>
<strong>{option.label}</strong>
<small>{option.note}</small>
</button>
))}
</div>
)}
{current.choice && (
<p className="tour-later">
You can change this later in Settings → Appearance.
</p>
)}
<div className="tour-actions">
<button
type="button"
className="commit"
onClick={() => (last ? onFinish() : go(step + 1))}
>
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
{last ? "Finish tour" : "Continue"}
</button>
{step > 0 && (
<button type="button" className="btn" onClick={() => go(step - 1)}>
Back
</button>
)}
<button type="button" className="tour-skip" onClick={onFinish}>
{step < 0 ? "Keep browsing" : "Skip tour"}
</button>
{closable && (
<button type="button" className="tour-skip" onClick={onFinish}>
Close tour
</button>
)}
</div>
</aside>
);