feat(FN-1998): instrument onboarding funnel analytics events
- Add onboarding event tracking utility with localStorage persistence, session IDs, and ring-buffer pruning - Emit funnel events from ModelOnboardingModal for step progress, dismissal/completion, and first-task/CTA actions - Track resume-card and auto-trigger onboarding entry points in OnboardingResumeCard and useAuthOnboarding - Expand dashboard tests and mocks to validate onboarding event storage behavior and new instrumentation calls
This commit is contained in:
@@ -411,6 +411,7 @@ import {
|
||||
getStepData,
|
||||
type OnboardingStep,
|
||||
} from "./model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "./onboarding-events";
|
||||
import type { SectionId } from "./SettingsModal";
|
||||
|
||||
export interface ModelOnboardingModalProps {
|
||||
@@ -492,6 +493,9 @@ export function ModelOnboardingModal({
|
||||
});
|
||||
const pollCountRef = useRef<number>(0);
|
||||
const previousCreatedTaskRef = useRef<Task | null | undefined>(firstCreatedTask);
|
||||
const hasTrackedWizardOpenRef = useRef(false);
|
||||
const resumedFromStep = persistedState?.currentStep;
|
||||
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
|
||||
|
||||
// Initialize skippedProviders from persisted state
|
||||
const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>(
|
||||
@@ -519,6 +523,18 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
}, [step, completedSteps, skippedSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTrackedWizardOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTrackedWizardOpenRef.current = true;
|
||||
trackOnboardingEvent("onboarding:wizard-opened", {
|
||||
source: isResumedFlow ? "resume" : "initial",
|
||||
resumedFromStep,
|
||||
});
|
||||
}, [isResumedFlow, resumedFromStep]);
|
||||
|
||||
useEffect(() => {
|
||||
const hadCreatedTask = previousCreatedTaskRef.current != null;
|
||||
const hasCreatedTask = firstCreatedTask != null;
|
||||
@@ -775,6 +791,7 @@ export function ModelOnboardingModal({
|
||||
setCompletedSteps((prev) => [...new Set([...prev, step])]);
|
||||
// Completing a step clears any prior skipped status
|
||||
setSkippedSteps((prev) => prev.filter((s) => s !== step));
|
||||
trackOnboardingEvent("onboarding:step-completed", { step });
|
||||
|
||||
if (step === "github" && !isGithubAuthenticated) {
|
||||
setGitHubSkippedState(false);
|
||||
@@ -791,6 +808,7 @@ export function ModelOnboardingModal({
|
||||
const handleSkip = useCallback(() => {
|
||||
setSkippedSteps((prev) => [...new Set([...prev, step])]);
|
||||
markStepSkipped(step);
|
||||
trackOnboardingEvent("onboarding:step-skipped", { step });
|
||||
|
||||
if (step === "github" && !isGithubAuthenticated) {
|
||||
setGitHubSkippedState(true);
|
||||
@@ -1126,11 +1144,12 @@ export function ModelOnboardingModal({
|
||||
setSaving(true);
|
||||
try {
|
||||
await completeOnboarding();
|
||||
trackOnboardingEvent("onboarding:completed", { completedSteps, skippedSteps });
|
||||
setStep("complete");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [completeOnboarding]);
|
||||
}, [completeOnboarding, completedSteps, skippedSteps]);
|
||||
|
||||
const handleCreateFirstTask = useCallback(async () => {
|
||||
const trimmedDescription = firstTaskDescription.trim();
|
||||
@@ -1148,6 +1167,7 @@ export function ModelOnboardingModal({
|
||||
const createdTask = await createTask({ description: trimmedDescription });
|
||||
setInlineCreatedTask(createdTask);
|
||||
setShowTaskCreated(true);
|
||||
trackOnboardingEvent("onboarding:first-task-created", { taskId: createdTask?.id });
|
||||
addToast("Task created", "success");
|
||||
success = true;
|
||||
} catch (err: unknown) {
|
||||
@@ -1177,6 +1197,7 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
|
||||
// Keep onboarding open so task creation can hand back to a success state
|
||||
trackOnboardingEvent("onboarding:open-new-task", {});
|
||||
onOpenNewTask?.();
|
||||
}, [completeOnboarding, onOpenNewTask]);
|
||||
|
||||
@@ -1193,11 +1214,18 @@ export function ModelOnboardingModal({
|
||||
// Close modal and trigger callback
|
||||
setIsOpen(false);
|
||||
onComplete();
|
||||
trackOnboardingEvent("onboarding:open-github-import", {});
|
||||
onOpenGitHubImport?.();
|
||||
}, [completeOnboarding, onComplete, onOpenGitHubImport]);
|
||||
|
||||
// Dismiss without completing (still marks onboarding complete)
|
||||
const handleDismiss = useCallback(async () => {
|
||||
trackOnboardingEvent("onboarding:dismissed", {
|
||||
currentStep: step,
|
||||
completedSteps,
|
||||
skippedSteps,
|
||||
});
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateGlobalSettings({ modelOnboardingComplete: true });
|
||||
@@ -1206,10 +1234,11 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
setIsOpen(false);
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
}, [step, completedSteps, skippedSteps, onComplete]);
|
||||
|
||||
// Close from the completion step
|
||||
const handleFinish = useCallback(() => {
|
||||
trackOnboardingEvent("onboarding:finished", {});
|
||||
setIsOpen(false);
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Play, Sparkles } from "lucide-react";
|
||||
import { getOnboardingResumeStep } from "./model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "./onboarding-events";
|
||||
|
||||
interface OnboardingResumeCardProps {
|
||||
/** Called when the user clicks "Continue onboarding" */
|
||||
@@ -45,7 +46,13 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
<div className="onboarding-resume-card__actions">
|
||||
<button
|
||||
className="onboarding-resume-card__resume-btn"
|
||||
onClick={onResume}
|
||||
onClick={() => {
|
||||
trackOnboardingEvent("onboarding:resumed", {
|
||||
source: "resume-card",
|
||||
resumedFromStep: resumeStep.currentStep,
|
||||
});
|
||||
onResume();
|
||||
}}
|
||||
>
|
||||
<Play size={14} aria-hidden="true" />
|
||||
<span>Continue onboarding</span>
|
||||
|
||||
@@ -61,6 +61,13 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
|
||||
vi.mock("../onboarding-events", () => ({
|
||||
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
|
||||
getOnboardingSessionId: () => "test-session-id",
|
||||
}));
|
||||
|
||||
// Mock ProviderIcon for test isolation
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
|
||||
@@ -124,6 +131,7 @@ async function navigateToFirstTaskStep() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTrackOnboardingEvent.mockReset();
|
||||
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
|
||||
mockFetchGlobalSettings.mockResolvedValue({});
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
|
||||
@@ -7,6 +7,13 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
getOnboardingResumeStep: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
|
||||
vi.mock("../onboarding-events", () => ({
|
||||
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
|
||||
getOnboardingSessionId: () => "test-session-id",
|
||||
}));
|
||||
|
||||
import { getOnboardingResumeStep } from "../model-onboarding-state";
|
||||
|
||||
describe("OnboardingResumeCard", () => {
|
||||
@@ -14,11 +21,13 @@ describe("OnboardingResumeCard", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetOnboardingResumeStep.mockReset();
|
||||
mockTrackOnboardingEvent.mockReset();
|
||||
mockGetOnboardingResumeStep.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetOnboardingResumeStep.mockReset();
|
||||
mockTrackOnboardingEvent.mockReset();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
@@ -93,6 +102,10 @@ describe("OnboardingResumeCard", () => {
|
||||
const button = screen.getByRole("button", { name: "Continue onboarding" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith(
|
||||
"onboarding:resumed",
|
||||
expect.objectContaining({ source: "resume-card" }),
|
||||
);
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__test_clearSessionId,
|
||||
clearOnboardingEvents,
|
||||
getOnboardingEvents,
|
||||
getOnboardingSessionId,
|
||||
trackOnboardingEvent,
|
||||
} from "../onboarding-events";
|
||||
|
||||
const EVENTS_STORAGE_KEY = "fusion_onboarding_events";
|
||||
const SESSION_STORAGE_KEY = "fusion_onboarding_session_id";
|
||||
|
||||
describe("onboarding-events", () => {
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(EVENTS_STORAGE_KEY);
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
__test_clearSessionId();
|
||||
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleInfoSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
localStorage.removeItem(EVENTS_STORAGE_KEY);
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
__test_clearSessionId();
|
||||
});
|
||||
|
||||
it("stores tracked events in localStorage", () => {
|
||||
const setItemSpy = vi.spyOn(window.localStorage, "setItem");
|
||||
|
||||
trackOnboardingEvent("onboarding:wizard-opened", { source: "initial" });
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(EVENTS_STORAGE_KEY, expect.any(String));
|
||||
|
||||
const raw = localStorage.getItem(EVENTS_STORAGE_KEY);
|
||||
expect(raw).toBeTruthy();
|
||||
|
||||
const events = JSON.parse(raw ?? "[]") as Array<Record<string, unknown>>;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "onboarding:wizard-opened",
|
||||
metadata: { source: "initial" },
|
||||
});
|
||||
expect(typeof events[0]?.timestamp).toBe("string");
|
||||
});
|
||||
|
||||
it("accumulates events in insertion order", () => {
|
||||
trackOnboardingEvent("onboarding:step-completed", { step: "ai-setup" });
|
||||
trackOnboardingEvent("onboarding:step-completed", { step: "github" });
|
||||
trackOnboardingEvent("onboarding:step-completed", { step: "first-task" });
|
||||
|
||||
const events = getOnboardingEvents();
|
||||
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events[0]?.metadata.step).toBe("ai-setup");
|
||||
expect(events[1]?.metadata.step).toBe("github");
|
||||
expect(events[2]?.metadata.step).toBe("first-task");
|
||||
});
|
||||
|
||||
it("prunes ring buffer by 50 when event count exceeds 200", () => {
|
||||
for (let index = 1; index <= 201; index += 1) {
|
||||
trackOnboardingEvent("onboarding:step-completed", { step: "ai-setup", sequence: index });
|
||||
}
|
||||
|
||||
const events = getOnboardingEvents();
|
||||
|
||||
expect(events).toHaveLength(151);
|
||||
expect(events[0]?.metadata.sequence).toBe(51);
|
||||
expect(events.at(-1)?.metadata.sequence).toBe(201);
|
||||
});
|
||||
|
||||
it("clears stored events", () => {
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
trackOnboardingEvent("onboarding:finished");
|
||||
expect(getOnboardingEvents()).toHaveLength(1);
|
||||
|
||||
clearOnboardingEvents();
|
||||
|
||||
expect(getOnboardingEvents()).toEqual([]);
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(EVENTS_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("returns a stable session id within one browser session", () => {
|
||||
const randomUuidSpy = vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("123e4567-e89b-12d3-a456-426614174000");
|
||||
|
||||
const first = getOnboardingSessionId();
|
||||
const second = getOnboardingSessionId();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(randomUuidSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("regenerates session id across storage resets", () => {
|
||||
const randomUuidSpy = vi
|
||||
.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174000")
|
||||
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174001");
|
||||
|
||||
const first = getOnboardingSessionId();
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
__test_clearSessionId();
|
||||
const second = getOnboardingSessionId();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(randomUuidSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is SSR-safe when window is unavailable", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
expect(getOnboardingEvents()).toEqual([]);
|
||||
expect(() => trackOnboardingEvent("onboarding:finished")).not.toThrow();
|
||||
});
|
||||
|
||||
it("stores events with the expected structure", () => {
|
||||
trackOnboardingEvent("onboarding:step-completed", { step: "github", source: "test" });
|
||||
|
||||
const [event] = getOnboardingEvents();
|
||||
expect(event).toBeDefined();
|
||||
expect(event).toMatchObject({
|
||||
type: "onboarding:step-completed",
|
||||
metadata: { step: "github", source: "test" },
|
||||
});
|
||||
expect(typeof event?.sessionId).toBe("string");
|
||||
expect(event?.sessionId.length).toBeGreaterThan(0);
|
||||
expect(Date.parse(event?.timestamp ?? "")).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("defaults metadata to an empty object", () => {
|
||||
trackOnboardingEvent("onboarding:finished");
|
||||
|
||||
const [event] = getOnboardingEvents();
|
||||
expect(event?.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("handles storage quota errors without throwing", () => {
|
||||
const setItemSpy = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
|
||||
expect(() => trackOnboardingEvent("onboarding:finished")).not.toThrow();
|
||||
expect(setItemSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs events in DEV mode", () => {
|
||||
trackOnboardingEvent("onboarding:finished");
|
||||
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith("[fusion:onboarding]", expect.any(String));
|
||||
});
|
||||
|
||||
it("resets session id via __test_clearSessionId", () => {
|
||||
const randomUuidSpy = vi
|
||||
.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174000")
|
||||
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174001");
|
||||
|
||||
const first = getOnboardingSessionId();
|
||||
__test_clearSessionId();
|
||||
const second = getOnboardingSessionId();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(randomUuidSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
230
packages/dashboard/app/components/onboarding-events.ts
Normal file
230
packages/dashboard/app/components/onboarding-events.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { OnboardingStep } from "./model-onboarding-state";
|
||||
|
||||
/** LocalStorage key used for persisted onboarding funnel events. */
|
||||
const ONBOARDING_EVENTS_STORAGE_KEY = "fusion_onboarding_events";
|
||||
/** SessionStorage key used for tab-scoped onboarding session IDs. */
|
||||
const ONBOARDING_SESSION_ID_STORAGE_KEY = "fusion_onboarding_session_id";
|
||||
/** Maximum number of events kept in localStorage before pruning. */
|
||||
const ONBOARDING_EVENTS_MAX = 200;
|
||||
/** Number of oldest events removed per prune operation. */
|
||||
const ONBOARDING_EVENTS_PRUNE_BATCH = 50;
|
||||
|
||||
/**
|
||||
* Known onboarding funnel event names.
|
||||
*/
|
||||
export type OnboardingEventType =
|
||||
| "onboarding:wizard-opened"
|
||||
| "onboarding:step-completed"
|
||||
| "onboarding:step-skipped"
|
||||
| "onboarding:dismissed"
|
||||
| "onboarding:completed"
|
||||
| "onboarding:finished"
|
||||
| "onboarding:first-task-created"
|
||||
| "onboarding:open-new-task"
|
||||
| "onboarding:open-github-import"
|
||||
| "onboarding:resumed"
|
||||
| "onboarding:auto-triggered";
|
||||
|
||||
/**
|
||||
* Source metadata for wizard-opened events.
|
||||
*
|
||||
* Includes legacy values (`"resume"`, `"initial"`) to preserve compatibility
|
||||
* with existing instrumentation call sites while funnel analytics migrate to
|
||||
* the canonical source labels.
|
||||
*/
|
||||
export type OnboardingWizardOpenedSource =
|
||||
| "auto-trigger"
|
||||
| "resume-card"
|
||||
| "settings"
|
||||
| "unknown"
|
||||
| "resume"
|
||||
| "initial";
|
||||
|
||||
/**
|
||||
* Structured onboarding analytics event persisted in localStorage.
|
||||
*/
|
||||
export interface OnboardingEvent {
|
||||
/** Event name (for example: "onboarding:step-completed"). */
|
||||
type: string;
|
||||
/** Event timestamp in ISO-8601 format. */
|
||||
timestamp: string;
|
||||
/** Optional duplicated step index for convenience querying. */
|
||||
step?: OnboardingStep;
|
||||
/** Event-specific metadata payload. */
|
||||
metadata: Record<string, unknown>;
|
||||
/** Session-scoped identifier persisted in sessionStorage. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an onboarding funnel event, persists it to localStorage (ring buffer),
|
||||
* and logs it to console.info in development mode.
|
||||
*
|
||||
* Event names expected by this module:
|
||||
* - `onboarding:wizard-opened` → `{ source, resumedFromStep? }`
|
||||
* - `onboarding:step-completed` → `{ step }`
|
||||
* - `onboarding:step-skipped` → `{ step }`
|
||||
* - `onboarding:dismissed` → `{ currentStep, completedSteps, skippedSteps }`
|
||||
* - `onboarding:completed` → `{ completedSteps, skippedSteps }`
|
||||
* - `onboarding:finished` → `{}`
|
||||
* - `onboarding:first-task-created` → `{ taskId? }`
|
||||
* - `onboarding:open-new-task` → `{}`
|
||||
* - `onboarding:open-github-import` → `{}`
|
||||
* - `onboarding:resumed` → `{ source, resumedFromStep? }`
|
||||
* - `onboarding:auto-triggered` → `{ trigger }`
|
||||
*/
|
||||
export function trackOnboardingEvent(type: string, metadata: Record<string, unknown> = {}): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const events = readOnboardingEventsFromStorage();
|
||||
const step = extractStep(metadata.step);
|
||||
const event: OnboardingEvent = {
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(step ? { step } : {}),
|
||||
metadata,
|
||||
sessionId: getOnboardingSessionId(),
|
||||
};
|
||||
|
||||
const nextEvents = pruneOnboardingEvents([...events, event]);
|
||||
writeOnboardingEventsToStorage(nextEvents);
|
||||
|
||||
if (import.meta.env?.DEV) {
|
||||
console.info("[fusion:onboarding]", JSON.stringify(event));
|
||||
}
|
||||
} catch {
|
||||
// Quota/malformed-storage/runtime errors should never break onboarding UX.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all persisted onboarding events for the current browser profile.
|
||||
* Returns an empty array in SSR/node contexts.
|
||||
*/
|
||||
export function getOnboardingEvents(): OnboardingEvent[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
return readOnboardingEventsFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all persisted onboarding events.
|
||||
*/
|
||||
export function clearOnboardingEvents(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(ONBOARDING_EVENTS_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the onboarding session ID for this browser tab.
|
||||
*
|
||||
* The session ID is persisted in sessionStorage, so it survives navigation
|
||||
* reloads but resets across separate tabs/sessions.
|
||||
*/
|
||||
export function getOnboardingSessionId(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "server-session";
|
||||
}
|
||||
|
||||
try {
|
||||
const existingId = sessionStorage.getItem(ONBOARDING_SESSION_ID_STORAGE_KEY);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const sessionId = createSessionId();
|
||||
sessionStorage.setItem(ONBOARDING_SESSION_ID_STORAGE_KEY, sessionId);
|
||||
return sessionId;
|
||||
} catch {
|
||||
return createSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper to reset the cached onboarding session id in sessionStorage.
|
||||
*/
|
||||
export function __test_clearSessionId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(ONBOARDING_SESSION_ID_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `onboarding-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
function readOnboardingEventsFromStorage(): OnboardingEvent[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ONBOARDING_EVENTS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter(isOnboardingEvent);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeOnboardingEventsToStorage(events: OnboardingEvent[]): void {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_EVENTS_STORAGE_KEY, JSON.stringify(events));
|
||||
} catch {
|
||||
// Ignore storage write errors (quota/private mode/etc).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ring-buffer bounds, pruning oldest events in batches for fewer writes.
|
||||
*/
|
||||
function pruneOnboardingEvents(events: OnboardingEvent[]): OnboardingEvent[] {
|
||||
const nextEvents = [...events];
|
||||
|
||||
while (nextEvents.length > ONBOARDING_EVENTS_MAX) {
|
||||
nextEvents.splice(0, ONBOARDING_EVENTS_PRUNE_BATCH);
|
||||
}
|
||||
|
||||
return nextEvents;
|
||||
}
|
||||
|
||||
function extractStep(value: unknown): OnboardingStep | undefined {
|
||||
if (
|
||||
value === "ai-setup"
|
||||
|| value === "github"
|
||||
|| value === "first-task"
|
||||
|| value === "complete"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOnboardingEvent(value: unknown): value is OnboardingEvent {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<OnboardingEvent>;
|
||||
return (
|
||||
typeof candidate.type === "string"
|
||||
&& typeof candidate.timestamp === "string"
|
||||
&& typeof candidate.sessionId === "string"
|
||||
&& !!candidate.metadata
|
||||
&& typeof candidate.metadata === "object"
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as api from "../../api";
|
||||
|
||||
// Mock model-onboarding-state for isOnboardingCompleted
|
||||
const mockIsOnboardingCompleted = vi.fn();
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(),
|
||||
@@ -15,6 +16,11 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/onboarding-events", () => ({
|
||||
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
|
||||
getOnboardingSessionId: () => "test-session-id",
|
||||
}));
|
||||
|
||||
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||
const mockFetchGlobalSettings = vi.mocked(api.fetchGlobalSettings);
|
||||
|
||||
@@ -24,6 +30,7 @@ describe("useAuthOnboarding", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTrackOnboardingEvent.mockReset();
|
||||
});
|
||||
|
||||
// --- Trigger branches ---
|
||||
@@ -50,6 +57,7 @@ describe("useAuthOnboarding", () => {
|
||||
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "first-run" });
|
||||
expect(openSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -101,6 +109,7 @@ describe("useAuthOnboarding", () => {
|
||||
expect(openSettings).toHaveBeenCalledWith("authentication");
|
||||
});
|
||||
|
||||
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "missing-provider" });
|
||||
expect(openModelOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
|
||||
import { isOnboardingCompleted } from "../components/model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "../components/onboarding-events";
|
||||
import type { SectionId } from "../components/SettingsModal";
|
||||
|
||||
export interface UseAuthOnboardingOptions {
|
||||
@@ -70,8 +71,10 @@ export function useAuthOnboarding({
|
||||
.then(() => {
|
||||
// Execute after the promise chain resolves
|
||||
if (shouldOpenOnboarding) {
|
||||
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "first-run" });
|
||||
openModelOnboarding();
|
||||
} else if (shouldOpenSettings) {
|
||||
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "missing-provider" });
|
||||
openSettings("authentication");
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user