diff --git a/apps/web/src/components/practice/.claude/STATE_MACHINE_PLAN.md b/apps/web/src/components/practice/.claude/STATE_MACHINE_PLAN.md new file mode 100644 index 00000000..96ae85f6 --- /dev/null +++ b/apps/web/src/components/practice/.claude/STATE_MACHINE_PLAN.md @@ -0,0 +1,135 @@ +# Practice Session State Machine Refactor Plan + +## Problem Statement + +Multiple independent boolean flags (`isPaused`, `isSubmitting`, `isTransitioning`) combined with attempt state create implicit states and scattered conditions. We need a state machine with **atomic migration** - no legacy state coexisting with state machine state. + +## Current State Inventory + +**Session-level (ActiveSession.tsx):** +- `isPaused: boolean` +- `isSubmitting: boolean` +- `isTransitioning: boolean` +- `outgoingAttempt: OutgoingAttempt | null` + +**Attempt-level (useProblemAttempt.ts):** +- `feedback: 'none' | 'correct' | 'incorrect'` +- `manualSubmitRequired: boolean` +- `rejectedDigit: string | null` +- `helpTermIndex: number | null` + +## Proposed Phase Type + +```typescript +type InteractionPhase = + | { phase: 'loading' } + | { phase: 'inputting'; attempt: ProblemAttempt } + | { phase: 'helpMode'; attempt: ProblemAttempt; helpContext: HelpContext } + | { phase: 'submitting'; attempt: ProblemAttempt } + | { phase: 'showingFeedback'; attempt: ProblemAttempt; result: 'correct' | 'incorrect' } + | { phase: 'transitioning'; outgoing: OutgoingAttempt; incoming: ProblemAttempt } + | { phase: 'paused'; resumePhase: Exclude } + +interface HelpContext { + termIndex: number + currentValue: number + targetValue: number + term: number +} +``` + +## State Transition Diagram + +``` + ┌─────────────────────────────────────────┐ + │ │ + v │ +┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ +│ loading │───>│inputting │───>│submitting│───>│showingFeed│─┘ +└─────────┘ └──────────┘ └──────────┘ │ back │ + │ ^ └───────────┘ + │ │ │ + v │ │ (if incorrect, + ┌──────────┐ │ │ end of part) + │ helpMode │────────┘ │ + └──────────┘ v + ┌───────────┐ + │transitioning│──> inputting + └───────────┘ + (if correct & + more problems) + +Any phase (except paused) ──pause──> paused ──resume──> previous phase +``` + +## Migration Strategy: Atomic Steps + +Each step is a complete, testable unit. **No step leaves dual state management.** + +### Step 1: Create hook skeleton with tests +- Create `useInteractionPhase.ts` with type definitions +- Create test file with phase transition tests +- Hook is complete but not yet integrated +- **Commit**: "feat: add useInteractionPhase hook with tests" + +### Step 2: Migrate `isSubmitting` + `feedback` +- Phase handles: `inputting` → `submitting` → `showingFeedback` +- DELETE `isSubmitting` useState from ActiveSession +- DELETE `feedback` from ProblemAttempt (now in phase) +- Update all UI that checked these flags to use phase +- **Commit**: "refactor: migrate submitting/feedback state to phase machine" + +### Step 3: Migrate `isTransitioning` + `outgoingAttempt` +- Phase handles: `showingFeedback` → `transitioning` → `inputting` +- DELETE `isTransitioning` useState +- DELETE `outgoingAttempt` useState +- Outgoing data now lives in `{ phase: 'transitioning', outgoing, incoming }` +- **Commit**: "refactor: migrate transition state to phase machine" + +### Step 4: Migrate `helpTermIndex` +- Phase handles: `inputting` ↔ `helpMode` +- DELETE `helpTermIndex` from ProblemAttempt +- `helpContext` now lives in `{ phase: 'helpMode', helpContext }` +- **Commit**: "refactor: migrate help mode state to phase machine" + +### Step 5: Migrate `isPaused` +- Phase handles: `* → paused → resumePhase` +- DELETE `isPaused` useState +- Previous phase stored in `{ phase: 'paused', resumePhase }` +- **Commit**: "refactor: migrate pause state to phase machine" + +### Step 6: Clean up ProblemAttempt +- Review what's left in ProblemAttempt +- Should only contain input-level state: `userAnswer`, `correctionCount`, `rejectedDigit`, `startTime`, etc. +- Remove any redundant derived state +- **Commit**: "refactor: simplify ProblemAttempt to input-only state" + +## Critical Rules + +1. **No dual state**: When phase machine manages X, delete the old X immediately +2. **Tests before migration**: Write failing tests for the new behavior, then migrate +3. **One aspect per step**: Each commit migrates one conceptual piece +4. **All tests pass**: Each commit leaves tests green +5. **No "temporary" bridges**: No helper functions that translate between old and new + +## What Stays in ProblemAttempt + +After migration, `ProblemAttempt` becomes purely about **input state for the current answer**: + +```typescript +interface ProblemAttempt { + problem: GeneratedProblem + slotIndex: number + partIndex: number + startTime: number + userAnswer: string + correctionCount: number + manualSubmitRequired: boolean // derived from correctionCount + rejectedDigit: string | null // transient animation state +} +``` + +Removed from ProblemAttempt (now in phase): +- `feedback` → phase is `showingFeedback` +- `helpTermIndex` → phase is `helpMode` +- `confirmedTermCount` → part of `helpContext` in `helpMode` phase diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 09971a7b..67a14db5 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -1,7 +1,7 @@ 'use client' import { animated, useSpring } from '@react-spring/web' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { flushSync } from 'react-dom' import { useTheme } from '@/contexts/ThemeContext' import type { @@ -23,6 +23,8 @@ import { css } from '../../../styled-system/css' import { DecompositionProvider, DecompositionSection } from '../decomposition' import { generateCoachHint } from './coachHintGenerator' import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection' +import { useInteractionPhase } from './hooks/useInteractionPhase' +import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects' import { NumericKeypad } from './NumericKeypad' import { PracticeHelpOverlay } from './PracticeHelpOverlay' import { VerticalProblem } from './VerticalProblem' @@ -42,21 +44,6 @@ interface ActiveSessionProps { onComplete: () => void } -interface CurrentProblem { - partIndex: number - slotIndex: number - problem: GeneratedProblem - startTime: number -} - -/** Snapshot of a problem that's animating out */ -interface OutgoingProblem { - key: string - problem: GeneratedProblem - userAnswer: string - isCorrect: true -} - /** * Get the part type description for display */ @@ -196,6 +183,11 @@ function LinearProblem({ * - Session health indicators * - On-screen abacus toggle (for abacus part only) * - Teacher controls (pause, end early) + * + * State Architecture: + * - Uses useInteractionPhase hook for interaction state machine + * - Single source of truth for all UI state + * - Explicit phase transitions instead of boolean flags */ export function ActiveSession({ plan, @@ -209,26 +201,37 @@ export function ActiveSession({ const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' - const [currentProblem, setCurrentProblem] = useState(null) - const [userAnswer, setUserAnswer] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - const [isPaused, setIsPaused] = useState(false) - const [feedback, setFeedback] = useState<'none' | 'correct' | 'incorrect'>('none') - const [incorrectAttempts, setIncorrectAttempts] = useState(0) - // Help mode: which terms have been confirmed correct so far - const [confirmedTermCount, setConfirmedTermCount] = useState(0) - // Which term we're currently showing help for (null = not showing help) - const [helpTermIndex, setHelpTermIndex] = useState(null) - // Track corrections for auto-submit (allow 1 correction, then require manual submit) - const [correctionCount, setCorrectionCount] = useState(0) - // Track if auto-submit was triggered (for celebration animation) - const [autoSubmitTriggered, setAutoSubmitTriggered] = useState(false) - // Track rejected digit for red X animation (null = no rejection, string = the rejected digit) - const [rejectedDigit, setRejectedDigit] = useState(null) + // Sound effects + const { playSound } = usePracticeSoundEffects() - // Problem transition animation state - const [outgoingProblem, setOutgoingProblem] = useState(null) - const [isTransitioning, setIsTransitioning] = useState(false) + // Interaction state machine - single source of truth for UI state + const { + phase, + canAcceptInput, + showAsCompleted, + showHelpOverlay, + showInputArea, + showFeedback, + inputIsFocused, + prefixSums, + matchedPrefixIndex, + canSubmit, + shouldAutoSubmit, + loadProblem, + handleDigit, + handleBackspace, + enterHelpMode, + exitHelpMode, + startSubmit, + completeSubmit, + startTransition, + completeTransition, + clearToLoading, + pause, + resume, + } = useInteractionPhase({ + onManualSubmitRequired: () => playSound('womp_womp'), + }) // Refs for measuring problem widths during animation const outgoingRef = useRef(null) @@ -247,6 +250,68 @@ export function ActiveSession({ config: { tension: 200, friction: 26 }, })) + // Extract attempt from phase for UI rendering + const attempt = useMemo(() => { + switch (phase.phase) { + case 'inputting': + case 'helpMode': + case 'submitting': + case 'showingFeedback': + return phase.attempt + case 'transitioning': + return phase.incoming + case 'paused': { + const inner = phase.resumePhase + if ( + inner.phase === 'inputting' || + inner.phase === 'helpMode' || + inner.phase === 'submitting' || + inner.phase === 'showingFeedback' + ) { + return inner.attempt + } + if (inner.phase === 'transitioning') { + return inner.incoming + } + return null + } + default: + return null + } + }, [phase]) + + // Extract help context from phase + const helpContext = useMemo(() => { + if (phase.phase === 'helpMode') { + return phase.helpContext + } + // Also check paused phase + if (phase.phase === 'paused' && phase.resumePhase.phase === 'helpMode') { + return phase.resumePhase.helpContext + } + return null + }, [phase]) + + // Extract outgoing attempt for transition animation + const outgoingAttempt = phase.phase === 'transitioning' ? phase.outgoing : null + + // Check if we're in transitioning phase + const isTransitioning = phase.phase === 'transitioning' + + // Check if we're paused + const isPaused = phase.phase === 'paused' + + // Check if we're submitting + const isSubmitting = phase.phase === 'submitting' + + // Spring for submit button entrance animation + const submitButtonSpring = useSpring({ + transform: attempt?.manualSubmitRequired ? 'translateY(0px)' : 'translateY(60px)', + opacity: attempt?.manualSubmitRequired ? 1 : 0, + scale: attempt?.manualSubmitRequired ? 1 : 0.8, + config: { tension: 280, friction: 14 }, + }) + // Apply centering offset before paint to prevent jank useLayoutEffect(() => { if (needsCenteringOffsetRef.current && outgoingRef.current) { @@ -270,98 +335,27 @@ export function ActiveSession({ config: { tension: 200, friction: 26 }, }) - // Start slide after a brief moment (150ms) - don't wait for fade-in to complete - // This eliminates the jarring pause between phases + // Start slide after a brief moment (150ms) setTimeout(() => { trackApi.start({ x: -centeringOffset, outgoingOpacity: 0, config: { tension: 170, friction: 22 }, onRest: () => { - // Outgoing is now invisible (opacity 0). - // Remove it and reset X to 0 in the same synchronous batch - // so flexbox recentering and track reset happen together. + // Outgoing is now invisible - complete the transition flushSync(() => { - setOutgoingProblem(null) - setIsTransitioning(false) - setFeedback('none') - setIsSubmitting(false) - setIncorrectAttempts(0) - setConfirmedTermCount(0) + completeTransition() }) - // Reset spring immediately after DOM update trackApi.set({ x: 0, outgoingOpacity: 1, activeOpacity: 1 }) }, }) }, 150) } - }, [outgoingProblem, trackApi]) + }, [outgoingAttempt, trackApi, completeTransition]) const hasPhysicalKeyboard = useHasPhysicalKeyboard() - // Compute all prefix sums for the current problem - // prefixSums[i] = sum of terms[0..i] (inclusive) - // e.g., for [23, 45, 12]: prefixSums = [23, 68, 80] - const prefixSums = useMemo(() => { - if (!currentProblem) return [] - const terms = currentProblem.problem.terms - const sums: number[] = [] - let total = 0 - for (const term of terms) { - total += term - sums.push(total) - } - return sums - }, [currentProblem]) - - // Check if user's input matches any prefix sum - // Returns the index of the matched prefix, or -1 if no match - const matchedPrefixIndex = useMemo(() => { - const answerNum = parseInt(userAnswer, 10) - if (Number.isNaN(answerNum)) return -1 - return prefixSums.indexOf(answerNum) - }, [userAnswer, prefixSums]) - - // Determine if submit button should be enabled - const canSubmit = useMemo(() => { - if (!userAnswer) return false - const answerNum = parseInt(userAnswer, 10) - return !Number.isNaN(answerNum) - }, [userAnswer]) - - // Compute context for help abacus when showing help - const helpContext = useMemo(() => { - if (helpTermIndex === null || !currentProblem) return null - const terms = currentProblem.problem.terms - // Current value is the prefix sum up to helpTermIndex (exclusive) - const currentValue = helpTermIndex === 0 ? 0 : prefixSums[helpTermIndex - 1] - // Target is the prefix sum including this term - const targetValue = prefixSums[helpTermIndex] - const term = terms[helpTermIndex] - return { currentValue, targetValue, term } - }, [helpTermIndex, currentProblem, prefixSums]) - - // Auto-trigger help when prefix sum is detected - useEffect(() => { - // Only auto-trigger if: - // 1. We detected a prefix sum match (but not the final answer) - // 2. We're not already showing help for this term - if ( - helpTermIndex === null && - matchedPrefixIndex >= 0 && - matchedPrefixIndex < prefixSums.length - 1 - ) { - const newConfirmedCount = matchedPrefixIndex + 1 - setConfirmedTermCount(newConfirmedCount) - - if (newConfirmedCount < (currentProblem?.problem.terms.length || 0)) { - setHelpTermIndex(newConfirmedCount) - setUserAnswer('') - } - } - }, [helpTermIndex, matchedPrefixIndex, prefixSums.length, currentProblem?.problem.terms.length]) - - // Get current part and slot + // Get current part and slot from plan const parts = plan.parts const currentPartIndex = plan.currentPartIndex const currentSlotIndex = plan.currentSlotIndex @@ -390,164 +384,72 @@ export function ActiveSession({ } }, [currentPartIndex, parts.length, onComplete]) - // Initialize or advance to current problem + // Initialize problem when slot changes and in loading phase useEffect(() => { - // Don't auto-load during transitions - startTransition handles this - if (currentPart && currentSlot && !currentProblem && !isTransitioning) { - // Generate problem from slot constraints (simplified for now) + if (currentPart && currentSlot && phase.phase === 'loading') { const problem = currentSlot.problem || generateProblemFromConstraints(currentSlot.constraints) - setCurrentProblem({ - partIndex: currentPartIndex, - slotIndex: currentSlotIndex, - problem, - startTime: Date.now(), - }) - setUserAnswer('') - setFeedback('none') + loadProblem(problem, currentSlotIndex, currentPartIndex) } - }, [ - currentPart, - currentSlot, - currentPartIndex, - currentSlotIndex, - currentProblem, - isTransitioning, - ]) + }, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem]) - // Check if adding a digit would be consistent with any prefix sum - const isDigitConsistent = useCallback( - (currentAnswer: string, digit: string): boolean => { - const newAnswer = currentAnswer + digit - const newAnswerNum = parseInt(newAnswer, 10) - if (Number.isNaN(newAnswerNum)) return false - - // Check if newAnswer is a prefix of any prefix sum's string representation - // e.g., if prefix sums are [23, 68, 80], and newAnswer is "6", that's consistent with "68" - // if newAnswer is "8", that's consistent with "80" - // if newAnswer is "68", that's an exact match - for (const sum of prefixSums) { - const sumStr = sum.toString() - if (sumStr.startsWith(newAnswer)) { - return true - } + // Auto-trigger help when prefix sum is detected + useEffect(() => { + if ( + phase.phase === 'inputting' && + matchedPrefixIndex >= 0 && + matchedPrefixIndex < prefixSums.length - 1 + ) { + const newConfirmedCount = matchedPrefixIndex + 1 + if (newConfirmedCount < phase.attempt.problem.terms.length) { + enterHelpMode(newConfirmedCount) } - return false - }, - [prefixSums] - ) + } + }, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode]) - const handleDigit = useCallback( - (digit: string) => { - setUserAnswer((prev) => { - if (isDigitConsistent(prev, digit)) { - return prev + digit - } else { - // Reject the digit - show red X and count as correction - setRejectedDigit(digit) - setCorrectionCount((c) => c + 1) - // Clear the rejection after a short delay - setTimeout(() => setRejectedDigit(null), 300) - return prev // Don't change the answer - } - }) - }, - [isDigitConsistent] - ) - - const handleBackspace = useCallback(() => { - setUserAnswer((prev) => { - if (prev.length > 0) { - setCorrectionCount((c) => c + 1) - } - return prev.slice(0, -1) - }) - }, []) - - // Handle when student reaches the target value on the help abacus - // This exits help mode completely and resets the problem to normal state + // Handle when student reaches target value on help abacus const handleTargetReached = useCallback(() => { - if (helpTermIndex === null || !currentProblem) return - - // Brief delay so user sees the success feedback, then exit help mode completely + if (phase.phase !== 'helpMode') return setTimeout(() => { - // Reset all help-related state - problem returns to as if they never entered a prefix - setHelpTermIndex(null) - setConfirmedTermCount(0) - setUserAnswer('') - }, 800) // 800ms delay to show "Perfect!" feedback - }, [helpTermIndex, currentProblem]) - - // Start transition animation to next problem - const startTransition = useCallback( - (nextProblem: GeneratedProblem, nextSlotIndex: number) => { - if (!currentProblem) return - - // Mark that we need to apply centering offset in useLayoutEffect - needsCenteringOffsetRef.current = true - - // Capture outgoing problem state - setOutgoingProblem({ - key: `${currentProblem.partIndex}-${currentProblem.slotIndex}`, - problem: currentProblem.problem, - userAnswer: userAnswer, - isCorrect: true, - }) - - // Set up next problem immediately (it fades in on right side) - setCurrentProblem({ - partIndex: currentPartIndex, - slotIndex: nextSlotIndex, - problem: nextProblem, - startTime: Date.now(), - }) - setUserAnswer('') - setHelpTermIndex(null) - setCorrectionCount(0) - setAutoSubmitTriggered(false) - setIsTransitioning(true) - // Animation is triggered by useLayoutEffect when outgoingProblem changes - }, - [currentProblem, userAnswer, currentPartIndex] - ) + exitHelpMode() + }, 800) + }, [phase.phase, exitHelpMode]) + // Handle submit const handleSubmit = useCallback(async () => { - if (!currentProblem || isSubmitting || !userAnswer) return + if (phase.phase !== 'inputting' && phase.phase !== 'helpMode') return + if (!phase.attempt.userAnswer) return - const answerNum = parseInt(userAnswer, 10) + const attemptData = phase.attempt + const answerNum = parseInt(attemptData.userAnswer, 10) if (Number.isNaN(answerNum)) return - setIsSubmitting(true) - const responseTimeMs = Date.now() - currentProblem.startTime - const isCorrect = answerNum === currentProblem.problem.answer + // Transition to submitting phase + startSubmit() - // Show feedback - setFeedback(isCorrect ? 'correct' : 'incorrect') - - // Track incorrect attempts - if (!isCorrect) { - setIncorrectAttempts((prev) => prev + 1) - } + const responseTimeMs = Date.now() - attemptData.startTime + const isCorrect = answerNum === attemptData.problem.answer // Record the result const result: Omit = { - slotIndex: currentProblem.slotIndex, - problem: currentProblem.problem, + slotIndex: attemptData.slotIndex, + problem: attemptData.problem, studentAnswer: answerNum, isCorrect, responseTimeMs, - skillsExercised: currentProblem.problem.skillsRequired, - usedOnScreenAbacus: confirmedTermCount > 0 || helpTermIndex !== null, - incorrectAttempts, - // Help level: 1 if any abacus help was used, 0 otherwise (simplified from multi-level system) - helpLevelUsed: helpTermIndex !== null ? 1 : 0, + skillsExercised: attemptData.problem.skillsRequired, + usedOnScreenAbacus: phase.phase === 'helpMode', + incorrectAttempts: 0, // TODO: track this properly + helpLevelUsed: phase.phase === 'helpMode' ? 1 : 0, } await onAnswer(result) + // Complete submit with result + completeSubmit(isCorrect ? 'correct' : 'incorrect') + // Wait for feedback display then advance setTimeout( () => { - // Check if there's a next problem in this part const nextSlotIndex = currentSlotIndex + 1 const nextSlot = currentPart?.slots[nextSlotIndex] @@ -555,60 +457,39 @@ export function ActiveSession({ // Has next problem - animate transition const nextProblem = nextSlot.problem || generateProblemFromConstraints(nextSlot.constraints) + + // Mark that we need to apply centering offset in useLayoutEffect + needsCenteringOffsetRef.current = true + startTransition(nextProblem, nextSlotIndex) } else { - // End of part or incorrect - no animation, just clean up - setCurrentProblem(null) - setIncorrectAttempts(0) - setConfirmedTermCount(0) - setHelpTermIndex(null) - setIsSubmitting(false) - setCorrectionCount(0) - setAutoSubmitTriggered(false) - setFeedback('none') + // End of part or incorrect - clear to loading + clearToLoading() } }, isCorrect ? 500 : 1500 ) }, [ - currentProblem, - isSubmitting, - userAnswer, - confirmedTermCount, - helpTermIndex, + phase, onAnswer, - incorrectAttempts, currentSlotIndex, currentPart, + startSubmit, + completeSubmit, startTransition, + clearToLoading, ]) - // Auto-submit when correct answer is entered on first attempt (allow minor corrections) + // Auto-submit when correct answer is entered useEffect(() => { - if (!currentProblem || isSubmitting || feedback !== 'none' || !userAnswer) return - // Allow up to 2 backspaces (one typo fix), but no more - if (correctionCount > 2) return - - const answerNum = parseInt(userAnswer, 10) - if (Number.isNaN(answerNum)) return - - // Check if answer matches - if (answerNum === currentProblem.problem.answer) { - // Trigger auto-submit with celebration - setAutoSubmitTriggered(true) - // Small delay to show the celebration animation before submitting - const timer = setTimeout(() => { - handleSubmit() - }, 400) - return () => clearTimeout(timer) + if (shouldAutoSubmit) { + handleSubmit() } - }, [userAnswer, currentProblem, isSubmitting, feedback, correctionCount, handleSubmit]) + }, [shouldAutoSubmit, handleSubmit]) - // Handle keyboard input (placed after handleSubmit to avoid temporal dead zone) + // Handle keyboard input useEffect(() => { - // Block input during transitions - if (!hasPhysicalKeyboard || isPaused || !currentProblem || isSubmitting || isTransitioning) - return + if (!hasPhysicalKeyboard || !canAcceptInput) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Backspace' || e.key === 'Delete') { @@ -620,31 +501,21 @@ export function ActiveSession({ } else if (/^[0-9]$/.test(e.key)) { handleDigit(e.key) } - // Note: removed negative sign handling since prefix sums are always positive } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [ - hasPhysicalKeyboard, - isPaused, - currentProblem, - isSubmitting, - isTransitioning, - handleSubmit, - handleDigit, - handleBackspace, - ]) + }, [hasPhysicalKeyboard, canAcceptInput, handleSubmit, handleDigit, handleBackspace]) const handlePause = useCallback(() => { - setIsPaused(true) + pause() onPause?.() - }, [onPause]) + }, [pause, onPause]) const handleResume = useCallback(() => { - setIsPaused(false) + resume() onResume?.() - }, [onResume]) + }, [resume, onResume]) const getHealthColor = (health: SessionHealth['overall']) => { switch (health) { @@ -672,7 +543,7 @@ export function ActiveSession({ } } - if (!currentPart || !currentProblem) { + if (!currentPart || !attempt) { return (
{/* Animated track for problem transitions */} @@ -998,7 +868,7 @@ export function ActiveSession({ }} > {/* Outgoing problem (slides left during transition) */} - {outgoingProblem && ( + {outgoingAttempt && ( {/* Feedback stays with outgoing problem */} @@ -1038,7 +908,7 @@ export function ActiveSession({ )} - {/* Problem container - relative positioning for help panel */} + {/* Current problem */} - {/* Problem display */} {currentPart.format === 'vertical' ? ( ) : ( = 0 && matchedPrefixIndex < prefixSums.length - 1 @@ -1094,7 +959,7 @@ export function ActiveSession({ )} {/* Help panel - absolutely positioned to the right of the problem */} - {!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && ( + {showHelpOverlay && helpContext && (
- {/* Feedback message */} - {feedback !== 'none' && ( + {/* Feedback message - only show for incorrect */} + {showFeedback && (
- {feedback === 'correct' - ? 'Correct!' - : `The answer was ${currentProblem.problem.answer}`} + The answer was {attempt.problem.answer}
)}
{/* Input area */} - {!isPaused && feedback === 'none' && ( + {showInputArea && !isPaused && (
- {/* Submit button */} + {/* Submit button - only shown when auto-submit threshold exceeded */}
- + Submit +
- {/* Physical keyboard hint */} - {hasPhysicalKeyboard && ( -
- Type your abacus total -
- )} - {/* On-screen keypad for mobile */} {hasPhysicalKeyboard === false && ( )}
@@ -1342,15 +1185,10 @@ export function ActiveSession({ /** * Generate a problem from slot constraints using the actual skill-based algorithm. - * - * Converts session plan constraints to the format expected by the problem generator, - * then generates a skill-appropriate problem. */ function generateProblemFromConstraints(constraints: ProblemConstraints): GeneratedProblem { - // Build a complete SkillSet from the partial constraints const baseSkillSet = createBasicSkillSet() - // Merge required skills if provided const requiredSkills: SkillSet = { basic: { ...baseSkillSet.basic, ...constraints.requiredSkills?.basic }, fiveComplements: { @@ -1371,7 +1209,6 @@ function generateProblemFromConstraints(constraints: ProblemConstraints): Genera }, } - // Convert to generator constraints format const maxDigits = constraints.digitRange?.max || 1 const maxValue = 10 ** maxDigits - 1 @@ -1381,7 +1218,6 @@ function generateProblemFromConstraints(constraints: ProblemConstraints): Genera problemCount: 1, } - // Try to generate using the skill-based algorithm const generatedProblem = generateSingleProblem( generatorConstraints, requiredSkills, @@ -1390,7 +1226,6 @@ function generateProblemFromConstraints(constraints: ProblemConstraints): Genera ) if (generatedProblem) { - // Convert from generator format to session format return { terms: generatedProblem.terms, answer: generatedProblem.answer, @@ -1398,7 +1233,7 @@ function generateProblemFromConstraints(constraints: ProblemConstraints): Genera } } - // Fallback: generate a simple problem if skill-based generation fails + // Fallback const termCount = constraints.termCount?.min || 3 const terms: number[] = [] for (let i = 0; i < termCount; i++) { diff --git a/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts b/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts new file mode 100644 index 00000000..a79b9170 --- /dev/null +++ b/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts @@ -0,0 +1,1029 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GeneratedProblem } from '@/db/schema/session-plans' +import { + computeHelpContext, + computePrefixSums, + createAttemptInput, + findMatchedPrefixIndex, + isDigitConsistent, + MANUAL_SUBMIT_THRESHOLD, + useInteractionPhase, +} from '../useInteractionPhase' + +// ============================================================================= +// Test Fixtures +// ============================================================================= + +const createTestProblem = (terms: number[]): GeneratedProblem => ({ + terms, + answer: terms.reduce((a, b) => a + b, 0), + skillsRequired: [], +}) + +const simpleProblem = createTestProblem([3, 4, 5]) // answer: 12 +const twoDigitProblem = createTestProblem([15, 27]) // answer: 42 + +// ============================================================================= +// Pure Function Tests +// ============================================================================= + +describe('computePrefixSums', () => { + it('computes running totals', () => { + expect(computePrefixSums([3, 4, 5])).toEqual([3, 7, 12]) + }) + + it('handles single term', () => { + expect(computePrefixSums([42])).toEqual([42]) + }) + + it('handles empty array', () => { + expect(computePrefixSums([])).toEqual([]) + }) + + it('handles negative terms', () => { + expect(computePrefixSums([10, -3, 5])).toEqual([10, 7, 12]) + }) +}) + +describe('isDigitConsistent', () => { + const sums = [3, 7, 12] // for problem [3, 4, 5] + + it('accepts digit that starts a valid prefix sum', () => { + expect(isDigitConsistent('', '1', sums)).toBe(true) // could be 12 + expect(isDigitConsistent('', '3', sums)).toBe(true) // could be 3 + expect(isDigitConsistent('', '7', sums)).toBe(true) // could be 7 + }) + + it('accepts digit that continues a valid prefix sum', () => { + expect(isDigitConsistent('1', '2', sums)).toBe(true) // 12 + }) + + it('rejects digit that cannot match any prefix sum', () => { + expect(isDigitConsistent('', '5', sums)).toBe(false) + expect(isDigitConsistent('1', '3', sums)).toBe(false) // 13 not in sums + }) + + it('handles two-digit prefix sums', () => { + const bigSums = [15, 42] // for problem [15, 27] + expect(isDigitConsistent('', '4', bigSums)).toBe(true) // could be 42 + expect(isDigitConsistent('4', '2', bigSums)).toBe(true) // 42 + expect(isDigitConsistent('4', '3', bigSums)).toBe(false) // 43 not valid + }) +}) + +describe('findMatchedPrefixIndex', () => { + const sums = [3, 7, 12] + + it('finds exact match', () => { + expect(findMatchedPrefixIndex('3', sums)).toBe(0) + expect(findMatchedPrefixIndex('7', sums)).toBe(1) + expect(findMatchedPrefixIndex('12', sums)).toBe(2) + }) + + it('returns -1 for no match', () => { + expect(findMatchedPrefixIndex('5', sums)).toBe(-1) + expect(findMatchedPrefixIndex('', sums)).toBe(-1) + }) + + it('returns -1 for non-numeric', () => { + expect(findMatchedPrefixIndex('abc', sums)).toBe(-1) + }) +}) + +describe('computeHelpContext', () => { + it('computes context for first term', () => { + const ctx = computeHelpContext([3, 4, 5], 0) + expect(ctx).toEqual({ + termIndex: 0, + currentValue: 0, + targetValue: 3, + term: 3, + }) + }) + + it('computes context for middle term', () => { + const ctx = computeHelpContext([3, 4, 5], 1) + expect(ctx).toEqual({ + termIndex: 1, + currentValue: 3, + targetValue: 7, + term: 4, + }) + }) + + it('computes context for last term', () => { + const ctx = computeHelpContext([3, 4, 5], 2) + expect(ctx).toEqual({ + termIndex: 2, + currentValue: 7, + targetValue: 12, + term: 5, + }) + }) +}) + +describe('createAttemptInput', () => { + it('creates fresh attempt with correct initial values', () => { + const before = Date.now() + const attempt = createAttemptInput(simpleProblem, 2, 1) + const after = Date.now() + + expect(attempt.problem).toBe(simpleProblem) + expect(attempt.slotIndex).toBe(2) + expect(attempt.partIndex).toBe(1) + expect(attempt.startTime).toBeGreaterThanOrEqual(before) + expect(attempt.startTime).toBeLessThanOrEqual(after) + expect(attempt.userAnswer).toBe('') + expect(attempt.correctionCount).toBe(0) + expect(attempt.manualSubmitRequired).toBe(false) + expect(attempt.rejectedDigit).toBe(null) + }) +}) + +// ============================================================================= +// Hook Tests: Initial State +// ============================================================================= + +describe('useInteractionPhase', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('initial state', () => { + it('starts in loading phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + expect(result.current.phase).toEqual({ phase: 'loading' }) + }) + + it('has correct UI predicates in loading phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + expect(result.current.canAcceptInput).toBe(false) + expect(result.current.showAsCompleted).toBe(false) + expect(result.current.showHelpOverlay).toBe(false) + expect(result.current.showInputArea).toBe(false) + expect(result.current.showFeedback).toBe(false) + expect(result.current.inputIsFocused).toBe(false) + }) + + it('has empty computed values in loading phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + expect(result.current.prefixSums).toEqual([]) + expect(result.current.matchedPrefixIndex).toBe(-1) + expect(result.current.canSubmit).toBe(false) + expect(result.current.shouldAutoSubmit).toBe(false) + }) + }) + + // =========================================================================== + // Phase Transitions + // =========================================================================== + + describe('loadProblem: loading → inputting', () => { + it('transitions to inputting phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + expect(result.current.phase.phase).toBe('inputting') + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.problem).toBe(simpleProblem) + expect(result.current.phase.attempt.slotIndex).toBe(0) + expect(result.current.phase.attempt.partIndex).toBe(0) + } + }) + + it('sets correct UI predicates in inputting phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + expect(result.current.canAcceptInput).toBe(true) + expect(result.current.showAsCompleted).toBe(false) + expect(result.current.showHelpOverlay).toBe(false) + expect(result.current.showInputArea).toBe(true) + expect(result.current.showFeedback).toBe(false) + expect(result.current.inputIsFocused).toBe(true) + }) + + it('computes prefix sums', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + expect(result.current.prefixSums).toEqual([3, 7, 12]) + }) + }) + + describe('handleDigit', () => { + it('accepts valid digit', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.userAnswer).toBe('1') + } + }) + + it('rejects invalid digit and increments correction count', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('5') // 5 is not a prefix of 3, 7, or 12 + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.userAnswer).toBe('') + expect(result.current.phase.attempt.correctionCount).toBe(1) + expect(result.current.phase.attempt.rejectedDigit).toBe('5') + } + }) + + it('clears rejected digit after timeout', async () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('5') + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.rejectedDigit).toBe('5') + } + + await act(async () => { + vi.advanceTimersByTime(301) + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.rejectedDigit).toBe(null) + } + }) + + it('triggers manual submit after threshold exceeded', async () => { + const onManualSubmitRequired = vi.fn() + const { result } = renderHook(() => useInteractionPhase({ onManualSubmitRequired })) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + // Make corrections to exceed threshold + for (let i = 0; i <= MANUAL_SUBMIT_THRESHOLD; i++) { + act(() => { + result.current.handleDigit('5') // invalid digit + }) + await act(async () => { + vi.advanceTimersByTime(301) + }) + } + + await act(async () => { + vi.advanceTimersByTime(1) + }) + + expect(onManualSubmitRequired).toHaveBeenCalled() + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.manualSubmitRequired).toBe(true) + } + }) + + it('does nothing in non-input phases', () => { + const { result } = renderHook(() => useInteractionPhase()) + + // In loading phase + act(() => { + result.current.handleDigit('1') + }) + + expect(result.current.phase.phase).toBe('loading') + }) + }) + + describe('handleBackspace', () => { + it('removes last digit', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + act(() => { + result.current.handleBackspace() + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.userAnswer).toBe('1') + } + }) + + it('increments correction count', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + }) + + act(() => { + result.current.handleBackspace() + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.correctionCount).toBe(1) + } + }) + + it('does nothing when answer is empty', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleBackspace() + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.correctionCount).toBe(0) + } + }) + }) + + describe('enterHelpMode: inputting → helpMode', () => { + it('transitions to helpMode with context', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.enterHelpMode(1) + }) + + expect(result.current.phase.phase).toBe('helpMode') + if (result.current.phase.phase === 'helpMode') { + expect(result.current.phase.helpContext).toEqual({ + termIndex: 1, + currentValue: 3, + targetValue: 7, + term: 4, + }) + } + }) + + it('clears user answer when entering help mode', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('3') + }) + + act(() => { + result.current.enterHelpMode(1) + }) + + if (result.current.phase.phase === 'helpMode') { + expect(result.current.phase.attempt.userAnswer).toBe('') + } + }) + + it('sets correct UI predicates in helpMode', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.enterHelpMode(1) + }) + + expect(result.current.canAcceptInput).toBe(true) + expect(result.current.showHelpOverlay).toBe(true) + expect(result.current.showInputArea).toBe(true) + expect(result.current.inputIsFocused).toBe(true) + }) + + it('does nothing if not in inputting phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.enterHelpMode(1) + }) + + expect(result.current.phase.phase).toBe('loading') + }) + }) + + describe('exitHelpMode: helpMode → inputting', () => { + it('transitions back to inputting', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.enterHelpMode(1) + }) + + act(() => { + result.current.exitHelpMode() + }) + + expect(result.current.phase.phase).toBe('inputting') + }) + + it('clears user answer when exiting help mode', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.enterHelpMode(1) + }) + + act(() => { + result.current.handleDigit('7') + }) + + act(() => { + result.current.exitHelpMode() + }) + + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.userAnswer).toBe('') + } + }) + }) + + describe('startSubmit: inputting → submitting', () => { + it('transitions to submitting phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + act(() => { + result.current.startSubmit() + }) + + expect(result.current.phase.phase).toBe('submitting') + }) + + it('preserves attempt state', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + act(() => { + result.current.startSubmit() + }) + + if (result.current.phase.phase === 'submitting') { + expect(result.current.phase.attempt.userAnswer).toBe('12') + } + }) + + it('sets showInputArea true but canAcceptInput false', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.startSubmit() + }) + + expect(result.current.showInputArea).toBe(true) + expect(result.current.canAcceptInput).toBe(false) + }) + }) + + describe('completeSubmit: submitting → showingFeedback', () => { + it('transitions to showingFeedback with correct result', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + act(() => { + result.current.startSubmit() + }) + + act(() => { + result.current.completeSubmit('correct') + }) + + expect(result.current.phase.phase).toBe('showingFeedback') + if (result.current.phase.phase === 'showingFeedback') { + expect(result.current.phase.result).toBe('correct') + } + }) + + it('sets showAsCompleted true for correct', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.startSubmit() + result.current.completeSubmit('correct') + }) + + expect(result.current.showAsCompleted).toBe(true) + }) + + it('sets showFeedback true for incorrect', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.startSubmit() + result.current.completeSubmit('incorrect') + }) + + expect(result.current.showFeedback).toBe(true) + }) + + it('sets showFeedback false for correct', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.startSubmit() + result.current.completeSubmit('correct') + }) + + expect(result.current.showFeedback).toBe(false) + }) + }) + + describe('startTransition: showingFeedback → transitioning', () => { + it('transitions with outgoing and incoming attempts', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + result.current.handleDigit('2') + result.current.startSubmit() + result.current.completeSubmit('correct') + }) + + const nextProblem = createTestProblem([5, 6]) + + act(() => { + result.current.startTransition(nextProblem, 1) + }) + + expect(result.current.phase.phase).toBe('transitioning') + if (result.current.phase.phase === 'transitioning') { + expect(result.current.phase.outgoing.userAnswer).toBe('12') + expect(result.current.phase.outgoing.result).toBe('correct') + expect(result.current.phase.incoming.problem).toBe(nextProblem) + expect(result.current.phase.incoming.slotIndex).toBe(1) + } + }) + + it('does nothing if not in showingFeedback', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + const nextProblem = createTestProblem([5, 6]) + + act(() => { + result.current.startTransition(nextProblem, 1) + }) + + expect(result.current.phase.phase).toBe('inputting') + }) + }) + + describe('completeTransition: transitioning → inputting', () => { + it('transitions to inputting with incoming attempt', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + result.current.handleDigit('2') + result.current.startSubmit() + result.current.completeSubmit('correct') + }) + + const nextProblem = createTestProblem([5, 6]) + + act(() => { + result.current.startTransition(nextProblem, 1) + }) + + act(() => { + result.current.completeTransition() + }) + + expect(result.current.phase.phase).toBe('inputting') + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.problem).toBe(nextProblem) + expect(result.current.phase.attempt.slotIndex).toBe(1) + } + }) + }) + + describe('clearToLoading', () => { + it('returns to loading phase from any phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.clearToLoading() + }) + + expect(result.current.phase.phase).toBe('loading') + }) + }) + + describe('pause/resume', () => { + it('pauses and stores resume phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('inputting') + } + }) + + it('resumes to previous phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + }) + + act(() => { + result.current.pause() + }) + + act(() => { + result.current.resume() + }) + + expect(result.current.phase.phase).toBe('inputting') + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.userAnswer).toBe('1') + } + }) + + it('does nothing when pausing from loading', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('loading') + }) + + it('does nothing when already paused', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + act(() => { + result.current.pause() + }) + + const pausedPhase = result.current.phase + + act(() => { + result.current.pause() + }) + + expect(result.current.phase).toEqual(pausedPhase) + }) + + it('can pause from helpMode', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.enterHelpMode(1) + }) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('helpMode') + } + }) + + it('can pause from submitting', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.startSubmit() + }) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('submitting') + } + }) + }) + + // =========================================================================== + // Computed Values + // =========================================================================== + + describe('canSubmit', () => { + it('is false when no answer', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + expect(result.current.canSubmit).toBe(false) + }) + + it('is true when answer has digits', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + }) + + expect(result.current.canSubmit).toBe(true) + }) + }) + + describe('shouldAutoSubmit', () => { + it('is true when answer matches and no corrections', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + expect(result.current.shouldAutoSubmit).toBe(true) + }) + + it('is false when answer is incorrect', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('3') + }) + + expect(result.current.shouldAutoSubmit).toBe(false) + }) + + it('is false when corrections exceed threshold', async () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + // Make corrections to exceed threshold + for (let i = 0; i <= MANUAL_SUBMIT_THRESHOLD; i++) { + act(() => { + result.current.handleDigit('5') + }) + await act(async () => { + vi.advanceTimersByTime(301) + }) + } + + // Now enter correct answer + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + expect(result.current.shouldAutoSubmit).toBe(false) + }) + + it('is false when not in inputting/helpMode phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + result.current.handleDigit('2') + result.current.startSubmit() + }) + + expect(result.current.shouldAutoSubmit).toBe(false) + }) + }) + + describe('matchedPrefixIndex', () => { + it('returns index when answer matches prefix sum', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('3') + }) + + expect(result.current.matchedPrefixIndex).toBe(0) + }) + + it('returns -1 when no match', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + }) + + expect(result.current.matchedPrefixIndex).toBe(-1) + }) + }) + + // =========================================================================== + // Full Flow Tests + // =========================================================================== + + describe('full correct answer flow', () => { + it('loading → inputting → submitting → showingFeedback → transitioning → inputting', () => { + const { result } = renderHook(() => useInteractionPhase()) + + // Start + expect(result.current.phase.phase).toBe('loading') + + // Load problem + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + expect(result.current.phase.phase).toBe('inputting') + + // Enter answer + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + // Submit + act(() => { + result.current.startSubmit() + }) + expect(result.current.phase.phase).toBe('submitting') + + // Complete submit + act(() => { + result.current.completeSubmit('correct') + }) + expect(result.current.phase.phase).toBe('showingFeedback') + expect(result.current.showAsCompleted).toBe(true) + + // Start transition + const nextProblem = createTestProblem([5, 6]) + act(() => { + result.current.startTransition(nextProblem, 1) + }) + expect(result.current.phase.phase).toBe('transitioning') + + // Complete transition + act(() => { + result.current.completeTransition() + }) + expect(result.current.phase.phase).toBe('inputting') + if (result.current.phase.phase === 'inputting') { + expect(result.current.phase.attempt.problem).toBe(nextProblem) + } + }) + }) + + describe('help mode flow', () => { + it('inputting → helpMode → inputting → submitting', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + }) + + // Enter prefix sum that triggers help + act(() => { + result.current.handleDigit('3') + }) + expect(result.current.matchedPrefixIndex).toBe(0) + + // Enter help mode + act(() => { + result.current.enterHelpMode(1) + }) + expect(result.current.phase.phase).toBe('helpMode') + expect(result.current.showHelpOverlay).toBe(true) + + // Exit help mode + act(() => { + result.current.exitHelpMode() + }) + expect(result.current.phase.phase).toBe('inputting') + expect(result.current.showHelpOverlay).toBe(false) + + // Enter final answer + act(() => { + result.current.handleDigit('1') + result.current.handleDigit('2') + }) + + // Submit + act(() => { + result.current.startSubmit() + }) + expect(result.current.phase.phase).toBe('submitting') + }) + }) +}) diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts new file mode 100644 index 00000000..69bfd7b1 --- /dev/null +++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts @@ -0,0 +1,515 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import type { GeneratedProblem } from '@/db/schema/session-plans' + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Input-level state for a problem attempt. + * This is what the student is actively working on - their answer input. + */ +export interface AttemptInput { + /** The problem being solved */ + problem: GeneratedProblem + /** Index in the current part */ + slotIndex: number + /** Part index in the session */ + partIndex: number + /** When the attempt started */ + startTime: number + /** User's current answer input */ + userAnswer: string + /** Number of times user used backspace or had digits rejected */ + correctionCount: number + /** Whether manual submit is required (exceeded auto-submit threshold) */ + manualSubmitRequired: boolean + /** Rejected digit to show as red X (null = no rejection) */ + rejectedDigit: string | null +} + +/** + * Context for help mode - computed when entering help + */ +export interface HelpContext { + /** Index of the term being helped with */ + termIndex: number + /** Current running total before this term */ + currentValue: number + /** Target value after adding this term */ + targetValue: number + /** The term being added */ + term: number +} + +/** + * Snapshot of an attempt that's animating out during transition + */ +export interface OutgoingAttempt { + key: string + problem: GeneratedProblem + userAnswer: string + result: 'correct' | 'incorrect' +} + +/** + * Discriminated union representing all possible interaction phases. + * Each phase carries exactly the data needed for that phase. + */ +export type InteractionPhase = + // No problem loaded yet, waiting for initialization + | { phase: 'loading' } + + // Student is actively entering digits for their answer + | { + phase: 'inputting' + attempt: AttemptInput + } + + // Student triggered help mode by entering a prefix sum + | { + phase: 'helpMode' + attempt: AttemptInput + helpContext: HelpContext + } + + // Answer submitted, waiting for server response + | { + phase: 'submitting' + attempt: AttemptInput + } + + // Showing feedback (correct/incorrect) after submission + | { + phase: 'showingFeedback' + attempt: AttemptInput + result: 'correct' | 'incorrect' + } + + // Animating transition to next problem + | { + phase: 'transitioning' + outgoing: OutgoingAttempt + incoming: AttemptInput + } + + // Session paused - remembers what phase to return to + | { + phase: 'paused' + resumePhase: Exclude + } + +/** Threshold for correction count before requiring manual submit */ +export const MANUAL_SUBMIT_THRESHOLD = 2 + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Creates a fresh attempt input for a new problem + */ +export function createAttemptInput( + problem: GeneratedProblem, + slotIndex: number, + partIndex: number +): AttemptInput { + return { + problem, + slotIndex, + partIndex, + startTime: Date.now(), + userAnswer: '', + correctionCount: 0, + manualSubmitRequired: false, + rejectedDigit: null, + } +} + +/** + * Computes prefix sums for a problem's terms. + * prefixSums[i] = sum of terms[0..i] (inclusive) + */ +export function computePrefixSums(terms: number[]): number[] { + const sums: number[] = [] + let total = 0 + for (const term of terms) { + total += term + sums.push(total) + } + return sums +} + +/** + * Checks if a digit would be consistent with any prefix sum. + */ +export function isDigitConsistent( + currentAnswer: string, + digit: string, + prefixSums: number[] +): boolean { + const newAnswer = currentAnswer + digit + const newAnswerNum = parseInt(newAnswer, 10) + if (Number.isNaN(newAnswerNum)) return false + + for (const sum of prefixSums) { + const sumStr = sum.toString() + if (sumStr.startsWith(newAnswer)) { + return true + } + } + return false +} + +/** + * Finds which prefix sum the user's answer matches, if any. + * Returns -1 if no match. + */ +export function findMatchedPrefixIndex(userAnswer: string, prefixSums: number[]): number { + const answerNum = parseInt(userAnswer, 10) + if (Number.isNaN(answerNum)) return -1 + return prefixSums.indexOf(answerNum) +} + +/** + * Computes help context for a given term index + */ +export function computeHelpContext(terms: number[], termIndex: number): HelpContext { + const sums = computePrefixSums(terms) + const currentValue = termIndex === 0 ? 0 : sums[termIndex - 1] + const targetValue = sums[termIndex] + const term = terms[termIndex] + return { termIndex, currentValue, targetValue, term } +} + +// ============================================================================= +// Hook +// ============================================================================= + +export interface UseInteractionPhaseOptions { + /** Called when auto-submit threshold is exceeded */ + onManualSubmitRequired?: () => void +} + +export interface UseInteractionPhaseReturn { + // Current phase + phase: InteractionPhase + + // Derived predicates for UI + /** Can we accept keyboard/keypad input? */ + canAcceptInput: boolean + /** Should the problem display show as completed? */ + showAsCompleted: boolean + /** Should the help overlay be shown? */ + showHelpOverlay: boolean + /** Should the input area (keypad/submit) be shown? */ + showInputArea: boolean + /** Should the feedback message be shown? */ + showFeedback: boolean + /** Is the input box focused? */ + inputIsFocused: boolean + + // Computed values (only valid when attempt exists) + /** Prefix sums for current problem */ + prefixSums: number[] + /** Matched prefix index (-1 if none) */ + matchedPrefixIndex: number + /** Can the submit button be pressed? */ + canSubmit: boolean + /** Should auto-submit trigger? */ + shouldAutoSubmit: boolean + + // Actions + /** Load a new problem (loading → inputting) */ + loadProblem: (problem: GeneratedProblem, slotIndex: number, partIndex: number) => void + /** Handle digit input */ + handleDigit: (digit: string) => void + /** Handle backspace */ + handleBackspace: () => void + /** Enter help mode (inputting → helpMode) */ + enterHelpMode: (termIndex: number) => void + /** Exit help mode (helpMode → inputting) */ + exitHelpMode: () => void + /** Submit answer (inputting/helpMode → submitting) */ + startSubmit: () => void + /** Handle submit result (submitting → showingFeedback) */ + completeSubmit: (result: 'correct' | 'incorrect') => void + /** Start transition to next problem (showingFeedback → transitioning) */ + startTransition: (nextProblem: GeneratedProblem, nextSlotIndex: number) => void + /** Complete transition (transitioning → inputting) */ + completeTransition: () => void + /** Clear to loading state */ + clearToLoading: () => void + /** Pause session (* → paused) */ + pause: () => void + /** Resume session (paused → resumePhase) */ + resume: () => void +} + +export function useInteractionPhase( + options: UseInteractionPhaseOptions = {} +): UseInteractionPhaseReturn { + const { onManualSubmitRequired } = options + const [phase, setPhase] = useState({ phase: 'loading' }) + + // ========================================================================== + // Derived State + // ========================================================================== + + // Extract attempt from phase if available + const attempt = useMemo((): AttemptInput | null => { + switch (phase.phase) { + case 'inputting': + case 'helpMode': + case 'submitting': + case 'showingFeedback': + return phase.attempt + case 'transitioning': + return phase.incoming + case 'paused': { + // Recurse into resumePhase + const inner = phase.resumePhase + if ( + inner.phase === 'inputting' || + inner.phase === 'helpMode' || + inner.phase === 'submitting' || + inner.phase === 'showingFeedback' + ) { + return inner.attempt + } + if (inner.phase === 'transitioning') { + return inner.incoming + } + return null + } + default: + return null + } + }, [phase]) + + const prefixSums = useMemo(() => { + if (!attempt) return [] + return computePrefixSums(attempt.problem.terms) + }, [attempt]) + + const matchedPrefixIndex = useMemo(() => { + if (!attempt) return -1 + return findMatchedPrefixIndex(attempt.userAnswer, prefixSums) + }, [attempt, prefixSums]) + + const canSubmit = useMemo(() => { + if (!attempt || !attempt.userAnswer) return false + const answerNum = parseInt(attempt.userAnswer, 10) + return !Number.isNaN(answerNum) + }, [attempt]) + + const shouldAutoSubmit = useMemo(() => { + if (phase.phase !== 'inputting' && phase.phase !== 'helpMode') return false + if (!attempt || !attempt.userAnswer) return false + if (attempt.correctionCount > MANUAL_SUBMIT_THRESHOLD) return false + + const answerNum = parseInt(attempt.userAnswer, 10) + if (Number.isNaN(answerNum)) return false + + return answerNum === attempt.problem.answer + }, [phase.phase, attempt]) + + // UI predicates + const canAcceptInput = phase.phase === 'inputting' || phase.phase === 'helpMode' + + const showAsCompleted = phase.phase === 'showingFeedback' + + const showHelpOverlay = phase.phase === 'helpMode' + + const showInputArea = + phase.phase === 'inputting' || phase.phase === 'helpMode' || phase.phase === 'submitting' + + const showFeedback = phase.phase === 'showingFeedback' && phase.result === 'incorrect' + + const inputIsFocused = phase.phase === 'inputting' || phase.phase === 'helpMode' + + // ========================================================================== + // Actions + // ========================================================================== + + const loadProblem = useCallback( + (problem: GeneratedProblem, slotIndex: number, partIndex: number) => { + const newAttempt = createAttemptInput(problem, slotIndex, partIndex) + setPhase({ phase: 'inputting', attempt: newAttempt }) + }, + [] + ) + + const handleDigit = useCallback( + (digit: string) => { + setPhase((prev) => { + if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev + + const attempt = prev.attempt + const sums = computePrefixSums(attempt.problem.terms) + + if (isDigitConsistent(attempt.userAnswer, digit, sums)) { + const updatedAttempt = { + ...attempt, + userAnswer: attempt.userAnswer + digit, + rejectedDigit: null, + } + return { ...prev, attempt: updatedAttempt } + } else { + // Reject the digit + const newCorrectionCount = attempt.correctionCount + 1 + const nowRequiresManualSubmit = + newCorrectionCount > MANUAL_SUBMIT_THRESHOLD && !attempt.manualSubmitRequired + + if (nowRequiresManualSubmit) { + setTimeout(() => onManualSubmitRequired?.(), 0) + } + + const updatedAttempt = { + ...attempt, + rejectedDigit: digit, + correctionCount: newCorrectionCount, + manualSubmitRequired: attempt.manualSubmitRequired || nowRequiresManualSubmit, + } + return { ...prev, attempt: updatedAttempt } + } + }) + + // Clear rejected digit after animation + setTimeout(() => { + setPhase((prev) => { + if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev + return { ...prev, attempt: { ...prev.attempt, rejectedDigit: null } } + }) + }, 300) + }, + [onManualSubmitRequired] + ) + + const handleBackspace = useCallback(() => { + setPhase((prev) => { + if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev + + const attempt = prev.attempt + if (attempt.userAnswer.length === 0) return prev + + const newCorrectionCount = attempt.correctionCount + 1 + const nowRequiresManualSubmit = + newCorrectionCount > MANUAL_SUBMIT_THRESHOLD && !attempt.manualSubmitRequired + + if (nowRequiresManualSubmit) { + setTimeout(() => onManualSubmitRequired?.(), 0) + } + + const updatedAttempt = { + ...attempt, + userAnswer: attempt.userAnswer.slice(0, -1), + correctionCount: newCorrectionCount, + manualSubmitRequired: attempt.manualSubmitRequired || nowRequiresManualSubmit, + } + return { ...prev, attempt: updatedAttempt } + }) + }, [onManualSubmitRequired]) + + const enterHelpMode = useCallback((termIndex: number) => { + setPhase((prev) => { + if (prev.phase !== 'inputting') return prev + + const helpContext = computeHelpContext(prev.attempt.problem.terms, termIndex) + const updatedAttempt = { ...prev.attempt, userAnswer: '' } + return { phase: 'helpMode', attempt: updatedAttempt, helpContext } + }) + }, []) + + const exitHelpMode = useCallback(() => { + setPhase((prev) => { + if (prev.phase !== 'helpMode') return prev + const updatedAttempt = { ...prev.attempt, userAnswer: '' } + return { phase: 'inputting', attempt: updatedAttempt } + }) + }, []) + + const startSubmit = useCallback(() => { + setPhase((prev) => { + if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev + return { phase: 'submitting', attempt: prev.attempt } + }) + }, []) + + const completeSubmit = useCallback((result: 'correct' | 'incorrect') => { + setPhase((prev) => { + if (prev.phase !== 'submitting') return prev + return { phase: 'showingFeedback', attempt: prev.attempt, result } + }) + }, []) + + const startTransition = useCallback((nextProblem: GeneratedProblem, nextSlotIndex: number) => { + setPhase((prev) => { + if (prev.phase !== 'showingFeedback') return prev + + const outgoing: OutgoingAttempt = { + key: `${prev.attempt.partIndex}-${prev.attempt.slotIndex}`, + problem: prev.attempt.problem, + userAnswer: prev.attempt.userAnswer, + result: prev.result, + } + + const incoming = createAttemptInput(nextProblem, nextSlotIndex, prev.attempt.partIndex) + + return { phase: 'transitioning', outgoing, incoming } + }) + }, []) + + const completeTransition = useCallback(() => { + setPhase((prev) => { + if (prev.phase !== 'transitioning') return prev + return { phase: 'inputting', attempt: prev.incoming } + }) + }, []) + + const clearToLoading = useCallback(() => { + setPhase({ phase: 'loading' }) + }, []) + + const pause = useCallback(() => { + setPhase((prev) => { + if (prev.phase === 'paused' || prev.phase === 'loading') return prev + return { phase: 'paused', resumePhase: prev } + }) + }, []) + + const resume = useCallback(() => { + setPhase((prev) => { + if (prev.phase !== 'paused') return prev + return prev.resumePhase + }) + }, []) + + return { + phase, + canAcceptInput, + showAsCompleted, + showHelpOverlay, + showInputArea, + showFeedback, + inputIsFocused, + prefixSums, + matchedPrefixIndex, + canSubmit, + shouldAutoSubmit, + loadProblem, + handleDigit, + handleBackspace, + enterHelpMode, + exitHelpMode, + startSubmit, + completeSubmit, + startTransition, + completeTransition, + clearToLoading, + pause, + resume, + } +}