'use client' import { useState, useCallback, useEffect, useRef, useReducer, useMemo } from 'react' import { AbacusReact } from '@soroban/abacus-react' import { css } from '../../../styled-system/css' import { stack, hstack, vstack } from '../../../styled-system/patterns' import { Tutorial, TutorialStep, PracticeStep, TutorialEvent, NavigationState, UIState } from '../../types/tutorial' import { PracticeProblemPlayer, PracticeResults } from './PracticeProblemPlayer' // Reducer state and actions interface TutorialPlayerState { currentStepIndex: number currentValue: number isStepCompleted: boolean error: string | null events: TutorialEvent[] stepStartTime: number uiState: UIState } type TutorialPlayerAction = | { type: 'INITIALIZE_STEP'; stepIndex: number; startValue: number; stepId: string } | { type: 'USER_VALUE_CHANGE'; oldValue: number; newValue: number; stepId: string } | { type: 'COMPLETE_STEP'; stepId: string } | { type: 'SET_ERROR'; error: string | null } | { type: 'ADD_EVENT'; event: TutorialEvent } | { type: 'UPDATE_UI_STATE'; updates: Partial } function tutorialPlayerReducer(state: TutorialPlayerState, action: TutorialPlayerAction): TutorialPlayerState { switch (action.type) { case 'INITIALIZE_STEP': return { ...state, currentStepIndex: action.stepIndex, currentValue: action.startValue, isStepCompleted: false, error: null, stepStartTime: Date.now(), events: [...state.events, { type: 'STEP_STARTED', stepId: action.stepId, timestamp: new Date() }] } case 'USER_VALUE_CHANGE': return { ...state, currentValue: action.newValue, events: [...state.events, { type: 'VALUE_CHANGED', stepId: action.stepId, oldValue: action.oldValue, newValue: action.newValue, timestamp: new Date() }] } case 'COMPLETE_STEP': return { ...state, isStepCompleted: true, error: null, events: [...state.events, { type: 'STEP_COMPLETED', stepId: action.stepId, success: true, timestamp: new Date() }] } case 'SET_ERROR': return { ...state, error: action.error } case 'ADD_EVENT': return { ...state, events: [...state.events, action.event] } case 'UPDATE_UI_STATE': return { ...state, uiState: { ...state.uiState, ...action.updates } } default: return state } } interface TutorialPlayerProps { tutorial: Tutorial initialStepIndex?: number isDebugMode?: boolean showDebugPanel?: boolean onStepChange?: (stepIndex: number, step: TutorialStep) => void onStepComplete?: (stepIndex: number, step: TutorialStep, success: boolean) => void onTutorialComplete?: (score: number, timeSpent: number) => void onEvent?: (event: TutorialEvent) => void className?: string } export function TutorialPlayer({ tutorial, initialStepIndex = 0, isDebugMode = false, showDebugPanel = false, onStepChange, onStepComplete, onTutorialComplete, onEvent, className }: TutorialPlayerProps) { const [startTime] = useState(Date.now()) const isProgrammaticChange = useRef(false) const [state, dispatch] = useReducer(tutorialPlayerReducer, { currentStepIndex: initialStepIndex, currentValue: 0, isStepCompleted: false, error: null, events: [], stepStartTime: Date.now(), uiState: { isPlaying: true, isPaused: false, isEditing: false, showDebugPanel, showStepList: false, autoAdvance: false, playbackSpeed: 1 } }) const { currentStepIndex, currentValue, isStepCompleted, error, events, stepStartTime, uiState } = state const currentStep = tutorial.steps[currentStepIndex] const beadRefs = useRef>(new Map()) // Navigation state const navigationState: NavigationState = { currentStepIndex, canGoNext: currentStepIndex < tutorial.steps.length - 1, canGoPrevious: currentStepIndex > 0, totalSteps: tutorial.steps.length, completionPercentage: (currentStepIndex / tutorial.steps.length) * 100 } // Event logging - now just notifies parent, state is managed by reducer const notifyEvent = useCallback((event: TutorialEvent) => { onEvent?.(event) }, [onEvent]) // Navigation functions - declare these first since they're used in useEffects const goToStep = useCallback((stepIndex: number) => { if (stepIndex >= 0 && stepIndex < tutorial.steps.length) { const step = tutorial.steps[stepIndex] // Mark this as a programmatic change to prevent feedback loop isProgrammaticChange.current = true dispatch({ type: 'INITIALIZE_STEP', stepIndex, startValue: step.startValue, stepId: step.id }) // Notify parent of step change onStepChange?.(stepIndex, step) } }, [tutorial.steps, onStepChange]) const goToNextStep = useCallback(() => { if (navigationState.canGoNext) { goToStep(currentStepIndex + 1) } else if (currentStepIndex === tutorial.steps.length - 1) { // Tutorial completed const timeSpent = (Date.now() - startTime) / 1000 const score = events.filter(e => e.type === 'STEP_COMPLETED' && e.success).length / tutorial.steps.length * 100 dispatch({ type: 'ADD_EVENT', event: { type: 'TUTORIAL_COMPLETED', tutorialId: tutorial.id, score, timestamp: new Date() } }) onTutorialComplete?.(score, timeSpent) } }, [navigationState.canGoNext, currentStepIndex, tutorial.steps.length, tutorial.id, startTime, events, onTutorialComplete, goToStep]) const goToPreviousStep = useCallback(() => { if (navigationState.canGoPrevious) { goToStep(currentStepIndex - 1) } }, [navigationState.canGoPrevious, currentStepIndex, goToStep]) // Initialize step on mount only useEffect(() => { if (currentStep && currentStepIndex === initialStepIndex) { // Mark this as a programmatic change to prevent feedback loop isProgrammaticChange.current = true // Dispatch initialization action dispatch({ type: 'INITIALIZE_STEP', stepIndex: currentStepIndex, startValue: currentStep.startValue, stepId: currentStep.id }) // Notify parent of step change onStepChange?.(currentStepIndex, currentStep) } }, []) // Only run on mount // Check if step is completed - now using useEffect only for side effects useEffect(() => { if (currentStep && currentValue === currentStep.targetValue && !isStepCompleted) { dispatch({ type: 'COMPLETE_STEP', stepId: currentStep.id }) onStepComplete?.(currentStepIndex, currentStep, true) // Auto-advance if enabled if (uiState.autoAdvance && navigationState.canGoNext) { setTimeout(() => goToNextStep(), 1500) } } }, [currentValue, currentStep, isStepCompleted, uiState.autoAdvance, navigationState.canGoNext, onStepComplete, currentStepIndex, goToNextStep]) // Notify parent of events when they're added to state useEffect(() => { if (events.length > 0) { const lastEvent = events[events.length - 1] notifyEvent(lastEvent) } }, [events, notifyEvent]) // Debounced value change handling for smooth gesture performance const valueChangeTimeoutRef = useRef(null) const lastValueRef = useRef(currentValue) const pendingValueRef = useRef(null) const handleValueChange = useCallback((newValue: number) => { // Ignore programmatic changes to prevent feedback loops if (isProgrammaticChange.current) { isProgrammaticChange.current = false return } // Store the pending value for immediate abacus updates pendingValueRef.current = newValue // Clear any existing timeout if (valueChangeTimeoutRef.current) { clearTimeout(valueChangeTimeoutRef.current) } // Debounce the tutorial system notification valueChangeTimeoutRef.current = setTimeout(() => { const finalValue = pendingValueRef.current if (finalValue !== null && finalValue !== lastValueRef.current) { dispatch({ type: 'USER_VALUE_CHANGE', oldValue: lastValueRef.current, newValue: finalValue, stepId: currentStep.id }) lastValueRef.current = finalValue } pendingValueRef.current = null }, 150) // 150ms debounce - gestures settle quickly }, [currentStep]) // Cleanup timeout on unmount useEffect(() => { return () => { if (valueChangeTimeoutRef.current) { clearTimeout(valueChangeTimeoutRef.current) } } }, []) // Keep lastValueRef in sync with currentValue changes from external sources useEffect(() => { lastValueRef.current = currentValue }, [currentValue]) const handleBeadClick = useCallback((beadInfo: any) => { dispatch({ type: 'ADD_EVENT', event: { type: 'BEAD_CLICKED', stepId: currentStep.id, beadInfo, timestamp: new Date() } }) // Check if this is the correct action if (currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads)) { const isCorrectBead = currentStep.highlightBeads.some(highlight => { // Get place value from highlight (convert columnIndex to placeValue if needed) const highlightPlaceValue = highlight.placeValue ?? (4 - highlight.columnIndex); // Get place value from bead click event const beadPlaceValue = beadInfo.bead ? beadInfo.bead.placeValue : (4 - beadInfo.columnIndex); return highlightPlaceValue === beadPlaceValue && highlight.beadType === beadInfo.beadType && (highlight.position === undefined || highlight.position === beadInfo.position); }); if (!isCorrectBead) { dispatch({ type: 'SET_ERROR', error: currentStep.errorMessages.wrongBead }) dispatch({ type: 'ADD_EVENT', event: { type: 'ERROR_OCCURRED', stepId: currentStep.id, error: currentStep.errorMessages.wrongBead, timestamp: new Date() } }) } else { dispatch({ type: 'SET_ERROR', error: null }) } } }, [currentStep]) const handleBeadRef = useCallback((bead: any, element: SVGElement | null) => { const key = `${bead.placeValue}-${bead.type}-${bead.position}` if (element) { beadRefs.current.set(key, element) } else { beadRefs.current.delete(key) } }, []) // UI state updaters const toggleDebugPanel = useCallback(() => { dispatch({ type: 'UPDATE_UI_STATE', updates: { showDebugPanel: !uiState.showDebugPanel } }) }, [uiState.showDebugPanel]) const toggleStepList = useCallback(() => { dispatch({ type: 'UPDATE_UI_STATE', updates: { showStepList: !uiState.showStepList } }) }, [uiState.showStepList]) const toggleAutoAdvance = useCallback(() => { dispatch({ type: 'UPDATE_UI_STATE', updates: { autoAdvance: !uiState.autoAdvance } }) }, [uiState.autoAdvance]) // Memoize custom styles calculation to avoid expensive recalculation on every render const customStyles = useMemo(() => { if (!currentStep.highlightBeads || !Array.isArray(currentStep.highlightBeads)) { return undefined; } return { beads: currentStep.highlightBeads.reduce((acc, highlight) => { // Convert placeValue to columnIndex for AbacusReact compatibility const columnIndex = highlight.placeValue !== undefined ? (4 - highlight.placeValue) : highlight.columnIndex; // Initialize column if it doesn't exist if (!acc[columnIndex]) { acc[columnIndex] = {}; } // Add the bead style to the appropriate type if (highlight.beadType === 'earth' && highlight.position !== undefined) { if (!acc[columnIndex].earth) { acc[columnIndex].earth = {}; } acc[columnIndex].earth[highlight.position] = { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 }; } else { acc[columnIndex][highlight.beadType] = { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 }; } return acc; }, {} as any) }; }, [currentStep.highlightBeads]); if (!currentStep) { return
No steps available
} return (
{/* Header */}

{tutorial.title}

Step {currentStepIndex + 1} of {tutorial.steps.length}: {currentStep.title}

{isDebugMode && ( <> )}
{/* Progress bar */}
{/* Step list sidebar */} {uiState.showStepList && (

Tutorial Steps

{tutorial.steps && Array.isArray(tutorial.steps) ? tutorial.steps.map((step, index) => ( )) : (
No tutorial steps available
)}
)} {/* Main content */}
{/* Step content */}
{/* Step instructions */}

{currentStep.problem}

{currentStep.description}

{currentStep.actionDescription}

{/* Error message */} {error && (
{error}
)} {/* Success message */} {isStepCompleted && (
Great! You completed this step correctly.
)} {/* Abacus */}
{/* Tooltip */} {currentStep.tooltip && (

{currentStep.tooltip.content}

{currentStep.tooltip.explanation}

)}
{/* Navigation controls */}
Step {currentStepIndex + 1} of {navigationState.totalSteps}
{/* Debug panel */} {uiState.showDebugPanel && (

Debug Panel

{/* Current state */}

Current State

Step: {currentStepIndex + 1}/{navigationState.totalSteps}
Value: {currentValue}
Target: {currentStep.targetValue}
Completed: {isStepCompleted ? 'Yes' : 'No'}
Time: {Math.round((Date.now() - stepStartTime) / 1000)}s
{/* Event log */}

Event Log

{events.slice(-20).reverse().map((event, index) => (
{event.type}
{event.timestamp.toLocaleTimeString()}
{event.type === 'VALUE_CHANGED' && (
{event.oldValue} → {event.newValue}
)} {event.type === 'ERROR_OCCURRED' && (
{event.error}
)}
))}
)}
) }