diff --git a/e2e/onboarding/interactive-tour.spec.ts b/e2e/onboarding/interactive-tour.spec.ts index 76c54c49a..ebe75ef15 100644 --- a/e2e/onboarding/interactive-tour.spec.ts +++ b/e2e/onboarding/interactive-tour.spec.ts @@ -21,6 +21,7 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => { const card = page.locator("[data-onboarding-tour]"); await expect(card).toContainText("Create with the keyboard"); + // Act 1: create, save, moveFocus, editSequence. await page.keyboard.press("c"); await expect(card).toContainText("Name it and save"); @@ -28,42 +29,58 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => { await fillTitleAndSaveEventForm(page, title); await expect(card).toContainText("Move between events"); - await page.keyboard.press("ArrowRight"); + // moveFocus auto-focuses today's seeded Morning standup event; today has + // several other seeded events, so an arrow key has somewhere real to go. + await page.keyboard.press("ArrowDown"); 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"); - // Palette stays open with search focused; the tour must not advance to "?" yet. - await expect(card).toContainText("Open the command palette"); - await page.keyboard.press("Escape"); - await expect(card).toContainText("Browse every shortcut"); - - // Shift+/ opens the legend (same as ? on US keyboards) once the calendar has focus. - await page.keyboard.press("Shift+/"); await expect(card).toContainText("That's the basics"); - await card.getByRole("button", { name: "I'm done" }).click(); + await card.getByRole("button", { name: "Keep going" }).click(); + await expect(card).toContainText("Jump to Dentist"); + + // Act 2: targetEvent, move, resizeEdge, placeDraft, undo. The exact + // Shift-hold jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts; + // here we drive the mission's actual completion signal (Dentist focused), + // same as a jump would leave it. + const dentistButton = page + .locator("#mainGrid") + .getByRole("button", { name: /Dentist/ }); + await dentistButton.focus(); + await expect(card).toContainText("Move Dentist out of the overlap"); + + await page.keyboard.press("Shift+ArrowRight"); + await expect(card).toContainText("Give Dentist more time"); + + // A single Tab reaches the end edge (the step seeds edge focus to start). + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+ArrowDown"); + await expect(card).toContainText("Place a new event on the grid"); + + // The step blurs focus on entry, so nothing is focused for the place-draft + // Shift+Arrow to move. + await page.keyboard.press("Shift+ArrowRight"); + await expect(card).toContainText("Never stress about a mistake"); + + await page.keyboard.press("ControlOrMeta+z"); + await page.keyboard.press("ControlOrMeta+Shift+z"); + await expect(card).toContainText("Graduate to Hardcore Mode"); + + // Act 3: hardcore graduation, the tour's finale. + await page.keyboard.down("Shift"); + await page.keyboard.up("Shift"); + await page.keyboard.down("Shift"); + await page.keyboard.up("Shift"); await expect(card).toHaveCount(0); + // Leave Hardcore Mode so it doesn't affect other assertions/reload below. + await page.keyboard.down("Shift"); + await page.keyboard.up("Shift"); + await page.keyboard.down("Shift"); + await page.keyboard.up("Shift"); + await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.locator("[data-onboarding-tour]")).toHaveCount(0); }); diff --git a/packages/backend/src/billing/billing.constants.ts b/packages/backend/src/billing/billing.constants.ts index 5136e5af5..8c1cf2605 100644 --- a/packages/backend/src/billing/billing.constants.ts +++ b/packages/backend/src/billing/billing.constants.ts @@ -6,5 +6,6 @@ * small diff instead of a re-implementation. */ export const BILLING_DEFAULTS = { - TRIAL_LENGTH_DAYS: 14, + // Aligned with the client-side anonymous trial clock (packages/web/src/billing/trial.storage.ts). + TRIAL_LENGTH_DAYS: 7, } as const; diff --git a/packages/web/src/api/billing.api.ts b/packages/web/src/api/billing.api.ts deleted file mode 100644 index 1829e67af..000000000 --- a/packages/web/src/api/billing.api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type BillingStatusResponse } from "@core/types/billing.types"; -import { BaseApi } from "@web/api/base/base.api"; - -const BillingApi = { - async getStatus(): Promise { - const response = - await BaseApi.get(`/billing/status`); - return response.data; - }, - - async startTrial(): Promise { - const response = - await BaseApi.post(`/billing/trial/start`); - return response.data; - }, -}; - -export { BillingApi }; diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts index 61dfd2e30..d53defde5 100644 --- a/packages/web/src/auth/posthog/track.ts +++ b/packages/web/src/auth/posthog/track.ts @@ -14,13 +14,15 @@ export type ProductEvent = | "onboarding_game_skipped" | "onboarding_game_finished" | "onboarding_game_replayed" + | "onboarding_step_assist_used" | "connect_cta_shown" | "connect_cta_accepted" | "connect_cta_skipped" - | "trial_cta_shown" | "trial_started" | "trial_converted" | "trial_expired" + | "trial_gate_shown" + | "trial_gate_cta_clicked" | "shortcut_tip_shown" | "shortcut_tip_acted_on"; diff --git a/packages/web/src/billing/TrialCountdownChip.tsx b/packages/web/src/billing/TrialCountdownChip.tsx new file mode 100644 index 000000000..1f93d6dec --- /dev/null +++ b/packages/web/src/billing/TrialCountdownChip.tsx @@ -0,0 +1,30 @@ +import { type FC } from "react"; +import { track } from "@web/auth/posthog/track"; +import { useTrialStatus } from "@web/billing/useTrialStatus"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; + +/** + * Sidebar status bar slot: quiet countdown for the anonymous browser trial. + * Never shown for authenticated users (server billing status governs them). + */ +export const TrialCountdownChip: FC = () => { + const { isExpired, daysLeft, isAnonymousTrial } = useTrialStatus(); + const { openModal } = useAuthModal(); + + if (!isAnonymousTrial || isExpired) return null; + + const isUrgent = daysLeft <= 2; + + return ( + + ); +}; diff --git a/packages/web/src/billing/TrialGateModal.tsx b/packages/web/src/billing/TrialGateModal.tsx new file mode 100644 index 000000000..fe05eb7ea --- /dev/null +++ b/packages/web/src/billing/TrialGateModal.tsx @@ -0,0 +1,93 @@ +import { type FC, useEffect, useRef, useState } from "react"; +import { track } from "@web/auth/posthog/track"; +import { Z_INDEX_MODAL } from "@web/common/constants/web.constants"; +import { runExportMyData } from "@web/common/storage/offline-data/export-user-data.util"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; +import { PixelPirateScouting } from "@web/components/WelcomeModal/PixelPirateScouting"; +import { useAppLockReason } from "@web/shortcuts/app-lock"; + +/** + * Full app-lock overlay shown once the anonymous browser trial has expired. + * Unlike every other overlay in onboarding, this one is intentionally NOT + * dismissible on Escape/backdrop — see 06-trial-spec.md. It still must be + * fully keyboard-operable: focus lands here on mount, all actions are real + * buttons, nothing depends on a mouse. + */ +export const TrialGateModal: FC = () => { + useAppLockReason("trialGate", true); + const { openModal } = useAuthModal(); + const panelRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + const shownRef = useRef(false); + + useEffect(() => { + panelRef.current?.focus(); + if (!shownRef.current) { + shownRef.current = true; + track("trial_expired"); + track("trial_gate_shown"); + } + }, []); + + const handleExport = async () => { + setIsExporting(true); + track("trial_gate_cta_clicked", { cta: "export" }); + try { + await runExportMyData(); + } finally { + setIsExporting(false); + } + }; + + return ( +
+
+ +

Your free trial has ended

+

+ Sign up to keep using Compass and pick up right where you left off. +

+
+ + +
+ +
+
+ ); +}; diff --git a/packages/web/src/billing/trial.storage.ts b/packages/web/src/billing/trial.storage.ts new file mode 100644 index 000000000..e7f406046 --- /dev/null +++ b/packages/web/src/billing/trial.storage.ts @@ -0,0 +1,36 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; + +/** Matches the server trial length (billing.constants.ts TRIAL_LENGTH_DAYS). */ +export const TRIAL_LENGTH_DAYS = 7; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Anonymous-only, client-side trial clock: no server identity exists yet to + * track it against. Deliberately unsophisticated — clearing storage renews + * the trial, and that's an accepted tradeoff, not a bug to fix. + */ +/** Returns true the one time it actually stamps the start (a fresh trial). */ +export function ensureTrialStarted(): boolean { + if (!persistentBrowserStore.isAvailable()) return false; + if (persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT)) return false; + persistentBrowserStore.set( + STORAGE_KEYS.TRIAL_STARTED_AT, + new Date().toISOString(), + ); + return true; +} + +/** Days remaining, floored at 0. Treats missing/unreadable state as a full trial. */ +export function getTrialDaysLeft(): number { + if (!persistentBrowserStore.isAvailable()) return TRIAL_LENGTH_DAYS; + const startedAt = persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT); + if (!startedAt) return TRIAL_LENGTH_DAYS; + + const startedMs = Date.parse(startedAt); + if (Number.isNaN(startedMs)) return TRIAL_LENGTH_DAYS; + + const elapsedDays = Math.floor((Date.now() - startedMs) / MS_PER_DAY); + return Math.max(0, TRIAL_LENGTH_DAYS - elapsedDays); +} diff --git a/packages/web/src/billing/useTrialStatus.test.ts b/packages/web/src/billing/useTrialStatus.test.ts new file mode 100644 index 000000000..5f0c386d0 --- /dev/null +++ b/packages/web/src/billing/useTrialStatus.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from "@testing-library/react"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import { useTrialStatus } from "@web/billing/useTrialStatus"; +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; + +const daysAgo = (days: number) => + new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + +describe("useTrialStatus", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("starts the clock on first use and reports a full trial", () => { + const { result } = renderHook(() => useTrialStatus()); + + expect(result.current.isExpired).toBe(false); + expect(result.current.daysLeft).toBe(7); + expect(result.current.isAnonymousTrial).toBe(true); + expect( + persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT), + ).toBeTruthy(); + }); + + it("counts down without expiring inside the window", () => { + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(5)); + + const { result } = renderHook(() => useTrialStatus()); + + expect(result.current.daysLeft).toBe(2); + expect(result.current.isExpired).toBe(false); + }); + + it("expires once the window has passed", () => { + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8)); + + const { result } = renderHook(() => useTrialStatus()); + + expect(result.current.daysLeft).toBe(0); + expect(result.current.isExpired).toBe(true); + }); + + // Regression: `authenticated` from SessionContext is false until the async + // SuperTokens check resolves, so it cannot be the only guard. Someone who + // tried Compass anonymously, signed up, and kept the same browser still has + // a stale trial.started-at; gating on the context alone flashed "your trial + // has ended" at them on every load. + // + // hasUserEverAuthenticated is spied directly rather than driven through + // real localStorage state: several other test files in this suite + // mock.module the whole auth.state.util module with a bare `hasAuthenticated` + // stub and no restoration, which leaks process-wide across bun test files - + // spying on this file's own resolved binding sidesteps that ordering- + // dependent pollution instead of adding to it. + it("never gates a user who has authenticated before, despite a stale expired clock", () => { + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(30)); + const hasUserEverAuthenticatedSpy = spyOn( + authStateUtil, + "hasUserEverAuthenticated", + ).mockReturnValue(true); + + const { result } = renderHook(() => useTrialStatus()); + + expect(result.current.isExpired).toBe(false); + expect(result.current.isAnonymousTrial).toBe(false); + + hasUserEverAuthenticatedSpy.mockRestore(); + }); +}); diff --git a/packages/web/src/billing/useTrialStatus.ts b/packages/web/src/billing/useTrialStatus.ts new file mode 100644 index 000000000..8bb11cb98 --- /dev/null +++ b/packages/web/src/billing/useTrialStatus.ts @@ -0,0 +1,62 @@ +import { useContext, useEffect, useState } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { track } from "@web/auth/posthog/track"; +import { + ensureTrialStarted, + getTrialDaysLeft, + TRIAL_LENGTH_DAYS, +} from "@web/billing/trial.storage"; + +export type TrialStatus = { + /** Always false for authenticated users in v1 — no payment product exists + * yet to gate them against; server billing status governs once Stripe lands. */ + isExpired: boolean; + daysLeft: number; + /** True only while the browser-local clock governs, i.e. a visitor who has + * never signed in. Surfaces that govern trial UI should render on this, not + * on `authenticated`, which is false until the session check resolves. */ + isAnonymousTrial: boolean; +}; + +/** + * Anonymous users run on the browser-local trial clock; signed-in users are + * never gated in v1 (see 06-trial-spec.md in compass-calendar-internal). + * + * `authenticated` starts false and only flips true once the async SuperTokens + * check resolves, so it cannot be the sole guard: the common path is to try + * Compass anonymously, sign up, and keep the same browser, which leaves a + * months-old trial.started-at behind. Gating on that alone would flash "your + * trial has ended" at a signed-up user on every load. hasUserEverAuthenticated + * reads localStorage synchronously, so the gate never renders for them. + */ +export function useTrialStatus(): TrialStatus { + const { authenticated } = useContext(SessionContext); + const isGateExempt = authenticated || hasUserEverAuthenticated(); + const [daysLeft, setDaysLeft] = useState(() => getTrialDaysLeft()); + + useEffect(() => { + if (ensureTrialStarted()) { + track("trial_started"); + } + if (isGateExempt) return; + + const recompute = () => setDaysLeft(getTrialDaysLeft()); + recompute(); + document.addEventListener("visibilitychange", recompute); + window.addEventListener("focus", recompute); + return () => { + document.removeEventListener("visibilitychange", recompute); + window.removeEventListener("focus", recompute); + }; + }, [isGateExempt]); + + if (isGateExempt) { + return { + isExpired: false, + daysLeft: TRIAL_LENGTH_DAYS, + isAnonymousTrial: false, + }; + } + return { isExpired: daysLeft <= 0, daysLeft, isAnonymousTrial: true }; +} diff --git a/packages/web/src/calendars/useCalendarLookup.ts b/packages/web/src/calendars/useCalendarLookup.ts index befd10313..c245afcfe 100644 --- a/packages/web/src/calendars/useCalendarLookup.ts +++ b/packages/web/src/calendars/useCalendarLookup.ts @@ -109,18 +109,13 @@ export function resolveCalendarCardIdentity( /** * Grid-only content flags that force read-only treatment regardless of * calendar write capability (passed as the `isBusy` argument to - * {@link isEventReadOnly}). isSandboxReadOnly covers ephemeral onboarding - * sandbox events (see OnboardingTour/onboarding.sandbox-events.ts), which - * have nothing real for a write to persist to. + * {@link isEventReadOnly}). */ export const isGridEventContentReadOnly = (event: { isBusy?: boolean; isTimedMultiDayDisplay?: boolean; - isSandboxReadOnly?: boolean; }): boolean => - (event.isBusy ?? false) || - (event.isTimedMultiDayDisplay ?? false) || - (event.isSandboxReadOnly ?? false); + (event.isBusy ?? false) || (event.isTimedMultiDayDisplay ?? false); /** * An event is read-only (inspectable but never mutable) when either: @@ -168,7 +163,6 @@ export function isGridEventInteractionReadOnly( calendarId?: CalendarId | null; isBusy?: boolean; isTimedMultiDayDisplay?: boolean; - isSandboxReadOnly?: boolean; }, ): boolean { return isEventReadOnly( diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts index 8a16f0e97..66d312d3e 100644 --- a/packages/web/src/common/constants/storage.constants.ts +++ b/packages/web/src/common/constants/storage.constants.ts @@ -13,6 +13,11 @@ type StorageKey = // 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" + // Step id of an unfinished tour, so an abandoned session (closed tab, + // navigated away) can be resumed instead of silently lost. Cleared on + // finish or skip. + | "compass.onboarding.tour-progress" + | "compass.trial.started-at" | "compass.shortcuts.tips-muted" | "compass.sidebar.width" | "compass.theme" @@ -40,6 +45,8 @@ export const STORAGE_KEYS: Record< | "HAS_DISMISSED_TASKS_REMOVAL_NOTICE" | "HAS_PENDING_TOUR_OFFER" | "POST_TOUR_STAGE" + | "TOUR_PROGRESS" + | "TRIAL_STARTED_AT" | "SHORTCUT_TIPS_MUTED" | "LIFE_PREFERENCES" | "SIDEBAR_WIDTH" @@ -62,6 +69,8 @@ export const STORAGE_KEYS: Record< "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", + TOUR_PROGRESS: "compass.onboarding.tour-progress", + TRIAL_STARTED_AT: "compass.trial.started-at", SHORTCUT_TIPS_MUTED: "compass.shortcuts.tips-muted", LIFE_PREFERENCES: "compass.life.preferences", SIDEBAR_WIDTH: "compass.sidebar.width", diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts index e11ed84be..a85c12ddf 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts @@ -5,7 +5,7 @@ import { EventSchema } from "@core/types/event.contracts"; import dayjs from "@core/util/date/dayjs"; import { createMockOfflineDataStore } from "@web/__tests__/utils/storage/mock-offline-data-store.util"; import { type LocalEventRecord } from "@web/events/types/local-event.record"; -import { demoDataSeedMigration } from "./demo-data-seed"; +import { DEMO_EVENT_IDS, demoDataSeedMigration } from "./demo-data-seed"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; describe("demoDataSeedMigration", () => { @@ -33,10 +33,48 @@ describe("demoDataSeedMigration", () => { expect(store.putEvents).toHaveBeenCalled(); const eventsCall = store.putEvents.mock.calls[0][0] as LocalEventRecord[]; - expect(eventsCall).toHaveLength(7); + // 7 today (unchanged) + 11 nearby-day events (2 + 3 + 3 + 3 across ±1/±2). + expect(eventsCall).toHaveLength(18); expect(eventsCall.every((record) => record.isDemo)).toBe(true); }); + it("seeds tomorrow's Dentist/Team sync overlap at stable, targetable ids", async () => { + const store = createMockOfflineDataStore(); + + await demoDataSeedMigration.migrate(store); + + const eventsCall = store.putEvents.mock.calls[0][0] as LocalEventRecord[]; + const dentist = eventsCall.find( + (record) => record.id === DEMO_EVENT_IDS.dentist, + ); + const teamSync = eventsCall.find( + (record) => record.id === DEMO_EVENT_IDS.teamSync, + ); + const morningStandup = eventsCall.find( + (record) => record.id === DEMO_EVENT_IDS.morningStandup, + ); + + expect(dentist?.event.content).toMatchObject({ title: "Dentist" }); + expect(teamSync?.event.content).toMatchObject({ title: "Team sync" }); + expect(morningStandup?.event.content).toMatchObject({ + title: "Morning standup", + }); + + if ( + dentist?.event.schedule.kind !== "timed" || + teamSync?.event.schedule.kind !== "timed" + ) { + throw new Error("expected timed schedules"); + } + // Dentist (14:30-15:30) overlaps Team sync (14:00-15:00). + expect(dentist.event.schedule.start < teamSync.event.schedule.end).toBe( + true, + ); + expect(teamSync.event.schedule.start < dentist.event.schedule.end).toBe( + true, + ); + }); + it("skips seeding when events already exist", async () => { const store = createMockOfflineDataStore(); store.getAllEvents.mockResolvedValue([{ id: "existing" }]); diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts index c762e5fda..5284d0994 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts @@ -36,8 +36,10 @@ function createEventRecord(overrides: { organizer?: Organizer; attendees?: Attendee[]; conference?: Conference; + /** Stable id override for events the guided tour targets by id. */ + id?: string; }): LocalEventRecord { - const id = EventIdSchema.parse(createObjectIdString()); + const id = EventIdSchema.parse(overrides.id ?? createObjectIdString()); const content: EventContent = { kind: "details", title: overrides.title, @@ -62,6 +64,20 @@ function createEventRecord(overrides: { return { version: 2, id, event, isDemo: true }; } +/** + * Stable ids for the demo events the guided tour targets by id (jump/move/ + * resize/undo missions need a fixed reference, not whatever random id a + * fresh seed happens to generate). + */ +export const DEMO_EVENT_IDS = { + /** Today, 9:00-9:30 - the tour's moveFocus-step focus anchor. */ + morningStandup: "demo-morning-standup", + /** Tomorrow, 14:30-15:30 - overlaps Team sync; the tour's capstone target. */ + dentist: "demo-dentist", + /** Tomorrow, 14:00-15:00 - overlaps Dentist. */ + teamSync: "demo-team-sync", +} as const; + /** * Generate demo data relative to the current date. */ @@ -70,14 +86,44 @@ function generateDemoData() { const today = now.toYearMonthDayString(); const timeZone = getBrowserTimeZone(); - // Helper for creating timed events today (clone to avoid mutating now). - // 15-minute-aligned, consistent with event creation in the app. - const todayAt = (h: number, m = 0) => - now.clone().hour(h).minute(m).second(0).millisecond(0).format(); + // Helper for creating timed events on today +/- offsetDays (clone to avoid + // mutating now). 15-minute-aligned, consistent with event creation in the app. + const dayAt = (offsetDays: number, h: number, m = 0) => + now + .clone() + .add(offsetDays, "day") + .hour(h) + .minute(m) + .second(0) + .millisecond(0) + .format(); + + const todayAt = (h: number, m = 0) => dayAt(0, h, m); + + const timedOn = ( + offsetDays: number, + title: string, + startHour: number, + startMinute: number, + endHour: number, + endMinute: number, + id?: string, + ) => + createEventRecord({ + id, + title, + schedule: { + kind: "timed", + start: dayAt(offsetDays, startHour, startMinute), + end: dayAt(offsetDays, endHour, endMinute), + timeZone, + }, + }); // ─── Regular Events (Today) ───────────────────────────────────────────────── const todayEvents: LocalEventRecord[] = [ createEventRecord({ + id: DEMO_EVENT_IDS.morningStandup, title: "Morning standup", description: "Let's be honest. No one here has actually done anything. You are just making things up as you go. And yet, all of you sit here, pretending as if we are making progress. It seems, my dear team, that the only thing we do efficiently is exceed the stand up time.", @@ -186,8 +232,30 @@ function generateDemoData() { }), ]; + // ─── Nearby days (±1, ±2) ──────────────────────────────────────────────── + // Gives arrow-focus and week navigation real targets on every nearby day. + // Tomorrow's Team sync / Dentist overlap is the guided tour's capstone + // mission target - titles make the conflict obvious at a glance. + const nearbyEvents: LocalEventRecord[] = [ + // Today - 2 + timedOn(-2, "Design review", 10, 0, 11, 0), + timedOn(-2, "1:1 with Avery", 15, 0, 15, 30), + // Today - 1 + timedOn(-1, "Gym", 7, 0, 7, 45), + timedOn(-1, "Lunch with Sam", 12, 0, 13, 0), + timedOn(-1, "Focus block", 14, 0, 16, 0), + // Today + 1 (tomorrow) - deliberate overlap + timedOn(1, "Design review", 10, 0, 11, 0), + timedOn(1, "Team sync", 14, 0, 15, 0, DEMO_EVENT_IDS.teamSync), + timedOn(1, "Dentist", 14, 30, 15, 30, DEMO_EVENT_IDS.dentist), + // Today + 2 + timedOn(2, "1:1 with Avery", 11, 0, 11, 30), + timedOn(2, "Focus block", 13, 0, 15, 0), + timedOn(2, "Gym", 17, 0, 17, 45), + ]; + return { - events: [...todayEvents], + events: [...todayEvents, ...nearbyEvents], }; } @@ -199,7 +267,7 @@ function generateDemoData() { * sample events so they can immediately explore functionality. */ -const DEMO_DATA_SEED_MIGRATION_ID = "demo-data-seed-v1"; +const DEMO_DATA_SEED_MIGRATION_ID = "demo-data-seed-v2"; /** localStorage flag key used to track demo data seed completion. */ export const DEMO_DATA_SEED_FLAG_KEY = `compass.migration.${DEMO_DATA_SEED_MIGRATION_ID}`; diff --git a/packages/web/src/common/types/web.event.types.ts b/packages/web/src/common/types/web.event.types.ts index e4f670cd5..23af1ec37 100644 --- a/packages/web/src/common/types/web.event.types.ts +++ b/packages/web/src/common/types/web.event.types.ts @@ -81,11 +81,6 @@ const GridEventSchema = WebEventSchema.extend({ // The meeting also exists on this other connected account; this card is the // surviving copy of a cross-account merge. Joined like isDemo above. otherAccount: CrossAccountDuplicateSchema.optional(), - // Ephemeral onboarding-sandbox event (see OnboardingTour/onboarding.sandbox-events.ts): - // forces read-only alongside isBusy/isTimedMultiDayDisplay in - // isGridEventContentReadOnly, since a fake event has nothing to persist a - // write to. Joined like isDemo above. - isSandboxReadOnly: z.boolean().optional(), // Read-only, provider-sourced. Joined like calendarId/color above. location: z.string().nullable().optional(), organizer: OrganizerSchema.nullable().optional(), diff --git a/packages/web/src/common/utils/toast/anonymous-save.toast.test.tsx b/packages/web/src/common/utils/toast/anonymous-save.toast.test.tsx index fd7e5c54f..3383a8bf2 100644 --- a/packages/web/src/common/utils/toast/anonymous-save.toast.test.tsx +++ b/packages/web/src/common/utils/toast/anonymous-save.toast.test.tsx @@ -2,6 +2,11 @@ import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { maybeShowAnonymousSaveToast } from "@web/common/utils/toast/anonymous-save.toast"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; +import { + initialOnboardingTourState, + useOnboardingTourStore, +} from "@web/components/OnboardingTour/onboarding.tour.store"; +import { usePostOnboardingFlowStore } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; import { beforeEach, describe, expect, it } from "bun:test"; describe("maybeShowAnonymousSaveToast", () => { @@ -13,6 +18,10 @@ describe("maybeShowAnonymousSaveToast", () => { mocks.toast.mockClear(); mocks.update.mockClear(); registerToastPort(port); + // These stores are module-level singletons shared across the whole test + // run; a suite that leaves the tour "active" would otherwise leak in. + useOnboardingTourStore.setState(initialOnboardingTourState); + usePostOnboardingFlowStore.setState({ stage: null }); }); it("shows after an anonymous calendar write", () => { diff --git a/packages/web/src/common/utils/toast/anonymous-save.toast.tsx b/packages/web/src/common/utils/toast/anonymous-save.toast.tsx index 5fbcf7255..17ab24cae 100644 --- a/packages/web/src/common/utils/toast/anonymous-save.toast.tsx +++ b/packages/web/src/common/utils/toast/anonymous-save.toast.tsx @@ -6,6 +6,14 @@ import { persistentBrowserStore } from "@web/common/storage/browser-key-value.st import { showStatusToast } from "@web/common/utils/toast/status-toast.util"; import { getToast } from "@web/common/utils/toast/toast.port"; import { VIEW_TO_PARAM } from "@web/components/AuthModal/hooks/useAuthModal"; +import { + selectOnboardingTourActive, + useOnboardingTourStore, +} from "@web/components/OnboardingTour/onboarding.tour.store"; +import { + selectPostOnboardingStage, + usePostOnboardingFlowStore, +} from "@web/components/PostOnboardingFlow/post-onboarding-flow.store"; const ANONYMOUS_SAVE_TOAST_ID: Id = "anonymous-save-toast"; @@ -16,6 +24,21 @@ function isAuthModalOpen(): boolean { return Object.values(VIEW_TO_PARAM).includes(auth?.toLowerCase() ?? ""); } +/** + * True while the guided tour or its connect-Google follow-up is on screen. + * The toast is a distraction there, not a removal: it's re-checked on every + * anonymous write, so a user who skips onboarding entirely still sees it. + */ +function isOnboardingFlowActive(): boolean { + if (selectOnboardingTourActive(useOnboardingTourStore.getState())) { + return true; + } + const stage = selectPostOnboardingStage( + usePostOnboardingFlowStore.getState(), + ); + return stage === "connect"; +} + function hasSeenAnonymousSaveToast(): boolean { if (!persistentBrowserStore.isAvailable()) return true; return ( @@ -71,6 +94,10 @@ const AnonymousSaveToast = ({ toastId }: AnonymousSaveToastProps) => { export function maybeShowAnonymousSaveToast(): void { if (isAuthModalOpen() || hasSeenAnonymousSaveToast()) return; + // Don't mark seen here: onboarding suppresses this toast, not removes it, + // so it still appears the first time a user writes an event after + // skipping or finishing onboarding entirely. + if (isOnboardingFlowActive()) return; markAnonymousSaveToastSeen(); showStatusToast( diff --git a/packages/web/src/components/OnboardingTour/OnboardingTour.test.tsx b/packages/web/src/components/OnboardingTour/OnboardingTour.test.tsx index ce081b119..ea5112848 100644 --- a/packages/web/src/components/OnboardingTour/OnboardingTour.test.tsx +++ b/packages/web/src/components/OnboardingTour/OnboardingTour.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { type ReactNode } from "react"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; @@ -39,13 +39,20 @@ describe("OnboardingTour", () => { expect(card).not.toHaveClass("bg-surface-overlay"); }); - it("renders a styled Next button with hover and focus affordances", () => { + it("hides the advance button on a fresh verified step, revealing 'Show me' after repeated attempts", () => { onboardingTourActions.start(); renderTour(); - const next = screen.getByRole("button", { name: "Next" }); - expect(next).toHaveClass("c-button"); - expect(next).toHaveClass("c-button-secondary"); + expect(screen.queryByRole("button", { name: "Show me" })).toBeNull(); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "x" })); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "x" })); + }); + + const showMe = screen.getByRole("button", { name: "Show me" }); + expect(showMe).toHaveClass("c-button"); + expect(showMe).toHaveClass("c-button-primary"); }); it("shows Previous after the first step", () => { diff --git a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx index 150b0142d..8f8d1b7ad 100644 --- a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx +++ b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx @@ -1,5 +1,6 @@ import { type FC } from "react"; import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; +import { OnboardingTourResumeCard } from "@web/components/OnboardingTour/OnboardingTourResumeCard"; import { getOnboardingTourSteps, getPreviousOnboardingStepId, @@ -7,11 +8,13 @@ import { } from "@web/components/OnboardingTour/onboarding.tour.steps"; import { onboardingTourActions, + selectIsConfirmingTourSkip, selectOnboardingTourActive, selectOnboardingTourStepId, useOnboardingTourStore, } from "@web/components/OnboardingTour/onboarding.tour.store"; -import { useOnboardingSandboxKeyboardOnly } from "@web/components/OnboardingTour/useOnboardingSandboxKeyboardOnly"; +import { useOnboardingStepAssist } from "@web/components/OnboardingTour/useOnboardingStepAssist"; +import { useOnboardingTourKeyboardOnly } from "@web/components/OnboardingTour/useOnboardingTourKeyboardOnly"; import { useOnboardingTourProgress } from "@web/components/OnboardingTour/useOnboardingTourProgress"; import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; @@ -19,8 +22,6 @@ const TOUR_TEXT_BUTTON_CLASS = "c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:bg-surface-overlay hover:text-text"; const TOUR_PRIMARY_BUTTON_CLASS = "c-button c-button-primary rounded-full px-4 py-1.5 text-xs"; -const TOUR_SECONDARY_BUTTON_CLASS = - "c-button c-button-secondary rounded-full px-4 py-1.5 text-xs"; /** * Hand-rolled coachmark card for the Start Now tour. Not an app-lock modal: @@ -29,19 +30,23 @@ const TOUR_SECONDARY_BUTTON_CLASS = */ export const OnboardingTour: FC = () => { useOnboardingTourProgress(); - useOnboardingSandboxKeyboardOnly(); + useOnboardingTourKeyboardOnly(); const isActive = useOnboardingTourStore(selectOnboardingTourActive); const stepId = useOnboardingTourStore(selectOnboardingTourStepId); + const isConfirmingSkip = useOnboardingTourStore(selectIsConfirmingTourSkip); + const isAssistVisible = useOnboardingStepAssist(isActive, stepId); - if (!isActive) return null; + if (!isActive) return ; const steps = getOnboardingTourSteps(); const step = steps.find((entry) => entry.id === stepId) ?? steps[0]; const stepIndex = ONBOARDING_TOUR_STEP_IDS.indexOf(stepId); - const isDone = stepId === "done"; + const isDone = stepId === "hardcore"; const isFork = stepId === "fork"; const canGoPrevious = getPreviousOnboardingStepId(stepId) !== null; + const progressPercent = + ((stepIndex + 1) / ONBOARDING_TOUR_STEP_IDS.length) * 100; return (
{ style={{ zIndex: Z_INDEX_TOOLTIP }} >
-
-

{step.title}

-

- {stepIndex + 1} / {ONBOARDING_TOUR_STEP_IDS.length} -

-
-

{step.body}

- {step.shortcutHint ? ( -

- -

- ) : null} -
- {isFork ? ( - <> + {isConfirmingSkip ? ( + <> +

Skip the tour?

+

+ Enter to skip, any other key to keep going. +

+
- - - ) : ( - <> -
- {canGoPrevious ? ( +
+ + ) : ( + <> +
+

{step.title}

+

+ {stepIndex + 1} / {ONBOARDING_TOUR_STEP_IDS.length} +

+
+ +
+ {canGoPrevious ? ( + + ) : null} + {isAssistVisible ? ( + + ) : null} +
+ + )} +
+ + )}
); diff --git a/packages/web/src/components/OnboardingTour/OnboardingTourResumeCard.tsx b/packages/web/src/components/OnboardingTour/OnboardingTourResumeCard.tsx new file mode 100644 index 000000000..b94c43af7 --- /dev/null +++ b/packages/web/src/components/OnboardingTour/OnboardingTourResumeCard.tsx @@ -0,0 +1,56 @@ +import { type FC } from "react"; +import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; +import { ONBOARDING_TOUR_STEP_IDS } from "@web/components/OnboardingTour/onboarding.tour.steps"; +import { + clearTourProgress, + loadTourProgress, +} from "@web/components/OnboardingTour/onboarding.tour.storage"; +import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store"; + +const TOUR_TEXT_BUTTON_CLASS = + "c-focus-ring rounded-md px-2 py-1 text-text-muted text-xs hover:bg-surface-overlay hover:text-text"; +const TOUR_PRIMARY_BUTTON_CLASS = + "c-button c-button-primary rounded-full px-4 py-1.5 text-xs"; + +/** + * Shown in the tour's card slot when the tour is inactive but was abandoned + * mid-way (tab closed, navigated away) rather than finished or skipped. Lets + * the user pick back up instead of the tour silently never returning. + */ +export const OnboardingTourResumeCard: FC = () => { + const stepId = loadTourProgress(); + + if (!stepId) return null; + + return ( +
+
+

Pick up where you left off?

+

+ {ONBOARDING_TOUR_STEP_IDS.indexOf(stepId)} of{" "} + {ONBOARDING_TOUR_STEP_IDS.length} done. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.test.ts b/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.test.ts deleted file mode 100644 index b038e37a1..000000000 --- a/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { CalendarIdSchema } from "@core/types/domain-primitives"; -import dayjs from "@core/util/date/dayjs"; -import { createObjectIdString } from "@web/common/utils/id/object-id.util"; -import { - buildSandboxEventData, - getSandboxFocusEventId, - isSandboxStep, - isTargetEventSandboxId, - mergeSandboxEventData, -} from "@web/components/OnboardingTour/onboarding.sandbox-events"; -import { describe, expect, it } from "bun:test"; - -const calendarId = CalendarIdSchema.parse(createObjectIdString()); -const anchor = dayjs("2026-08-10T00:00:00-05:00"); - -describe("isSandboxStep", () => { - it("is true only for moveFocus/editSequence/targetEvent/nudge", () => { - expect(isSandboxStep("moveFocus")).toBe(true); - expect(isSandboxStep("editSequence")).toBe(true); - expect(isSandboxStep("targetEvent")).toBe(true); - expect(isSandboxStep("nudge")).toBe(true); - expect(isSandboxStep("create")).toBe(false); - expect(isSandboxStep("fork")).toBe(false); - expect(isSandboxStep("done")).toBe(false); - }); -}); - -describe("getSandboxFocusEventId", () => { - it("returns the primary practice event for focusable sandbox steps", () => { - expect(getSandboxFocusEventId("editSequence")).toBe( - "sandbox-editSequence-1", - ); - expect(getSandboxFocusEventId("nudge")).toBe("sandbox-nudge-1"); - expect(getSandboxFocusEventId("create")).toBeUndefined(); - }); -}); - -describe("isTargetEventSandboxId", () => { - it("matches only targetEvent sandbox ids", () => { - expect(isTargetEventSandboxId("sandbox-targetEvent-1")).toBe(true); - expect(isTargetEventSandboxId("sandbox-nudge-1")).toBe(false); - }); -}); - -describe("buildSandboxEventData", () => { - it("returns undefined for a non-sandbox step", () => { - expect( - buildSandboxEventData("create", anchor, calendarId, "UTC"), - ).toBeUndefined(); - }); - - it("marks every moveFocus event read-only", () => { - const data = buildSandboxEventData("moveFocus", anchor, calendarId, "UTC"); - expect(data).toBeDefined(); - expect(data!.ids.length).toBeGreaterThan(0); - expect(data!.sandboxReadOnlyEventIds).toEqual(data!.ids); - }); - - it("leaves the nudge event mutable (the one lesson that teaches a mutation)", () => { - const data = buildSandboxEventData("nudge", anchor, calendarId, "UTC"); - expect(data).toBeDefined(); - expect(data!.ids.length).toBe(1); - expect(data!.sandboxReadOnlyEventIds).toEqual([]); - }); - - it("anchors every event to the given day and calendar", () => { - const data = buildSandboxEventData( - "targetEvent", - anchor, - calendarId, - "UTC", - ); - for (const id of data!.ids) { - const event = data!.entities[id]; - expect(event.calendarId).toBe(calendarId); - expect(event.schedule.kind).toBe("timed"); - if (event.schedule.kind === "timed") { - expect(dayjs(event.schedule.start).isSame(anchor, "day")).toBe(true); - } - } - }); -}); - -describe("mergeSandboxEventData", () => { - const realId = "real-event-id" as never; - const real = { - ids: [realId], - entities: { [realId]: { id: realId } } as never, - }; - - it("returns the real data unchanged when there is nothing to splice", () => { - expect(mergeSandboxEventData(real, undefined)).toBe(real); - }); - - it("returns the sandbox data unchanged when there is no real data yet", () => { - const sandbox = buildSandboxEventData("nudge", anchor, calendarId, "UTC"); - expect(mergeSandboxEventData(undefined, sandbox)).toBe(sandbox); - }); - - it("splices sandbox events onto real data without dropping either", () => { - const sandbox = buildSandboxEventData( - "moveFocus", - anchor, - calendarId, - "UTC", - )!; - const merged = mergeSandboxEventData(real, sandbox)!; - expect(merged.ids).toEqual([realId, ...sandbox.ids]); - expect(merged.entities[realId]).toBeDefined(); - for (const id of sandbox.ids) { - expect(merged.entities[id]).toBeDefined(); - } - expect(merged.sandboxReadOnlyEventIds).toEqual(sandbox.ids); - }); -}); diff --git a/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.ts b/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.ts deleted file mode 100644 index 86099bd19..000000000 --- a/packages/web/src/components/OnboardingTour/onboarding.sandbox-events.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - type CalendarId, - DateTimeSchema, - EventIdSchema, - TimeZoneSchema, -} from "@core/types/domain-primitives"; -import { type Event, EventScheduleSchema } from "@core/types/event.contracts"; -import { type Dayjs } from "@core/util/date/dayjs"; -import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types"; -import { type OnboardingTourStepId } from "./onboarding.tour.steps"; - -/** - * Tour steps that need visible practice events - see 06's brief: mouse - * clicks are disabled during these (useOnboardingSandboxKeyboardOnly.ts) and - * this module supplies the ephemeral events they target. Every other step - * (create, save, palette, shortcuts, fork, undo, done) works against the - * user's real calendar as-is. - */ -export const SANDBOX_STEP_IDS: ReadonlySet = new Set([ - "moveFocus", - "editSequence", - "targetEvent", - "nudge", -]); - -export function isSandboxStep(stepId: OnboardingTourStepId): boolean { - return SANDBOX_STEP_IDS.has(stepId); -} - -const sandboxEventId = (suffix: string) => - EventIdSchema.parse(`sandbox-${suffix}`); - -const SANDBOX_FOCUS_EVENT_IDS: Partial> = { - editSequence: sandboxEventId("editSequence-1"), - moveFocus: sandboxEventId("moveFocus-1"), - nudge: sandboxEventId("nudge-1"), - targetEvent: sandboxEventId("targetEvent-1"), -}; - -/** Primary practice event to auto-focus when a sandbox step starts. */ -export function getSandboxFocusEventId( - stepId: OnboardingTourStepId, -): string | undefined { - return SANDBOX_FOCUS_EVENT_IDS[stepId]; -} - -/** True when a focused calendar event id belongs to the targetEvent lesson. */ -export function isTargetEventSandboxId(eventId: string): boolean { - return eventId.startsWith("sandbox-targetEvent-"); -} - -/** 15-minute-aligned timed event at `hour:minute` on the anchor's day. */ -function buildEvent( - idSuffix: string, - title: string, - anchor: Dayjs, - calendarId: CalendarId, - timeZone: string, - hour: number, - minute = 0, - durationMinutes = 30, -): Event { - const start = anchor - .clone() - .hour(hour) - .minute(minute) - .second(0) - .millisecond(0); - const end = start.clone().add(durationMinutes, "minute"); - - return { - id: sandboxEventId(idSuffix), - calendarId, - content: { kind: "details", title, description: "" }, - schedule: EventScheduleSchema.parse({ - kind: "timed", - start: start.format(), - end: end.format(), - timeZone: TimeZoneSchema.parse(timeZone), - }), - recurrence: { kind: "single" }, - createdAt: DateTimeSchema.parse(start.toISOString()), - updatedAt: null, - }; -} - -/** - * Per-step practice events, anchored to the tour's active view so they land - * on whatever day/week is currently open. Every event is read-only except - * nudge's, which is the one lesson that teaches a mutation. - */ -export function buildSandboxEventData( - stepId: OnboardingTourStepId, - anchor: Dayjs, - calendarId: CalendarId, - timeZone: string, -): NormalizedEventQueryData | undefined { - if (!isSandboxStep(stepId)) return undefined; - - const build = (idSuffix: string, title: string, hour: number, minute = 0) => - buildEvent(idSuffix, title, anchor, calendarId, timeZone, hour, minute); - - const events: Event[] = - stepId === "moveFocus" - ? [ - build("moveFocus-1", "Practice: focus me", 9), - build("moveFocus-2", "Practice: then me", 11), - build("moveFocus-3", "Practice: and me", 14), - ] - : stepId === "editSequence" - ? [build("editSequence-1", "Practice: E then T", 10)] - : stepId === "targetEvent" - ? [ - build("targetEvent-1", "Practice: jump here", 9, 30), - build("targetEvent-2", "Practice: or here", 12), - build("targetEvent-3", "Practice: or here", 15, 30), - ] - : [build("nudge-1", "Practice: nudge me", 13)]; - - // nudge's event is deliberately left out of sandboxReadOnlyEventIds so its - // Shift+Arrow shortcut isn't blocked by the read-only gate. The write - // itself still safely no-ops (useUpdateEvent's findEventInCache never - // resolves a sandbox id, since these are never written into the query - // cache - see mergeSandboxEventData) - no error, no toast, nothing - // persisted. Wiring a real, visible local reposition would mean touching - // the shared grid-focus/mutation pipeline the brief calls out as needing - // isolated review; left as a known follow-up rather than a rushed patch - // to that code under this brief. - const sandboxReadOnlyEventIds = - stepId === "nudge" ? [] : events.map((event) => event.id); - - return { - ids: events.map((event) => event.id), - entities: Object.fromEntries(events.map((event) => [event.id, event])), - sandboxReadOnlyEventIds, - }; -} - -/** - * Splices sandbox events into real query data. Reference-stable when there - * is nothing to splice, so the pipeline cache in useCalendarEventViewModel - * keeps hitting outside the tour. - */ -export function mergeSandboxEventData( - real: NormalizedEventQueryData | undefined, - sandbox: NormalizedEventQueryData | undefined, -): NormalizedEventQueryData | undefined { - if (!sandbox) return real; - if (!real) return sandbox; - - return { - ...real, - ids: [...real.ids, ...sandbox.ids], - entities: { ...real.entities, ...sandbox.entities }, - sandboxReadOnlyEventIds: sandbox.sandboxReadOnlyEventIds, - }; -} 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 bfb28b969..0a80ea4b0 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts @@ -19,27 +19,37 @@ describe("onboarding tour steps", () => { } }); - it("orders basics create → save → moveFocus → editSequence → palette → shortcuts → fork", () => { + it("keeps 'move' language, never reintroducing 'nudge'", () => { + for (const step of getOnboardingTourSteps()) { + expect(step.title.toLowerCase()).not.toContain("nudge"); + expect(step.body.toLowerCase()).not.toContain("nudge"); + } + }); + + it("orders Act 1 create → save → moveFocus → editSequence → fork", () => { expect(getNextOnboardingStepId("create")).toBe("save"); expect(getNextOnboardingStepId("save")).toBe("moveFocus"); expect(getNextOnboardingStepId("moveFocus")).toBe("editSequence"); - expect(getNextOnboardingStepId("editSequence")).toBe("palette"); - expect(getNextOnboardingStepId("palette")).toBe("shortcuts"); - expect(getNextOnboardingStepId("shortcuts")).toBe("fork"); + expect(getNextOnboardingStepId("editSequence")).toBe("fork"); }); - it("orders advanced fork → targetEvent → nudge → undo → done", () => { + it("orders Act 2 fork → targetEvent → move → resizeEdge → placeDraft → undo", () => { expect(getNextOnboardingStepId("fork")).toBe("targetEvent"); - expect(getNextOnboardingStepId("targetEvent")).toBe("nudge"); - expect(getNextOnboardingStepId("nudge")).toBe("undo"); - expect(getNextOnboardingStepId("undo")).toBe("done"); - expect(getNextOnboardingStepId("done")).toBeNull(); + expect(getNextOnboardingStepId("targetEvent")).toBe("move"); + expect(getNextOnboardingStepId("move")).toBe("resizeEdge"); + expect(getNextOnboardingStepId("resizeEdge")).toBe("placeDraft"); + expect(getNextOnboardingStepId("placeDraft")).toBe("undo"); + }); + + it("orders Act 3: undo → hardcore, the graduation finale", () => { + expect(getNextOnboardingStepId("undo")).toBe("hardcore"); + expect(getNextOnboardingStepId("hardcore")).toBeNull(); }); it("retreats one step at a time", () => { expect(getPreviousOnboardingStepId("create")).toBeNull(); expect(getPreviousOnboardingStepId("save")).toBe("create"); - expect(getPreviousOnboardingStepId("nudge")).toBe("targetEvent"); + expect(getPreviousOnboardingStepId("move")).toBe("targetEvent"); }); it("trims unnecessary create/save copy", () => { @@ -56,10 +66,6 @@ describe("onboarding tour steps", () => { expect( steps.find((step) => step.id === "editSequence")?.shortcutHint, ).toEqual(["E", "T"]); - expect(steps.find((step) => step.id === "palette")?.shortcutHint).toEqual([ - "Mod", - "K", - ]); expect(steps.find((step) => step.id === "undo")?.shortcutHint).toEqual([ "Mod", "Z", @@ -75,25 +81,38 @@ describe("onboarding tour steps", () => { expect(fork?.body).toMatch(/skip anytime/i); }); - it("keeps palette and shortcuts lessons non-contradictory", () => { + it("names the capstone mission target directly, not abstractly", () => { const steps = getOnboardingTourSteps(); - const palette = steps.find((step) => step.id === "palette"); - const shortcuts = steps.find((step) => step.id === "shortcuts"); + const targetEvent = steps.find((step) => step.id === "targetEvent"); + const move = steps.find((step) => step.id === "move"); + const resizeEdge = steps.find((step) => step.id === "resizeEdge"); + + expect(targetEvent?.title).toMatch(/Dentist/); + expect(targetEvent?.body).toMatch(/Dentist/); + expect(move?.title).toMatch(/Dentist/); + expect(move?.body).toMatch(/overlap/i); + expect(resizeEdge?.body).toMatch(/Tab/); + expect(resizeEdge?.body).toMatch(/start time stays put/i); + }); - expect(palette?.body).toMatch(/close with Escape/i); - expect(palette?.body).not.toMatch(/Show keyboard shortcuts/i); - expect(shortcuts?.body).toMatch(/from the calendar/i); - expect(shortcuts?.shortcutHint).toBe("?"); + it("teaches placing a new draft via Shift+Arrow on empty focus", () => { + const placeDraft = getOnboardingTourSteps().find( + (step) => step.id === "placeDraft", + ); + expect(placeDraft?.body).toMatch(/nothing focused/i); + expect(placeDraft?.shortcutHint).toEqual(["Shift", "ArrowRight"]); }); - it("points the finale at Hardcore Mode practice", () => { - const done = getOnboardingTourSteps().find((step) => step.id === "done"); + it("points the finale at Hardcore Mode as the graduation mission", () => { + const hardcore = getOnboardingTourSteps().find( + (step) => step.id === "hardcore", + ); - expect(done?.body).toMatch(/anything with the keyboard/i); - expect(done?.body).toMatch(/Shift Shift/i); - expect(done?.body).toMatch(/Hardcore Mode/i); - expect(done?.body).toMatch(/clicks/i); - expect(done?.body).toMatch(/command palette/i); - expect(done?.shortcutHint).toEqual(["Shift", "Shift"]); + expect(hardcore?.title).toMatch(/Graduate/i); + expect(hardcore?.body).toMatch(/Shift twice/i); + expect(hardcore?.body).toMatch(/keyboard-only/i); + expect(hardcore?.body).toMatch(/clicks/i); + expect(hardcore?.body).toMatch(/command palette/i); + expect(hardcore?.shortcutHint).toEqual(["Shift", "Shift"]); }); }); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts index 72c3195f3..ca6bc6a3c 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts @@ -1,20 +1,21 @@ /** * 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. + * exit ramp between Act 1 (basics, required-feeling) and Act 2 (extra + * credit) — see OnboardingTour.tsx for its two-button UI. Act 3 is a single + * graduation step ("hardcore"). */ const STEP_IDS = [ "create", "save", "moveFocus", "editSequence", - "palette", - "shortcuts", "fork", "targetEvent", - "nudge", + "move", + "resizeEdge", + "placeDraft", "undo", - "done", + "hardcore", ] as const; export type OnboardingTourStepId = (typeof STEP_IDS)[number]; @@ -50,46 +51,46 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] { }, moveFocus: { title: "Move between events", - body: "Press an arrow key to move focus from event to event without touching the mouse.", + body: "Press an arrow key to move focus onto a different event, without touching the mouse.", shortcutHint: ["ArrowLeft", "ArrowUp", "ArrowDown", "ArrowRight"], }, editSequence: { title: "Jump straight to a field", - body: "Press E, then T, to open the practice event and jump straight into its title. Every field has its own letter.", + body: "Press E, then T, to open the focused event and jump straight into its title. Every field has its own letter.", shortcutHint: ["E", "T"], }, - palette: { - title: "Open the command palette", - body: "Open the command palette for commands. Browse or search, then close with Escape.", - shortcutHint: ["Mod", "K"], - }, - shortcuts: { - title: "Browse every shortcut", - 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: "A few extra-credit moves for rescheduling fast are next. Skip anytime if you want.", }, 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.", + title: "Jump to Dentist", + body: "Tap Shift once to flash a key over every visible event, then press Dentist's key to jump straight to it. 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.", + move: { + title: "Move Dentist out of the overlap", + body: "Dentist overlaps Team sync tomorrow. With Dentist focused, hold Shift and press an arrow key to slide it clear.", + shortcutHint: ["Shift", "ArrowRight"], + }, + resizeEdge: { + title: "Give Dentist more time", + body: "Press Tab to focus just Dentist's end time, then hold Shift and press an arrow key to stretch it. The start time stays put.", + shortcutHint: ["Tab", "Shift", "ArrowDown"], + }, + placeDraft: { + title: "Place a new event on the grid", + body: "With nothing focused, hold Shift and press an arrow key to drop a draft on the grid at that time.", shortcutHint: ["Shift", "ArrowRight"], }, undo: { title: "Never stress about a mistake", - body: "Made a change you didn't mean? Undo it, or add Shift to redo.", + body: "Undo your changes to Dentist with Mod+Z, then bring them back with Mod+Shift+Z.", shortcutHint: ["Mod", "Z"], }, - done: { - title: "You are ready", - body: "You can do anything with the keyboard. Try Shift Shift to enter Hardcore Mode; clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.", + hardcore: { + title: "Graduate to Hardcore Mode", + body: "Press Shift twice to go keyboard-only, clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.", shortcutHint: ["Shift", "Shift"], }, }; @@ -115,4 +116,4 @@ export function getPreviousOnboardingStepId( /** Steps where arrow keys teach a lesson, so tour Previous/Next arrows stand down. */ export const ONBOARDING_ARROW_LESSON_STEP_IDS: ReadonlySet = - new Set(["moveFocus", "nudge"]); + new Set(["moveFocus", "move", "resizeEdge", "placeDraft"]); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts index 2792e5ba2..1992ca108 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts @@ -1,5 +1,9 @@ import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { + ONBOARDING_TOUR_STEP_IDS, + type OnboardingTourStepId, +} from "@web/components/OnboardingTour/onboarding.tour.steps"; export function hasSeenOnboardingTour(): boolean { if (!persistentBrowserStore.isAvailable()) return true; @@ -27,3 +31,23 @@ export function consumePendingTourOffer(): boolean { } return pending; } + +/** Saved on every step change while the tour is active, so an abandoned tab + * can resume instead of losing position. Cleared on finish or skip. */ +export function saveTourProgress(stepId: OnboardingTourStepId): void { + persistentBrowserStore.set(STORAGE_KEYS.TOUR_PROGRESS, stepId); +} + +export function clearTourProgress(): void { + persistentBrowserStore.remove(STORAGE_KEYS.TOUR_PROGRESS); +} + +/** Returns the saved step id, or null if there's no resumable progress. */ +export function loadTourProgress(): OnboardingTourStepId | null { + if (!persistentBrowserStore.isAvailable()) return null; + const value = persistentBrowserStore.get(STORAGE_KEYS.TOUR_PROGRESS); + if (!value) return null; + return ONBOARDING_TOUR_STEP_IDS.includes(value as OnboardingTourStepId) + ? (value as OnboardingTourStepId) + : null; +} 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 bfa1da18a..95cf4a82c 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts @@ -25,19 +25,19 @@ describe("onboardingTourActions", () => { 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"); + expect(useOnboardingTourStore.getState().stepId).toBe("move"); + onboardingTourActions.advance(); + expect(useOnboardingTourStore.getState().stepId).toBe("resizeEdge"); + onboardingTourActions.advance(); + expect(useOnboardingTourStore.getState().stepId).toBe("placeDraft"); onboardingTourActions.advance(); expect(useOnboardingTourStore.getState().stepId).toBe("undo"); onboardingTourActions.advance(); - expect(useOnboardingTourStore.getState().stepId).toBe("done"); + expect(useOnboardingTourStore.getState().stepId).toBe("hardcore"); }); it("skip at the fork ends the tour without entering the advanced segment", () => { @@ -103,6 +103,7 @@ describe("onboardingTourActions", () => { expect(useOnboardingTourStore.getState()).toEqual({ isActive: true, stepId: "create", + isConfirmingSkip: false, }); }); }); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts index c30ca11fc..549c6bc38 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts @@ -6,21 +6,26 @@ import { type OnboardingTourStepId, } from "@web/components/OnboardingTour/onboarding.tour.steps"; import { + clearTourProgress, consumePendingTourOffer, hasSeenOnboardingTour, markOnboardingTourSeen, markTourOfferPending, + saveTourProgress, } from "@web/components/OnboardingTour/onboarding.tour.storage"; import { draftActions } from "@web/events/stores/draft.store"; export type OnboardingTourState = { isActive: boolean; stepId: OnboardingTourStepId; + /** True while the "skip the tour?" inline confirm is showing (Escape path). */ + isConfirmingSkip: boolean; }; export const initialOnboardingTourState: OnboardingTourState = { isActive: false, stepId: "create", + isConfirmingSkip: false, }; export const useOnboardingTourStore = create()(() => ({ @@ -31,24 +36,46 @@ export const useOnboardingTourStore = create()(() => ({ const endTour = () => { draftActions.discard(); markOnboardingTourSeen(); + clearTourProgress(); useOnboardingTourStore.setState({ ...initialOnboardingTourState }); }; export const onboardingTourActions = { - /** Start Now: begin the interactive tour when it has not been finished. */ - start: () => { + /** Start Now / Escape: begin the interactive tour when it has not been finished. */ + start: (entry: "start_now" | "escape" = "start_now") => { if (hasSeenOnboardingTour()) return; - useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); - track("onboarding_game_started"); + useOnboardingTourStore.setState({ + isActive: true, + stepId: "create", + isConfirmingSkip: false, + }); + saveTourProgress("create"); + track("onboarding_game_started", { entry }); + }, + /** Resume card: pick up an abandoned tour at its saved step. */ + resume: (stepId: OnboardingTourStepId) => { + if (hasSeenOnboardingTour()) return; + useOnboardingTourStore.setState({ + isActive: true, + stepId, + isConfirmingSkip: false, + }); + track("onboarding_game_started", { entry: "resume" }); }, /** Palette re-entry: always restart from the first step. */ restart: () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); + useOnboardingTourStore.setState({ + isActive: true, + stepId: "create", + isConfirmingSkip: false, + }); + saveTourProgress("create"); track("onboarding_game_replayed", { source: "palette" }); }, advance: () => { - const { isActive, stepId } = useOnboardingTourStore.getState(); - if (!isActive) return; + const { isActive, stepId, isConfirmingSkip } = + useOnboardingTourStore.getState(); + if (!isActive || isConfirmingSkip) return; track("onboarding_task_completed", { task: stepId }); const next = getNextOnboardingStepId(stepId); if (!next) { @@ -58,14 +85,17 @@ export const onboardingTourActions = { if (stepId === "fork") { track("onboarding_segment_reached", { segment: "advanced" }); } + saveTourProgress(next); useOnboardingTourStore.setState({ stepId: next }); }, /** Step back one lesson; no-op on the first step. */ retreat: () => { - const { isActive, stepId } = useOnboardingTourStore.getState(); - if (!isActive) return; + const { isActive, stepId, isConfirmingSkip } = + useOnboardingTourStore.getState(); + if (!isActive || isConfirmingSkip) return; const previous = getPreviousOnboardingStepId(stepId); if (!previous) return; + saveTourProgress(previous); useOnboardingTourStore.setState({ stepId: previous }); }, /** Reached the last step. */ @@ -73,12 +103,22 @@ export const onboardingTourActions = { track("onboarding_game_finished"); endTour(); }, - /** User dismissed the tour early (Skip button, the fork's "I'm done", or Escape). */ + /** User dismissed the tour early (Skip button, the fork's "I'm done", or a confirmed Escape). */ skip: () => { const { stepId } = useOnboardingTourStore.getState(); track("onboarding_game_skipped", { step: stepId }); endTour(); }, + /** Escape, first press: show the inline "skip the tour?" confirm instead of skipping immediately. */ + requestSkipConfirm: () => { + const { isActive } = useOnboardingTourStore.getState(); + if (!isActive) return; + useOnboardingTourStore.setState({ isConfirmingSkip: true }); + }, + /** Any key other than Enter while confirming: cancel and keep going. */ + cancelSkipConfirm: () => { + useOnboardingTourStore.setState({ isConfirmingSkip: false }); + }, /** * 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 @@ -96,8 +136,13 @@ export const onboardingTourActions = { offerAfterSignupIfPending: () => { if (!consumePendingTourOffer()) return; if (hasSeenOnboardingTour()) return; - useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); - track("onboarding_game_started"); + useOnboardingTourStore.setState({ + isActive: true, + stepId: "create", + isConfirmingSkip: false, + }); + saveTourProgress("create"); + track("onboarding_game_started", { entry: "post_signup" }); }, }; @@ -106,3 +151,6 @@ export const selectOnboardingTourActive = (state: OnboardingTourState) => export const selectOnboardingTourStepId = (state: OnboardingTourState) => state.stepId; + +export const selectIsConfirmingTourSkip = (state: OnboardingTourState) => + state.isConfirmingSkip; diff --git a/packages/web/src/components/OnboardingTour/useOnboardingSandboxEvents.ts b/packages/web/src/components/OnboardingTour/useOnboardingSandboxEvents.ts deleted file mode 100644 index 14f5c2a88..000000000 --- a/packages/web/src/components/OnboardingTour/useOnboardingSandboxEvents.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { type Dayjs } from "@core/util/date/dayjs"; -import { useCalendarsQuery } from "@web/calendars/calendar.query"; -import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; -import { getBrowserTimeZone } from "@web/common/utils/datetime/web.date.util"; -import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types"; -import { buildSandboxEventData } from "./onboarding.sandbox-events"; -import { - selectOnboardingTourActive, - selectOnboardingTourStepId, - useOnboardingTourStore, -} from "./onboarding.tour.store"; - -const NO_CALENDARS: never[] = []; - -/** - * Ephemeral practice events for the tour's sandbox steps (see - * onboarding.sandbox-events.ts), or undefined outside of them. Merge into - * real query data with {@link import("./onboarding.sandbox-events").mergeSandboxEventData} - * before handing off to useCalendarEventViewModel - never written to - * IndexedDB or the mutation pipeline. - * - * `anchor` is undefined when the caller opts out (e.g. a component that - * isn't the tour's actual grid, like the Up Next card) - always call this - * hook unconditionally and pass undefined rather than skipping the call, so - * hook order stays stable. - */ -export function useOnboardingSandboxEventData( - anchor: Dayjs | undefined, -): NormalizedEventQueryData | undefined { - const isActive = useOnboardingTourStore(selectOnboardingTourActive); - const stepId = useOnboardingTourStore(selectOnboardingTourStepId); - const { data: calendars } = useCalendarsQuery(); - const defaultCalendar = useDefaultTargetCalendar(calendars ?? NO_CALENDARS); - - if (!isActive || !anchor || !defaultCalendar) return undefined; - - return buildSandboxEventData( - stepId, - anchor, - defaultCalendar.id, - getBrowserTimeZone(), - ); -} diff --git a/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.test.ts b/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.test.ts deleted file mode 100644 index 39a56655b..000000000 --- a/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { - initialOnboardingTourState, - useOnboardingTourStore, -} from "@web/components/OnboardingTour/onboarding.tour.store"; -import { useOnboardingSandboxKeyboardOnly } from "@web/components/OnboardingTour/useOnboardingSandboxKeyboardOnly"; -import { - initialKeyboardOnlyState, - selectKeyboardOnlyActive, - useKeyboardOnlyStore, -} from "@web/shortcuts/keyboard-only/keyboard-only.store"; -import { beforeEach, describe, expect, it } from "bun:test"; - -const isKeyboardOnlyActive = () => - selectKeyboardOnlyActive(useKeyboardOnlyStore.getState()); - -beforeEach(() => { - useOnboardingTourStore.setState({ ...initialOnboardingTourState }); - useKeyboardOnlyStore.setState({ ...initialKeyboardOnlyState }); -}); - -describe("useOnboardingSandboxKeyboardOnly", () => { - it("enters keyboard-only mode on a sandbox step and exits on a non-sandbox step", async () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "create" }); - renderHook(() => useOnboardingSandboxKeyboardOnly()); - - expect(isKeyboardOnlyActive()).toBe(false); - - act(() => { - useOnboardingTourStore.setState({ stepId: "moveFocus" }); - }); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(true)); - - act(() => { - useOnboardingTourStore.setState({ stepId: "palette" }); - }); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(false)); - }); - - it("exits on tour skip (isActive -> false) from a sandbox step", async () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "nudge" }); - renderHook(() => useOnboardingSandboxKeyboardOnly()); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(true)); - - act(() => { - useOnboardingTourStore.setState({ ...initialOnboardingTourState }); - }); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(false)); - }); - - it("exits on the fork exit path (targetEvent step change, e.g. after fork)", async () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "targetEvent" }); - renderHook(() => useOnboardingSandboxKeyboardOnly()); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(true)); - - act(() => { - useOnboardingTourStore.setState({ stepId: "undo" }); - }); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(false)); - }); - - it("exits on unmount while a sandbox step is active (covers Escape/skip and browser back)", async () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "editSequence" }); - const { unmount } = renderHook(() => useOnboardingSandboxKeyboardOnly()); - await waitFor(() => expect(isKeyboardOnlyActive()).toBe(true)); - - unmount(); - expect(isKeyboardOnlyActive()).toBe(false); - }); - - it("never enters keyboard-only mode for non-sandbox steps", async () => { - useOnboardingTourStore.setState({ isActive: true, stepId: "save" }); - renderHook(() => useOnboardingSandboxKeyboardOnly()); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(isKeyboardOnlyActive()).toBe(false); - }); -}); diff --git a/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.ts b/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.ts deleted file mode 100644 index 87d6cef96..000000000 --- a/packages/web/src/components/OnboardingTour/useOnboardingSandboxKeyboardOnly.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect } from "react"; -import { keyboardOnlyActions } from "@web/shortcuts/keyboard-only/keyboard-only.store"; -import { isSandboxStep } from "./onboarding.sandbox-events"; -import { - selectOnboardingTourActive, - selectOnboardingTourStepId, - useOnboardingTourStore, -} from "./onboarding.tour.store"; - -/** - * Programmatic keyboard-only entry for the tour's sandbox steps (see brief - * 06's "Why this is split out from 01"): mouse clicks disable for the steps - * that target a synthetic practice event, on top of the existing - * double-Shift gesture in useKeyboardOnlyMode.ts. - * - * The effect cleanup is the whole exit-path audit: it fires on every path - * that stops `shouldBeActive` being true - tour skip/finish (isActive flips - * false), the fork exit and every other step change (stepId leaves the - * sandbox set), and unmount - so there is exactly one place that can leak - * keyboard-only mode, and it self-corrects by construction. - */ -export function useOnboardingSandboxKeyboardOnly() { - const isTourActive = useOnboardingTourStore(selectOnboardingTourActive); - const stepId = useOnboardingTourStore(selectOnboardingTourStepId); - const shouldBeActive = isTourActive && isSandboxStep(stepId); - - useEffect(() => { - if (!shouldBeActive) return; - - keyboardOnlyActions.enter(); - return () => keyboardOnlyActions.exit(); - }, [shouldBeActive]); -} diff --git a/packages/web/src/components/OnboardingTour/useOnboardingStepAssist.ts b/packages/web/src/components/OnboardingTour/useOnboardingStepAssist.ts new file mode 100644 index 000000000..3bcbc494c --- /dev/null +++ b/packages/web/src/components/OnboardingTour/useOnboardingStepAssist.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from "react"; +import { track } from "@web/auth/posthog/track"; +import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; +import { type OnboardingTourStepId } from "@web/components/OnboardingTour/onboarding.tour.steps"; + +const ASSIST_IDLE_MS = 15_000; +const ASSIST_ATTEMPT_THRESHOLD = 2; + +/** + * Reveals a "Show me" fallback on a verified step after two failed + * keypresses or ~15s idle, so mission verification never becomes a hard + * wall (spec principle 4, "stuck != trapped"). Resets on every step change. + */ +export function useOnboardingStepAssist( + isActive: boolean, + stepId: OnboardingTourStepId, +): boolean { + const [isVisible, setIsVisible] = useState(false); + const attemptsRef = useRef(0); + const revealedRef = useRef(false); + + useEffect(() => { + setIsVisible(false); + attemptsRef.current = 0; + revealedRef.current = false; + if (!isActive) return; + + const reveal = () => { + if (revealedRef.current) return; + revealedRef.current = true; + setIsVisible(true); + track("onboarding_step_assist_used", { step: stepId }); + }; + + const idleTimer = window.setTimeout(reveal, ASSIST_IDLE_MS); + + const onKeyDown = (event: KeyboardEvent) => { + // Typing into a field (e.g. the title while creating/saving) is + // progress, not a failed attempt at the current step's shortcut. + if (isEditableKeyboardTarget(event)) return; + attemptsRef.current += 1; + if (attemptsRef.current >= ASSIST_ATTEMPT_THRESHOLD) reveal(); + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + window.clearTimeout(idleTimer); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isActive, stepId]); + + return isVisible; +} diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts new file mode 100644 index 000000000..ef6a420d9 --- /dev/null +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts @@ -0,0 +1,45 @@ +import { useEffect } from "react"; +import { type OnboardingTourStepId } from "@web/components/OnboardingTour/onboarding.tour.steps"; +import { + selectOnboardingTourActive, + selectOnboardingTourStepId, + useOnboardingTourStore, +} from "@web/components/OnboardingTour/onboarding.tour.store"; +import { keyboardOnlyActions } from "@web/shortcuts/keyboard-only/keyboard-only.store"; + +/** + * Mission steps that target a real grid event: mouse clicks disable for + * these on top of the existing double-Shift gesture in useKeyboardOnlyMode.ts, + * so the keyboard is the only way through. `undo`/`hardcore` don't touch a + * grid event directly and stay mouse-permissive; `create`/`save` are the + * very first lesson and stay permissive too. + */ +const MISSION_STEP_IDS: ReadonlySet = new Set([ + "moveFocus", + "editSequence", + "targetEvent", + "move", + "resizeEdge", + "placeDraft", +]); + +/** + * Programmatic keyboard-only entry for the tour's mission steps. The effect + * cleanup is the whole exit-path audit: it fires on every path that stops + * `shouldBeActive` being true - tour skip/finish (isActive flips false), the + * fork exit and every other step change (stepId leaves the mission set), and + * unmount - so there is exactly one place that can leak keyboard-only mode, + * and it self-corrects by construction. + */ +export function useOnboardingTourKeyboardOnly() { + const isTourActive = useOnboardingTourStore(selectOnboardingTourActive); + const stepId = useOnboardingTourStore(selectOnboardingTourStepId); + const shouldBeActive = isTourActive && MISSION_STEP_IDS.has(stepId); + + useEffect(() => { + if (!shouldBeActive) return; + + keyboardOnlyActions.enter(); + return () => keyboardOnlyActions.exit(); + }, [shouldBeActive]); +} diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts index 8ec1c63a3..cb731eef3 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts @@ -1,162 +1,338 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; +import { EventIdSchema } from "@core/types/domain-primitives"; +import { EventScheduleSchema } from "@core/types/event.contracts"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { DEMO_EVENT_IDS } from "@web/common/storage/migrations/external/demo-data-seed"; import { initialOnboardingTourState, - onboardingTourActions, useOnboardingTourStore, } from "@web/components/OnboardingTour/onboarding.tour.store"; import { - shouldAdvancePaletteStep, shouldAdvanceTargetEventStep, useOnboardingTourProgress, } from "@web/components/OnboardingTour/useOnboardingTourProgress"; +import { eventQueryKeys } from "@web/events/queries/event.query.keys"; import { - initialViewState, - useViewStore, - viewActions, -} from "@web/events/stores/view.store"; + initialDraftState, + useDraftStore, +} from "@web/events/stores/draft.store"; import { - initialSettingsState, - settingsActions, - useSettingsStore, -} from "@web/settings/settings.store"; -import { beforeEach, describe, expect, it } from "bun:test"; - -describe("shouldAdvancePaletteStep", () => { - it("does not advance when the palette has only opened", () => { - expect( - shouldAdvancePaletteStep({ - paletteOpened: true, - isPaletteOpen: true, - isShortcutsOpen: false, - }), - ).toBe(false); - }); + edgeFocusActions, + initialEdgeFocusState, + useEdgeFocusStore, +} from "@web/grid/shortcuts/edge-focus.store"; +import { + initialKeyboardOnlyState, + useKeyboardOnlyStore, +} from "@web/shortcuts/keyboard-only/keyboard-only.store"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +// Every test in this file renders useOnboardingTourProgress, which attaches +// document-level keydown/focusin listeners for as long as it stays mounted. +// Without an explicit unmount, a leaked listener from one test can react to +// key/focus events dispatched by a later test and desync shared global +// stores (tour/draft/edge-focus/keyboard-only) - a prior version of this +// file hung on exactly that cross-test pollution. +afterEach(cleanup); + +const DENTIST_WEEK_KEY = eventQueryKeys.week({ + source: "local", + start: "2026-05-01T00:00:00.000Z", + end: "2026-05-08T00:00:00.000Z", +}); - it("advances after the palette was opened and then closed", () => { - expect( - shouldAdvancePaletteStep({ - paletteOpened: true, - isPaletteOpen: false, - isShortcutsOpen: false, - }), - ).toBe(true); +const dentistEvent = (start: string, end: string) => + createMockEvent({ + id: EventIdSchema.parse(DEMO_EVENT_IDS.dentist), + schedule: EventScheduleSchema.parse({ + kind: "timed", + start, + end, + timeZone: "America/Chicago", + }), }); - it("does not advance before the palette has ever opened", () => { - expect( - shouldAdvancePaletteStep({ - paletteOpened: false, - isPaletteOpen: false, - isShortcutsOpen: false, - }), - ).toBe(false); +const seedDentist = ( + client: QueryClient, + start = "2026-05-05T14:30:00.000-05:00", + end = "2026-05-05T15:30:00.000-05:00", +) => { + const event = dentistEvent(start, end); + client.setQueryData(DENTIST_WEEK_KEY, { + ids: [event.id], + entities: { [event.id]: event }, + }); + return event; +}; + +describe("shouldAdvanceTargetEventStep", () => { + it("advances only for the Dentist demo event, named directly in the mission", () => { + expect(shouldAdvanceTargetEventStep(DEMO_EVENT_IDS.dentist)).toBe(true); + expect(shouldAdvanceTargetEventStep(DEMO_EVENT_IDS.teamSync)).toBe(false); + expect(shouldAdvanceTargetEventStep(DEMO_EVENT_IDS.morningStandup)).toBe( + false, + ); + expect(shouldAdvanceTargetEventStep(null)).toBe(false); }); +}); - it("advances when shortcuts open from inside the palette", () => { - expect( - shouldAdvancePaletteStep({ - paletteOpened: true, - isPaletteOpen: true, - isShortcutsOpen: true, - }), - ).toBe(true); +describe("useOnboardingTourProgress mission verification", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + useOnboardingTourStore.setState(initialOnboardingTourState); + useDraftStore.setState(initialDraftState); + useEdgeFocusStore.setState(initialEdgeFocusState); + useKeyboardOnlyStore.setState(initialKeyboardOnlyState); + persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR, ""); }); - it("does not advance on shortcuts alone before the palette opened", () => { - expect( - shouldAdvancePaletteStep({ - paletteOpened: false, - isPaletteOpen: false, - isShortcutsOpen: true, + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + + const dispatchKey = (key: string, init: KeyboardEventInit = {}) => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...init, }), - ).toBe(false); - }); -}); + ); + }; -describe("useOnboardingTourProgress palette step", () => { - beforeEach(() => { + it("targetEvent: advances only when the Dentist event itself receives focus", async () => { useOnboardingTourStore.setState({ ...initialOnboardingTourState, isActive: true, - stepId: "palette", + stepId: "targetEvent", }); - useSettingsStore.setState(initialSettingsState); - useViewStore.setState(initialViewState); - persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR, ""); - }); + renderHook(() => useOnboardingTourProgress(), { wrapper }); - const wrapper = ({ children }: { children: ReactNode }) => - createElement(QueryClientProvider, { client: new QueryClient() }, children); + const decoy = document.createElement("div"); + decoy.setAttribute( + "data-week-interaction-event-id", + DEMO_EVENT_IDS.teamSync, + ); + decoy.tabIndex = 0; + document.body.appendChild(decoy); + decoy.focus(); + expect(useOnboardingTourStore.getState().stepId).toBe("targetEvent"); + decoy.remove(); + + const dentist = document.createElement("div"); + dentist.setAttribute( + "data-week-interaction-event-id", + DEMO_EVENT_IDS.dentist, + ); + dentist.tabIndex = 0; + document.body.appendChild(dentist); + dentist.focus(); + + await waitFor(() => { + expect(useOnboardingTourStore.getState().stepId).toBe("move"); + }); + dentist.remove(); + }); - it("stays on palette while the command palette is open", async () => { + it("move: advances only once Dentist's start time actually changes", async () => { + seedDentist(queryClient); + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "move", + }); renderHook(() => useOnboardingTourProgress(), { wrapper }); + // A re-render with the same schedule must not count as a move. act(() => { - settingsActions.openCmdPalette(); + seedDentist(queryClient); + }); + expect(useOnboardingTourStore.getState().stepId).toBe("move"); + + act(() => { + seedDentist( + queryClient, + "2026-05-05T16:00:00.000-05:00", + "2026-05-05T17:00:00.000-05:00", + ); }); await waitFor(() => { - expect(useSettingsStore.getState().isCmdPaletteOpen).toBe(true); + expect(useOnboardingTourStore.getState().stepId).toBe("resizeEdge"); + }); + }); + + it("resizeEdge: a single real Tab (as the card promises) reaches the end edge", async () => { + seedDentist(queryClient); + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "resizeEdge", }); - expect(useOnboardingTourStore.getState().stepId).toBe("palette"); + renderHook(() => useOnboardingTourProgress(), { wrapper }); + + // The step must seed edge focus to startDate on entry - EDGE_CYCLE is + // [null, startDate, endDate], and nothing before this step touches edge + // focus, so without a startDate seed a single real Tab cycle would land + // on startDate, contradicting the card's single-Tab copy/shortcutHint. + expect(useEdgeFocusStore.getState().edge).toBe("startDate"); + + act(() => { + edgeFocusActions.cycle(DEMO_EVENT_IDS.dentist, "forward"); + }); + expect(useEdgeFocusStore.getState().edge).toBe("endDate"); }); - it("advances to shortcuts after the palette opens then closes", async () => { + it("resizeEdge: requires the end edge focused AND only the end time to move", async () => { + seedDentist(queryClient); + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "resizeEdge", + }); renderHook(() => useOnboardingTourProgress(), { wrapper }); + // End time moving before the user Tabs off startDate (the step's seeded + // edge) must not count as a resize. + expect(useEdgeFocusStore.getState().edge).toBe("startDate"); act(() => { - settingsActions.openCmdPalette(); + seedDentist( + queryClient, + "2026-05-05T14:30:00.000-05:00", + "2026-05-05T16:00:00.000-05:00", + ); }); + expect(useOnboardingTourStore.getState().stepId).toBe("resizeEdge"); + act(() => { - settingsActions.closeCmdPalette(); + // A single real Tab cycle from the step's seeded startDate. + edgeFocusActions.cycle(DEMO_EVENT_IDS.dentist, "forward"); + seedDentist( + queryClient, + "2026-05-05T14:30:00.000-05:00", + "2026-05-05T17:00:00.000-05:00", + ); }); await waitFor(() => { - expect(useOnboardingTourStore.getState().stepId).toBe("shortcuts"); + expect(useOnboardingTourStore.getState().stepId).toBe("placeDraft"); }); }); - it("skips ahead when shortcuts open from the palette", async () => { + it("placeDraft: advances once a Shift+Arrow keyboard-placed draft lands, and discards it", async () => { + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "placeDraft", + }); renderHook(() => useOnboardingTourProgress(), { wrapper }); act(() => { - settingsActions.openCmdPalette(); - viewActions.toggleShortcuts(); + useDraftStore.setState({ + gridDraft: { clientId: "draft-1" } as never, + status: { + activity: "keyboardPlace", + isDrafting: true, + isFormOpen: false, + }, + }); }); await waitFor(() => { - // Palette advance + shortcuts already-open cascade lands on fork. - expect(useOnboardingTourStore.getState().stepId).toBe("fork"); + expect(useOnboardingTourStore.getState().stepId).toBe("undo"); }); + expect(useDraftStore.getState().gridDraft).toBeNull(); }); - it("does not skip the palette lesson when shortcuts open alone", async () => { + it("undo: requires a revert then a reapply of Dentist's schedule, not just Mod+Z keydowns", async () => { + seedDentist( + queryClient, + "2026-05-05T16:00:00.000-05:00", + "2026-05-05T17:00:00.000-05:00", + ); + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "undo", + }); renderHook(() => useOnboardingTourProgress(), { wrapper }); + // Pressing the key alone, with no state change, must not advance. + act(() => { + dispatchKey("z", { metaKey: true }); + }); + expect(useOnboardingTourStore.getState().stepId).toBe("undo"); + + // Phase 1: the revert. + act(() => { + seedDentist( + queryClient, + "2026-05-05T14:30:00.000-05:00", + "2026-05-05T15:30:00.000-05:00", + ); + }); + expect(useOnboardingTourStore.getState().stepId).toBe("undo"); + + // A manual re-edit that changes the schedule again, but not back to the + // exact pre-undo value, must not be mistaken for a real redo. act(() => { - viewActions.toggleShortcuts(); + seedDentist( + queryClient, + "2026-05-05T18:00:00.000-05:00", + "2026-05-05T19:00:00.000-05:00", + ); + }); + expect(useOnboardingTourStore.getState().stepId).toBe("undo"); + + // Phase 2: the reapply - restores exactly the pre-undo schedule. + act(() => { + seedDentist( + queryClient, + "2026-05-05T16:00:00.000-05:00", + "2026-05-05T17:00:00.000-05:00", + ); }); await waitFor(() => { - expect(useViewStore.getState().shortcuts.isOpen).toBe(true); + expect(useOnboardingTourStore.getState().stepId).toBe("hardcore"); }); - expect(useOnboardingTourStore.getState().stepId).toBe("palette"); }); -}); -describe("useOnboardingTourProgress navigation and Escape", () => { - beforeEach(() => { + it("hardcore: advances (and finishes the tour) once Hardcore Mode is actually active", async () => { useOnboardingTourStore.setState({ ...initialOnboardingTourState, isActive: true, - stepId: "palette", + stepId: "hardcore", + }); + renderHook(() => useOnboardingTourProgress(), { wrapper }); + + expect(useOnboardingTourStore.getState().isActive).toBe(true); + + act(() => { + useKeyboardOnlyStore.setState({ isActive: true }); + }); + + await waitFor(() => { + expect(useOnboardingTourStore.getState().isActive).toBe(false); }); - useSettingsStore.setState(initialSettingsState); - useViewStore.setState(initialViewState); + expect( + persistentBrowserStore.get(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR), + ).toBe("true"); + }); +}); + +describe("useOnboardingTourProgress navigation and Escape", () => { + beforeEach(() => { + useOnboardingTourStore.setState(initialOnboardingTourState); + useDraftStore.setState(initialDraftState); + useEdgeFocusStore.setState(initialEdgeFocusState); + useKeyboardOnlyStore.setState(initialKeyboardOnlyState); persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR, ""); }); @@ -174,7 +350,7 @@ describe("useOnboardingTourProgress navigation and Escape", () => { ); }; - it("skips the tour on Escape when no lesson overlay owns it", async () => { + it("shows a skip confirm on Escape instead of skipping immediately", async () => { useOnboardingTourStore.setState({ ...initialOnboardingTourState, isActive: true, @@ -187,56 +363,51 @@ describe("useOnboardingTourProgress navigation and Escape", () => { }); await waitFor(() => { - expect(useOnboardingTourStore.getState().isActive).toBe(false); + expect(useOnboardingTourStore.getState().isConfirmingSkip).toBe(true); }); + expect(useOnboardingTourStore.getState().isActive).toBe(true); }); - it("lets Escape close a leftover form during the palette lesson", async () => { - const { draftActions, initialDraftState, useDraftStore } = await import( - "@web/events/stores/draft.store" - ); - - useDraftStore.setState({ - ...initialDraftState, - gridDraft: { id: "draft" } as never, - status: { - activity: "keyboardEdit", - eventType: "timed", - isDrafting: true, - isFormOpen: true, - } as never, + it("skips the tour when Enter confirms the skip", async () => { + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "create", + isConfirmingSkip: true, }); renderHook(() => useOnboardingTourProgress(), { wrapper }); act(() => { - dispatchKey("Escape"); + dispatchKey("Enter"); }); - expect(useOnboardingTourStore.getState().isActive).toBe(true); - expect(useOnboardingTourStore.getState().stepId).toBe("palette"); - - draftActions.discard(); + await waitFor(() => { + expect(useOnboardingTourStore.getState().isActive).toBe(false); + }); }); - it("lets Escape close the open palette during the palette lesson", async () => { + it("cancels the skip confirm and keeps the tour going on any other key", async () => { + useOnboardingTourStore.setState({ + ...initialOnboardingTourState, + isActive: true, + stepId: "create", + isConfirmingSkip: true, + }); renderHook(() => useOnboardingTourProgress(), { wrapper }); act(() => { - settingsActions.openCmdPalette(); - }); - act(() => { - dispatchKey("Escape"); + dispatchKey("a"); }); + expect(useOnboardingTourStore.getState().isConfirmingSkip).toBe(false); expect(useOnboardingTourStore.getState().isActive).toBe(true); - expect(useOnboardingTourStore.getState().stepId).toBe("palette"); }); it("advances and retreats with ArrowRight and ArrowLeft outside arrow lessons", async () => { useOnboardingTourStore.setState({ ...initialOnboardingTourState, isActive: true, - stepId: "shortcuts", + stepId: "undo", }); renderHook(() => useOnboardingTourProgress(), { wrapper }); @@ -244,14 +415,14 @@ describe("useOnboardingTourProgress navigation and Escape", () => { dispatchKey("ArrowRight"); }); await waitFor(() => { - expect(useOnboardingTourStore.getState().stepId).toBe("fork"); + expect(useOnboardingTourStore.getState().stepId).toBe("hardcore"); }); act(() => { dispatchKey("ArrowLeft"); }); await waitFor(() => { - expect(useOnboardingTourStore.getState().stepId).toBe("shortcuts"); + expect(useOnboardingTourStore.getState().stepId).toBe("undo"); }); }); @@ -267,48 +438,8 @@ describe("useOnboardingTourProgress navigation and Escape", () => { dispatchKey("ArrowRight"); }); - // Lesson handler advances once; nav arrows are disabled so we do not skip - // past editSequence in the same press. - await waitFor(() => { - expect(useOnboardingTourStore.getState().stepId).toBe("editSequence"); - }); - }); - - it("advances targetEvent when a sandbox jump event receives focus", async () => { - expect(shouldAdvanceTargetEventStep("sandbox-targetEvent-2")).toBe(true); - expect(shouldAdvanceTargetEventStep("sandbox-nudge-1")).toBe(false); - expect(shouldAdvanceTargetEventStep(null)).toBe(false); - - useOnboardingTourStore.setState({ - ...initialOnboardingTourState, - isActive: true, - stepId: "targetEvent", - }); - renderHook(() => useOnboardingTourProgress(), { wrapper }); - - act(() => { - if (shouldAdvanceTargetEventStep("sandbox-targetEvent-1")) { - onboardingTourActions.advance(); - } - }); - - await waitFor(() => { - expect(useOnboardingTourStore.getState().stepId).toBe("nudge"); - }); - }); - - it("does not advance targetEvent on Shift alone", async () => { - useOnboardingTourStore.setState({ - ...initialOnboardingTourState, - isActive: true, - stepId: "targetEvent", - }); - renderHook(() => useOnboardingTourProgress(), { wrapper }); - - act(() => { - dispatchKey("Shift"); - }); - - expect(useOnboardingTourStore.getState().stepId).toBe("targetEvent"); + // Arrow-lesson steps disable nav arrows entirely; a plain ArrowRight + // keydown with nothing focused must not fake-complete the lesson. + expect(useOnboardingTourStore.getState().stepId).toBe("moveFocus"); }); }); diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts index 32c774a06..2574d9f7f 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts @@ -1,54 +1,75 @@ import { useEffect, useRef } from "react"; +import { type Event } from "@core/types/event.contracts"; +import { ID_EVENT_FORM } from "@web/common/constants/web.constants"; +import { DEMO_EVENT_IDS } from "@web/common/storage/migrations/external/demo-data-seed"; import { focusCalendarEventElement, getCalendarEventIdFromElement, } from "@web/common/utils/event/event.util"; import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; import { - getSandboxFocusEventId, - isTargetEventSandboxId, -} from "@web/components/OnboardingTour/onboarding.sandbox-events"; -import { ONBOARDING_ARROW_LESSON_STEP_IDS } from "@web/components/OnboardingTour/onboarding.tour.steps"; + ONBOARDING_ARROW_LESSON_STEP_IDS, + type OnboardingTourStepId, +} from "@web/components/OnboardingTour/onboarding.tour.steps"; import { onboardingTourActions, + selectIsConfirmingTourSkip, selectOnboardingTourActive, selectOnboardingTourStepId, useOnboardingTourStore, } from "@web/components/OnboardingTour/onboarding.tour.store"; import { useHasPendingEventMutations } from "@web/events/mutations/useEventPending"; +import { useEventById } from "@web/events/queries/useEventById"; import { + draftActions, + selectDraftActivity, + selectGridDraft, selectIsEventFormOpen, useDraftStore, } from "@web/events/stores/draft.store"; import { - selectIsShortcutsOpen, - useViewStore, -} from "@web/events/stores/view.store"; + edgeFocusActions, + selectEdgeForEvent, + useEdgeFocusStore, +} from "@web/grid/shortcuts/edge-focus.store"; import { - selectIsCmdPaletteOpen, - useSettingsStore, -} from "@web/settings/settings.store"; - -/** Palette step completes after open→close, or if shortcuts open from inside it. */ -export function shouldAdvancePaletteStep({ - paletteOpened, - isPaletteOpen, - isShortcutsOpen, -}: { - paletteOpened: boolean; - isPaletteOpen: boolean; - isShortcutsOpen: boolean; -}): boolean { - // Require the palette to have opened first so a bare "?" / sidebar toggle - // cannot skip the Mod+K lesson. - return paletteOpened && (!isPaletteOpen || isShortcutsOpen); -} + selectKeyboardOnlyActive, + useKeyboardOnlyStore, +} from "@web/shortcuts/keyboard-only/keyboard-only.store"; +import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; + +const TITLE_FIELD_SELECTOR = `form[name="${ID_EVENT_FORM}"] input[name="Event Title"]`; -/** targetEvent completes once a sandbox jump event is focused. */ +/** targetEvent completes once the Dentist demo event is focused. */ export function shouldAdvanceTargetEventStep(eventId: string | null): boolean { - return Boolean(eventId && isTargetEventSandboxId(eventId)); + return eventId === DEMO_EVENT_IDS.dentist; } +type TimedSchedule = { start: string; end: string }; + +const timedScheduleOf = (event: Event | null): TimedSchedule | null => { + if (!event || event.schedule.kind !== "timed") return null; + return { start: event.schedule.start, end: event.schedule.end }; +}; + +const sameSchedule = (a: TimedSchedule | null, b: TimedSchedule | null) => + a !== null && b !== null && a.start === b.start && a.end === b.end; + +/** Shared by move/resizeEdge/undo: focus Dentist and snapshot its schedule. */ +const enterDentistMission = ( + dentistEvent: Event | null, + scheduleAtEntryRef: { current: TimedSchedule | null }, +) => { + focusCalendarEventElement(DEMO_EVENT_IDS.dentist); + scheduleAtEntryRef.current = timedScheduleOf(dentistEvent); +}; + +const STEPS_NEEDING_DENTIST: ReadonlySet = new Set([ + "move", + "resizeEdge", + "undo", +]); + /** * Advances tour steps when the user performs the prompted action. * Does not take the app lock; coachmarks stay out of the modal Escape stack @@ -57,59 +78,30 @@ export function shouldAdvanceTargetEventStep(eventId: string | null): boolean { export function useOnboardingTourProgress() { const isActive = useOnboardingTourStore(selectOnboardingTourActive); const stepId = useOnboardingTourStore(selectOnboardingTourStepId); + const isConfirmingSkip = useOnboardingTourStore(selectIsConfirmingTourSkip); const isFormOpen = useDraftStore(selectIsEventFormOpen); const isSaving = useHasPendingEventMutations(); - const isPaletteOpen = useSettingsStore(selectIsCmdPaletteOpen); - const isShortcutsOpen = useViewStore(selectIsShortcutsOpen); - const paletteOpenedRef = useRef(false); - /** Tracks whether Shift is currently held, for nudge bleed-through guard. */ - const shiftPressedRef = useRef(false); - /** Shift must be released after entering nudge before Shift+Arrow counts. */ - const nudgeShiftArmedRef = useRef(false); - - useEffect(() => { - if (!isActive) { - shiftPressedRef.current = false; - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Shift") shiftPressedRef.current = true; - }; - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === "Shift") shiftPressedRef.current = false; - }; - - document.addEventListener("keydown", onKeyDown, true); - document.addEventListener("keyup", onKeyUp, true); - return () => { - document.removeEventListener("keydown", onKeyDown, true); - document.removeEventListener("keyup", onKeyUp, true); - }; - }, [isActive]); - - useEffect(() => { - if (!isActive || stepId !== "palette") { - paletteOpenedRef.current = false; - return; - } - - if (isPaletteOpen) { - paletteOpenedRef.current = true; - } - - if ( - shouldAdvancePaletteStep({ - paletteOpened: paletteOpenedRef.current, - isPaletteOpen, - isShortcutsOpen, - }) - ) { - paletteOpenedRef.current = false; - onboardingTourActions.advance(); - } - }, [isActive, stepId, isPaletteOpen, isShortcutsOpen]); - + const draftActivity = useDraftStore(selectDraftActivity); + const gridDraft = useDraftStore(selectGridDraft); + // Only the move/resizeEdge/undo missions read Dentist's live schedule; + // gating the id keeps the (always-mounted) tour hook from running a full + // query-cache scan on every render outside those three steps. + const dentistEvent = useEventById( + isActive && STEPS_NEEDING_DENTIST.has(stepId) + ? DEMO_EVENT_IDS.dentist + : undefined, + ); + const dentistEdge = useEdgeFocusStore( + selectEdgeForEvent(DEMO_EVENT_IDS.dentist), + ); + const isKeyboardOnly = useKeyboardOnlyStore(selectKeyboardOnlyActive); + + /** move/resizeEdge/undo capture the schedule at step entry to diff against. */ + const scheduleAtEntryRef = useRef(null); + /** undo has two phases: wait for a revert, then wait for a reapply. */ + const undoPhaseRef = useRef<"pending-undo" | "pending-redo">("pending-undo"); + + // create / save / editSequence: unchanged encouragement-based checks. useEffect(() => { if (!isActive) return; @@ -121,36 +113,55 @@ export function useOnboardingTourProgress() { // Optimistic create flips pending; that is enough to count as saved. if (stepId === "save" && isSaving) { onboardingTourActions.advance(); - return; } + }, [isActive, stepId, isFormOpen, isSaving]); - // The E-then-T sequence reopens the form; enough to count as the lesson. - if (stepId === "editSequence" && isFormOpen) { - onboardingTourActions.advance(); - return; - } + // moveFocus: auto-focus the anchor event, then require focus to land on a + // genuinely different event id (upgrade over "any arrow keydown counts"). + useEffect(() => { + if (!isActive || stepId !== "moveFocus") return; + focusCalendarEventElement(DEMO_EVENT_IDS.morningStandup); - if (stepId === "shortcuts" && isShortcutsOpen) { - onboardingTourActions.advance(); - } - }, [isActive, stepId, isFormOpen, isSaving, isShortcutsOpen]); + const onFocusChange = () => { + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + const eventId = getCalendarEventIdFromElement(active); + if (eventId && eventId !== DEMO_EVENT_IDS.morningStandup) { + onboardingTourActions.advance(); + } + }; + + document.addEventListener("focusin", onFocusChange); + return () => document.removeEventListener("focusin", onFocusChange); + }, [isActive, stepId]); - // Auto-focus the practice event when a sandbox lesson needs a starting focus. + // editSequence: require the title field itself to be focused, not just + // "some form is open" (upgrade over the old form-open-only check). Closes + // the practice form once verified - Act 2's grid shortcuts (move, + // resizeEdge, placeDraft) all stand down while any form is open, so a + // form left over from this step would silently block every mission after + // it until the stuck-fallback bailed the user out. useEffect(() => { - if (!isActive) return; - if ( - stepId !== "editSequence" && - stepId !== "nudge" && - stepId !== "moveFocus" - ) { - return; - } - const eventId = getSandboxFocusEventId(stepId); - if (!eventId) return; - focusCalendarEventElement(eventId); + if (!isActive || stepId !== "editSequence") return; + + const checkTitleFocused = () => { + const active = document.activeElement; + if ( + active instanceof HTMLElement && + active.matches(TITLE_FIELD_SELECTOR) + ) { + draftActions.discard(); + onboardingTourActions.advance(); + } + }; + + document.addEventListener("focusin", checkTitleFocused); + checkTitleFocused(); + return () => document.removeEventListener("focusin", checkTitleFocused); }, [isActive, stepId]); - // targetEvent completes when a sandbox jump target receives focus. + // targetEvent: completes only when the Dentist demo event is focused, + // named directly in the mission copy (upgrade over "jump to anything"). useEffect(() => { if (!isActive || stepId !== "targetEvent") return; @@ -166,9 +177,7 @@ export function useOnboardingTourProgress() { // Jump focuses on keydown; some test envs do not bubble focusin reliably, // so also re-check after a printable keyup (the letter that completes jump). const onKeyUp = (event: KeyboardEvent) => { - if (event.key.length === 1) { - tryAdvanceFromActiveElement(); - } + if (event.key.length === 1) tryAdvanceFromActiveElement(); }; document.addEventListener("focusin", tryAdvanceFromActiveElement); @@ -180,61 +189,116 @@ export function useOnboardingTourProgress() { }; }, [isActive, stepId]); - // 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. + // move / resizeEdge: keep Dentist focused through both missions (in case + // the previous step's completion came via the stuck-fallback rather than + // an actual jump), and capture its schedule at entry to diff against. + // resizeEdge also seeds edge focus to "startDate": EDGE_CYCLE is + // [null, startDate, endDate] and nothing before this step ever touches + // edge focus, so without this a single Tab (what the card's copy and + // shortcutHint promise) would land on startDate, not endDate. + // biome-ignore lint/correctness/useExhaustiveDependencies: capture the schedule once, on step entry - dentistEvent updating mid-step must not reset the snapshot we diff against. useEffect(() => { - if (!isActive) return; - if (stepId !== "moveFocus" && stepId !== "nudge" && stepId !== "undo") { - return; + if (!isActive || (stepId !== "move" && stepId !== "resizeEdge")) return; + enterDentistMission(dentistEvent, scheduleAtEntryRef); + if (stepId === "resizeEdge") { + edgeFocusActions.setEdge( + DEMO_EVENT_IDS.dentist, + "startDate", + "Editing start time", + ); } + }, [isActive, stepId]); - // If Shift is still held from targetEvent, wait for release; otherwise - // arm immediately so Next-button entry can complete Shift+Arrow once. - if (stepId === "nudge") { - nudgeShiftArmedRef.current = !shiftPressedRef.current; + // move: verify the whole event's start time actually changed (upgrade + // over "any Shift+Arrow keydown counts"; also fixes the sandbox no-op). + useEffect(() => { + if (!isActive || stepId !== "move") return; + const entry = scheduleAtEntryRef.current; + const current = timedScheduleOf(dentistEvent); + if (entry && current && current.start !== entry.start) { + onboardingTourActions.advance(); } + }, [isActive, stepId, dentistEvent]); - 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 === "nudge" && - nudgeShiftArmedRef.current && - event.shiftKey && - event.key.startsWith("Arrow") - ) { - onboardingTourActions.advance(); - } else if (stepId === "undo" && mod && event.key.toLowerCase() === "z") { - onboardingTourActions.advance(); - } - }; + // resizeEdge: verify Tab focused the end edge and only the end time moved. + useEffect(() => { + if (!isActive || stepId !== "resizeEdge") return; + const entry = scheduleAtEntryRef.current; + const current = timedScheduleOf(dentistEvent); + if ( + entry && + current && + dentistEdge === "endDate" && + current.end !== entry.end && + current.start === entry.start + ) { + onboardingTourActions.advance(); + } + }, [isActive, stepId, dentistEvent, dentistEdge]); - const onKeyUp = (event: KeyboardEvent) => { - if (stepId !== "nudge") return; - if (event.key === "Shift") { - nudgeShiftArmedRef.current = true; - } - }; + // placeDraft: clear focus/edge state on entry so Shift+Arrow has nothing + // focused to move, then verify a new keyboard-placed draft landed. + useEffect(() => { + if (!isActive || stepId !== "placeDraft") return; + edgeFocusActions.reset(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }, [isActive, stepId]); - document.addEventListener("keydown", onKeyDown); - document.addEventListener("keyup", onKeyUp); - return () => { - document.removeEventListener("keydown", onKeyDown); - document.removeEventListener("keyup", onKeyUp); - }; + useEffect(() => { + if (!isActive || stepId !== "placeDraft") return; + if (draftActivity === "keyboardPlace" && gridDraft !== null) { + draftActions.discard(); + onboardingTourActions.advance(); + } + }, [isActive, stepId, draftActivity, gridDraft]); + + // undo: two phases against real event state, not keydowns - wait for + // Dentist's schedule to change (undo), then to change again (redo). Also + // resets the shared Shift-tap gesture: Act 2's several prior Shift+Arrow + // missions can leave it mid-cycle, and an in-flight arm treats the very + // next non-arrow keydown as a (failed) day-jump letter and swallows it - + // which is exactly what Mod+Shift+Z's "Z" looks like to that listener. + // biome-ignore lint/correctness/useExhaustiveDependencies: capture the schedule once, on step entry - dentistEvent updating mid-step must not reset the snapshot we diff against. + useEffect(() => { + if (!isActive || stepId !== "undo") return; + resetSharedShiftTapGesture(); + enterDentistMission(dentistEvent, scheduleAtEntryRef); + undoPhaseRef.current = "pending-undo"; }, [isActive, stepId]); - // ArrowLeft / ArrowRight move Previous / Next when arrows are not the lesson - // and focus is not inside an editable field. useEffect(() => { - if (!isActive) return; + if (!isActive || stepId !== "undo") return; + const entry = scheduleAtEntryRef.current; + const current = timedScheduleOf(dentistEvent); + if (!entry || !current) return; + + if (undoPhaseRef.current === "pending-undo") { + if (!sameSchedule(current, entry)) { + undoPhaseRef.current = "pending-redo"; + } + return; + } + + // Redo must restore exactly the schedule captured at step entry - not + // merely "changed again" - or a manual re-edit of Dentist (rather than + // pressing redo) would be misread as a successful redo. + if (sameSchedule(current, entry)) { + onboardingTourActions.advance(); + } + }, [isActive, stepId, dentistEvent]); + + // hardcore: graduation completes once Hardcore Mode is actually active. + useEffect(() => { + if (!isActive || stepId !== "hardcore") return; + if (isKeyboardOnly) onboardingTourActions.advance(); + }, [isActive, stepId, isKeyboardOnly]); + + // ArrowLeft / ArrowRight move Previous / Next when arrows are not the + // lesson and focus is not inside an editable field. + useEffect(() => { + if (!isActive || isConfirmingSkip) return; if (ONBOARDING_ARROW_LESSON_STEP_IDS.has(stepId)) return; const onKeyDown = (event: KeyboardEvent) => { @@ -258,34 +322,37 @@ export function useOnboardingTourProgress() { return () => { document.removeEventListener("keydown", onKeyDown); }; - }, [isActive, stepId]); + }, [isActive, stepId, isConfirmingSkip]); - // ESC skips the tour unless the current lesson is mid-overlay dismiss - // (palette / shortcuts), or a leftover form is still open on the palette - // step after E-then-T. Capture so we win over unrelated lower handlers; - // create/save still exit the tour even with the form open. + // Escape never traps: first press shows an inline "skip the tour?" confirm. + // While confirming, Enter skips and any other key cancels the confirm and + // lets the keypress fall through, so pressing the actual lesson key still + // counts. Capture so this wins over unrelated lower handlers. useEffect(() => { if (!isActive) return; const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (event.defaultPrevented) return; - - const { stepId: currentStep } = useOnboardingTourStore.getState(); - const paletteOpen = selectIsCmdPaletteOpen(useSettingsStore.getState()); - const shortcutsOpen = selectIsShortcutsOpen(useViewStore.getState()); - const formOpen = selectIsEventFormOpen(useDraftStore.getState()); - - if (currentStep === "palette" && (paletteOpen || formOpen)) { - return; - } - if (currentStep === "shortcuts" && shortcutsOpen) { + const { isConfirmingSkip: confirming } = + useOnboardingTourStore.getState(); + + if (confirming) { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + onboardingTourActions.skip(); + } else { + // Any other key (including a second Escape) means "keep going". + onboardingTourActions.cancelSkipConfirm(); + } return; } + if (event.key !== "Escape") return; + if (event.defaultPrevented) return; + event.preventDefault(); event.stopPropagation(); - onboardingTourActions.skip(); + onboardingTourActions.requestSkipConfirm(); }; document.addEventListener("keydown", onKeyDown, true); diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx index 531aa2b61..708f508a4 100644 --- a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx +++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx @@ -28,31 +28,19 @@ describe("PostOnboardingFlow", () => { 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" }); + it("never renders for an authenticated user, even with a stale connect stage", () => { + // Simulates a user who reached "connect" 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: "connect" }); 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", () => { + it("resolves a stale connect stage to done once authenticated becomes true", () => { usePostOnboardingFlowStore.setState({ stage: "connect" }); renderFlow(, true); diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx index e617ce9fe..f3f2b58ae 100644 --- a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx +++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx @@ -5,17 +5,17 @@ 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. + * Renders the connect-Google CTA after the onboarding tour ends. The trial + * itself is automatic (see packages/web/src/billing/), so there is no + * separate trial CTA step — connecting is the only optional step left. * * 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 + * Connect Google, which would otherwise leave a stale "connect" stage in + * localStorage that renders on their next load even though * usePostOnboardingFlowTrigger's own effect resolves it moments later. */ export const PostOnboardingFlow: FC = () => { @@ -25,6 +25,5 @@ export const PostOnboardingFlow: FC = () => { 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 deleted file mode 100644 index 9e1e8cf6f..000000000 --- a/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { type FC, useContext, useEffect, useRef, useState } from "react"; -import { BillingApi } from "@web/api/billing.api"; -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 { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { getToast } from "@web/common/utils/toast/toast.port"; -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. - * Records a real, durable trial start server-side (see - * keyboard-education/03), but there is no payment collection or Stripe - * integration yet — accepting for an anonymous user opens signup first; - * an authenticated user (arrived via Connect Google, which signs up on its - * own) gets the trial recorded directly with no card step. - */ -export const TrialCTA: FC = () => { - const { authenticated } = useContext(SessionContext); - const { openModal } = useAuthModal(); - const shownRef = useRef(false); - const [isStarting, setIsStarting] = useState(false); - - useEffect(() => { - if (!shownRef.current) { - shownRef.current = true; - track("trial_cta_shown"); - } - }, []); - - const onStartTrial = async () => { - if (!authenticated) { - openModal("signUp"); - return; - } - if (isStarting) return; - - setIsStarting(true); - try { - const status = await BillingApi.startTrial(); - track("trial_started", { subscriptionStatus: status.subscriptionStatus }); - getToast().info("Your free trial has started"); - postOnboardingFlowActions.dismissTrial(); - } catch { - showErrorToast("We couldn't start your trial. Please try again."); - } finally { - setIsStarting(false); - } - }; - - 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 index 304bd1524..1c3f5e509 100644 --- a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts @@ -1,12 +1,12 @@ 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"; +export type PostOnboardingStage = "connect" | "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") { + if (value === "connect" || value === "done") { return value; } return null; 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 index cc50e9930..f2045282a 100644 --- a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts @@ -24,39 +24,26 @@ describe("postOnboardingFlowActions", () => { postOnboardingFlowActions.startAfterTour(); postOnboardingFlowActions.skipConnect(); postOnboardingFlowActions.startAfterTour(); - expect(usePostOnboardingFlowStore.getState().stage).toBe("trial"); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); }); - it("skip and accept both move connect to trial", () => { + it("skip and accept both move connect to done", () => { postOnboardingFlowActions.startAfterTour(); postOnboardingFlowActions.skipConnect(); - expect(usePostOnboardingFlowStore.getState().stage).toBe("trial"); + expect(usePostOnboardingFlowStore.getState().stage).toBe("done"); 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", () => { + it("resolveOnAuth clears a stale connect 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", () => { diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts index 79d5bffbf..a7ea6d440 100644 --- a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts +++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts @@ -35,23 +35,20 @@ export const postOnboardingFlowActions = { /** Google OAuth is a full navigation; persist the next stage first. */ acceptConnect: () => { track("connect_cta_accepted"); - setStage("trial"); + setStage("done"); }, 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. + * Resolves a stale "connect" stage once the user is authenticated by any + * path (not just accepting the Connect Google CTA), so a returning + * established user is never shown the CTA again. */ resolveOnAuth: () => { const { stage } = usePostOnboardingFlowStore.getState(); - if (stage === "connect" || stage === "trial") { + if (stage === "connect") { setStage("done"); } }, diff --git a/packages/web/src/components/RootShell/RootShell.tsx b/packages/web/src/components/RootShell/RootShell.tsx index 93ecaacf8..12c6c8bff 100644 --- a/packages/web/src/components/RootShell/RootShell.tsx +++ b/packages/web/src/components/RootShell/RootShell.tsx @@ -1,4 +1,6 @@ import { Outlet } from "@tanstack/react-router"; +import { TrialGateModal } from "@web/billing/TrialGateModal"; +import { useTrialStatus } from "@web/billing/useTrialStatus"; import { AuthModal } from "@web/components/AuthModal/AuthModal"; import { AuthModalProvider } from "@web/components/AuthModal/AuthModalProvider"; import { OnboardingTour } from "@web/components/OnboardingTour/OnboardingTour"; @@ -31,10 +33,25 @@ export function RootShell() { selectReleaseNotesPromptOpen, ); const isWelcomeGuideOpen = useWelcomeGuideStore(selectWelcomeGuideOpen); + const { isExpired: isTrialExpired } = useTrialStatus(); useNavigationShortcuts(); useCalendarShellShortcuts(); useKeyboardOnlyMode(); + // An expired trial owns the whole screen. The onboarding cards sit at + // Z_INDEX_TOOLTIP (above Z_INDEX_MODAL), so leaving them mounted would let + // a gated user click straight through the gate and keep touring. AuthModal + // stays because the gate's only ways forward are sign up and log in. + if (isTrialExpired) { + return ( + + + + + + ); + } + return ( diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx index 5e804c1fb..b228bc52e 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx @@ -85,14 +85,17 @@ describe("SidebarStatusBar", () => { expect(screen.getByText("Saving changes…")).toBeInTheDocument(); }); - it("reserves space for the status line when idle", () => { + it("reserves space for the status line when idle, showing the anonymous trial chip", () => { const { wrapper } = createStoreWrapper(); render(, { wrapper }); - const status = screen.getByRole("status"); - expect(status).toBeInTheDocument(); - expect(status.textContent).toBe(""); + // No SessionContext.Provider means these tests render as anonymous, so + // an idle bar falls back to the trial countdown chip rather than blank + // space — there is no truly empty state for an anonymous user anymore. + expect( + screen.getByRole("button", { name: /Trial: \d+ days? left/ }), + ).toBeInTheDocument(); }); it("renders exactly one save status region, regardless of account count", () => { @@ -134,8 +137,8 @@ describe("SidebarStatusBar", () => { render(, { wrapper }); - expect(screen.getByRole("status").textContent).toBe(""); expect(screen.queryByText("Adding your calendar…")).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); }); it("shows Syncing in the background when catch-up is more than two minutes behind", () => { @@ -221,7 +224,7 @@ describe("SidebarStatusBar", () => { render(, { wrapper }); - expect(screen.getByRole("status").textContent).toBe(""); + expect(screen.queryByRole("status")).toBeNull(); }); it("shows a live-updates warning when SSE is degraded and sync is otherwise silent", () => { diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx index cd7d89fd8..c3b873702 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx @@ -1,10 +1,12 @@ -import { type FC } from "react"; +import { type FC, useContext } from "react"; +import { SessionContext } from "@web/auth/compass/session/session.context"; import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { getSidebarSyncStatus, SSE_DEGRADED_STATUS, } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; import { useGoogleSyncRefreshSnapshot } from "@web/auth/google/state/google.sync.refresh"; +import { TrialCountdownChip } from "@web/billing/TrialCountdownChip"; import { SYNC_STATUS_VARIANT_CLASSNAME } from "@web/calendars/sync-status.types"; import { useHasPendingEventMutations } from "@web/events/mutations/useEventPending"; import { EdgeFocusIndicator } from "@web/grid/shortcuts/EdgeFocusIndicator"; @@ -45,6 +47,7 @@ import { useSseDegraded } from "@web/sse/hooks/useSseDegraded"; * the meaning; `title` is the safety net if anything still overflows. */ export const SidebarStatusBar: FC = () => { + const { authenticated } = useContext(SessionContext); useShortcutTipTrigger(); const activeTipId = useShortcutTipsStore(selectActiveShortcutTipId); const isKeyboardOnly = useKeyboardOnlyStore(selectKeyboardOnlyActive); @@ -98,6 +101,10 @@ export const SidebarStatusBar: FC = () => {
+ ) : !status && !authenticated ? ( +
+ +
) : ( @@ -148,10 +149,12 @@ export function WelcomeModal() { diff --git a/packages/web/src/components/WelcomeModal/faq.ts b/packages/web/src/components/WelcomeModal/faq.ts index 9a9ab7fbc..dee8d091b 100644 --- a/packages/web/src/components/WelcomeModal/faq.ts +++ b/packages/web/src/components/WelcomeModal/faq.ts @@ -1,18 +1,11 @@ export const FAQ_ITEMS = [ { question: "Who is Compass for?", - answer: - "Compass is for busy minimalists who'd rather fly through their calendar at the keyboard than hunt for it with a mouse. If that's you, Compass will help you do more with less.", - }, - { - question: "Can I try it without handing over my email?", - answer: - "Yes! Just press ESC or click Start Now. No trial, email or credit card required.", + answer: "Compass is for busy professionals who live at their keyboard.", }, { question: "How is Compass different?", - answer: - "Every action, including scheduling itself, is faster from the keyboard than any other calendar. We're simpler, faster, open-source...er. We remind you of your mortality (/life).", + answer: "Everything here is faster, simpler, open-source...er.", }, { question: "What does 'keyboard-first' actually mean?", diff --git a/packages/web/src/events/event-view.types.ts b/packages/web/src/events/event-view.types.ts index 148dd7148..8408a5c10 100644 --- a/packages/web/src/events/event-view.types.ts +++ b/packages/web/src/events/event-view.types.ts @@ -1,10 +1,5 @@ import { type CalendarId, type EventId } from "@core/types/domain-primitives"; -import { - type BusyPeriod, - type Event, - type EventContent, - type EventRecurrence, -} from "@core/types/event.contracts"; +import { type BusyPeriod, type Event } from "@core/types/event.contracts"; import { type CrossAccountDuplicate } from "@web/common/types/web.event.types"; export type EventEntityMap = Record; @@ -20,14 +15,6 @@ export type NormalizedEvents = { * `otherAccount` the same way demoEventIds becomes `isDemo`. */ crossAccountDuplicates?: ReadonlyMap; - /** - * Ephemeral onboarding-sandbox event ids (see - * OnboardingTour/onboarding.sandbox-events.ts) - joined onto GridEvent as - * `isSandboxReadOnly` the same way demoEventIds becomes `isDemo`. Absent - * for real query data; only set by the sandbox merge in - * useWeekEventsQuery/useDayEventsQuery. - */ - sandboxReadOnlyEventIds?: readonly EventId[]; }; export type OptimisticEvent = { diff --git a/packages/web/src/events/mutations/useUndoRedo.test.tsx b/packages/web/src/events/mutations/useUndoRedo.test.tsx index f28ffbfac..2e06c8aef 100644 --- a/packages/web/src/events/mutations/useUndoRedo.test.tsx +++ b/packages/web/src/events/mutations/useUndoRedo.test.tsx @@ -581,6 +581,74 @@ describe("useUndoRedo", () => { expect(context.hook.result.current.undoRedo.canRedo).toBe(false); }); + // Regression: a demo-seeded event (or any Event built outside the normal + // create mutation) has no `color` key at all, while the cache's live copy + // picks one up as an explicit `color: null` once it passes through a + // mutation's optimistic merge. Comparing the two shapes directly used to + // read as "the event changed since this entry was recorded" and silently + // decline the redo — even though nothing about the event actually changed. + test("does not decline a redo when only the presence of a null `color` key differs", async () => { + const context = setup(); + const original = event(); // content has no `color` key, like a demo seed. + context.queryClient.setQueryData(calendarKey, normalized(original)); + + act(() => + context.hook.result.current.mutations.replace({ + id: original.id, + input: { + content: original.content as { + kind: "details"; + title: string; + description: string; + location: string; + }, + schedule: { + kind: "timed", + start: "2026-07-03T16:00:00.000Z" as never, + end: "2026-07-03T17:00:00.000Z" as never, + timeZone: "UTC" as never, + }, + recurrence: { kind: "preserve" }, + scope: "this", + }, + }), + ); + await waitFor(() => { + expect(context.hook.result.current.undoRedo.canUndo).toBe(true); + }); + + act(() => context.hook.result.current.undoRedo.undo()); + await waitFor(() => { + expect(context.hook.result.current.undoRedo.canRedo).toBe(true); + }); + + // Simulate what a real optimistic merge leaves behind: the same content + // and schedule the undo just restored, but with an explicit `color: null` + // the original snapshot never had. + const current = + context.queryClient.getQueryData(calendarKey) + ?.entities[original.id]; + context.queryClient.setQueryData( + calendarKey, + normalized({ + ...(current as Event), + content: { ...(current as Event).content, color: null } as never, + }), + ); + + act(() => context.hook.result.current.undoRedo.redo()); + + await waitFor(() => { + const schedule = + context.queryClient.getQueryData(calendarKey) + ?.entities[original.id].schedule; + expect(schedule?.kind === "timed" && schedule.start).toBe( + "2026-07-03T16:00:00.000Z", + ); + }); + expect(context.hook.result.current.undoRedo.canUndo).toBe(true); + }); + test("undoes a series create with a scope-all delete", async () => { const context = setup(); const created = event({ diff --git a/packages/web/src/events/mutations/useUndoRedo.ts b/packages/web/src/events/mutations/useUndoRedo.ts index 70c446fc3..dafb09d9a 100644 --- a/packages/web/src/events/mutations/useUndoRedo.ts +++ b/packages/web/src/events/mutations/useUndoRedo.ts @@ -56,16 +56,23 @@ const showUndoDeclinedToast = () => showStatusToast(UNDO_DECLINED_TOAST_ID, "Can't undo — event changed since"); // A snapshot recorded before this event was ever replayed may omit `location` -// entirely (an older local record, or any Event built without it) — replaying -// it always fills the key in (replaySnapshot below), since the editable- -// content contract requires a definite string. Comparing raw JSON.stringify -// output would then read "gained a location key" as an external change. -// Normalizing both sides the same way (via the same detailsLocation default -// replaySnapshot/undoDelete/redo use below) keeps the comparison about the -// actual value, not whether the key happened to be present. +// or `color` entirely (an older local record, a demo-seeded event built +// outside the normal create mutation, or any Event built without them) — +// replaying always fills `location` in (replaySnapshot below), since the +// editable-content contract requires a definite string, and the rest of the +// app treats a missing `color` the same as `color: null` (see +// grid-event-draft.adapter.ts's `color: event.content.color ?? null`). +// Comparing raw JSON.stringify output would then read "gained a location/ +// color key" as an external change. Normalizing both sides the same way +// keeps the comparison about the actual value, not whether the key happened +// to be present. const normalizedDetailsContent = (content: Event["content"]) => content.kind === "details" - ? { ...content, location: detailsLocation(content) } + ? { + ...content, + location: detailsLocation(content), + color: content.color ?? null, + } : content; // Whether `current` still matches the state an undo/redo entry expects to diff --git a/packages/web/src/events/queries/event.view-model.ts b/packages/web/src/events/queries/event.view-model.ts index 9678286f5..bd2fdd20a 100644 --- a/packages/web/src/events/queries/event.view-model.ts +++ b/packages/web/src/events/queries/event.view-model.ts @@ -27,7 +27,7 @@ export const BUSY_EVENT_TITLE = "Busy"; // crossAccountDuplicates -> otherAccount). type EventAnnotations = Pick< NormalizedEventQueryData, - "demoEventIds" | "crossAccountDuplicates" | "sandboxReadOnlyEventIds" + "demoEventIds" | "crossAccountDuplicates" >; type EventToGridEventOptions = EventAnnotations & { @@ -50,7 +50,6 @@ const eventToGridEvent = ( { demoEventIds, crossAccountDuplicates, - sandboxReadOnlyEventIds, scheduleOverride, }: EventToGridEventOptions = {}, ): GridEvent => { @@ -81,7 +80,6 @@ const eventToGridEvent = ( calendarId: event.calendarId, isBusy, isDemo: Boolean(demoEventIds?.includes(event.id)), - isSandboxReadOnly: Boolean(sandboxReadOnlyEventIds?.includes(event.id)), ...(crossAccountDuplicates?.has(event.id) ? { otherAccount: crossAccountDuplicates.get(event.id) } : {}), @@ -210,7 +208,6 @@ const computeCalendarEventViewModel = ( const annotations: EventAnnotations = { demoEventIds, crossAccountDuplicates: data?.crossAccountDuplicates, - sandboxReadOnlyEventIds: data?.sandboxReadOnlyEventIds, }; const timedEvents = timedEventsFrom(events, annotations); const allDayEvents = allDayEventsFrom(events, annotations); diff --git a/packages/web/src/events/queries/useDayEventsQuery.ts b/packages/web/src/events/queries/useDayEventsQuery.ts index 85032be65..6e607ddad 100644 --- a/packages/web/src/events/queries/useDayEventsQuery.ts +++ b/packages/web/src/events/queries/useDayEventsQuery.ts @@ -1,7 +1,4 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import dayjs from "@core/util/date/dayjs"; -import { mergeSandboxEventData } from "@web/components/OnboardingTour/onboarding.sandbox-events"; -import { useOnboardingSandboxEventData } from "@web/components/OnboardingTour/useOnboardingSandboxEvents"; import { deriveOverlappingEventQueryData } from "@web/events/queries/event.query.cache"; import { dayEventsQueryOptions } from "@web/events/queries/event.query.options"; import { useEventRepositorySource } from "@web/events/repositories/event.repository.source.store"; @@ -29,23 +26,8 @@ export function useDayEventsQuery({ startDate, endDate }: DayEventsQueryArgs) { return { ...query, calendarIds }; } -/** - * `includeSandboxEvents` defaults false: this hook also backs the sidebar's - * Up Next card (useUpNextEvent.ts), which queries "today" independent of - * whichever view the tour actually has open - opt in only from the day - * grid itself so a sandbox lesson's practice events never leak into a - * component the tour isn't pointing at. - */ -export function useDayEventViewModel( - args: DayEventsQueryArgs, - options: { includeSandboxEvents?: boolean } = {}, -) { +export function useDayEventViewModel(args: DayEventsQueryArgs) { const query = useDayEventsQuery(args); - const sandboxData = useOnboardingSandboxEventData( - options.includeSandboxEvents ? dayjs(args.startDate) : undefined, - ); - const viewModel = useCalendarEventViewModel( - mergeSandboxEventData(query.data, sandboxData), - ); + const viewModel = useCalendarEventViewModel(query.data); return { ...query, ...viewModel }; } diff --git a/packages/web/src/events/queries/useWeekEventsQuery.ts b/packages/web/src/events/queries/useWeekEventsQuery.ts index 41ef4fcec..ac2323fdc 100644 --- a/packages/web/src/events/queries/useWeekEventsQuery.ts +++ b/packages/web/src/events/queries/useWeekEventsQuery.ts @@ -1,8 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { type Dayjs } from "@core/util/date/dayjs"; import { toUTCOffset } from "@web/common/utils/datetime/web.date.util"; -import { mergeSandboxEventData } from "@web/components/OnboardingTour/onboarding.sandbox-events"; -import { useOnboardingSandboxEventData } from "@web/components/OnboardingTour/useOnboardingSandboxEvents"; import { deriveOverlappingEventQueryData } from "@web/events/queries/event.query.cache"; import { weekEventsQueryOptions } from "@web/events/queries/event.query.options"; import { useEventRepositorySource } from "@web/events/repositories/event.repository.source.store"; @@ -43,9 +41,6 @@ export function useWeekEventsQuery({ export function useWeekEventViewModel(args: WeekEventsQueryArgs) { const query = useWeekEventsQuery(args); - const sandboxData = useOnboardingSandboxEventData(args.startOfView); - const viewModel = useCalendarEventViewModel( - mergeSandboxEventData(query.data, sandboxData), - ); + const viewModel = useCalendarEventViewModel(query.data); return { ...query, ...viewModel }; } diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts index 06c7d34d1..52d64c681 100644 --- a/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts @@ -14,7 +14,7 @@ describe("getTipPlainText", () => { "Press E then T to jump to the title", ); expect(plainTextById.nudge).toBe( - "Hold Shift and press an arrow to nudge this event", + "Hold Shift and press an arrow to move this event", ); expect(plainTextById["target-event"]).toBe( "Tap Shift to jump to any visible event", diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts index 27b1e32fc..4869a120b 100644 --- a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts @@ -34,7 +34,7 @@ export function getShortcutTips(): ShortcutTip[] { parts: [ "Hold ", { key: "Shift" }, - " and press an arrow to nudge this event", + " and press an arrow to move this event", ], }, { diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx index a96b2281a..01ea5f697 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx @@ -88,9 +88,7 @@ export function DayCalendarGrid() { isPending, refetch, timedEvents, - } = useDayEventViewModel(dayEventQueryRange(dateInView), { - includeSandboxEvents: true, - }); + } = useDayEventViewModel(dayEventQueryRange(dateInView)); // Session expiry already surfaces SessionExpiredToast — don't also show // "Couldn't load events" / Retry for the same failure. const showEventsLoadError = shouldShowContextualLoadError(