diff --git a/e2e/onboarding/interactive-tour.spec.ts b/e2e/onboarding/interactive-tour.spec.ts index 3d6340b67..76c54c49a 100644 --- a/e2e/onboarding/interactive-tour.spec.ts +++ b/e2e/onboarding/interactive-tour.spec.ts @@ -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"); @@ -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" }); diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts index 9087e6389..61dfd2e30 100644 --- a/packages/web/src/auth/posthog/track.ts +++ b/packages/web/src/auth/posthog/track.ts @@ -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. diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts index 0702d6133..8a16f0e97 100644 --- a/packages/web/src/common/constants/storage.constants.ts +++ b/packages/web/src/common/constants/storage.constants.ts @@ -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" @@ -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" @@ -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", diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts index e6ce4f291..44efadcca 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts @@ -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"; @@ -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"); diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts index 0f6cb9c29..c0663ea79 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts @@ -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"), ); }); diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts index 1a32fe7b6..cee2c3c84 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts @@ -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, }); } diff --git a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx index 91397218e..3b624b46a 100644 --- a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx +++ b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx @@ -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 (
{

) : null}
- - {isDone ? ( - + {isFork ? ( + <> + + + ) : ( - + <> + + {isDone ? ( + + ) : ( + + )} + )}
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts index 1ef343589..912ead1e8 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts @@ -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(); }); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts index 9dd208f12..b60954ac9 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts @@ -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]; @@ -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.`, @@ -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.", diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts index af06ace0d..2792e5ba2 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts @@ -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; +} diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts index 12ed3c5c9..3cfb0bb22 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts @@ -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(); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts index 16f71d48a..b54304cd7 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts @@ -1,11 +1,14 @@ import { create } from "zustand"; +import { track } from "@web/auth/posthog/track"; import { getNextOnboardingStepId, type OnboardingTourStepId, } from "@web/components/OnboardingTour/onboarding.tour.steps"; import { + consumePendingTourOffer, hasSeenOnboardingTour, markOnboardingTourSeen, + markTourOfferPending, } from "@web/components/OnboardingTour/onboarding.tour.storage"; export type OnboardingTourState = { @@ -33,29 +36,58 @@ export const onboardingTourActions = { start: () => { if (hasSeenOnboardingTour()) return; useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); + track("onboarding_game_started"); }, /** Palette re-entry: always restart from the first step. */ restart: () => { useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); + track("onboarding_game_replayed", { source: "palette" }); }, advance: () => { const { isActive, stepId } = useOnboardingTourStore.getState(); if (!isActive) return; + track("onboarding_task_completed", { task: stepId }); const next = getNextOnboardingStepId(stepId); if (!next) { onboardingTourActions.finish(); return; } + if (stepId === "fork") { + track("onboarding_segment_reached", { segment: "advanced" }); + } useOnboardingTourStore.setState({ stepId: next }); }, /** Reached the last step. */ - finish: endTour, - /** User dismissed the tour early (Skip button or Escape). */ - skip: endTour, - /** Welcome backdrop/auth dismiss: never trap; mark seen without starting. */ - markSkippedWithoutStarting: () => { + finish: () => { + track("onboarding_game_finished"); + endTour(); + }, + /** User dismissed the tour early (Skip button, the fork's "I'm done", or Escape). */ + skip: () => { + const { stepId } = useOnboardingTourStore.getState(); + track("onboarding_game_skipped", { step: stepId }); + endTour(); + }, + /** + * Welcome backdrop/auth dismiss: never trap. If the user is heading into + * signup, defer the seen-flag so the tour can be offered once, right after + * signup completes, instead of being burned forever (log-in and plain + * dismiss mark it seen immediately — only signup gets the deferred offer). + */ + markSkippedWithoutStarting: (options?: { pendingSignup?: boolean }) => { + if (options?.pendingSignup) { + markTourOfferPending(); + return; + } markOnboardingTourSeen(); }, + /** Called once, right after signup completes, to redeem a pending offer. */ + offerAfterSignupIfPending: () => { + if (!consumePendingTourOffer()) return; + if (hasSeenOnboardingTour()) return; + useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); + track("onboarding_game_started"); + }, }; export const selectOnboardingTourActive = (state: OnboardingTourState) => diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts index 7783a52a8..9a2fd0464 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts @@ -127,8 +127,8 @@ describe("useOnboardingTourProgress palette step", () => { }); await waitFor(() => { - // Palette advance + shortcuts already-open cascade lands on done. - expect(useOnboardingTourStore.getState().stepId).toBe("done"); + // Palette advance + shortcuts already-open cascade lands on fork. + expect(useOnboardingTourStore.getState().stepId).toBe("fork"); }); }); diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts index ae2f7687b..656e36a23 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts @@ -85,11 +85,59 @@ export function useOnboardingTourProgress() { return; } + // The E-then-T sequence reopens the form; enough to count as the lesson. + if (stepId === "editSequence" && isFormOpen) { + onboardingTourActions.advance(); + return; + } + if (stepId === "shortcuts" && isShortcutsOpen) { onboardingTourActions.advance(); } }, [isActive, stepId, isFormOpen, isSaving, isShortcutsOpen]); + // Lessons taught by a single keypress: encouragement-based, like the rest + // of this hook — pressing the key is enough to count as the lesson, we do + // not verify the resulting focus/nudge/undo actually landed. + useEffect(() => { + if (!isActive) return; + if ( + stepId !== "moveFocus" && + stepId !== "targetEvent" && + stepId !== "nudge" && + stepId !== "undo" + ) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + const mod = event.metaKey || event.ctrlKey; + if ( + stepId === "moveFocus" && + !event.shiftKey && + !mod && + event.key.startsWith("Arrow") + ) { + onboardingTourActions.advance(); + } else if (stepId === "targetEvent" && event.key === "Shift") { + onboardingTourActions.advance(); + } else if ( + stepId === "nudge" && + event.shiftKey && + event.key.startsWith("Arrow") + ) { + onboardingTourActions.advance(); + } else if (stepId === "undo" && mod && event.key.toLowerCase() === "z") { + onboardingTourActions.advance(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [isActive, stepId]); + // ESC skips the tour when nothing higher owns Escape. Capture + stand down // for app lock / form / floating layers so we never trap the user. useEffect(() => { diff --git a/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx b/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx new file mode 100644 index 000000000..0fc25d474 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx @@ -0,0 +1,72 @@ +import { type FC, useEffect, useRef } from "react"; +import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { track } from "@web/auth/posthog/track"; +import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; +import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; + +/** + * Shown once, right after the onboarding tour ends. Connecting starts a + * background import (see useGoogleUiState / isFirstImportInProgress) so the + * user is never blocked from continuing to explore. + */ +export const ConnectGoogleCTA: FC = () => { + const { connect, isAvailable } = useConnectGoogle(); + const shownRef = useRef(false); + + useEffect(() => { + if (!isAvailable) { + // isAvailable starts false while the /config fetch that determines it + // is still in flight, so an instant skip here would race that fetch + // and strand real deployments. Give it a moment; if Google genuinely + // isn't configured, don't leave the user stuck on a step with nothing + // to click. + const timer = window.setTimeout(() => { + if (!isAvailable) postOnboardingFlowActions.skipConnect(); + }, 4000); + return () => window.clearTimeout(timer); + } + if (!shownRef.current) { + shownRef.current = true; + track("connect_cta_shown"); + } + }, [isAvailable]); + + if (!isAvailable) return null; + + const onConnect = () => { + postOnboardingFlowActions.acceptConnect(); + connect(); + }; + + return ( +
+
+

Bring in your calendar

+

+ Connect Google Calendar and we will import your events in the + background while you keep exploring. You can always do this later. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx new file mode 100644 index 000000000..531aa2b61 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx @@ -0,0 +1,61 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { PostOnboardingFlow } from "@web/components/PostOnboardingFlow/PostOnboardingFlow"; +import { usePostOnboardingFlowStore } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; +import { beforeEach, describe, expect, it } from "bun:test"; + +function renderFlow(ui: ReactNode, authenticated: boolean) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {} }} + > + {ui} + + , + ); +} + +describe("PostOnboardingFlow", () => { + beforeEach(() => { + persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, ""); + }); + + it("renders the trial CTA for an anonymous user with a pending trial stage", () => { + usePostOnboardingFlowStore.setState({ stage: "trial" }); + renderFlow(, false); + + expect( + screen.getByRole("region", { name: "Start your trial" }), + ).toBeInTheDocument(); + }); + + it("never renders for an authenticated user, even with a stale connect/trial stage", () => { + // Simulates a user who reached "connect"/"trial" anonymously, then + // authenticated by a path other than the Connect Google CTA, leaving a + // stale stage in localStorage/the store from a prior session. + usePostOnboardingFlowStore.setState({ stage: "trial" }); + renderFlow(, true); + + expect( + screen.queryByRole("region", { name: "Start your trial" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Connect Google Calendar" }), + ).not.toBeInTheDocument(); + }); + + it("resolves a stale connect/trial stage to done once authenticated becomes true", () => { + usePostOnboardingFlowStore.setState({ stage: "connect" }); + renderFlow(, true); + + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); + }); +}); diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx new file mode 100644 index 000000000..e617ce9fe --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx @@ -0,0 +1,30 @@ +import { type FC, useContext } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { ConnectGoogleCTA } from "@web/components/PostOnboardingFlow/ConnectGoogleCTA"; +import { + selectPostOnboardingStage, + usePostOnboardingFlowStore, +} from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; +import { TrialCTA } from "@web/components/PostOnboardingFlow/TrialCTA"; +import { usePostOnboardingFlowTrigger } from "@web/components/PostOnboardingFlow/usePostOnboardingFlowTrigger"; + +/** + * Renders the connect-Google and trial CTAs after the onboarding tour ends. + * Both steps are skippable; skipping connect goes straight to the trial CTA. + * + * Gated on `authenticated` here too (not just at the trigger that sets + * `stage`): a user can become authenticated by a path other than accepting + * Connect Google, which would otherwise leave a stale "connect"/"trial" + * stage in localStorage that renders on their next load even though + * usePostOnboardingFlowTrigger's own effect resolves it moments later. + */ +export const PostOnboardingFlow: FC = () => { + usePostOnboardingFlowTrigger(); + const { authenticated } = useContext(SessionContext); + const stage = usePostOnboardingFlowStore(selectPostOnboardingStage); + + if (authenticated) return null; + if (stage === "connect") return ; + if (stage === "trial") return ; + return null; +}; diff --git a/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx b/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx new file mode 100644 index 000000000..f5fb07f76 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx @@ -0,0 +1,66 @@ +import { type FC, useContext, useEffect, useRef } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { track } from "@web/auth/posthog/track"; +import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; +import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; + +/** + * "Ready to try it for real?" — the last step of the connect/trial flow. + * Billing does not exist yet (see keyboard-education/03), so accepting for + * an anonymous user opens signup; an already-authenticated user (arrived via + * Connect Google, which signs up on its own) gets a coming-soon toast in + * place of real checkout until 03 ships. + */ +export const TrialCTA: FC = () => { + const { authenticated } = useContext(SessionContext); + const { openModal } = useAuthModal(); + const shownRef = useRef(false); + + useEffect(() => { + if (!shownRef.current) { + shownRef.current = true; + track("trial_cta_shown"); + } + }, []); + + const onStartTrial = () => { + track("trial_started"); + if (!authenticated) { + openModal("signUp"); + return; + } + postOnboardingFlowActions.dismissTrial(); + }; + + return ( +
+
+

Ready to try it for real?

+

+ Start a free trial and keep everything you just set up. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts new file mode 100644 index 000000000..304bd1524 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts @@ -0,0 +1,17 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; + +export type PostOnboardingStage = "connect" | "trial" | "done"; + +/** Null means the flow has never been triggered for this browser. */ +export function getPostOnboardingStage(): PostOnboardingStage | null { + const value = persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE); + if (value === "connect" || value === "trial" || value === "done") { + return value; + } + return null; +} + +export function setPostOnboardingStage(stage: PostOnboardingStage): void { + persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, stage); +} diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts new file mode 100644 index 000000000..cc50e9930 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts @@ -0,0 +1,71 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { + postOnboardingFlowActions, + usePostOnboardingFlowStore, +} from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; +import { beforeEach, describe, expect, it } from "bun:test"; + +describe("postOnboardingFlowActions", () => { + beforeEach(() => { + persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, ""); + usePostOnboardingFlowStore.setState({ stage: null }); + }); + + it("starts at connect and persists the stage", () => { + postOnboardingFlowActions.startAfterTour(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("connect"); + expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe( + "connect", + ); + }); + + it("does not retrigger once already started", () => { + postOnboardingFlowActions.startAfterTour(); + postOnboardingFlowActions.skipConnect(); + postOnboardingFlowActions.startAfterTour(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("trial"); + }); + + it("skip and accept both move connect to trial", () => { + postOnboardingFlowActions.startAfterTour(); + postOnboardingFlowActions.skipConnect(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("trial"); + + usePostOnboardingFlowStore.setState({ stage: "connect" }); + postOnboardingFlowActions.acceptConnect(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("trial"); + }); + + it("dismissing the trial CTA ends the flow for good", () => { + usePostOnboardingFlowStore.setState({ stage: "trial" }); + postOnboardingFlowActions.dismissTrial(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); + expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe( + "done", + ); + }); + + it("resolveOnAuth clears a stale connect/trial stage left by a non-Google login", () => { + usePostOnboardingFlowStore.setState({ stage: "connect" }); + postOnboardingFlowActions.resolveOnAuth(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); + expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe( + "done", + ); + + usePostOnboardingFlowStore.setState({ stage: "trial" }); + postOnboardingFlowActions.resolveOnAuth(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); + }); + + it("resolveOnAuth is a no-op when there is nothing to resolve", () => { + usePostOnboardingFlowStore.setState({ stage: null }); + postOnboardingFlowActions.resolveOnAuth(); + expect(usePostOnboardingFlowStore.getState().stage).toBeNull(); + + usePostOnboardingFlowStore.setState({ stage: "done" }); + postOnboardingFlowActions.resolveOnAuth(); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); + }); +}); diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts new file mode 100644 index 000000000..79d5bffbf --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; +import { track } from "@web/auth/posthog/track"; +import { + getPostOnboardingStage, + type PostOnboardingStage, + setPostOnboardingStage, +} from "@web/components/PostOnboardingFlow/post-onboarding-flow.storage"; + +export type PostOnboardingFlowState = { + /** null: never triggered, so nothing renders. */ + stage: PostOnboardingStage | null; +}; + +export const usePostOnboardingFlowStore = create()( + () => ({ + stage: getPostOnboardingStage(), + }), +); + +const setStage = (stage: PostOnboardingStage) => { + setPostOnboardingStage(stage); + usePostOnboardingFlowStore.setState({ stage }); +}; + +export const postOnboardingFlowActions = { + /** + * Called once the onboarding tour ends (finish or skip). A no-op if this + * browser already went through the flow, so replaying the tour from the + * command palette never re-shows connect/trial CTAs to a returning user. + */ + startAfterTour: () => { + if (usePostOnboardingFlowStore.getState().stage !== null) return; + setStage("connect"); + }, + /** Google OAuth is a full navigation; persist the next stage first. */ + acceptConnect: () => { + track("connect_cta_accepted"); + setStage("trial"); + }, + skipConnect: () => { + track("connect_cta_skipped"); + setStage("trial"); + }, + dismissTrial: () => { + setStage("done"); + }, + /** + * Resolves a stale "connect"/"trial" stage once the user is authenticated + * by any path (not just accepting the Connect Google CTA), so a returning + * established user is never shown these CTAs again. + */ + resolveOnAuth: () => { + const { stage } = usePostOnboardingFlowStore.getState(); + if (stage === "connect" || stage === "trial") { + setStage("done"); + } + }, +}; + +export const selectPostOnboardingStage = (state: PostOnboardingFlowState) => + state.stage; diff --git a/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts b/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts new file mode 100644 index 000000000..adf638569 --- /dev/null +++ b/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts @@ -0,0 +1,37 @@ +import { useContext, useEffect, useRef } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { + selectOnboardingTourActive, + useOnboardingTourStore, +} from "@web/components/OnboardingTour/onboarding.tour.store"; +import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; + +/** + * Starts the connect/trial flow the moment the onboarding tour ends + * (finish or skip), for anonymous users only. Authenticated users already + * connected or paying have nothing to gain from these CTAs. + */ +export function usePostOnboardingFlowTrigger() { + const { authenticated } = useContext(SessionContext); + const isTourActive = useOnboardingTourStore(selectOnboardingTourActive); + const wasTourActive = useRef(false); + + useEffect(() => { + if (wasTourActive.current && !isTourActive && !authenticated) { + postOnboardingFlowActions.startAfterTour(); + } + wasTourActive.current = isTourActive; + }, [isTourActive, authenticated]); + + // A user can become authenticated by a path other than accepting the + // Connect Google CTA (e.g. email/password login, or Google auth from + // elsewhere in the app), leaving a stale "connect"/"trial" stage in + // localStorage. Resolve it so an established, possibly already-paying + // user is never shown these CTAs on a later load. PostOnboardingFlow also + // gates its own render on `authenticated` as defense in depth. + useEffect(() => { + if (authenticated) { + postOnboardingFlowActions.resolveOnAuth(); + } + }, [authenticated]); +} diff --git a/packages/web/src/components/RootShell/RootShell.tsx b/packages/web/src/components/RootShell/RootShell.tsx index 7c16c2097..93ecaacf8 100644 --- a/packages/web/src/components/RootShell/RootShell.tsx +++ b/packages/web/src/components/RootShell/RootShell.tsx @@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router"; import { AuthModal } from "@web/components/AuthModal/AuthModal"; import { AuthModalProvider } from "@web/components/AuthModal/AuthModalProvider"; import { OnboardingTour } from "@web/components/OnboardingTour/OnboardingTour"; +import { PostOnboardingFlow } from "@web/components/PostOnboardingFlow/PostOnboardingFlow"; import { ReleaseNotesPrompt } from "@web/components/ReleaseNotesPrompt/ReleaseNotesPrompt"; import { selectReleaseNotesPromptOpen, @@ -40,6 +41,7 @@ export function RootShell() { + {isWelcomeGuideOpen && } {isReleaseNotesPromptOpen && } diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx index f5f0b37af..cd7d89fd8 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx @@ -25,6 +25,12 @@ import { selectEventJumpAnnouncement, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; +import { ShortcutTipIndicator } from "@web/shortcuts/tips/ShortcutTipIndicator"; +import { + selectActiveShortcutTipId, + useShortcutTipsStore, +} from "@web/shortcuts/tips/shortcut-tips.store"; +import { useShortcutTipTrigger } from "@web/shortcuts/tips/useShortcutTipTrigger"; import { useSseDegraded } from "@web/sse/hooks/useSseDegraded"; /** @@ -39,6 +45,8 @@ import { useSseDegraded } from "@web/sse/hooks/useSseDegraded"; * the meaning; `title` is the safety net if anything still overflows. */ export const SidebarStatusBar: FC = () => { + useShortcutTipTrigger(); + const activeTipId = useShortcutTipsStore(selectActiveShortcutTipId); const isKeyboardOnly = useKeyboardOnlyStore(selectKeyboardOnlyActive); const isEventJump = useEventJumpStore(selectEventJumpActive); const eventJumpAnnouncement = useEventJumpStore(selectEventJumpAnnouncement); @@ -86,6 +94,10 @@ export const SidebarStatusBar: FC = () => {
+ ) : !status && activeTipId ? ( +
+ +
) : ( + ); +}; diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts new file mode 100644 index 000000000..53e0046ce --- /dev/null +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts @@ -0,0 +1,20 @@ +export type ShortcutTipId = + | "edit-sequence" + | "nudge" + | "target-event" + | "edge-cycle"; + +export type ShortcutTip = { + id: ShortcutTipId; + text: string; +}; + +/** Small fixed rotation; content mirrors the onboarding tour's advanced lessons. */ +export function getShortcutTips(): ShortcutTip[] { + return [ + { id: "edit-sequence", text: "Press E then T to jump to the title" }, + { id: "nudge", text: "Hold Shift and press an arrow to nudge this event" }, + { id: "target-event", text: "Tap Shift to jump to any visible event" }, + { id: "edge-cycle", text: "Press Tab to move between start and end" }, + ]; +} diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.storage.ts b/packages/web/src/shortcuts/tips/shortcut-tips.storage.ts new file mode 100644 index 000000000..80bb83042 --- /dev/null +++ b/packages/web/src/shortcuts/tips/shortcut-tips.storage.ts @@ -0,0 +1,12 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; + +export function hasMutedShortcutTips(): boolean { + return ( + persistentBrowserStore.get(STORAGE_KEYS.SHORTCUT_TIPS_MUTED) === "true" + ); +} + +export function muteShortcutTips(): void { + persistentBrowserStore.set(STORAGE_KEYS.SHORTCUT_TIPS_MUTED, "true"); +} diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.store.test.ts b/packages/web/src/shortcuts/tips/shortcut-tips.store.test.ts new file mode 100644 index 000000000..6a396a377 --- /dev/null +++ b/packages/web/src/shortcuts/tips/shortcut-tips.store.test.ts @@ -0,0 +1,89 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { + shortcutTipsActions, + useShortcutTipsStore, +} from "@web/shortcuts/tips/shortcut-tips.store"; +import { beforeEach, describe, expect, it } from "bun:test"; + +describe("shortcutTipsActions", () => { + beforeEach(() => { + persistentBrowserStore.set(STORAGE_KEYS.SHORTCUT_TIPS_MUTED, ""); + useShortcutTipsStore.setState({ + muted: false, + mouseStreak: 0, + activeTipId: null, + lastShownTipId: null, + lastRotatedAt: null, + }); + }); + + it("shows a tip on first eligibility", () => { + shortcutTipsActions.maybeRotate(); + expect(useShortcutTipsStore.getState().activeTipId).not.toBeNull(); + }); + + it("does not show a second tip while one is already active", () => { + shortcutTipsActions.maybeRotate(); + const first = useShortcutTipsStore.getState().activeTipId; + shortcutTipsActions.maybeRotate(); + expect(useShortcutTipsStore.getState().activeTipId).toBe(first); + }); + + it("respects the cooldown after hide before showing a new tip", () => { + shortcutTipsActions.maybeRotate(); + shortcutTipsActions.hide(); + expect(useShortcutTipsStore.getState().activeTipId).toBeNull(); + + // Cooldown has not elapsed: re-becoming eligible shows nothing. + shortcutTipsActions.maybeRotate(); + expect(useShortcutTipsStore.getState().activeTipId).toBeNull(); + }); + + it("advances through the rotation across cooldown-elapsed cycles", () => { + shortcutTipsActions.maybeRotate(); + const first = useShortcutTipsStore.getState().activeTipId; + shortcutTipsActions.hide(); + // Simulate the cooldown having elapsed. + useShortcutTipsStore.setState({ lastRotatedAt: 0 }); + shortcutTipsActions.maybeRotate(); + const second = useShortcutTipsStore.getState().activeTipId; + expect(second).not.toBeNull(); + expect(second).not.toBe(first); + }); + + it("biases toward edit-sequence after a mouse-driven streak", () => { + shortcutTipsActions.recordActivity("gridClick"); + shortcutTipsActions.recordActivity("gridClick"); + shortcutTipsActions.recordActivity("gridClick"); + shortcutTipsActions.maybeRotate(); + expect(useShortcutTipsStore.getState().activeTipId).toBe("edit-sequence"); + expect(useShortcutTipsStore.getState().mouseStreak).toBe(0); + }); + + it("a keyboard edit resets an in-progress mouse streak", () => { + shortcutTipsActions.recordActivity("gridClick"); + shortcutTipsActions.recordActivity("gridClick"); + shortcutTipsActions.recordActivity("keyboardEdit"); + expect(useShortcutTipsStore.getState().mouseStreak).toBe(0); + }); + + it("mute persists and clears the active tip", () => { + shortcutTipsActions.maybeRotate(); + shortcutTipsActions.mute(); + expect(useShortcutTipsStore.getState().activeTipId).toBeNull(); + expect(persistentBrowserStore.get(STORAGE_KEYS.SHORTCUT_TIPS_MUTED)).toBe( + "true", + ); + shortcutTipsActions.maybeRotate(); + expect(useShortcutTipsStore.getState().activeTipId).toBeNull(); + }); + + it("actedOn only clears the tip it names", () => { + useShortcutTipsStore.setState({ activeTipId: "nudge" }); + shortcutTipsActions.actedOn("edit-sequence"); + expect(useShortcutTipsStore.getState().activeTipId).toBe("nudge"); + shortcutTipsActions.actedOn("nudge"); + expect(useShortcutTipsStore.getState().activeTipId).toBeNull(); + }); +}); diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.store.ts b/packages/web/src/shortcuts/tips/shortcut-tips.store.ts new file mode 100644 index 000000000..14e2bfae2 --- /dev/null +++ b/packages/web/src/shortcuts/tips/shortcut-tips.store.ts @@ -0,0 +1,115 @@ +import { create } from "zustand"; +import { track } from "@web/auth/posthog/track"; +import { + getShortcutTips, + type ShortcutTip, + type ShortcutTipId, +} from "@web/shortcuts/tips/shortcut-tips.data"; +import { + hasMutedShortcutTips, + muteShortcutTips, +} from "@web/shortcuts/tips/shortcut-tips.storage"; + +/** Never rotate in a new tip more often than this, so the strip stays quiet. */ +const MIN_ROTATION_INTERVAL_MS = 45_000; +/** Mouse-driven edits in a row before we bias toward the edit-sequence tip. */ +const MOUSE_STREAK_THRESHOLD = 3; + +export type ShortcutTipsState = { + muted: boolean; + /** In-memory only: consecutive mouse-driven edits since the last keyboard one. */ + mouseStreak: number; + activeTipId: ShortcutTipId | null; + /** Survives activeTipId being cleared, so rotation keeps advancing through the list. */ + lastShownTipId: ShortcutTipId | null; + lastRotatedAt: number | null; +}; + +export const useShortcutTipsStore = create()(() => ({ + muted: hasMutedShortcutTips(), + mouseStreak: 0, + activeTipId: null, + lastShownTipId: null, + lastRotatedAt: null, +})); + +function nextTipInRotation( + tips: ShortcutTip[], + lastShownTipId: ShortcutTipId | null, +): ShortcutTip { + if (lastShownTipId === null) return tips[0]; + const lastIndex = tips.findIndex((tip) => tip.id === lastShownTipId); + return tips[(lastIndex + 1) % tips.length] ?? tips[0]; +} + +const MOUSE_ACTIVITIES = new Set([ + "gridClick", + "sidebarClick", + "dnd", + "eventRightClick", + "creating", +]); +const KEYBOARD_ACTIVITIES = new Set(["createShortcut", "keyboardEdit"]); + +export const shortcutTipsActions = { + mute: () => { + muteShortcutTips(); + useShortcutTipsStore.setState({ muted: true, activeTipId: null }); + }, + /** Tracks mouse-vs-keyboard edit activity to bias which tip shows next. */ + recordActivity: (activity: string | null) => { + if (activity === null) return; + if (MOUSE_ACTIVITIES.has(activity)) { + useShortcutTipsStore.setState((state) => ({ + mouseStreak: state.mouseStreak + 1, + })); + } else if (KEYBOARD_ACTIVITIES.has(activity)) { + useShortcutTipsStore.setState({ mouseStreak: 0 }); + } + }, + /** + * Called while a tip is eligible to show (event focused, form closed). + * The cooldown is tracked independently of `activeTipId` so rapidly + * refocusing/blurring an event cannot bypass it — re-becoming eligible + * inside the cooldown window just shows nothing, which is the quiet + * behavior we want, not a fresh tip every time. + */ + maybeRotate: () => { + const state = useShortcutTipsStore.getState(); + if (state.muted) return; + if (state.activeTipId !== null) return; + if ( + state.lastRotatedAt !== null && + Date.now() - state.lastRotatedAt < MIN_ROTATION_INTERVAL_MS + ) { + return; + } + + const tips = getShortcutTips(); + const biased = state.mouseStreak >= MOUSE_STREAK_THRESHOLD; + const next = biased + ? (tips.find((tip) => tip.id === "edit-sequence") ?? tips[0]) + : nextTipInRotation(tips, state.lastShownTipId); + + useShortcutTipsStore.setState({ + activeTipId: next.id, + lastShownTipId: next.id, + lastRotatedAt: Date.now(), + mouseStreak: biased ? 0 : state.mouseStreak, + }); + track("shortcut_tip_shown", { tip: next.id }); + }, + /** Called when the tip is no longer eligible (focus lost, form opened). */ + hide: () => { + useShortcutTipsStore.setState({ activeTipId: null }); + }, + /** The user pressed the key the active tip was teaching. */ + actedOn: (tipId: ShortcutTipId) => { + if (useShortcutTipsStore.getState().activeTipId !== tipId) return; + track("shortcut_tip_acted_on", { tip: tipId, action: "used" }); + useShortcutTipsStore.setState({ activeTipId: null }); + }, +}; + +export const selectActiveShortcutTipId = (state: ShortcutTipsState) => + state.activeTipId; diff --git a/packages/web/src/shortcuts/tips/useIsAnyCalendarEventFocused.ts b/packages/web/src/shortcuts/tips/useIsAnyCalendarEventFocused.ts new file mode 100644 index 000000000..457f5aac7 --- /dev/null +++ b/packages/web/src/shortcuts/tips/useIsAnyCalendarEventFocused.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { calendarEventIdElementSelector } from "@web/grid/interaction/view-event-registry"; + +/** + * View-agnostic version of useIsGridEventFocused: the sidebar status bar is + * mounted regardless of which calendar view is active, so it checks either + * view's id attribute (Day and Week are never co-mounted) instead of taking + * a view-specific getFocused callback. + */ +export function useIsAnyCalendarEventFocused(): boolean { + const [focused, setFocused] = useState( + () => + document.activeElement?.closest(calendarEventIdElementSelector()) != null, + ); + + useEffect(() => { + let frame = 0; + const sync = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + setFocused( + document.activeElement?.closest(calendarEventIdElementSelector()) != + null, + ); + }); + }; + + document.addEventListener("focusin", sync); + document.addEventListener("focusout", sync); + sync(); + + return () => { + cancelAnimationFrame(frame); + document.removeEventListener("focusin", sync); + document.removeEventListener("focusout", sync); + }; + }, []); + + return focused; +} diff --git a/packages/web/src/shortcuts/tips/useShortcutTipTrigger.ts b/packages/web/src/shortcuts/tips/useShortcutTipTrigger.ts new file mode 100644 index 000000000..de871b95a --- /dev/null +++ b/packages/web/src/shortcuts/tips/useShortcutTipTrigger.ts @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { + selectDraftActivity, + selectIsEventFormOpen, + useDraftStore, +} from "@web/events/stores/draft.store"; +import { + selectActiveShortcutTipId, + shortcutTipsActions, + useShortcutTipsStore, +} from "@web/shortcuts/tips/shortcut-tips.store"; +import { useIsAnyCalendarEventFocused } from "@web/shortcuts/tips/useIsAnyCalendarEventFocused"; + +/** + * Drives the quiet sidebar tip: eligible only when an event is focused and + * the form is closed, and biased by recent mouse-vs-keyboard edit activity. + */ +export function useShortcutTipTrigger() { + const eventFocused = useIsAnyCalendarEventFocused(); + const isFormOpen = useDraftStore(selectIsEventFormOpen); + const activity = useDraftStore(selectDraftActivity); + const activeTipId = useShortcutTipsStore(selectActiveShortcutTipId); + + useEffect(() => { + shortcutTipsActions.recordActivity(activity ?? null); + }, [activity]); + + useEffect(() => { + if (eventFocused && !isFormOpen) { + shortcutTipsActions.maybeRotate(); + } else { + shortcutTipsActions.hide(); + } + }, [eventFocused, isFormOpen]); + + // Encouragement-based, like the onboarding tour's advance triggers: the + // matching keypress counts as "acted on" without verifying the resulting + // action landed. + useEffect(() => { + if (!activeTipId) return; + + const onKeyDown = (event: KeyboardEvent) => { + const mod = event.metaKey || event.ctrlKey; + if ( + activeTipId === "edit-sequence" && + !event.shiftKey && + !mod && + event.key.toLowerCase() === "e" + ) { + shortcutTipsActions.actedOn("edit-sequence"); + } else if ( + activeTipId === "nudge" && + event.shiftKey && + event.key.startsWith("Arrow") + ) { + shortcutTipsActions.actedOn("nudge"); + } else if (activeTipId === "target-event" && event.key === "Shift") { + shortcutTipsActions.actedOn("target-event"); + } else if (activeTipId === "edge-cycle" && event.key === "Tab") { + shortcutTipsActions.actedOn("edge-cycle"); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [activeTipId]); +} diff --git a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx index a3924be3d..18279d6e6 100644 --- a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx +++ b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx @@ -5,6 +5,7 @@ import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAu import { completeGoogleAuthorization } from "@web/auth/google/authorization/complete-google-authorization"; import { track } from "@web/auth/posthog/track"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; +import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store"; @@ -32,6 +33,7 @@ export async function completeGoogleAuthCallback({ } else if (result.isNewUser) { track("signup_completed", { method: "google" }); releaseNotesPromptActions.scheduleOpen(); + onboardingTourActions.offerAfterSignupIfPending(); } else { track("login_completed", { method: "google" }); }