Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public enum ProductEvent: String, Codable, Hashable, Sendable, CaseIterable {
case firstEventPromptCompleted = "first_event_prompt_completed"
case firstEventPromptDismissed = "first_event_prompt_dismissed"
case connectCalendarPromptDismissed = "connect_calendar_prompt_dismissed"
case connectPromptShown = "connect_prompt_shown"
case connectPromptClicked = "connect_prompt_clicked"
case connectPromptDismissed = "connect_prompt_dismissed"
case trialConverted = "trial_converted"
case trialCelebrationShown = "trial_celebration_shown"
case billingGateShown = "billing_gate_shown"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public enum ProductEventNativeCoverage: Sendable {
.firstEventPromptCompleted,
.firstEventPromptDismissed,
.connectCalendarPromptDismissed,
.connectPromptShown,
.connectPromptClicked,
.connectPromptDismissed,
.trialConverted,
.trialCelebrationShown,
.billingGateShown,
Expand Down
2 changes: 2 additions & 0 deletions apps/calendar-web/src/__tests__/utils/state/reset-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
initialFirstEventPromptState,
useFirstEventPromptStore,
} from "@web/components/FirstEventPrompt/first-event.store";
import { resetLocalModeConnectPromptStoreForTests } from "@web/components/LocalModeConnectPrompt/local-mode-connect.store";
import {
initialShortcutShowcaseState,
useShortcutShowcaseStore,
Expand Down Expand Up @@ -118,6 +119,7 @@ const storeResets: StoreReset[] = [
() => useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true),
() => useFirstEventPromptStore.setState(initialFirstEventPromptState, true),
resetConnectCalendarPromptStoreForTests,
resetLocalModeConnectPromptStoreForTests,
() =>
useWelcomeGuideStore.setState(useWelcomeGuideStore.getInitialState(), true),
() => useThemeStore.setState(useThemeStore.getInitialState(), true),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";

export function readAnonymousCalendarWriteCount(): number {
if (!persistentBrowserStore.isAvailable()) return 0;
const raw = persistentBrowserStore.get(
STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT,
);
const parsed = raw ? Number.parseInt(raw, 10) : 0;
return Number.isFinite(parsed) ? parsed : 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const GRID_CHAIN_MARKERS = [...GRID_ELEMENT_IDS, ...GRID_ELEMENT_ATTRS];
export const CALENDAR_GRID_DEAD_CLICK_IGNORE_SELECTORS = [
...GRID_ELEMENT_IDS.map((id) => `#${id}`),
...GRID_ELEMENT_ATTRS.map((attr) => `[${attr}]`),
"[data-local-mode-connect-prompt]",
];

/** True when autocapture element metadata points at the timed or all-day grid. */
Expand Down
1 change: 1 addition & 0 deletions apps/calendar-web/src/auth/posthog/signup-funnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type SignupSource =
| "welcome_modal"
| `welcome_modal_${ProviderKind}`
| "anon_nudge"
| "local_mode_connect_prompt"
| "command_palette"
| "connect_chooser"
| "shortcut_showcase"
Expand Down
3 changes: 3 additions & 0 deletions apps/calendar-web/src/auth/posthog/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export type ProductEvent =
| "first_event_prompt_completed"
| "first_event_prompt_dismissed"
| "connect_calendar_prompt_dismissed"
| "connect_prompt_shown"
| "connect_prompt_clicked"
| "connect_prompt_dismissed"
| "trial_converted"
| "trial_celebration_shown"
| "billing_gate_shown"
Expand Down
2 changes: 2 additions & 0 deletions apps/calendar-web/src/common/constants/storage.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const STORAGE_KEYS = {
"compass.onboarding.has-seen-anonymous-save-toast",
ANONYMOUS_CALENDAR_WRITE_COUNT:
"compass.onboarding.anonymous-calendar-write-count",
HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT:
"compass.onboarding.has-dismissed-local-mode-connect-prompt",
HAS_DISMISSED_DEMO_EVENTS_BANNER:
"compass.onboarding.has-dismissed-demo-events-banner",
HAS_DISMISSED_TASKS_REMOVAL_NOTICE:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function markAnonymousSaveToastSeen(): void {
);
}

function incrementAnonymousCalendarWriteCount(): number {
export function incrementAnonymousCalendarWriteCount(): number {
if (!persistentBrowserStore.isAvailable()) return 0;
const raw = persistentBrowserStore.get(
STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import "@testing-library/jest-dom";
import { HotkeysProvider } from "@tanstack/react-hotkeys";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { pressKey } from "@web/__tests__/utils/keyboard.test.util";
import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util";
import { SessionContext } from "@web/auth/compass/session/session.context";
import * as realSignupFunnel from "@web/auth/posthog/signup-funnel";
import * as realTrack from "@web/auth/posthog/track";
import * as realProviderAvailability from "@web/auth/providers/useIsProviderAvailable";
import * as realSignInProviders from "@web/auth/providers/useSignInProviders";
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
import { AuthModalContext } from "@web/components/AuthModal/hooks/useAuthModal";
import {
LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL,
LOCAL_MODE_CONNECT_PROMPT_MESSAGE,
LocalModeConnectPrompt,
} from "@web/components/LocalModeConnectPrompt/LocalModeConnectPrompt";
import { resetLocalModeConnectPromptStoreForTests } from "@web/components/LocalModeConnectPrompt/local-mode-connect.store";
import {
initialShortcutShowcaseState,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { beforeEach, describe, expect, it, mock } from "bun:test";

const mockStartSignIn = mock();
const trackSpy = mock();
const trackSignupStartedAtClickSpy = mock();

mockModuleForFile(
"@web/auth/providers/useSignInProviders",
realSignInProviders,
{
useSignInProviders: () => ({
available: ["google"],
startSignIn: mockStartSignIn,
isLoading: false,
loadingKind: null,
byKind: {},
}),
},
);

mockModuleForFile(
"@web/auth/providers/useIsProviderAvailable",
realProviderAvailability,
{
useIsProviderAvailable: () => true,
},
);

mockModuleForFile("@web/auth/posthog/track", realTrack, {
track: trackSpy,
});

mockModuleForFile("@web/auth/posthog/signup-funnel", realSignupFunnel, {
trackSignupStartedAtClick: trackSignupStartedAtClickSpy,
});

const renderPrompt = () =>
render(
<HotkeysProvider>
<SessionContext.Provider
value={{ authenticated: false, setAuthenticated: mock() }}
>
<AuthModalContext.Provider
value={{
isOpen: false,
currentView: "login",
openModal: mock(),
closeModal: mock(),
setView: mock(),
}}
>
<LocalModeConnectPrompt />
</AuthModalContext.Provider>
</SessionContext.Provider>
</HotkeysProvider>,
);

describe("LocalModeConnectPrompt", () => {
beforeEach(() => {
localStorage.clear();
mockStartSignIn.mockClear();
trackSpy.mockClear();
trackSignupStartedAtClickSpy.mockClear();
useShortcutShowcaseStore.setState(initialShortcutShowcaseState);
persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_WELCOME, "true");
persistentBrowserStore.set(
STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT,
"3",
);
resetLocalModeConnectPromptStoreForTests();
});

it("does not render before the write threshold", () => {
persistentBrowserStore.set(
STORAGE_KEYS.ANONYMOUS_CALENDAR_WRITE_COUNT,
"2",
);
resetLocalModeConnectPromptStoreForTests();
renderPrompt();
expect(
screen.queryByRole("complementary", { name: "Connect Google" }),
).toBeNull();
});

it("renders after three anonymous writes with connect copy", () => {
renderPrompt();
expect(
screen.getByRole("complementary", { name: "Connect Google" }),
).toBeInTheDocument();
expect(
screen.getByText(LOCAL_MODE_CONNECT_PROMPT_MESSAGE),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL,
}),
).toBeInTheDocument();
});

it("fires connect_prompt_shown once with event count", async () => {
renderPrompt();
await waitFor(() => {
expect(trackSpy).toHaveBeenCalledWith("connect_prompt_shown", {
events_created_count: 3,
provider: "google",
});
});
expect(
trackSpy.mock.calls.filter(([event]) => event === "connect_prompt_shown"),
).toHaveLength(1);
});

it("starts Google signup and tracks click", async () => {
const user = userEvent.setup();
renderPrompt();
await user.click(
screen.getByRole("button", {
name: LOCAL_MODE_CONNECT_PROMPT_CONNECT_LABEL,
}),
);

expect(trackSpy).toHaveBeenCalledWith("connect_prompt_clicked", {
events_created_count: 3,
provider: "google",
});
expect(trackSignupStartedAtClickSpy).toHaveBeenCalledWith(
"local_mode_connect_prompt",
);
expect(mockStartSignIn).toHaveBeenCalledWith("google");
});

it("dismisses on Escape and persists", async () => {
renderPrompt();
pressKey("Escape");

await waitFor(() => {
expect(
screen.queryByRole("complementary", { name: "Connect Google" }),
).toBeNull();
});
expect(trackSpy).toHaveBeenCalledWith("connect_prompt_dismissed", {
events_created_count: 3,
provider: "google",
});
expect(
persistentBrowserStore.get(
STORAGE_KEYS.HAS_DISMISSED_LOCAL_MODE_CONNECT_PROMPT,
),
).toBe("true");
});

it("connects on Enter when focus is not in a field", async () => {
renderPrompt();
pressKey("Enter");
expect(mockStartSignIn).toHaveBeenCalledWith("google");
});
});
Loading
Loading