refactor(practice): replace boolean flags with state machine

Replace scattered boolean state flags (isPaused, isSubmitting, isTransitioning,
feedback, helpTermIndex) with a single discriminated union state machine.

- Add useInteractionPhase hook with 7 explicit phases:
  loading, inputting, helpMode, submitting, showingFeedback, transitioning, paused
- Derive all UI predicates from phase state (canAcceptInput, showHelpOverlay, etc.)
- Delete useProblemAttempt hook (superseded by state machine)
- Add 62 comprehensive tests for phase transitions and derived state

Benefits:
- Single source of truth for all interaction state
- Impossible states eliminated (can't be paused AND submitting)
- Explicit phase transitions instead of scattered boolean flipping
- Type safety ensures phase-appropriate data access

Both vertical and linear problem formats use the same state machine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-08 15:18:05 -06:00
parent fd7e317151
commit 4d41c9c54a
4 changed files with 1892 additions and 378 deletions

View File

@@ -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<InteractionPhase, { phase: 'paused' }> }
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

View File

@@ -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<CurrentProblem | null>(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<number | null>(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<string | null>(null)
// Sound effects
const { playSound } = usePracticeSoundEffects()
// Problem transition animation state
const [outgoingProblem, setOutgoingProblem] = useState<OutgoingProblem | null>(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<HTMLDivElement>(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<SlotResult, 'timestamp' | 'partNumber'> = {
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 (
<div
data-component="active-session"
@@ -700,6 +571,7 @@ export function ActiveSession({
<div
data-component="active-session"
data-status={isPaused ? 'paused' : 'active'}
data-phase={phase.phase}
data-part-type={currentPart.type}
className={css({
display: 'flex',
@@ -922,8 +794,7 @@ export function ActiveSession({
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
// Use explicit padding values - shorthand 'padding' can override specific paddingTop
paddingTop: '4rem', // Extra top padding for help overlay that extends above
paddingTop: '4rem',
paddingRight: '2rem',
paddingBottom: '2rem',
paddingLeft: '2rem',
@@ -985,7 +856,6 @@ export function ActiveSession({
display: 'flex',
justifyContent: 'center',
width: '100%',
// No overflow clipping - outgoing problem fades to opacity 0 anyway
})}
>
{/* Animated track for problem transitions */}
@@ -998,7 +868,7 @@ export function ActiveSession({
}}
>
{/* Outgoing problem (slides left during transition) */}
{outgoingProblem && (
{outgoingAttempt && (
<animated.div
ref={outgoingRef}
data-element="outgoing-problem"
@@ -1009,10 +879,10 @@ export function ActiveSession({
}}
>
<VerticalProblem
terms={outgoingProblem.problem.terms}
userAnswer={outgoingProblem.userAnswer}
terms={outgoingAttempt.problem.terms}
userAnswer={outgoingAttempt.userAnswer}
isCompleted={true}
correctAnswer={outgoingProblem.problem.answer}
correctAnswer={outgoingAttempt.problem.answer}
size="large"
/>
{/* Feedback stays with outgoing problem */}
@@ -1038,7 +908,7 @@ export function ActiveSession({
</animated.div>
)}
{/* Problem container - relative positioning for help panel */}
{/* Current problem */}
<animated.div
ref={activeRef}
data-element="problem-container"
@@ -1047,23 +917,18 @@ export function ActiveSession({
position: 'relative' as const,
}}
>
{/* Problem display */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
size="large"
currentHelpTermIndex={helpTermIndex ?? undefined}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
currentHelpTermIndex={helpContext?.termIndex}
rejectedDigit={attempt.rejectedDigit}
helpOverlay={
!isSubmitting &&
feedback === 'none' &&
helpTermIndex !== null &&
helpContext ? (
showHelpOverlay && helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
@@ -1079,11 +944,11 @@ export function ActiveSession({
/>
) : (
<LinearProblem
terms={currentProblem.problem.terms}
userAnswer={userAnswer}
isFocused={!isPaused && !isSubmitting}
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 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 && (
<div
data-element="help-panel"
className={css({
@@ -1146,7 +1011,7 @@ export function ActiveSession({
)
})()}
{/* Decomposition display - hides when not meaningful */}
{/* Decomposition display */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
@@ -1185,8 +1050,8 @@ export function ActiveSession({
</animated.div>
</div>
{/* Feedback message */}
{feedback !== 'none' && (
{/* Feedback message - only show for incorrect */}
{showFeedback && (
<div
data-element="feedback"
className={css({
@@ -1194,81 +1059,58 @@ export function ActiveSession({
borderRadius: '8px',
fontSize: '1.25rem',
fontWeight: 'bold',
backgroundColor:
feedback === 'correct'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'red.900'
: 'red.100',
color:
feedback === 'correct'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'red.200'
: 'red.700',
backgroundColor: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.200' : 'red.700',
})}
>
{feedback === 'correct'
? 'Correct!'
: `The answer was ${currentProblem.problem.answer}`}
The answer was {attempt.problem.answer}
</div>
)}
</div>
{/* Input area */}
{!isPaused && feedback === 'none' && (
{showInputArea && !isPaused && (
<div data-section="input-area">
{/* Submit button */}
{/* Submit button - only shown when auto-submit threshold exceeded */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '1rem',
minHeight: '52px',
overflow: 'hidden',
})}
>
<button
<animated.button
type="button"
data-action="submit"
data-visible={attempt.manualSubmitRequired}
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
disabled={!canSubmit || isSubmitting || !attempt.manualSubmitRequired}
style={submitButtonSpring}
className={css({
padding: '0.75rem 2rem',
fontSize: '1.125rem',
fontWeight: 'bold',
borderRadius: '8px',
border: 'none',
cursor: !canSubmit ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
cursor: !canSubmit || !attempt.manualSubmitRequired ? 'not-allowed' : 'pointer',
backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white',
opacity: !canSubmit ? 0.5 : 1,
_hover: {
backgroundColor: canSubmit ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
backgroundColor:
canSubmit && attempt.manualSubmitRequired
? 'blue.600'
: isDark
? 'gray.600'
: 'gray.300',
},
})}
>
{canSubmit ? 'Submit' : 'Enter Total'}
</button>
Submit
</animated.button>
</div>
{/* Physical keyboard hint */}
{hasPhysicalKeyboard && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: '0.875rem',
marginBottom: '1rem',
})}
>
Type your abacus total
</div>
)}
{/* On-screen keypad for mobile */}
{hasPhysicalKeyboard === false && (
<NumericKeypad
@@ -1276,7 +1118,8 @@ export function ActiveSession({
onBackspace={handleBackspace}
onSubmit={handleSubmit}
disabled={isSubmitting}
currentValue={userAnswer}
currentValue={attempt.userAnswer}
showSubmitButton={attempt.manualSubmitRequired}
/>
)}
</div>
@@ -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++) {

File diff suppressed because it is too large Load Diff

View File

@@ -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<InteractionPhase, { phase: 'paused' }>
}
/** 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<InteractionPhase>({ 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,
}
}