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:
parent
36c9ec3301
commit
46ff5f528a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue