feat(practice): add prefix sum disambiguation and debug panel

- Add ProblemDebugPanel component for viewing current problem details
  when visual debug mode is enabled (fixed position, collapsible, copy JSON)

- Fix false positive help mode triggers when typing multi-digit answers
  - "3" when answer is "33" now shows "need help?" prompt instead of
    immediately triggering help mode
  - 4 second timer before auto-triggering help in ambiguous cases

- Add leading zero disambiguation for requesting help
  - Typing "03" explicitly requests help for prefix sum 3
  - isDigitConsistent now allows leading zeros
  - findMatchedPrefixIndex treats leading zeros as unambiguous help request

- Add "need help?" styled pill prompt on ambiguous prefix matches
  - Yellow pill badge with arrow pointing to the term
  - Pulse animation for visibility

🤖 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-09 09:32:27 -06:00
parent 36c9ec3301
commit 46ff5f528a
4 changed files with 505 additions and 101 deletions

View File

@ -5,20 +5,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { flushSync } from 'react-dom'
import { useTheme } from '@/contexts/ThemeContext'
import type {
GeneratedProblem,
ProblemConstraints,
ProblemSlot,
SessionHealth,
SessionPart,
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
import { createBasicSkillSet, type SkillSet } from '@/types/tutorial'
import {
analyzeRequiredSkills,
type ProblemConstraints as GeneratorConstraints,
generateSingleProblem,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { generateCoachHint } from './coachHintGenerator'
@ -27,6 +19,7 @@ import { useInteractionPhase } from './hooks/useInteractionPhase'
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
interface ActiveSessionProps {
@ -204,6 +197,20 @@ export function ActiveSession({
// 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,
@ -217,6 +224,8 @@ export function ActiveSession({
matchedPrefixIndex,
canSubmit,
shouldAutoSubmit,
ambiguousHelpTermIndex,
ambiguousTimerElapsed,
loadProblem,
handleDigit,
handleBackspace,
@ -230,6 +239,7 @@ export function ActiveSession({
pause,
resume,
} = useInteractionPhase({
initialProblem,
onManualSubmitRequired: () => playSound('womp_womp'),
})
@ -387,24 +397,46 @@ export function ActiveSession({
// Initialize problem when slot changes and in loading phase
useEffect(() => {
if (currentPart && currentSlot && phase.phase === 'loading') {
const problem = currentSlot.problem || generateProblemFromConstraints(currentSlot.constraints)
loadProblem(problem, currentSlotIndex, currentPartIndex)
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 prefix sum is detected
// For unambiguous matches: trigger immediately
// For ambiguous matches: wait for the disambiguation timer to elapse
useEffect(() => {
if (
phase.phase === 'inputting' &&
matchedPrefixIndex >= 0 &&
matchedPrefixIndex < prefixSums.length - 1
) {
if (phase.phase !== 'inputting') return
// If there's an ambiguous match, only trigger help when timer has elapsed
if (ambiguousHelpTermIndex >= 0) {
if (ambiguousTimerElapsed) {
enterHelpMode(ambiguousHelpTermIndex)
}
// Otherwise, wait - the "need help?" prompt is shown via ambiguousHelpTermIndex
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])
}, [
phase,
matchedPrefixIndex,
prefixSums.length,
ambiguousHelpTermIndex,
ambiguousTimerElapsed,
enterHelpMode,
])
// Handle when student reaches target value on help abacus
const handleTargetReached = useCallback(() => {
@ -455,13 +487,16 @@ export function ActiveSession({
if (nextSlot && currentPart && isCorrect) {
// Has next problem - animate transition
const nextProblem =
nextSlot.problem || generateProblemFromConstraints(nextSlot.constraints)
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(nextProblem, nextSlotIndex)
startTransition(nextSlot.problem, nextSlotIndex)
} else {
// End of part or incorrect - clear to loading
clearToLoading()
@ -926,6 +961,12 @@ export function ActiveSession({
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={
showHelpOverlay && helpContext ? (
@ -1179,74 +1220,20 @@ export function ActiveSession({
</div>
</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>
)
}
/**
* Generate a problem from slot constraints using the actual skill-based algorithm.
*/
function generateProblemFromConstraints(constraints: ProblemConstraints): GeneratedProblem {
const baseSkillSet = createBasicSkillSet()
const requiredSkills: SkillSet = {
basic: { ...baseSkillSet.basic, ...constraints.requiredSkills?.basic },
fiveComplements: {
...baseSkillSet.fiveComplements,
...constraints.requiredSkills?.fiveComplements,
},
tenComplements: {
...baseSkillSet.tenComplements,
...constraints.requiredSkills?.tenComplements,
},
fiveComplementsSub: {
...baseSkillSet.fiveComplementsSub,
...constraints.requiredSkills?.fiveComplementsSub,
},
tenComplementsSub: {
...baseSkillSet.tenComplementsSub,
...constraints.requiredSkills?.tenComplementsSub,
},
}
const maxDigits = constraints.digitRange?.max || 1
const maxValue = 10 ** maxDigits - 1
const generatorConstraints: GeneratorConstraints = {
numberRange: { min: 1, max: maxValue },
maxTerms: constraints.termCount?.max || 5,
problemCount: 1,
}
const generatedProblem = generateSingleProblem(
generatorConstraints,
requiredSkills,
constraints.targetSkills,
constraints.forbiddenSkills
)
if (generatedProblem) {
return {
terms: generatedProblem.terms,
answer: generatedProblem.answer,
skillsRequired: generatedProblem.requiredSkills,
}
}
// Fallback
const termCount = constraints.termCount?.min || 3
const terms: number[] = []
for (let i = 0; i < termCount; i++) {
terms.push(Math.floor(Math.random() * Math.min(maxValue, 9)) + 1)
}
const answer = terms.reduce((sum, t) => sum + t, 0)
const skillsRequired = analyzeRequiredSkills(terms, answer)
return {
terms,
answer,
skillsRequired,
}
}
export default ActiveSession

View File

@ -0,0 +1,208 @@
'use client'
import { useCallback, useState } from 'react'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import type { GeneratedProblem, ProblemSlot, SessionPart } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
interface ProblemDebugPanelProps {
/** The current problem being displayed */
problem: GeneratedProblem
/** The current slot */
slot: ProblemSlot
/** The current part */
part: SessionPart
/** Current part index */
partIndex: number
/** Current slot index */
slotIndex: number
/** Current user input */
userInput: string
/** Current phase name */
phaseName: string
}
/**
* Debug panel that shows current problem details when visual debug mode is on.
* Allows easy copying of problem data for bug reports.
*/
export function ProblemDebugPanel({
problem,
slot,
part,
partIndex,
slotIndex,
userInput,
phaseName,
}: ProblemDebugPanelProps) {
const { isVisualDebugEnabled } = useVisualDebugSafe()
const [copied, setCopied] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const debugData = {
problem: {
terms: problem.terms,
answer: problem.answer,
skillsRequired: problem.skillsRequired,
},
slot: {
index: slot.index,
purpose: slot.purpose,
constraints: slot.constraints,
},
part: {
number: part.partNumber,
type: part.type,
},
position: {
partIndex,
slotIndex,
},
state: {
userInput,
phaseName,
},
}
const debugJson = JSON.stringify(debugData, null, 2)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(debugJson)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = debugJson
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [debugJson])
if (!isVisualDebugEnabled) {
return null
}
return (
<div
data-component="problem-debug-panel"
className={css({
position: 'fixed',
bottom: '10px',
right: '10px',
width: isCollapsed ? 'auto' : '360px',
maxHeight: isCollapsed ? 'auto' : '400px',
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: 'white',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
fontFamily: 'monospace',
fontSize: '11px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.2)',
})}
>
{/* Header */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderBottom: isCollapsed ? 'none' : '1px solid rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
})}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<span className={css({ fontWeight: 'bold', color: '#4ade80' })}>
Problem Debug {isCollapsed ? '(expand)' : ''}
</span>
<div className={css({ display: 'flex', gap: '8px' })}>
{!isCollapsed && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className={css({
padding: '4px 8px',
backgroundColor: copied ? '#22c55e' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px',
fontWeight: 'bold',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: copied ? '#16a34a' : '#2563eb',
},
})}
>
{copied ? 'Copied!' : 'Copy JSON'}
</button>
)}
<span className={css({ color: 'gray.400' })}>{isCollapsed ? '▲' : '▼'}</span>
</div>
</div>
{/* Content */}
{!isCollapsed && (
<div
className={css({
padding: '12px',
overflowY: 'auto',
maxHeight: '340px',
})}
>
{/* Quick summary */}
<div
className={css({
marginBottom: '12px',
padding: '8px',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: '4px',
})}
>
<div className={css({ color: '#fbbf24', marginBottom: '4px' })}>
Part {part.partNumber} ({part.type}) - Slot {slotIndex + 1}
</div>
<div className={css({ color: '#f472b6' })}>
{problem.terms
.map((t, i) => (i === 0 ? t : t >= 0 ? `+ ${t}` : `- ${Math.abs(t)}`))
.join(' ')}{' '}
= {problem.answer}
</div>
<div className={css({ color: '#67e8f9', marginTop: '4px' })}>
Skills: {problem.skillsRequired.join(', ')}
</div>
<div className={css({ color: '#a3a3a3', marginTop: '4px' })}>
Phase: {phaseName} | Input: "{userInput}"
</div>
</div>
{/* Full JSON */}
<pre
className={css({
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: '#a3e635',
margin: 0,
lineHeight: '1.4',
})}
>
{debugJson}
</pre>
</div>
)}
</div>
)
}

View File

@ -19,6 +19,8 @@ interface VerticalProblemProps {
size?: 'normal' | 'large'
/** Index of the term currently being helped with (shows arrow indicator) */
currentHelpTermIndex?: number
/** Index of the term to show "need help?" prompt for (ambiguous prefix case) */
needHelpTermIndex?: number
/** Rejected digit to show as red X (null = no rejection) */
rejectedDigit?: string | null
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
@ -42,6 +44,7 @@ export function VerticalProblem({
correctAnswer,
size = 'normal',
currentHelpTermIndex,
needHelpTermIndex,
rejectedDigit = null,
helpOverlay,
}: VerticalProblemProps) {
@ -118,12 +121,14 @@ export function VerticalProblem({
// Check if this term row should show the help overlay
const isCurrentHelp = index === currentHelpTermIndex
// Check if this term row should show "need help?" prompt (ambiguous case)
const showNeedHelp = index === needHelpTermIndex && !isCurrentHelp
return (
<div
key={index}
data-element="term-row"
data-term-status={isCurrentHelp ? 'current' : 'pending'}
data-term-status={isCurrentHelp ? 'current' : showNeedHelp ? 'need-help' : 'pending'}
className={css({
display: 'flex',
alignItems: 'center',
@ -132,6 +137,44 @@ export function VerticalProblem({
transition: 'all 0.2s ease',
})}
>
{/* "Need help?" prompt for ambiguous prefix case */}
{showNeedHelp && (
<div
data-element="need-help-prompt"
className={css({
position: 'absolute',
right: '100%',
marginRight: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.625rem',
backgroundColor: isDark ? 'rgba(250, 204, 21, 0.15)' : 'rgba(202, 138, 4, 0.1)',
border: '1px solid',
borderColor: isDark ? 'yellow.500' : 'yellow.400',
borderRadius: '9999px',
color: isDark ? 'yellow.300' : 'yellow.700',
fontSize: '0.6875rem',
fontWeight: '600',
whiteSpace: 'nowrap',
boxShadow: isDark
? '0 0 12px rgba(250, 204, 21, 0.2)'
: '0 1px 3px rgba(0, 0, 0, 0.1)',
animation: 'pulse 2s ease-in-out infinite',
})}
>
<span>need help?</span>
<span
className={css({
fontSize: '0.875rem',
lineHeight: 1,
})}
>
</span>
</div>
)}
{/* Arrow indicator for current help term (the term being added) */}
{isCurrentHelp && (
<div

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GeneratedProblem } from '@/db/schema/session-plans'
// =============================================================================
@ -81,6 +81,9 @@ export type InteractionPhase =
/** Threshold for correction count before requiring manual submit */
export const MANUAL_SUBMIT_THRESHOLD = 2
/** Time to wait before auto-triggering help in ambiguous cases (ms) */
export const AMBIGUOUS_HELP_DELAY_MS = 4000
// =============================================================================
// Helper Functions
// =============================================================================
@ -145,6 +148,11 @@ export function computePrefixSums(terms: number[]): number[] {
/**
* Checks if a digit would be consistent with any prefix sum.
*
* Leading zeros are allowed as a disambiguation mechanism:
* - Typing "0" when answer is empty is allowed (starts a multi-digit entry)
* - Typing "03" signals intent to enter a 2+ digit number ending in 3
* - The numeric value after stripping leading zeros must be consistent
*/
export function isDigitConsistent(
currentAnswer: string,
@ -152,12 +160,25 @@ export function isDigitConsistent(
prefixSums: number[]
): boolean {
const newAnswer = currentAnswer + digit
const newAnswerNum = parseInt(newAnswer, 10)
// Allow leading zeros as disambiguation (e.g., "0" or "03" for 3)
// Strip leading zeros to get the numeric prefix we're building toward
const strippedAnswer = newAnswer.replace(/^0+/, '') || '0'
// If the result is just zeros, allow it (user is typing leading zeros)
if (strippedAnswer === '0' && newAnswer.length > 0) {
// Allow typing zeros as long as we haven't exceeded max answer length
const maxLength = Math.max(...prefixSums.map((s) => s.toString().length))
return newAnswer.length <= maxLength
}
const newAnswerNum = parseInt(strippedAnswer, 10)
if (Number.isNaN(newAnswerNum)) return false
for (const sum of prefixSums) {
const sumStr = sum.toString()
if (sumStr.startsWith(newAnswer)) {
// Check if stripped answer is a prefix of any sum
if (sumStr.startsWith(strippedAnswer)) {
return true
}
}
@ -165,13 +186,70 @@ export function isDigitConsistent(
}
/**
* Finds which prefix sum the user's answer matches, if any.
* Returns -1 if no match.
* Result of checking for prefix sum matches
*/
export function findMatchedPrefixIndex(userAnswer: string, prefixSums: number[]): number {
export interface PrefixMatchResult {
/** Index of matched prefix sum (-1 if none) */
matchedIndex: number
/** Whether this is an ambiguous match (could be digit-prefix of final answer) */
isAmbiguous: boolean
/** The term index to show help for (matchedIndex + 1, since we help with the NEXT term) */
helpTermIndex: number
}
/**
* Finds which prefix sum the user's answer matches, if any.
* Also detects ambiguous cases where the input could be either:
* 1. An intermediate prefix sum (user is stuck)
* 2. The first digit(s) of the final answer (user is still typing)
*
* Leading zeros disambiguate and REQUEST help:
* - "3" alone is ambiguous (could be prefix sum 3 OR first digit of 33)
* - "03" is unambiguous - user clearly wants help with prefix sum 3
*/
export function findMatchedPrefixIndex(userAnswer: string, prefixSums: number[]): PrefixMatchResult {
const noMatch: PrefixMatchResult = { matchedIndex: -1, isAmbiguous: false, helpTermIndex: -1 }
if (!userAnswer) return noMatch
// Leading zeros indicate user is explicitly requesting help for that prefix sum
// "03" means "I want help with prefix sum 3" - this is NOT ambiguous
const hasLeadingZero = userAnswer.startsWith('0') && userAnswer.length > 1
const answerNum = parseInt(userAnswer, 10)
if (Number.isNaN(answerNum)) return -1
return prefixSums.indexOf(answerNum)
if (Number.isNaN(answerNum)) return noMatch
const finalAnswer = prefixSums[prefixSums.length - 1]
const finalAnswerStr = finalAnswer.toString()
// Check if this is the final answer
if (answerNum === finalAnswer) {
return { matchedIndex: prefixSums.length - 1, isAmbiguous: false, helpTermIndex: -1 }
}
// Check if user's input matches an intermediate prefix sum
const matchedIndex = prefixSums.findIndex((sum, i) => i < prefixSums.length - 1 && sum === answerNum)
if (matchedIndex === -1) return noMatch
// If they used leading zeros, they're explicitly requesting help - NOT ambiguous
// "03" clearly means "help me with prefix sum 3"
if (hasLeadingZero) {
return {
matchedIndex,
isAmbiguous: false, // Leading zero removes ambiguity - they want help
helpTermIndex: matchedIndex + 1,
}
}
// Check if user's input could be a digit-prefix of the final answer
const couldBeFinalAnswerPrefix = finalAnswerStr.startsWith(userAnswer)
return {
matchedIndex,
isAmbiguous: couldBeFinalAnswerPrefix,
helpTermIndex: matchedIndex + 1, // Help with the NEXT term after the matched sum
}
}
/**
@ -189,7 +267,15 @@ export function computeHelpContext(terms: number[], termIndex: number): HelpCont
// Hook
// =============================================================================
export interface InitialProblemData {
problem: GeneratedProblem
slotIndex: number
partIndex: number
}
export interface UseInteractionPhaseOptions {
/** Initial problem to hydrate with (for SSR) */
initialProblem?: InitialProblemData
/** Called when auto-submit threshold is exceeded */
onManualSubmitRequired?: () => void
}
@ -215,13 +301,21 @@ export interface UseInteractionPhaseReturn {
// Computed values (only valid when attempt exists)
/** Prefix sums for current problem */
prefixSums: number[]
/** Matched prefix index (-1 if none) */
/** Full prefix match result with ambiguity info */
prefixMatch: PrefixMatchResult
/** Matched prefix index (-1 if none) - shorthand for prefixMatch.matchedIndex */
matchedPrefixIndex: number
/** Can the submit button be pressed? */
canSubmit: boolean
/** Should auto-submit trigger? */
shouldAutoSubmit: boolean
// Ambiguous prefix state
/** Term index to show "need help?" prompt for (-1 if not in ambiguous state) */
ambiguousHelpTermIndex: number
/** Whether the disambiguation timer has elapsed */
ambiguousTimerElapsed: boolean
// Actions
/** Load a new problem (loading → inputting) */
loadProblem: (problem: GeneratedProblem, slotIndex: number, partIndex: number) => void
@ -256,8 +350,20 @@ export interface UseInteractionPhaseReturn {
export function useInteractionPhase(
options: UseInteractionPhaseOptions = {}
): UseInteractionPhaseReturn {
const { onManualSubmitRequired } = options
const [phase, setPhase] = useState<InteractionPhase>({ phase: 'loading' })
const { initialProblem, onManualSubmitRequired } = options
// Initialize state with problem if provided (for SSR hydration)
const [phase, setPhase] = useState<InteractionPhase>(() => {
if (initialProblem) {
const attempt = createAttemptInput(
initialProblem.problem,
initialProblem.slotIndex,
initialProblem.partIndex
)
return { phase: 'inputting', attempt }
}
return { phase: 'loading' }
})
// ==========================================================================
// Derived State
@ -299,11 +405,68 @@ export function useInteractionPhase(
return computePrefixSums(attempt.problem.terms)
}, [attempt])
const matchedPrefixIndex = useMemo(() => {
if (!attempt) return -1
const prefixMatch = useMemo((): PrefixMatchResult => {
if (!attempt) return { matchedIndex: -1, isAmbiguous: false, helpTermIndex: -1 }
return findMatchedPrefixIndex(attempt.userAnswer, prefixSums)
}, [attempt, prefixSums])
// Shorthand for backward compatibility
const matchedPrefixIndex = prefixMatch.matchedIndex
// ==========================================================================
// Ambiguous Prefix Timer
// ==========================================================================
// Track when the current ambiguous match started
const [ambiguousTimerElapsed, setAmbiguousTimerElapsed] = useState(false)
const ambiguousTimerRef = useRef<NodeJS.Timeout | null>(null)
const lastAmbiguousKeyRef = useRef<string | null>(null)
// Create a stable key for the current ambiguous state
const ambiguousKey = useMemo(() => {
if (!prefixMatch.isAmbiguous || prefixMatch.helpTermIndex === -1) return null
// Key includes the matched sum and term index so timer resets if they change
return `${attempt?.userAnswer}-${prefixMatch.helpTermIndex}`
}, [prefixMatch.isAmbiguous, prefixMatch.helpTermIndex, attempt?.userAnswer])
// Manage the timer
useEffect(() => {
// Clear existing timer
if (ambiguousTimerRef.current) {
clearTimeout(ambiguousTimerRef.current)
ambiguousTimerRef.current = null
}
// If no ambiguous state, reset
if (!ambiguousKey) {
setAmbiguousTimerElapsed(false)
lastAmbiguousKeyRef.current = null
return
}
// If this is a new ambiguous state, reset and start timer
if (ambiguousKey !== lastAmbiguousKeyRef.current) {
setAmbiguousTimerElapsed(false)
lastAmbiguousKeyRef.current = ambiguousKey
ambiguousTimerRef.current = setTimeout(() => {
setAmbiguousTimerElapsed(true)
}, AMBIGUOUS_HELP_DELAY_MS)
}
return () => {
if (ambiguousTimerRef.current) {
clearTimeout(ambiguousTimerRef.current)
}
}
}, [ambiguousKey])
// Compute the term index to show "need help?" for
const ambiguousHelpTermIndex = useMemo(() => {
if (!prefixMatch.isAmbiguous) return -1
return prefixMatch.helpTermIndex
}, [prefixMatch])
const canSubmit = useMemo(() => {
if (!attempt || !attempt.userAnswer) return false
const answerNum = parseInt(attempt.userAnswer, 10)
@ -514,9 +677,12 @@ export function useInteractionPhase(
showFeedback,
inputIsFocused,
prefixSums,
prefixMatch,
matchedPrefixIndex,
canSubmit,
shouldAutoSubmit,
ambiguousHelpTermIndex,
ambiguousTimerElapsed,
loadProblem,
handleDigit,
handleBackspace,