mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Add local-only analytics hooks for wizard and tour completion tracking
Add comprehensive onboarding analytics that stores all data locally (no external telemetry) to track wizard and tour usage patterns: - New OnboardingStats interface with 19 metrics covering: - Wizard stats: start/completion/abandon/resume counts, duration tracking - Tour stats: start/completion/skip counts, steps viewed tracking - Conversation and phase generation metrics - 8 tracking functions in useSettings.ts: - recordWizardStart/Resume/Abandon/Complete - recordTourStart/Complete/Skip - getOnboardingAnalytics for computed completion rates - Integration with MaestroWizard and TourOverlay components - 24 new tests verifying all analytics functions All analytics are stored locally only - respects user privacy.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSettings } from '../../../renderer/hooks/useSettings';
|
||||
import type { GlobalStats, AutoRunStats, CustomAICommand } from '../../../renderer/types';
|
||||
import type { GlobalStats, AutoRunStats, OnboardingStats, CustomAICommand } from '../../../renderer/types';
|
||||
import { DEFAULT_SHORTCUTS } from '../../../renderer/constants/shortcuts';
|
||||
|
||||
// Helper to wait for settings to load
|
||||
@@ -1329,4 +1329,299 @@ describe('useSettings', () => {
|
||||
expect(result.current.getUnacknowledgedBadgeLevel()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onboarding stats', () => {
|
||||
it('should have default onboarding stats (all zeros)', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
expect(result.current.onboardingStats).toEqual({
|
||||
wizardStartCount: 0,
|
||||
wizardCompletionCount: 0,
|
||||
wizardAbandonCount: 0,
|
||||
wizardResumeCount: 0,
|
||||
averageWizardDurationMs: 0,
|
||||
totalWizardDurationMs: 0,
|
||||
lastWizardCompletedAt: 0,
|
||||
tourStartCount: 0,
|
||||
tourCompletionCount: 0,
|
||||
tourSkipCount: 0,
|
||||
tourStepsViewedTotal: 0,
|
||||
averageTourStepsViewed: 0,
|
||||
totalConversationExchanges: 0,
|
||||
averageConversationExchanges: 0,
|
||||
totalConversationsCompleted: 0,
|
||||
totalPhasesGenerated: 0,
|
||||
averagePhasesPerWizard: 0,
|
||||
totalTasksGenerated: 0,
|
||||
averageTasksPerPhase: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load saved onboarding stats', async () => {
|
||||
const savedStats: Partial<OnboardingStats> = {
|
||||
wizardStartCount: 5,
|
||||
wizardCompletionCount: 3,
|
||||
tourStartCount: 4,
|
||||
tourCompletionCount: 2,
|
||||
};
|
||||
|
||||
vi.mocked(window.maestro.settings.get).mockImplementation(async (key: string) => {
|
||||
if (key === 'onboardingStats') return savedStats;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
expect(result.current.onboardingStats.wizardStartCount).toBe(5);
|
||||
expect(result.current.onboardingStats.wizardCompletionCount).toBe(3);
|
||||
expect(result.current.onboardingStats.tourStartCount).toBe(4);
|
||||
expect(result.current.onboardingStats.tourCompletionCount).toBe(2);
|
||||
// Other fields should have default values
|
||||
expect(result.current.onboardingStats.wizardAbandonCount).toBe(0);
|
||||
});
|
||||
|
||||
describe('recordWizardStart', () => {
|
||||
it('should increment wizard start count', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordWizardStart();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardStartCount).toBe(1);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('onboardingStats', expect.objectContaining({
|
||||
wizardStartCount: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should increment from existing count', async () => {
|
||||
vi.mocked(window.maestro.settings.get).mockImplementation(async (key: string) => {
|
||||
if (key === 'onboardingStats') return { wizardStartCount: 5 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordWizardStart();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardStartCount).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordWizardComplete', () => {
|
||||
it('should update wizard completion stats correctly', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
const durationMs = 300000; // 5 minutes
|
||||
const conversationExchanges = 10;
|
||||
const phasesGenerated = 3;
|
||||
const tasksGenerated = 15;
|
||||
|
||||
act(() => {
|
||||
result.current.recordWizardComplete(durationMs, conversationExchanges, phasesGenerated, tasksGenerated);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardCompletionCount).toBe(1);
|
||||
expect(result.current.onboardingStats.totalWizardDurationMs).toBe(300000);
|
||||
expect(result.current.onboardingStats.averageWizardDurationMs).toBe(300000);
|
||||
expect(result.current.onboardingStats.totalConversationExchanges).toBe(10);
|
||||
expect(result.current.onboardingStats.averageConversationExchanges).toBe(10);
|
||||
expect(result.current.onboardingStats.totalConversationsCompleted).toBe(1);
|
||||
expect(result.current.onboardingStats.totalPhasesGenerated).toBe(3);
|
||||
expect(result.current.onboardingStats.averagePhasesPerWizard).toBe(3);
|
||||
expect(result.current.onboardingStats.totalTasksGenerated).toBe(15);
|
||||
expect(result.current.onboardingStats.averageTasksPerPhase).toBe(5); // 15 / 3
|
||||
expect(result.current.onboardingStats.lastWizardCompletedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should calculate averages correctly over multiple completions', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
// First completion
|
||||
act(() => {
|
||||
result.current.recordWizardComplete(300000, 10, 3, 15);
|
||||
});
|
||||
|
||||
// Second completion
|
||||
act(() => {
|
||||
result.current.recordWizardComplete(600000, 20, 5, 25);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardCompletionCount).toBe(2);
|
||||
expect(result.current.onboardingStats.totalWizardDurationMs).toBe(900000); // 300000 + 600000
|
||||
expect(result.current.onboardingStats.averageWizardDurationMs).toBe(450000); // 900000 / 2
|
||||
expect(result.current.onboardingStats.totalConversationExchanges).toBe(30); // 10 + 20
|
||||
expect(result.current.onboardingStats.averageConversationExchanges).toBe(15); // 30 / 2
|
||||
expect(result.current.onboardingStats.totalPhasesGenerated).toBe(8); // 3 + 5
|
||||
expect(result.current.onboardingStats.averagePhasesPerWizard).toBe(4); // 8 / 2
|
||||
expect(result.current.onboardingStats.totalTasksGenerated).toBe(40); // 15 + 25
|
||||
expect(result.current.onboardingStats.averageTasksPerPhase).toBe(5); // 40 / 8
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordWizardAbandon', () => {
|
||||
it('should increment wizard abandon count', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordWizardAbandon();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardAbandonCount).toBe(1);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('onboardingStats', expect.objectContaining({
|
||||
wizardAbandonCount: 1,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordWizardResume', () => {
|
||||
it('should increment wizard resume count', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordWizardResume();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.wizardResumeCount).toBe(1);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('onboardingStats', expect.objectContaining({
|
||||
wizardResumeCount: 1,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTourStart', () => {
|
||||
it('should increment tour start count', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordTourStart();
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.tourStartCount).toBe(1);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('onboardingStats', expect.objectContaining({
|
||||
tourStartCount: 1,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTourComplete', () => {
|
||||
it('should update tour completion stats correctly', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordTourComplete(8);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.tourCompletionCount).toBe(1);
|
||||
expect(result.current.onboardingStats.tourStepsViewedTotal).toBe(8);
|
||||
expect(result.current.onboardingStats.averageTourStepsViewed).toBe(8);
|
||||
});
|
||||
|
||||
it('should calculate average steps over multiple tours', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
// First completion
|
||||
act(() => {
|
||||
result.current.recordTourComplete(8);
|
||||
});
|
||||
|
||||
// Second completion
|
||||
act(() => {
|
||||
result.current.recordTourComplete(10);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.tourCompletionCount).toBe(2);
|
||||
expect(result.current.onboardingStats.tourStepsViewedTotal).toBe(18); // 8 + 10
|
||||
expect(result.current.onboardingStats.averageTourStepsViewed).toBe(9); // 18 / 2
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTourSkip', () => {
|
||||
it('should update tour skip stats correctly', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.recordTourSkip(3);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.tourSkipCount).toBe(1);
|
||||
expect(result.current.onboardingStats.tourStepsViewedTotal).toBe(3);
|
||||
expect(result.current.onboardingStats.averageTourStepsViewed).toBe(3);
|
||||
});
|
||||
|
||||
it('should include skipped tours in average calculation', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
// Complete tour with 8 steps
|
||||
act(() => {
|
||||
result.current.recordTourComplete(8);
|
||||
});
|
||||
|
||||
// Skip tour after 2 steps
|
||||
act(() => {
|
||||
result.current.recordTourSkip(2);
|
||||
});
|
||||
|
||||
expect(result.current.onboardingStats.tourCompletionCount).toBe(1);
|
||||
expect(result.current.onboardingStats.tourSkipCount).toBe(1);
|
||||
expect(result.current.onboardingStats.tourStepsViewedTotal).toBe(10); // 8 + 2
|
||||
expect(result.current.onboardingStats.averageTourStepsViewed).toBe(5); // 10 / 2 tours
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOnboardingAnalytics', () => {
|
||||
it('should return 0 rates when no wizard or tour attempts', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
const analytics = result.current.getOnboardingAnalytics();
|
||||
|
||||
expect(analytics.wizardCompletionRate).toBe(0);
|
||||
expect(analytics.tourCompletionRate).toBe(0);
|
||||
expect(analytics.averageConversationExchanges).toBe(0);
|
||||
expect(analytics.averagePhasesPerWizard).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate correct completion rates', async () => {
|
||||
vi.mocked(window.maestro.settings.get).mockImplementation(async (key: string) => {
|
||||
if (key === 'onboardingStats') {
|
||||
return {
|
||||
wizardStartCount: 10,
|
||||
wizardCompletionCount: 7,
|
||||
tourStartCount: 8,
|
||||
tourCompletionCount: 6,
|
||||
averageConversationExchanges: 12.5,
|
||||
averagePhasesPerWizard: 3.2,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
const analytics = result.current.getOnboardingAnalytics();
|
||||
|
||||
expect(analytics.wizardCompletionRate).toBe(70); // 7/10 * 100
|
||||
expect(analytics.tourCompletionRate).toBe(75); // 6/8 * 100
|
||||
expect(analytics.averageConversationExchanges).toBe(12.5);
|
||||
expect(analytics.averagePhasesPerWizard).toBe(3.2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,8 @@ export default function MaestroConsole() {
|
||||
autoRunStats, recordAutoRunComplete, updateAutoRunProgress, acknowledgeBadge, getUnacknowledgedBadgeLevel,
|
||||
tourCompleted, setTourCompleted,
|
||||
firstAutoRunCompleted, setFirstAutoRunCompleted,
|
||||
recordWizardStart, recordWizardComplete, recordWizardAbandon, recordWizardResume,
|
||||
recordTourStart, recordTourComplete, recordTourSkip,
|
||||
} = settings;
|
||||
|
||||
// --- STATE ---
|
||||
@@ -7286,7 +7288,13 @@ export default function MaestroConsole() {
|
||||
)}
|
||||
|
||||
{/* --- MAESTRO WIZARD (onboarding wizard for new users) --- */}
|
||||
<MaestroWizard theme={theme} />
|
||||
<MaestroWizard
|
||||
theme={theme}
|
||||
onWizardStart={recordWizardStart}
|
||||
onWizardResume={recordWizardResume}
|
||||
onWizardAbandon={recordWizardAbandon}
|
||||
onWizardComplete={recordWizardComplete}
|
||||
/>
|
||||
|
||||
{/* --- TOUR OVERLAY (onboarding tour for interface guidance) --- */}
|
||||
<TourOverlay
|
||||
@@ -7296,6 +7304,9 @@ export default function MaestroConsole() {
|
||||
setTourOpen(false);
|
||||
setTourCompleted(true);
|
||||
}}
|
||||
onTourStart={recordTourStart}
|
||||
onTourComplete={recordTourComplete}
|
||||
onTourSkip={recordTourSkip}
|
||||
/>
|
||||
|
||||
{/* --- FLASH NOTIFICATION (centered, auto-dismiss) --- */}
|
||||
|
||||
@@ -32,6 +32,19 @@ interface MaestroWizardProps {
|
||||
theme: Theme;
|
||||
/** Callback to create session and launch Auto Run when wizard completes */
|
||||
onLaunchSession?: (wantsTour: boolean) => Promise<void>;
|
||||
/** Analytics callback: Called when wizard is started fresh */
|
||||
onWizardStart?: () => void;
|
||||
/** Analytics callback: Called when wizard is resumed from saved state */
|
||||
onWizardResume?: () => void;
|
||||
/** Analytics callback: Called when wizard is abandoned before completion */
|
||||
onWizardAbandon?: () => void;
|
||||
/** Analytics callback: Called when wizard completes successfully */
|
||||
onWizardComplete?: (
|
||||
durationMs: number,
|
||||
conversationExchanges: number,
|
||||
phasesGenerated: number,
|
||||
tasksGenerated: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +72,14 @@ function getStepTitle(step: WizardStep): string {
|
||||
* the current step from WizardContext. Integrates with LayerStack for
|
||||
* proper modal behavior including Escape key handling.
|
||||
*/
|
||||
export function MaestroWizard({ theme, onLaunchSession }: MaestroWizardProps): JSX.Element | null {
|
||||
export function MaestroWizard({
|
||||
theme,
|
||||
onLaunchSession,
|
||||
onWizardStart,
|
||||
onWizardResume,
|
||||
onWizardAbandon,
|
||||
onWizardComplete,
|
||||
}: MaestroWizardProps): JSX.Element | null {
|
||||
const {
|
||||
state,
|
||||
closeWizard,
|
||||
@@ -72,6 +92,11 @@ export function MaestroWizard({ theme, onLaunchSession }: MaestroWizardProps): J
|
||||
// State for exit confirmation modal
|
||||
const [showExitConfirm, setShowExitConfirm] = useState(false);
|
||||
|
||||
// Track wizard start time for duration calculation
|
||||
const wizardStartTimeRef = useRef<number>(0);
|
||||
// Track if wizard start has been recorded for this open session
|
||||
const wizardStartedRef = useRef(false);
|
||||
|
||||
// State for screen transition animations
|
||||
// displayedStep is the step actually being rendered (lags behind currentStep during transitions)
|
||||
const [displayedStep, setDisplayedStep] = useState<WizardStep>(state.currentStep);
|
||||
@@ -112,8 +137,12 @@ export function MaestroWizard({ theme, onLaunchSession }: MaestroWizardProps): J
|
||||
const handleConfirmExit = useCallback(() => {
|
||||
saveStateForResumeRef.current();
|
||||
setShowExitConfirm(false);
|
||||
// Record wizard abandonment for analytics
|
||||
if (onWizardAbandon) {
|
||||
onWizardAbandon();
|
||||
}
|
||||
closeWizardRef.current();
|
||||
}, []);
|
||||
}, [onWizardAbandon]);
|
||||
|
||||
/**
|
||||
* Handle cancel exit - close confirmation and stay in wizard
|
||||
@@ -157,6 +186,29 @@ export function MaestroWizard({ theme, onLaunchSession }: MaestroWizardProps): J
|
||||
}
|
||||
}, [state.isOpen, state.currentStep]);
|
||||
|
||||
// Track wizard start for analytics
|
||||
useEffect(() => {
|
||||
if (state.isOpen && !wizardStartedRef.current) {
|
||||
wizardStartedRef.current = true;
|
||||
wizardStartTimeRef.current = Date.now();
|
||||
|
||||
// Determine if this is a fresh start or resume based on current step
|
||||
// If we're on step 1, it's a fresh start. Otherwise, it's a resume.
|
||||
if (getCurrentStepNumber() === 1) {
|
||||
if (onWizardStart) {
|
||||
onWizardStart();
|
||||
}
|
||||
} else {
|
||||
if (onWizardResume) {
|
||||
onWizardResume();
|
||||
}
|
||||
}
|
||||
} else if (!state.isOpen) {
|
||||
// Reset when wizard closes
|
||||
wizardStartedRef.current = false;
|
||||
}
|
||||
}, [state.isOpen, getCurrentStepNumber, onWizardStart, onWizardResume]);
|
||||
|
||||
// Announce step changes to screen readers
|
||||
useEffect(() => {
|
||||
// Only announce when wizard is open and not transitioning
|
||||
@@ -210,12 +262,14 @@ export function MaestroWizard({ theme, onLaunchSession }: MaestroWizardProps): J
|
||||
<PhaseReviewScreen
|
||||
theme={theme}
|
||||
onLaunchSession={onLaunchSession || (async () => {})}
|
||||
onWizardComplete={onWizardComplete}
|
||||
wizardStartTime={wizardStartTimeRef.current}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [displayedStep, theme, onLaunchSession]);
|
||||
}, [displayedStep, theme, onLaunchSession, onWizardComplete]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -47,6 +47,15 @@ const AUTO_SAVE_DELAY = 2000;
|
||||
interface PhaseReviewScreenProps {
|
||||
theme: Theme;
|
||||
onLaunchSession: (wantsTour: boolean) => Promise<void>;
|
||||
/** Analytics callback: Called when wizard completes successfully */
|
||||
onWizardComplete?: (
|
||||
durationMs: number,
|
||||
conversationExchanges: number,
|
||||
phasesGenerated: number,
|
||||
tasksGenerated: number
|
||||
) => void;
|
||||
/** Start time of the wizard for duration calculation */
|
||||
wizardStartTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -859,9 +868,18 @@ function countTasks(content: string): number {
|
||||
function DocumentReview({
|
||||
theme,
|
||||
onLaunchSession,
|
||||
onWizardComplete,
|
||||
wizardStartTime,
|
||||
}: {
|
||||
theme: Theme;
|
||||
onLaunchSession: (wantsTour: boolean) => Promise<void>;
|
||||
onWizardComplete?: (
|
||||
durationMs: number,
|
||||
conversationExchanges: number,
|
||||
phasesGenerated: number,
|
||||
tasksGenerated: number
|
||||
) => void;
|
||||
wizardStartTime?: number;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
@@ -982,6 +1000,33 @@ function DocumentReview({
|
||||
setEditedPhase1Content(localContent);
|
||||
}
|
||||
|
||||
// Record wizard completion for analytics
|
||||
if (onWizardComplete) {
|
||||
// Calculate wizard duration
|
||||
const durationMs = wizardStartTime
|
||||
? Date.now() - wizardStartTime
|
||||
: 0;
|
||||
|
||||
// Count conversation exchanges (user messages in the conversation)
|
||||
const conversationExchanges = state.conversationHistory.filter(
|
||||
(msg) => msg.role === 'user'
|
||||
).length;
|
||||
|
||||
// Count phases and tasks generated
|
||||
const phasesGenerated = generatedDocuments.length;
|
||||
const tasksGenerated = generatedDocuments.reduce(
|
||||
(total, doc) => total + countTasks(doc.content),
|
||||
0
|
||||
);
|
||||
|
||||
onWizardComplete(
|
||||
durationMs,
|
||||
conversationExchanges,
|
||||
phasesGenerated,
|
||||
tasksGenerated
|
||||
);
|
||||
}
|
||||
|
||||
await onLaunchSession(wantsTour);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
@@ -997,6 +1042,10 @@ function DocumentReview({
|
||||
setEditedPhase1Content,
|
||||
setWantsTour,
|
||||
onLaunchSession,
|
||||
onWizardComplete,
|
||||
wizardStartTime,
|
||||
state.conversationHistory,
|
||||
generatedDocuments,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1254,6 +1303,8 @@ function DocumentReview({
|
||||
export function PhaseReviewScreen({
|
||||
theme,
|
||||
onLaunchSession,
|
||||
onWizardComplete,
|
||||
wizardStartTime,
|
||||
}: PhaseReviewScreenProps): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
@@ -1436,7 +1487,12 @@ export function PhaseReviewScreen({
|
||||
return (
|
||||
<>
|
||||
{announcementElement}
|
||||
<DocumentReview theme={theme} onLaunchSession={onLaunchSession} />
|
||||
<DocumentReview
|
||||
theme={theme}
|
||||
onLaunchSession={onLaunchSession}
|
||||
onWizardComplete={onWizardComplete}
|
||||
wizardStartTime={wizardStartTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ interface TourOverlayProps {
|
||||
onClose: () => void;
|
||||
/** Optional starting step index */
|
||||
startStep?: number;
|
||||
/** Analytics callback: Called when tour starts */
|
||||
onTourStart?: () => void;
|
||||
/** Analytics callback: Called when tour completes all steps */
|
||||
onTourComplete?: (stepsViewed: number) => void;
|
||||
/** Analytics callback: Called when tour is skipped before completion */
|
||||
onTourSkip?: (stepsViewed: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,10 +85,18 @@ export function TourOverlay({
|
||||
isOpen,
|
||||
onClose,
|
||||
startStep = 0,
|
||||
onTourStart,
|
||||
onTourComplete,
|
||||
onTourSkip,
|
||||
}: TourOverlayProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { registerLayer, unregisterLayer } = useLayerStack();
|
||||
|
||||
// Track if tour start has been recorded for this open session
|
||||
const tourStartedRef = useRef(false);
|
||||
// Track maximum step viewed (1-indexed for reporting)
|
||||
const maxStepViewedRef = useRef(1);
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
@@ -91,14 +105,54 @@ export function TourOverlay({
|
||||
isTransitioning,
|
||||
nextStep,
|
||||
previousStep,
|
||||
skipTour,
|
||||
skipTour: internalSkipTour,
|
||||
isLastStep,
|
||||
} = useTour({
|
||||
isOpen,
|
||||
onComplete: onClose,
|
||||
onComplete: () => {
|
||||
// Tour completed - user viewed all steps
|
||||
if (onTourComplete) {
|
||||
onTourComplete(maxStepViewedRef.current);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
startStep,
|
||||
});
|
||||
|
||||
// Wrapper for skipTour that calls analytics callback
|
||||
const skipTour = useCallback(() => {
|
||||
// Tour skipped before completion
|
||||
if (onTourSkip) {
|
||||
onTourSkip(maxStepViewedRef.current);
|
||||
}
|
||||
internalSkipTour();
|
||||
}, [internalSkipTour, onTourSkip]);
|
||||
|
||||
// Track tour start when it opens
|
||||
useEffect(() => {
|
||||
if (isOpen && !tourStartedRef.current) {
|
||||
tourStartedRef.current = true;
|
||||
maxStepViewedRef.current = 1; // Reset to 1 (first step)
|
||||
if (onTourStart) {
|
||||
onTourStart();
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
// Reset when tour closes
|
||||
tourStartedRef.current = false;
|
||||
}
|
||||
}, [isOpen, onTourStart]);
|
||||
|
||||
// Track the maximum step viewed
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// currentStepIndex is 0-based, we track 1-based for human-readable reporting
|
||||
const stepNumber = currentStepIndex + 1;
|
||||
if (stepNumber > maxStepViewedRef.current) {
|
||||
maxStepViewedRef.current = stepNumber;
|
||||
}
|
||||
}
|
||||
}, [isOpen, currentStepIndex]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats } from '../types';
|
||||
import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats } from '../types';
|
||||
import { DEFAULT_SHORTCUTS } from '../constants/shortcuts';
|
||||
|
||||
// Default global stats
|
||||
@@ -26,6 +26,36 @@ const DEFAULT_AUTO_RUN_STATS: AutoRunStats = {
|
||||
badgeHistory: [],
|
||||
};
|
||||
|
||||
// Default onboarding stats (all local, no external telemetry)
|
||||
const DEFAULT_ONBOARDING_STATS: OnboardingStats = {
|
||||
// Wizard statistics
|
||||
wizardStartCount: 0,
|
||||
wizardCompletionCount: 0,
|
||||
wizardAbandonCount: 0,
|
||||
wizardResumeCount: 0,
|
||||
averageWizardDurationMs: 0,
|
||||
totalWizardDurationMs: 0,
|
||||
lastWizardCompletedAt: 0,
|
||||
|
||||
// Tour statistics
|
||||
tourStartCount: 0,
|
||||
tourCompletionCount: 0,
|
||||
tourSkipCount: 0,
|
||||
tourStepsViewedTotal: 0,
|
||||
averageTourStepsViewed: 0,
|
||||
|
||||
// Conversation statistics
|
||||
totalConversationExchanges: 0,
|
||||
averageConversationExchanges: 0,
|
||||
totalConversationsCompleted: 0,
|
||||
|
||||
// Phase generation statistics
|
||||
totalPhasesGenerated: 0,
|
||||
averagePhasesPerWizard: 0,
|
||||
totalTasksGenerated: 0,
|
||||
averageTasksPerPhase: 0,
|
||||
};
|
||||
|
||||
// Default AI commands that ship with Maestro
|
||||
// Template variables available: {{AGENT_NAME}}, {{AGENT_PATH}}, {{TAB_NAME}}, {{AGENT_GROUP}}, {{AGENT_SESSION_ID}}, {{DATE}}, {{TIME}}, etc.
|
||||
const DEFAULT_AI_COMMANDS: CustomAICommand[] = [
|
||||
@@ -157,6 +187,23 @@ export interface UseSettingsReturn {
|
||||
setTourCompleted: (value: boolean) => void;
|
||||
firstAutoRunCompleted: boolean;
|
||||
setFirstAutoRunCompleted: (value: boolean) => void;
|
||||
|
||||
// Onboarding Stats (persistent, local-only analytics)
|
||||
onboardingStats: OnboardingStats;
|
||||
setOnboardingStats: (value: OnboardingStats) => void;
|
||||
recordWizardStart: () => void;
|
||||
recordWizardComplete: (durationMs: number, conversationExchanges: number, phasesGenerated: number, tasksGenerated: number) => void;
|
||||
recordWizardAbandon: () => void;
|
||||
recordWizardResume: () => void;
|
||||
recordTourStart: () => void;
|
||||
recordTourComplete: (stepsViewed: number) => void;
|
||||
recordTourSkip: (stepsViewed: number) => void;
|
||||
getOnboardingAnalytics: () => {
|
||||
wizardCompletionRate: number;
|
||||
tourCompletionRate: number;
|
||||
averageConversationExchanges: number;
|
||||
averagePhasesPerWizard: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
@@ -230,6 +277,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
const [tourCompleted, setTourCompletedState] = useState(false);
|
||||
const [firstAutoRunCompleted, setFirstAutoRunCompletedState] = useState(false);
|
||||
|
||||
// Onboarding Stats (persistent, local-only analytics)
|
||||
const [onboardingStats, setOnboardingStatsState] = useState<OnboardingStats>(DEFAULT_ONBOARDING_STATS);
|
||||
|
||||
// Wrapper functions that persist to electron-store
|
||||
const setLlmProvider = (value: LLMProvider) => {
|
||||
setLlmProviderState(value);
|
||||
@@ -566,6 +616,160 @@ export function useSettings(): UseSettingsReturn {
|
||||
window.maestro.settings.set('firstAutoRunCompleted', value);
|
||||
};
|
||||
|
||||
// Onboarding Stats functions
|
||||
const setOnboardingStats = (value: OnboardingStats) => {
|
||||
setOnboardingStatsState(value);
|
||||
window.maestro.settings.set('onboardingStats', value);
|
||||
};
|
||||
|
||||
// Record when wizard is started
|
||||
const recordWizardStart = () => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
wizardStartCount: prev.wizardStartCount + 1,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when wizard is completed successfully
|
||||
const recordWizardComplete = (
|
||||
durationMs: number,
|
||||
conversationExchanges: number,
|
||||
phasesGenerated: number,
|
||||
tasksGenerated: number
|
||||
) => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const newCompletionCount = prev.wizardCompletionCount + 1;
|
||||
const newTotalDuration = prev.totalWizardDurationMs + durationMs;
|
||||
const newTotalExchanges = prev.totalConversationExchanges + conversationExchanges;
|
||||
const newTotalPhases = prev.totalPhasesGenerated + phasesGenerated;
|
||||
const newTotalTasks = prev.totalTasksGenerated + tasksGenerated;
|
||||
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
wizardCompletionCount: newCompletionCount,
|
||||
totalWizardDurationMs: newTotalDuration,
|
||||
averageWizardDurationMs: Math.round(newTotalDuration / newCompletionCount),
|
||||
lastWizardCompletedAt: Date.now(),
|
||||
|
||||
// Conversation stats
|
||||
totalConversationExchanges: newTotalExchanges,
|
||||
totalConversationsCompleted: prev.totalConversationsCompleted + 1,
|
||||
averageConversationExchanges: newCompletionCount > 0
|
||||
? Math.round((newTotalExchanges / newCompletionCount) * 10) / 10
|
||||
: 0,
|
||||
|
||||
// Phase generation stats
|
||||
totalPhasesGenerated: newTotalPhases,
|
||||
averagePhasesPerWizard: newCompletionCount > 0
|
||||
? Math.round((newTotalPhases / newCompletionCount) * 10) / 10
|
||||
: 0,
|
||||
totalTasksGenerated: newTotalTasks,
|
||||
averageTasksPerPhase: newTotalPhases > 0
|
||||
? Math.round((newTotalTasks / newTotalPhases) * 10) / 10
|
||||
: 0,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when wizard is abandoned (closed before completion)
|
||||
const recordWizardAbandon = () => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
wizardAbandonCount: prev.wizardAbandonCount + 1,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when wizard is resumed from saved state
|
||||
const recordWizardResume = () => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
wizardResumeCount: prev.wizardResumeCount + 1,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when tour is started
|
||||
const recordTourStart = () => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
tourStartCount: prev.tourStartCount + 1,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when tour is completed (all steps viewed)
|
||||
const recordTourComplete = (stepsViewed: number) => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const newCompletionCount = prev.tourCompletionCount + 1;
|
||||
const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed;
|
||||
const totalTours = newCompletionCount + prev.tourSkipCount;
|
||||
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
tourCompletionCount: newCompletionCount,
|
||||
tourStepsViewedTotal: newTotalStepsViewed,
|
||||
averageTourStepsViewed: totalTours > 0
|
||||
? Math.round((newTotalStepsViewed / totalTours) * 10) / 10
|
||||
: stepsViewed,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Record when tour is skipped before completion
|
||||
const recordTourSkip = (stepsViewed: number) => {
|
||||
setOnboardingStatsState(prev => {
|
||||
const newSkipCount = prev.tourSkipCount + 1;
|
||||
const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed;
|
||||
const totalTours = prev.tourCompletionCount + newSkipCount;
|
||||
|
||||
const updated: OnboardingStats = {
|
||||
...prev,
|
||||
tourSkipCount: newSkipCount,
|
||||
tourStepsViewedTotal: newTotalStepsViewed,
|
||||
averageTourStepsViewed: totalTours > 0
|
||||
? Math.round((newTotalStepsViewed / totalTours) * 10) / 10
|
||||
: stepsViewed,
|
||||
};
|
||||
window.maestro.settings.set('onboardingStats', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Get computed analytics for display
|
||||
const getOnboardingAnalytics = () => {
|
||||
const totalWizardAttempts = onboardingStats.wizardStartCount;
|
||||
const totalTourAttempts = onboardingStats.tourStartCount;
|
||||
|
||||
return {
|
||||
wizardCompletionRate: totalWizardAttempts > 0
|
||||
? Math.round((onboardingStats.wizardCompletionCount / totalWizardAttempts) * 100)
|
||||
: 0,
|
||||
tourCompletionRate: totalTourAttempts > 0
|
||||
? Math.round((onboardingStats.tourCompletionCount / totalTourAttempts) * 100)
|
||||
: 0,
|
||||
averageConversationExchanges: onboardingStats.averageConversationExchanges,
|
||||
averagePhasesPerWizard: onboardingStats.averagePhasesPerWizard,
|
||||
};
|
||||
};
|
||||
|
||||
// Load settings from electron-store on mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
@@ -603,6 +807,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const savedWizardCompleted = await window.maestro.settings.get('wizardCompleted');
|
||||
const savedTourCompleted = await window.maestro.settings.get('tourCompleted');
|
||||
const savedFirstAutoRunCompleted = await window.maestro.settings.get('firstAutoRunCompleted');
|
||||
const savedOnboardingStats = await window.maestro.settings.get('onboardingStats');
|
||||
|
||||
if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI);
|
||||
if (savedEnterToSendTerminal !== undefined) setEnterToSendTerminalState(savedEnterToSendTerminal);
|
||||
@@ -669,6 +874,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (savedTourCompleted !== undefined) setTourCompletedState(savedTourCompleted);
|
||||
if (savedFirstAutoRunCompleted !== undefined) setFirstAutoRunCompletedState(savedFirstAutoRunCompleted);
|
||||
|
||||
// Load onboarding stats
|
||||
if (savedOnboardingStats !== undefined) {
|
||||
setOnboardingStatsState({ ...DEFAULT_ONBOARDING_STATS, ...savedOnboardingStats });
|
||||
}
|
||||
|
||||
// Mark settings as loaded
|
||||
setSettingsLoaded(true);
|
||||
};
|
||||
@@ -753,5 +963,15 @@ export function useSettings(): UseSettingsReturn {
|
||||
setTourCompleted,
|
||||
firstAutoRunCompleted,
|
||||
setFirstAutoRunCompleted,
|
||||
onboardingStats,
|
||||
setOnboardingStats,
|
||||
recordWizardStart,
|
||||
recordWizardComplete,
|
||||
recordWizardAbandon,
|
||||
recordWizardResume,
|
||||
recordTourStart,
|
||||
recordTourComplete,
|
||||
recordTourSkip,
|
||||
getOnboardingAnalytics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +233,37 @@ export interface AutoRunStats {
|
||||
badgeHistory: BadgeUnlockRecord[]; // History of badge unlocks with timestamps
|
||||
}
|
||||
|
||||
// Onboarding analytics statistics (survives app restarts)
|
||||
// These are stored locally only - no data is sent externally
|
||||
export interface OnboardingStats {
|
||||
// Wizard statistics
|
||||
wizardStartCount: number; // Number of times wizard was started
|
||||
wizardCompletionCount: number; // Number of times wizard was completed
|
||||
wizardAbandonCount: number; // Number of times wizard was abandoned (exited before completion)
|
||||
wizardResumeCount: number; // Number of times wizard was resumed from saved state
|
||||
averageWizardDurationMs: number; // Average time to complete wizard (0 if none completed)
|
||||
totalWizardDurationMs: number; // Total cumulative wizard duration
|
||||
lastWizardCompletedAt: number; // Timestamp of last wizard completion (0 if never)
|
||||
|
||||
// Tour statistics
|
||||
tourStartCount: number; // Number of times tour was started
|
||||
tourCompletionCount: number; // Number of times tour was completed (all steps)
|
||||
tourSkipCount: number; // Number of times tour was skipped before completion
|
||||
tourStepsViewedTotal: number; // Total tour steps viewed across all tours
|
||||
averageTourStepsViewed: number; // Average steps viewed per tour (completed + skipped)
|
||||
|
||||
// Conversation statistics
|
||||
totalConversationExchanges: number; // Total user<->AI exchanges across all wizards
|
||||
averageConversationExchanges: number; // Average exchanges per completed wizard
|
||||
totalConversationsCompleted: number; // Number of wizard conversations that reached ready state
|
||||
|
||||
// Phase generation statistics
|
||||
totalPhasesGenerated: number; // Total phase documents generated
|
||||
averagePhasesPerWizard: number; // Average phases per completed wizard
|
||||
totalTasksGenerated: number; // Total tasks generated across all phases
|
||||
averageTasksPerPhase: number; // Average tasks per phase
|
||||
}
|
||||
|
||||
// AI Tab for multi-tab support within a Maestro session
|
||||
// Each tab represents a separate Claude Code conversation
|
||||
export interface AITab {
|
||||
|
||||
Reference in New Issue
Block a user