Files
soroban-abacus-flashcards/apps/web/src/components/practice/ActiveSession.tsx
Thomas Hallock 18ce1f41af feat(practice): add response time tracking and live timing display
- Fix response time bug: exclude pause duration from calculations
- Add global per-kid stats tracking with new DB columns
- Create SkillPerformanceReports component for dashboard
- Add PracticeTimingDisplay with live problem timer and speed meter
- Extract SpeedMeter to shared component
- Add defensive handling for empty JSON in abacus-settings API

New features:
- Live timer showing elapsed time on current problem
- Speed visualization bar showing position vs average
- Per-part-type timing breakdown (abacus/visualize/linear)
- Skill performance analysis on dashboard (fast/slow/weak skills)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:23:03 -06:00

1479 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import type {
ProblemSlot,
SessionHealth,
SessionPart,
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
import type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
// Re-export types for consumers
export type { AutoPauseStats, PauseInfo }
// ============================================================================
// Auto-pause threshold calculation
// ============================================================================
const DEFAULT_PAUSE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes default
const MIN_SAMPLES_FOR_STATISTICS = 5 // Minimum problems needed for statistical calculation
/**
* Calculate mean and standard deviation of response times
*/
function calculateResponseTimeStats(results: SlotResult[]): {
mean: number
stdDev: number
count: number
} {
if (results.length === 0) {
return { mean: 0, stdDev: 0, count: 0 }
}
const times = results.map((r) => r.responseTimeMs)
const count = times.length
const mean = times.reduce((sum, t) => sum + t, 0) / count
if (count < 2) {
return { mean, stdDev: 0, count }
}
const squaredDiffs = times.map((t) => (t - mean) ** 2)
const variance = squaredDiffs.reduce((sum, d) => sum + d, 0) / (count - 1) // Sample std dev
const stdDev = Math.sqrt(variance)
return { mean, stdDev, count }
}
/**
* Calculate the auto-pause threshold and full stats for display.
*/
function calculateAutoPauseInfo(results: SlotResult[]): {
threshold: number
stats: AutoPauseStats
} {
const { mean, stdDev, count } = calculateResponseTimeStats(results)
const usedStatistics = count >= MIN_SAMPLES_FOR_STATISTICS
let threshold: number
if (usedStatistics) {
// Use mean + 2 standard deviations
threshold = mean + 2 * stdDev
// Clamp between 30 seconds and 5 minutes
threshold = Math.max(30_000, Math.min(threshold, DEFAULT_PAUSE_TIMEOUT_MS))
} else {
threshold = DEFAULT_PAUSE_TIMEOUT_MS
}
return {
threshold,
stats: {
meanMs: mean,
stdDevMs: stdDev,
thresholdMs: threshold,
sampleCount: count,
usedStatistics,
},
}
}
import { css } from '../../../styled-system/css'
import { AbacusDock } from '../AbacusDock'
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 { PracticeTimingDisplay } from './PracticeTimingDisplay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
interface ActiveSessionProps {
plan: SessionPlan
studentName: string
/** Called when a problem is answered */
onAnswer: (result: Omit<SlotResult, 'timestamp' | 'partNumber'>) => Promise<void>
/** Called when session is ended early */
onEndEarly: (reason?: string) => void
/** Called when session is paused (with info about why) */
onPause?: (pauseInfo: PauseInfo) => void
/** Called when session is resumed */
onResume?: () => void
/** Called when session completes */
onComplete: () => void
/** Hide the built-in HUD (when using external HUD in PracticeSubNav) */
hideHud?: boolean
}
/**
* Get the part type description for display
*/
function getPartTypeLabel(type: SessionPart['type']): string {
switch (type) {
case 'abacus':
return 'Use Abacus'
case 'visualization':
return 'Mental Math (Visualization)'
case 'linear':
return 'Mental Math (Linear)'
}
}
/**
* Get part type emoji
*/
function getPartTypeEmoji(type: SessionPart['type']): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
}
}
/**
* Linear problem display component for Part 3
*/
function LinearProblem({
terms,
userAnswer,
isFocused,
isCompleted,
correctAnswer,
isDark,
detectedPrefixIndex,
}: {
terms: number[]
userAnswer: string
isFocused: boolean
isCompleted: boolean
correctAnswer: number
isDark: boolean
/** Detected prefix index - shows "..." instead of "=" for partial sums */
detectedPrefixIndex?: number
}) {
// Build the equation string
const equation = terms
.map((term, i) => {
if (i === 0) return String(term)
return term < 0 ? ` - ${Math.abs(term)}` : ` + ${term}`
})
.join('')
// Use "..." for prefix sums (mathematically incomplete), "=" for final answer
const isPrefixSum = detectedPrefixIndex !== undefined
const operator = isPrefixSum ? '…' : '='
return (
<div
data-component="linear-problem"
data-prefix-mode={isPrefixSum ? 'true' : undefined}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1rem',
fontFamily: 'monospace',
fontSize: '2rem',
fontWeight: 'bold',
})}
>
<span className={css({ color: isDark ? 'gray.200' : 'gray.800' })}>
{equation}{' '}
<span
className={css({
color: isPrefixSum
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'gray.200'
: 'gray.800',
})}
>
{operator}
</span>
</span>
<span
className={css({
minWidth: '80px',
padding: '0.5rem 1rem',
borderRadius: '8px',
textAlign: 'center',
backgroundColor: isCompleted
? userAnswer === String(correctAnswer)
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'red.900'
: 'red.100'
: isDark
? 'gray.800'
: 'gray.100',
color: isCompleted
? userAnswer === String(correctAnswer)
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'red.200'
: 'red.700'
: isDark
? 'gray.200'
: 'gray.800',
border: '2px solid',
borderColor: isFocused ? 'blue.400' : isDark ? 'gray.600' : 'gray.300',
})}
>
{userAnswer || (isFocused ? '?' : '')}
</span>
</div>
)
}
/**
* ActiveSession - The main practice session component
*
* Features:
* - Three-part session structure (abacus, visualization, linear)
* - Part-specific display and instructions
* - Adaptive input (keyboard on desktop, on-screen keypad on mobile)
* - 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,
studentName,
onAnswer,
onEndEarly,
onPause,
onResume,
onComplete,
hideHud = false,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Check if abacus is docked (to force show submit button)
const { isDockedByUser } = useMyAbacus()
// Sound effects
const { playSound } = usePracticeSoundEffects()
// Compute initial problem from plan for SSR hydration (must be before useInteractionPhase)
const initialProblem = useMemo(() => {
const currentPart = plan.parts[plan.currentPartIndex]
const currentSlot = currentPart?.slots[plan.currentSlotIndex]
if (currentPart && currentSlot?.problem) {
return {
problem: currentSlot.problem,
slotIndex: plan.currentSlotIndex,
partIndex: plan.currentPartIndex,
}
}
return undefined
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
// Interaction state machine - single source of truth for UI state
const {
phase,
attempt,
helpContext,
outgoingAttempt,
canAcceptInput,
showAsCompleted,
showHelpOverlay,
showInputArea,
showFeedback,
inputIsFocused,
isTransitioning,
isPaused,
isSubmitting,
prefixSums,
matchedPrefixIndex,
canSubmit,
shouldAutoSubmit,
ambiguousHelpTermIndex,
loadProblem,
handleDigit,
handleBackspace,
enterHelpMode,
exitHelpMode,
clearAnswer,
setAnswer,
startSubmit,
completeSubmit,
startTransition,
completeTransition,
clearToLoading,
pause,
resume,
} = useInteractionPhase({
initialProblem,
onManualSubmitRequired: () => playSound('womp_womp'),
})
// Track which help elements have been individually dismissed
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
const [helpPanelDismissed, setHelpPanelDismissed] = useState(false)
// Track when answer is fading out (for dream sequence)
const [answerFadingOut, setAnswerFadingOut] = useState(false)
// Reset dismissed states when help context changes (new help session)
useEffect(() => {
if (helpContext) {
setHelpAbacusDismissed(false)
setHelpPanelDismissed(false)
setAnswerFadingOut(false)
}
}, [helpContext])
// Exit help mode when both help elements are dismissed
useEffect(() => {
if (showHelpOverlay && helpAbacusDismissed && helpPanelDismissed) {
exitHelpMode()
}
}, [showHelpOverlay, helpAbacusDismissed, helpPanelDismissed, exitHelpMode])
// Refs for measuring problem widths during animation
const outgoingRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLDivElement>(null)
// Track problem container height for AbacusDock sizing
const [problemHeight, setProblemHeight] = useState<number | null>(null)
// Measure problem container height with ResizeObserver
useEffect(() => {
const element = activeRef.current
if (!element) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setProblemHeight(entry.contentRect.height)
}
})
observer.observe(element)
// Initial measurement
setProblemHeight(element.offsetHeight)
return () => observer.disconnect()
}, [])
// Track if we need to apply centering offset (set true when transition starts)
const needsCenteringOffsetRef = useRef(false)
// Store the centering offset value for the animation end
const centeringOffsetRef = useRef(0)
// Spring for problem transition animation
const [trackSpring, trackApi] = useSpring(() => ({
x: 0,
outgoingOpacity: 1,
activeOpacity: 1,
config: { tension: 200, friction: 26 },
}))
// Spring for submit button entrance animation
// Show submit button when: manual submit is required OR abacus is docked (user needs way to submit)
const showSubmitButton = attempt?.manualSubmitRequired || isDockedByUser
const submitButtonSpring = useSpring({
transform: showSubmitButton ? 'translateY(0px)' : 'translateY(60px)',
opacity: showSubmitButton ? 1 : 0,
scale: showSubmitButton ? 1 : 0.8,
config: { tension: 280, friction: 14 },
})
// Apply centering offset before paint to prevent jank
useLayoutEffect(() => {
if (needsCenteringOffsetRef.current && outgoingRef.current) {
const outgoingWidth = outgoingRef.current.offsetWidth
const gap = 32 // 2rem gap
const centeringOffset = (outgoingWidth + gap) / 2
centeringOffsetRef.current = centeringOffset
// Set initial position to compensate for flexbox centering
trackApi.set({
x: centeringOffset,
outgoingOpacity: 1,
activeOpacity: 0,
})
needsCenteringOffsetRef.current = false
// Start fade-in of new problem
trackApi.start({
activeOpacity: 1,
config: { tension: 200, friction: 26 },
})
// Start slide after a brief moment (150ms)
setTimeout(() => {
trackApi.start({
x: -centeringOffset,
outgoingOpacity: 0,
config: { tension: 170, friction: 22 },
onRest: () => {
// Outgoing is now invisible - complete the transition
flushSync(() => {
completeTransition()
})
trackApi.set({ x: 0, outgoingOpacity: 1, activeOpacity: 1 })
},
})
}, 150)
}
}, [outgoingAttempt, trackApi, completeTransition])
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
// Track if keypad was ever shown - once shown, keep it visible
// This prevents the keypad from disappearing if user uses physical keyboard
const keypadWasShownRef = useRef(false)
if (hasPhysicalKeyboard === false) {
keypadWasShownRef.current = true
}
const showOnScreenKeypad = hasPhysicalKeyboard === false || keypadWasShownRef.current
// Get current part and slot from plan
const parts = plan.parts
const currentPartIndex = plan.currentPartIndex
const currentSlotIndex = plan.currentSlotIndex
const currentPart = parts[currentPartIndex] as SessionPart | undefined
const currentSlot = currentPart?.slots[currentSlotIndex] as ProblemSlot | undefined
const sessionHealth = plan.sessionHealth as SessionHealth | null
// Calculate total progress across all parts
const totalProblems = useMemo(() => {
return parts.reduce((sum, part) => sum + part.slots.length, 0)
}, [parts])
const completedProblems = useMemo(() => {
let count = 0
for (let i = 0; i < currentPartIndex; i++) {
count += parts[i].slots.length
}
count += currentSlotIndex
return count
}, [parts, currentPartIndex, currentSlotIndex])
// Check for session completion
useEffect(() => {
if (currentPartIndex >= parts.length) {
onComplete()
}
}, [currentPartIndex, parts.length, onComplete])
// Initialize problem when slot changes and in loading phase
useEffect(() => {
if (currentPart && currentSlot && phase.phase === 'loading') {
if (!currentSlot.problem) {
throw new Error(
`Problem not pre-generated for slot ${currentSlotIndex} in part ${currentPartIndex}. ` +
'This indicates a bug in session planning - problems should be generated at plan creation time.'
)
}
loadProblem(currentSlot.problem, currentSlotIndex, currentPartIndex)
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem])
// Auto-trigger help when an unambiguous prefix sum is detected
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
// This effect only handles the inputting phase case for unambiguous matches
// DISABLED when abacus is docked - user controls when to submit, help triggers on submit if needed
useEffect(() => {
// Skip auto-help when abacus is docked - user has manual control
if (isDockedByUser) return
// Only handle unambiguous prefix matches in inputting phase
// Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode
if (phase.phase !== 'inputting') return
// For unambiguous matches, trigger immediately
if (matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1) {
const newConfirmedCount = matchedPrefixIndex + 1
if (newConfirmedCount < phase.attempt.problem.terms.length) {
enterHelpMode(newConfirmedCount)
}
}
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode, isDockedByUser])
// Handle when student reaches target value on help abacus
// Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit
const handleTargetReached = useCallback(() => {
if (phase.phase !== 'helpMode' || !helpContext) return
// Step 1: Set the answer to the target value (shows in answer boxes behind abacus)
setAnswer(String(helpContext.targetValue))
// Step 2: After a brief moment, dismiss the help abacus to reveal the answer
setTimeout(() => {
setHelpAbacusDismissed(true)
// Step 3: After abacus fades out (300ms), answer boxes are visible with target value
// Wait 1 second to let user see the result
setTimeout(() => {
// Step 4: Start fading out the answer
setAnswerFadingOut(true)
// Step 5: After fade-out completes (300ms), clear and exit
setTimeout(() => {
clearAnswer()
setAnswerFadingOut(false)
exitHelpMode()
}, 300) // Match fade-out duration
}, 1000) // Show target value in answer boxes for 1 second
}, 600) // Brief pause to see success state on abacus
}, [phase.phase, helpContext, setAnswer, clearAnswer, exitHelpMode])
// Handle value change from the docked abacus
const handleAbacusDockValueChange = useCallback(
(newValue: number) => {
// When the abacus shows the correct answer, set it and auto-submit will trigger
setAnswer(String(newValue))
},
[setAnswer]
)
// Handle submit
const handleSubmit = useCallback(async () => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
if (
phase.phase !== 'inputting' &&
phase.phase !== 'awaitingDisambiguation' &&
phase.phase !== 'helpMode'
) {
return
}
if (!phase.attempt.userAnswer) return
const attemptData = phase.attempt
const answerNum = parseInt(attemptData.userAnswer, 10)
if (Number.isNaN(answerNum)) return
// When abacus is docked and not already in help mode, check if answer is a prefix sum
// If so, trigger help mode instead of submitting (mimic auto-help behavior on submit)
if (isDockedByUser && phase.phase !== 'helpMode') {
// Check if the answer matches a prefix sum (but not the final answer)
const prefixIndex = prefixSums.indexOf(answerNum)
if (prefixIndex >= 0 && prefixIndex < prefixSums.length - 1) {
// Answer matches a prefix sum - enter help mode instead of submitting
const newConfirmedCount = prefixIndex + 1
if (newConfirmedCount < attemptData.problem.terms.length) {
enterHelpMode(newConfirmedCount)
return
}
}
}
// Transition to submitting phase
startSubmit()
// Subtract accumulated pause time to get actual response time
const responseTimeMs = Date.now() - attemptData.startTime - attemptData.accumulatedPauseMs
const isCorrect = answerNum === attemptData.problem.answer
// Record the result
const result: Omit<SlotResult, 'timestamp' | 'partNumber'> = {
slotIndex: attemptData.slotIndex,
problem: attemptData.problem,
studentAnswer: answerNum,
isCorrect,
responseTimeMs,
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(
() => {
const nextSlotIndex = currentSlotIndex + 1
const nextSlot = currentPart?.slots[nextSlotIndex]
if (nextSlot && currentPart && isCorrect) {
// Has next problem - animate transition
if (!nextSlot.problem) {
throw new Error(
`Problem not pre-generated for slot ${nextSlotIndex} in part ${currentPartIndex}. ` +
'This indicates a bug in session planning - problems should be generated at plan creation time.'
)
}
// Mark that we need to apply centering offset in useLayoutEffect
needsCenteringOffsetRef.current = true
startTransition(nextSlot.problem, nextSlotIndex)
} else {
// End of part or incorrect - clear to loading
clearToLoading()
}
},
isCorrect ? 500 : 1500
)
}, [
phase,
onAnswer,
currentSlotIndex,
currentPart,
currentPartIndex,
startSubmit,
completeSubmit,
startTransition,
clearToLoading,
isDockedByUser,
prefixSums,
enterHelpMode,
])
// Auto-submit when correct answer is entered
useEffect(() => {
if (shouldAutoSubmit) {
handleSubmit()
}
}, [shouldAutoSubmit, handleSubmit])
// Handle keyboard input
useEffect(() => {
if (!hasPhysicalKeyboard || !canAcceptInput) return
const handleKeyDown = (e: KeyboardEvent) => {
// Escape or Delete/Backspace exits help mode when in help mode
if (e.key === 'Escape') {
e.preventDefault()
if (showHelpOverlay) {
exitHelpMode()
}
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
if (showHelpOverlay) {
exitHelpMode()
} else {
handleBackspace()
}
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
handleDigit(e.key)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [
hasPhysicalKeyboard,
canAcceptInput,
handleSubmit,
handleDigit,
handleBackspace,
showHelpOverlay,
exitHelpMode,
])
// Auto-pause when user takes too long on a problem
// Uses mean + 2 standard deviations of response times if we have enough data,
// otherwise defaults to 5 minutes
useEffect(() => {
// Only run auto-pause when actively inputting
if (
phase.phase !== 'inputting' &&
phase.phase !== 'awaitingDisambiguation' &&
phase.phase !== 'helpMode'
) {
return
}
// Don't auto-pause if already paused or no attempt yet
if (isPaused || !attempt) return
// Calculate the threshold and stats from historical results
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
// Calculate remaining time until auto-pause (using actual working time, not total elapsed)
const elapsedMs = Date.now() - attempt.startTime - attempt.accumulatedPauseMs
const remainingMs = threshold - elapsedMs
// Create pause info for auto-timeout
const pauseInfo: PauseInfo = {
pausedAt: new Date(),
reason: 'auto-timeout',
autoPauseStats: stats,
}
// If already over threshold, pause immediately
if (remainingMs <= 0) {
pause()
onPause?.(pauseInfo)
return
}
// Set timeout to trigger pause when threshold is reached
const timeoutId = setTimeout(() => {
// Update pausedAt to actual pause time
pauseInfo.pausedAt = new Date()
pause()
onPause?.(pauseInfo)
}, remainingMs)
return () => clearTimeout(timeoutId)
}, [
phase.phase,
isPaused,
attempt?.startTime,
attempt?.accumulatedPauseMs,
plan.results,
pause,
onPause,
])
const handlePause = useCallback(() => {
const pauseInfo: PauseInfo = {
pausedAt: new Date(),
reason: 'manual',
}
pause()
onPause?.(pauseInfo)
}, [pause, onPause])
const handleResume = useCallback(() => {
resume()
onResume?.()
}, [resume, onResume])
const getHealthColor = (health: SessionHealth['overall']) => {
switch (health) {
case 'good':
return 'green.500'
case 'warning':
return 'yellow.500'
case 'struggling':
return 'red.500'
default:
return 'gray.500'
}
}
const getHealthEmoji = (health: SessionHealth['overall']) => {
switch (health) {
case 'good':
return '🟢'
case 'warning':
return '🟡'
case 'struggling':
return '🔴'
default:
return '⚪'
}
}
if (!currentPart || !attempt) {
return (
<div
data-component="active-session"
data-status="loading"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '300px',
})}
>
<div
className={css({
fontSize: '1.25rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Loading next problem...
</div>
</div>
)
}
return (
<div
data-component="active-session"
data-status={isPaused ? 'paused' : 'active'}
data-phase={phase.phase}
data-part-type={currentPart.type}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
padding: '1rem',
maxWidth: '600px',
margin: '0 auto',
})}
>
{/* Practice Session HUD - Control bar with session info and tape-deck controls */}
{/* Only render if hideHud is false (default) - when using external HUD in PracticeSubNav */}
{!hideHud && (
<div
data-section="session-hud"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1rem',
backgroundColor: 'gray.900',
borderRadius: '12px',
boxShadow: 'lg',
})}
>
{/* Tape deck controls */}
<div
data-element="transport-controls"
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{/* Pause/Play button */}
<button
type="button"
data-action={isPaused ? 'resume' : 'pause'}
onClick={isPaused ? handleResume : handlePause}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'white',
backgroundColor: isPaused ? 'green.500' : 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: isPaused ? 'green.400' : 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isPaused ? 'green.400' : 'gray.600',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label={isPaused ? 'Resume session' : 'Pause session'}
>
{isPaused ? '▶' : '⏸'}
</button>
{/* Stop button */}
<button
type="button"
data-action="end-early"
onClick={() => onEndEarly('Session ended')}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'red.300',
backgroundColor: 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: 'red.900',
borderColor: 'red.700',
color: 'red.200',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label="End session"
>
</button>
</div>
{/* Session info display */}
<div
data-element="session-info"
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
})}
>
{/* Part type with emoji */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '1rem',
})}
>
{getPartTypeEmoji(currentPart.type)}
</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'white',
})}
>
Part {currentPart.partNumber}: {getPartTypeLabel(currentPart.type)}
</span>
</div>
{/* Progress within part */}
<div
className={css({
fontSize: '0.75rem',
color: 'gray.400',
})}
>
Problem {currentSlotIndex + 1} of {currentPart.slots.length} in this part
</div>
</div>
{/* Overall progress and health */}
<div
data-element="progress-display"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Problem counter */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
})}
>
<div
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
fontFamily: 'monospace',
})}
>
{completedProblems + 1}/{totalProblems}
</div>
<div
className={css({
fontSize: '0.625rem',
color: 'gray.500',
textTransform: 'uppercase',
})}
>
Total
</div>
</div>
{/* Health indicator */}
{sessionHealth && (
<div
data-element="session-health"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '0.25rem 0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
})}
>
<span className={css({ fontSize: '1rem' })}>
{getHealthEmoji(sessionHealth.overall)}
</span>
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: getHealthColor(sessionHealth.overall),
})}
>
{Math.round(sessionHealth.accuracy * 100)}%
</span>
</div>
)}
</div>
</div>
)}
{/* Timing Display - shows current problem timer, average, and per-part-type breakdown */}
{/* Always shown regardless of hideHud - timing info is always useful */}
{attempt && (
<PracticeTimingDisplay
results={plan.results}
parts={plan.parts}
attemptStartTime={attempt.startTime}
accumulatedPauseMs={attempt.accumulatedPauseMs}
isPaused={isPaused}
currentPartType={currentPart.type}
isDark={isDark}
/>
)}
{/* Problem display */}
<div
data-section="problem-area"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
paddingTop: '4rem',
paddingRight: '2rem',
paddingBottom: '2rem',
paddingLeft: '2rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
boxShadow: 'md',
})}
>
{/* Purpose badge */}
<div
data-element="problem-purpose"
className={css({
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
backgroundColor:
currentSlot?.purpose === 'focus'
? isDark
? 'blue.900'
: 'blue.100'
: currentSlot?.purpose === 'reinforce'
? isDark
? 'orange.900'
: 'orange.100'
: currentSlot?.purpose === 'review'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'purple.900'
: 'purple.100',
color:
currentSlot?.purpose === 'focus'
? isDark
? 'blue.200'
: 'blue.700'
: currentSlot?.purpose === 'reinforce'
? isDark
? 'orange.200'
: 'orange.700'
: currentSlot?.purpose === 'review'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'purple.200'
: 'purple.700',
})}
>
{currentSlot?.purpose}
</div>
{/* Problem display - centered, with help panel positioned outside */}
<div
data-element="problem-with-help"
className={css({
display: 'flex',
justifyContent: 'center',
width: '100%',
})}
>
{/* Animated track for problem transitions */}
<animated.div
data-element="problem-track"
style={{
display: 'flex',
alignItems: 'flex-start',
transform: trackSpring.x.to((x) => `translateX(${x}px)`),
}}
>
{/* Outgoing problem (slides left during transition) */}
{outgoingAttempt && (
<animated.div
ref={outgoingRef}
data-element="outgoing-problem"
style={{
opacity: trackSpring.outgoingOpacity,
marginRight: '2rem',
position: 'relative' as const,
}}
>
<VerticalProblem
terms={outgoingAttempt.problem.terms}
userAnswer={outgoingAttempt.userAnswer}
isCompleted={true}
correctAnswer={outgoingAttempt.problem.answer}
size="large"
generationTrace={outgoingAttempt.problem.generationTrace}
/>
{/* Feedback stays with outgoing problem */}
<div
data-element="outgoing-feedback"
className={css({
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
padding: '0.5rem 1rem',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
backgroundColor: isDark ? 'green.900' : 'green.100',
color: isDark ? 'green.200' : 'green.700',
whiteSpace: 'nowrap',
})}
>
Correct!
</div>
</animated.div>
)}
{/* Current problem */}
<animated.div
ref={activeRef}
data-element="problem-container"
style={{
opacity: trackSpring.activeOpacity,
position: 'relative' as const,
}}
>
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
size="large"
currentHelpTermIndex={helpContext?.termIndex}
needHelpTermIndex={
// Only show "need help?" prompt when not already in help mode
!showHelpOverlay && ambiguousHelpTermIndex >= 0
? ambiguousHelpTermIndex
: undefined
}
rejectedDigit={attempt.rejectedDigit}
helpOverlay={
// Always render overlay when in help mode (for exit transition)
showHelpOverlay && helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString()
.length
)}
onTargetReached={handleTargetReached}
onDismiss={() => {
setHelpAbacusDismissed(true)
clearAnswer()
}}
visible={!helpAbacusDismissed}
/>
) : undefined
}
helpOverlayVisible={showHelpOverlay && !helpAbacusDismissed}
helpOverlayTransitionMs={helpAbacusDismissed ? 300 : 1000}
onHelpOverlayTransitionEnd={clearAnswer}
answerFadingOut={answerFadingOut}
generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/>
) : (
<LinearProblem
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
/>
)}
{/* Help panel - absolutely positioned to the right of the problem */}
{showHelpOverlay && helpContext && !helpPanelDismissed && (
<div
data-element="help-panel"
className={css({
position: 'absolute',
left: '100%',
top: 0,
marginLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
minWidth: '200px',
maxWidth: '280px',
})}
>
{/* Close button for help panel */}
<button
type="button"
data-action="close-help-panel"
onClick={() => setHelpPanelDismissed(true)}
className={css({
position: 'absolute',
top: '-8px',
right: '-8px',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '50%',
cursor: 'pointer',
zIndex: 10,
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.200' : 'gray.700',
},
})}
aria-label="Close help panel"
>
×
</button>
{/* Coach hint */}
{(() => {
const hint = generateCoachHint(
helpContext.currentValue,
helpContext.targetValue
)
if (!hint) return null
return (
<div
data-element="coach-hint"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.4',
margin: 0,
})}
>
{hint}
</p>
</div>
)
})()}
{/* Decomposition display */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
currentStepIndex={0}
abacusColumns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
>
<DecompositionSection
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
whiteSpace: 'nowrap',
})}
labelClassName={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.25rem',
textTransform: 'uppercase',
})}
contentClassName={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
color: isDark ? 'gray.100' : 'gray.800',
})}
/>
</DecompositionProvider>
</div>
)}
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
{/* Width 100% matches problem width, height matches problem height */}
{currentPart.type === 'abacus' && !showHelpOverlay && problemHeight && (
<AbacusDock
id="practice-abacus"
columns={String(Math.abs(attempt.problem.answer)).length}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
)}
</animated.div>
</animated.div>
</div>
{/* Feedback message - only show for incorrect */}
{showFeedback && (
<div
data-element="feedback"
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '8px',
fontSize: '1.25rem',
fontWeight: 'bold',
backgroundColor: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.200' : 'red.700',
})}
>
The answer was {attempt.problem.answer}
</div>
)}
</div>
{/* Input area */}
{showInputArea && !isPaused && (
<div data-section="input-area">
{/* Submit button - only shown when auto-submit threshold exceeded */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '1rem',
minHeight: '52px',
overflow: 'hidden',
})}
>
<animated.button
type="button"
data-action="submit"
data-visible={showSubmitButton}
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting || !showSubmitButton}
style={submitButtonSpring}
className={css({
padding: '0.75rem 2rem',
fontSize: '1.125rem',
fontWeight: 'bold',
borderRadius: '8px',
border: 'none',
cursor: !canSubmit || !showSubmitButton ? 'not-allowed' : 'pointer',
backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white',
_hover: {
backgroundColor:
canSubmit && showSubmitButton ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Submit
</animated.button>
</div>
{/* On-screen keypad for mobile */}
{showOnScreenKeypad && (
<NumericKeypad
onDigit={handleDigit}
onBackspace={handleBackspace}
onSubmit={handleSubmit}
disabled={isSubmitting}
currentValue={attempt.userAnswer}
showSubmitButton={showSubmitButton}
/>
)}
</div>
)}
{/* Debug panel - shows current problem details when visual debug mode is on */}
{currentSlot?.problem && (
<ProblemDebugPanel
problem={currentSlot.problem}
slot={currentSlot}
part={currentPart}
partIndex={currentPartIndex}
slotIndex={currentSlotIndex}
userInput={attempt.userAnswer}
phaseName={phase.phase}
/>
)}
</div>
)
}
export default ActiveSession