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:
Pedram Amini
2025-12-10 04:52:47 -06:00
parent e925eb28ca
commit 67ea18dfbd
7 changed files with 730 additions and 9 deletions

View File

@@ -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);
});
});
});
});

View File

@@ -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) --- */}

View File

@@ -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

View File

@@ -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}
/>
</>
);
}

View File

@@ -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) => {

View File

@@ -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,
};
}

View File

@@ -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 {