Skip to content
25 changes: 23 additions & 2 deletions e2e/onboarding/interactive-tour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,29 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {

const title = createEventTitle("Tour Event");
await fillTitleAndSaveEventForm(page, title);
await expect(card).toContainText("Move between events");

await page.keyboard.press("ArrowRight");
await expect(card).toContainText("Jump straight to a field");

// E then T is the edit sequence; it acts on whichever event has DOM
// focus. There is only one event on the calendar, so the ArrowRight
// above (there being no adjacent event to move to) may not have kept
// focus on it -- refocus it explicitly, mirroring
// e2e/timed/edit-sequence-title.spec.ts.
const eventButton = page
.locator("#mainGrid")
.getByRole("button", { name: title });
await eventButton.focus();

await page.keyboard.press("e");
await page.keyboard.press("t");
await expect(card).toContainText("Open the command palette");

// Close the form the edit sequence opened before testing the palette
// shortcut, so Escape here closes the form rather than the palette.
await page.keyboard.press("Escape");

// Linux CI uses Ctrl; macOS local runs use Meta. Press both modifiers' chord
// via ControlOrMeta through Playwright's platform-aware ControlOrMeta token.
await page.keyboard.press("ControlOrMeta+k");
Expand All @@ -38,9 +59,9 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {

// Shift+/ opens the legend (same as ? on US keyboards) once the calendar has focus.
await page.keyboard.press("Shift+/");
await expect(card).toContainText("You are ready");
await expect(card).toContainText("That's the basics");

await card.getByRole("button", { name: "Finish" }).click();
await card.getByRole("button", { name: "I'm done" }).click();
await expect(card).toHaveCount(0);

await page.reload({ waitUntil: "domcontentloaded" });
Expand Down
17 changes: 16 additions & 1 deletion packages/web/src/auth/posthog/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,22 @@ export type ProductEvent =
| "signup_completed"
| "login_completed"
| "event_created"
| "calendar_connected";
| "calendar_connected"
| "onboarding_game_started"
| "onboarding_task_completed"
| "onboarding_segment_reached"
| "onboarding_game_skipped"
| "onboarding_game_finished"
| "onboarding_game_replayed"
| "connect_cta_shown"
| "connect_cta_accepted"
| "connect_cta_skipped"
| "trial_cta_shown"
| "trial_started"
| "trial_converted"
| "trial_expired"
| "shortcut_tip_shown"
| "shortcut_tip_acted_on";

/**
* Fire-and-forget capture for the small set of product-activation events.
Expand Down
15 changes: 15 additions & 0 deletions packages/web/src/common/constants/storage.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ type StorageKey =
| "compass.onboarding.has-seen-anonymous-save-toast"
| "compass.onboarding.has-dismissed-demo-events-banner"
| "compass.onboarding.has-dismissed-tasks-removal-notice"
// Set when a new user hands off to signup/login from the welcome modal
// before starting the tour; consumed once, right after signup completes,
// to offer the tour instead of silently burning it forever.
| "compass.onboarding.has-pending-tour-offer"
// Persists which stage of the post-tour connect/trial flow a user is on,
// so a full-page OAuth redirect can resume on the right stage. "done"
// means completed or dismissed; absent means never triggered.
| "compass.onboarding.post-tour-stage"
| "compass.shortcuts.tips-muted"
| "compass.sidebar.width"
| "compass.theme"
| "compass.life.preferences"
Expand All @@ -29,6 +38,9 @@ export const STORAGE_KEYS: Record<
| "HAS_SEEN_ANONYMOUS_SAVE_TOAST"
| "HAS_DISMISSED_DEMO_EVENTS_BANNER"
| "HAS_DISMISSED_TASKS_REMOVAL_NOTICE"
| "HAS_PENDING_TOUR_OFFER"
| "POST_TOUR_STAGE"
| "SHORTCUT_TIPS_MUTED"
| "LIFE_PREFERENCES"
| "SIDEBAR_WIDTH"
| "SIDEBAR_OPEN"
Expand All @@ -48,6 +60,9 @@ export const STORAGE_KEYS: Record<
"compass.onboarding.has-dismissed-demo-events-banner",
HAS_DISMISSED_TASKS_REMOVAL_NOTICE:
"compass.onboarding.has-dismissed-tasks-removal-notice",
HAS_PENDING_TOUR_OFFER: "compass.onboarding.has-pending-tour-offer",
POST_TOUR_STAGE: "compass.onboarding.post-tour-stage",
SHORTCUT_TIPS_MUTED: "compass.shortcuts.tips-muted",
LIFE_PREFERENCES: "compass.life.preferences",
SIDEBAR_WIDTH: "compass.sidebar.width",
SIDEBAR_OPEN: "compass.view.sidebar-open",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SignUpFormData,
} from "@web/auth/compass/schemas/auth.schemas";
import { track } from "@web/auth/posthog/track";
import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store";
import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store";
import { getAuthSubmitErrorMessage } from "./useAuthFormHandlers.util";
import { type AuthView } from "./useAuthModal";
Expand Down Expand Up @@ -77,6 +78,7 @@ export function useAuthFormHandlers({
track("signup_completed", { method: "email" });
closeModal();
releaseNotesPromptActions.scheduleOpen();
onboardingTourActions.offerAfterSignupIfPending();
return;
case "FIELD_ERROR":
setSubmitError(response.formFields[0]?.error ?? "Sign up failed");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ describe("getNavigationCommandItems", () => {
onShowWelcomeGuide: () => {},
}).map((item) => item.label);

expect(labels).toContain("Restart onboarding tour");
expect(labels).toContain("Practice shortcuts");
expect(labels).toContain("Show welcome guide");
expect(labels.indexOf("Restart onboarding tour")).toBeLessThan(
expect(labels.indexOf("Practice shortcuts")).toBeLessThan(
labels.indexOf("Show welcome guide"),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,19 @@ export const getNavigationCommandItems = ({
if (onShowOnboardingTour) {
calendarItems.push({
id: "show-onboarding-tour",
label: "Restart onboarding tour",
label: "Practice shortcuts",
icon: CompassIcon,
keywords: ["onboarding", "tour", "intro", "help", "tutorial", "coach"],
keywords: [
"onboarding",
"tour",
"intro",
"help",
"tutorial",
"coach",
"sandbox",
"practice",
"shortcuts",
],
onClick: onShowOnboardingTour,
});
}
Expand Down
66 changes: 44 additions & 22 deletions packages/web/src/components/OnboardingTour/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const OnboardingTour: FC = () => {
const step = steps.find((entry) => entry.id === stepId) ?? steps[0];
const stepIndex = ONBOARDING_TOUR_STEP_IDS.indexOf(stepId);
const isDone = stepId === "done";
const isFork = stepId === "fork";

return (
<section
Expand All @@ -53,29 +54,50 @@ export const OnboardingTour: FC = () => {
</p>
) : null}
<div className="mt-4 flex items-center justify-between gap-3">
<button
className="c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:text-text"
onClick={onboardingTourActions.skip}
type="button"
>
Skip tour
</button>
{isDone ? (
<button
className="c-button c-button-primary rounded-full px-4 py-1.5 text-xs"
onClick={onboardingTourActions.finish}
type="button"
>
Finish
</button>
{isFork ? (
<>
<button
className="c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:text-text"
onClick={onboardingTourActions.skip}
type="button"
>
I'm done
</button>
<button
className="c-button c-button-primary rounded-full px-4 py-1.5 text-xs"
onClick={onboardingTourActions.advance}
type="button"
>
Keep going
</button>
</>
) : (
<button
className="c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:text-text"
onClick={onboardingTourActions.advance}
type="button"
>
Next
</button>
<>
<button
className="c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:text-text"
onClick={onboardingTourActions.skip}
type="button"
>
Skip tour
</button>
{isDone ? (
<button
className="c-button c-button-primary rounded-full px-4 py-1.5 text-xs"
onClick={onboardingTourActions.finish}
type="button"
>
Finish
</button>
) : (
<button
className="c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:text-text"
onClick={onboardingTourActions.advance}
type="button"
>
Next
</button>
)}
</>
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@ describe("onboarding tour steps", () => {
}
});

it("orders steps create → save → palette → shortcuts → done", () => {
it("orders basics create → save → moveFocus → editSequence → palette → shortcuts → fork", () => {
expect(getNextOnboardingStepId("create")).toBe("save");
expect(getNextOnboardingStepId("save")).toBe("palette");
expect(getNextOnboardingStepId("save")).toBe("moveFocus");
expect(getNextOnboardingStepId("moveFocus")).toBe("editSequence");
expect(getNextOnboardingStepId("editSequence")).toBe("palette");
expect(getNextOnboardingStepId("palette")).toBe("shortcuts");
expect(getNextOnboardingStepId("shortcuts")).toBe("done");
expect(getNextOnboardingStepId("shortcuts")).toBe("fork");
});

it("orders advanced fork → targetEvent → nudge → undo → done", () => {
expect(getNextOnboardingStepId("fork")).toBe("targetEvent");
expect(getNextOnboardingStepId("targetEvent")).toBe("nudge");
expect(getNextOnboardingStepId("nudge")).toBe("undo");
expect(getNextOnboardingStepId("undo")).toBe("done");
expect(getNextOnboardingStepId("done")).toBeNull();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { getModifierKeyLabel } from "@web/shortcuts/shortcut.util";

/** Single source of truth for step order; the type and id list below derive from it. */
const STEP_IDS = ["create", "save", "palette", "shortcuts", "done"] as const;
/**
* Single source of truth for step order. "fork" is not a lesson: it's the
* exit ramp between the basics (required-feeling) and advanced (extra
* credit) segments — see OnboardingTour.tsx for its two-button UI.
*/
const STEP_IDS = [
"create",
"save",
"moveFocus",
"editSequence",
"palette",
"shortcuts",
"fork",
"targetEvent",
"nudge",
"undo",
"done",
] as const;

export type OnboardingTourStepId = (typeof STEP_IDS)[number];

Expand Down Expand Up @@ -31,6 +47,16 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
body: "Type a title, then press Enter to save. Changes show up instantly.",
shortcutHint: "Enter",
},
moveFocus: {
title: "Move between events",
body: "Press an arrow key to move focus from event to event without touching the mouse.",
shortcutHint: "Arrow keys",
},
editSequence: {
title: "Jump straight to a field",
body: "Press E, then T, to jump straight into an event's title. Every field has its own letter.",
shortcutHint: "E then T",
},
palette: {
title: "Open the command palette",
body: `Press ${mod}+K for commands. Browse or search, then close with Escape.`,
Expand All @@ -41,6 +67,25 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
body: "Press ? from the calendar to open the shortcut legend. Search it anytime you forget a key.",
shortcutHint: "?",
},
fork: {
title: "That's the basics",
body: "You know enough to fly. Want a few extra-credit moves for rescheduling fast, or are you good for now?",
},
targetEvent: {
title: "Jump to any event",
body: "Tap Shift once to flash a key over every visible event, then press it to jump straight there. Great when there are a few on the same day.",
shortcutHint: "Shift",
},
nudge: {
title: "Nudge into the perfect slot",
body: "With an event focused, hold Shift and press an arrow key to slide it a few minutes at a time.",
shortcutHint: "Shift + Arrow",
},
undo: {
title: "Never stress about a mistake",
body: `Made a change you didn't mean? Press ${mod}+Z to undo it, ${mod}+Shift+Z to redo.`,
shortcutHint: `${mod}+Z`,
},
done: {
title: "You are ready",
body: "You can do anything with the keyboard. Try Shift Shift to practice; clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,19 @@ export function hasSeenOnboardingTour(): boolean {
export function markOnboardingTourSeen(): void {
persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR, "true");
}

/** Set when a welcome-modal exit hands off to signup instead of starting the tour. */
export function markTourOfferPending(): void {
persistentBrowserStore.set(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER, "true");
}

/** Consumed once, right after signup completes, to decide whether to offer the tour. */
export function consumePendingTourOffer(): boolean {
if (!persistentBrowserStore.isAvailable()) return false;
const pending =
persistentBrowserStore.get(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER) === "true";
if (pending) {
persistentBrowserStore.remove(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER);
}
return pending;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,49 @@ describe("onboardingTourActions", () => {
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("save");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("moveFocus");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("editSequence");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("palette");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("shortcuts");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("fork");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("targetEvent");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("nudge");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("undo");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("done");
});

it("skip at the fork ends the tour without entering the advanced segment", () => {
onboardingTourActions.start();
useOnboardingTourStore.setState({ stepId: "fork" });
onboardingTourActions.skip();
expect(useOnboardingTourStore.getState().isActive).toBe(false);
expect(useOnboardingTourStore.getState().stepId).toBe("create");
});

it("defers the seen flag when heading into signup, then redeems it once", () => {
onboardingTourActions.markSkippedWithoutStarting({ pendingSignup: true });
expect(
persistentBrowserStore.get(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR),
).not.toBe("true");

onboardingTourActions.offerAfterSignupIfPending();
expect(useOnboardingTourStore.getState().isActive).toBe(true);
expect(useOnboardingTourStore.getState().stepId).toBe("create");

// Second redemption attempt is a no-op: the pending flag was consumed.
useOnboardingTourStore.setState(initialOnboardingTourState);
onboardingTourActions.offerAfterSignupIfPending();
expect(useOnboardingTourStore.getState().isActive).toBe(false);
});

it("finish and skip persist the seen flag and clear active state", () => {
onboardingTourActions.start();
onboardingTourActions.finish();
Expand Down
Loading