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 { flushSync } from 'react-dom'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import type {
|
import type {
|
||||||
GeneratedProblem,
|
|
||||||
ProblemConstraints,
|
|
||||||
ProblemSlot,
|
ProblemSlot,
|
||||||
SessionHealth,
|
SessionHealth,
|
||||||
SessionPart,
|
SessionPart,
|
||||||
SessionPlan,
|
SessionPlan,
|
||||||
SlotResult,
|
SlotResult,
|
||||||
} from '@/db/schema/session-plans'
|
} 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 { css } from '../../../styled-system/css'
|
||||||
import { DecompositionProvider, DecompositionSection } from '../decomposition'
|
import { DecompositionProvider, DecompositionSection } from '../decomposition'
|
||||||
import { generateCoachHint } from './coachHintGenerator'
|
import { generateCoachHint } from './coachHintGenerator'
|
||||||
|
|
@ -27,6 +19,7 @@ import { useInteractionPhase } from './hooks/useInteractionPhase'
|
||||||
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
|
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
|
||||||
import { NumericKeypad } from './NumericKeypad'
|
import { NumericKeypad } from './NumericKeypad'
|
||||||
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
||||||
|
import { ProblemDebugPanel } from './ProblemDebugPanel'
|
||||||
import { VerticalProblem } from './VerticalProblem'
|
import { VerticalProblem } from './VerticalProblem'
|
||||||
|
|
||||||
interface ActiveSessionProps {
|
interface ActiveSessionProps {
|
||||||
|
|
@ -204,6 +197,20 @@ export function ActiveSession({
|
||||||
// Sound effects
|
// Sound effects
|
||||||
const { playSound } = usePracticeSoundEffects()
|
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
|
// Interaction state machine - single source of truth for UI state
|
||||||
const {
|
const {
|
||||||
phase,
|
phase,
|
||||||
|
|
@ -217,6 +224,8 @@ export function ActiveSession({
|
||||||
matchedPrefixIndex,
|
matchedPrefixIndex,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
shouldAutoSubmit,
|
shouldAutoSubmit,
|
||||||
|
ambiguousHelpTermIndex,
|
||||||
|
ambiguousTimerElapsed,
|
||||||
loadProblem,
|
loadProblem,
|
||||||
handleDigit,
|
handleDigit,
|
||||||
handleBackspace,
|
handleBackspace,
|
||||||
|
|
@ -230,6 +239,7 @@ export function ActiveSession({
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
} = useInteractionPhase({
|
} = useInteractionPhase({
|
||||||
|
initialProblem,
|
||||||
onManualSubmitRequired: () => playSound('womp_womp'),
|
onManualSubmitRequired: () => playSound('womp_womp'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -387,24 +397,46 @@ export function ActiveSession({
|
||||||
// Initialize problem when slot changes and in loading phase
|
// Initialize problem when slot changes and in loading phase
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPart && currentSlot && phase.phase === 'loading') {
|
if (currentPart && currentSlot && phase.phase === 'loading') {
|
||||||
const problem = currentSlot.problem || generateProblemFromConstraints(currentSlot.constraints)
|
if (!currentSlot.problem) {
|
||||||
loadProblem(problem, currentSlotIndex, currentPartIndex)
|
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])
|
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem])
|
||||||
|
|
||||||
// Auto-trigger help when prefix sum is detected
|
// Auto-trigger help when prefix sum is detected
|
||||||
|
// For unambiguous matches: trigger immediately
|
||||||
|
// For ambiguous matches: wait for the disambiguation timer to elapse
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (phase.phase !== 'inputting') return
|
||||||
phase.phase === 'inputting' &&
|
|
||||||
matchedPrefixIndex >= 0 &&
|
// If there's an ambiguous match, only trigger help when timer has elapsed
|
||||||
matchedPrefixIndex < prefixSums.length - 1
|
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
|
const newConfirmedCount = matchedPrefixIndex + 1
|
||||||
if (newConfirmedCount < phase.attempt.problem.terms.length) {
|
if (newConfirmedCount < phase.attempt.problem.terms.length) {
|
||||||
enterHelpMode(newConfirmedCount)
|
enterHelpMode(newConfirmedCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode])
|
}, [
|
||||||
|
phase,
|
||||||
|
matchedPrefixIndex,
|
||||||
|
prefixSums.length,
|
||||||
|
ambiguousHelpTermIndex,
|
||||||
|
ambiguousTimerElapsed,
|
||||||
|
enterHelpMode,
|
||||||
|
])
|
||||||
|
|
||||||
// Handle when student reaches target value on help abacus
|
// Handle when student reaches target value on help abacus
|
||||||
const handleTargetReached = useCallback(() => {
|
const handleTargetReached = useCallback(() => {
|
||||||
|
|
@ -455,13 +487,16 @@ export function ActiveSession({
|
||||||
|
|
||||||
if (nextSlot && currentPart && isCorrect) {
|
if (nextSlot && currentPart && isCorrect) {
|
||||||
// Has next problem - animate transition
|
// Has next problem - animate transition
|
||||||
const nextProblem =
|
if (!nextSlot.problem) {
|
||||||
nextSlot.problem || generateProblemFromConstraints(nextSlot.constraints)
|
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
|
// Mark that we need to apply centering offset in useLayoutEffect
|
||||||
needsCenteringOffsetRef.current = true
|
needsCenteringOffsetRef.current = true
|
||||||
|
|
||||||
startTransition(nextProblem, nextSlotIndex)
|
startTransition(nextSlot.problem, nextSlotIndex)
|
||||||
} else {
|
} else {
|
||||||
// End of part or incorrect - clear to loading
|
// End of part or incorrect - clear to loading
|
||||||
clearToLoading()
|
clearToLoading()
|
||||||
|
|
@ -926,6 +961,12 @@ export function ActiveSession({
|
||||||
correctAnswer={attempt.problem.answer}
|
correctAnswer={attempt.problem.answer}
|
||||||
size="large"
|
size="large"
|
||||||
currentHelpTermIndex={helpContext?.termIndex}
|
currentHelpTermIndex={helpContext?.termIndex}
|
||||||
|
needHelpTermIndex={
|
||||||
|
// Only show "need help?" prompt when not already in help mode
|
||||||
|
!showHelpOverlay && ambiguousHelpTermIndex >= 0
|
||||||
|
? ambiguousHelpTermIndex
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
rejectedDigit={attempt.rejectedDigit}
|
rejectedDigit={attempt.rejectedDigit}
|
||||||
helpOverlay={
|
helpOverlay={
|
||||||
showHelpOverlay && helpContext ? (
|
showHelpOverlay && helpContext ? (
|
||||||
|
|
@ -1179,74 +1220,20 @@ export function ActiveSession({
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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
|
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'
|
size?: 'normal' | 'large'
|
||||||
/** Index of the term currently being helped with (shows arrow indicator) */
|
/** Index of the term currently being helped with (shows arrow indicator) */
|
||||||
currentHelpTermIndex?: number
|
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) */
|
/** Rejected digit to show as red X (null = no rejection) */
|
||||||
rejectedDigit?: string | null
|
rejectedDigit?: string | null
|
||||||
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
|
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
|
||||||
|
|
@ -42,6 +44,7 @@ export function VerticalProblem({
|
||||||
correctAnswer,
|
correctAnswer,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
currentHelpTermIndex,
|
currentHelpTermIndex,
|
||||||
|
needHelpTermIndex,
|
||||||
rejectedDigit = null,
|
rejectedDigit = null,
|
||||||
helpOverlay,
|
helpOverlay,
|
||||||
}: VerticalProblemProps) {
|
}: VerticalProblemProps) {
|
||||||
|
|
@ -118,12 +121,14 @@ export function VerticalProblem({
|
||||||
|
|
||||||
// Check if this term row should show the help overlay
|
// Check if this term row should show the help overlay
|
||||||
const isCurrentHelp = index === currentHelpTermIndex
|
const isCurrentHelp = index === currentHelpTermIndex
|
||||||
|
// Check if this term row should show "need help?" prompt (ambiguous case)
|
||||||
|
const showNeedHelp = index === needHelpTermIndex && !isCurrentHelp
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
data-element="term-row"
|
data-element="term-row"
|
||||||
data-term-status={isCurrentHelp ? 'current' : 'pending'}
|
data-term-status={isCurrentHelp ? 'current' : showNeedHelp ? 'need-help' : 'pending'}
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -132,6 +137,44 @@ export function VerticalProblem({
|
||||||
transition: 'all 0.2s ease',
|
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) */}
|
{/* Arrow indicator for current help term (the term being added) */}
|
||||||
{isCurrentHelp && (
|
{isCurrentHelp && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { GeneratedProblem } from '@/db/schema/session-plans'
|
import type { GeneratedProblem } from '@/db/schema/session-plans'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -81,6 +81,9 @@ export type InteractionPhase =
|
||||||
/** Threshold for correction count before requiring manual submit */
|
/** Threshold for correction count before requiring manual submit */
|
||||||
export const MANUAL_SUBMIT_THRESHOLD = 2
|
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
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -145,6 +148,11 @@ export function computePrefixSums(terms: number[]): number[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a digit would be consistent with any prefix sum.
|
* 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(
|
export function isDigitConsistent(
|
||||||
currentAnswer: string,
|
currentAnswer: string,
|
||||||
|
|
@ -152,12 +160,25 @@ export function isDigitConsistent(
|
||||||
prefixSums: number[]
|
prefixSums: number[]
|
||||||
): boolean {
|
): boolean {
|
||||||
const newAnswer = currentAnswer + digit
|
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
|
if (Number.isNaN(newAnswerNum)) return false
|
||||||
|
|
||||||
for (const sum of prefixSums) {
|
for (const sum of prefixSums) {
|
||||||
const sumStr = sum.toString()
|
const sumStr = sum.toString()
|
||||||
if (sumStr.startsWith(newAnswer)) {
|
// Check if stripped answer is a prefix of any sum
|
||||||
|
if (sumStr.startsWith(strippedAnswer)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,13 +186,70 @@ export function isDigitConsistent(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds which prefix sum the user's answer matches, if any.
|
* Result of checking for prefix sum matches
|
||||||
* Returns -1 if no match.
|
|
||||||
*/
|
*/
|
||||||
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)
|
const answerNum = parseInt(userAnswer, 10)
|
||||||
if (Number.isNaN(answerNum)) return -1
|
if (Number.isNaN(answerNum)) return noMatch
|
||||||
return prefixSums.indexOf(answerNum)
|
|
||||||
|
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
|
// Hook
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface InitialProblemData {
|
||||||
|
problem: GeneratedProblem
|
||||||
|
slotIndex: number
|
||||||
|
partIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseInteractionPhaseOptions {
|
export interface UseInteractionPhaseOptions {
|
||||||
|
/** Initial problem to hydrate with (for SSR) */
|
||||||
|
initialProblem?: InitialProblemData
|
||||||
/** Called when auto-submit threshold is exceeded */
|
/** Called when auto-submit threshold is exceeded */
|
||||||
onManualSubmitRequired?: () => void
|
onManualSubmitRequired?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -215,13 +301,21 @@ export interface UseInteractionPhaseReturn {
|
||||||
// Computed values (only valid when attempt exists)
|
// Computed values (only valid when attempt exists)
|
||||||
/** Prefix sums for current problem */
|
/** Prefix sums for current problem */
|
||||||
prefixSums: number[]
|
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
|
matchedPrefixIndex: number
|
||||||
/** Can the submit button be pressed? */
|
/** Can the submit button be pressed? */
|
||||||
canSubmit: boolean
|
canSubmit: boolean
|
||||||
/** Should auto-submit trigger? */
|
/** Should auto-submit trigger? */
|
||||||
shouldAutoSubmit: boolean
|
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
|
// Actions
|
||||||
/** Load a new problem (loading → inputting) */
|
/** Load a new problem (loading → inputting) */
|
||||||
loadProblem: (problem: GeneratedProblem, slotIndex: number, partIndex: number) => void
|
loadProblem: (problem: GeneratedProblem, slotIndex: number, partIndex: number) => void
|
||||||
|
|
@ -256,8 +350,20 @@ export interface UseInteractionPhaseReturn {
|
||||||
export function useInteractionPhase(
|
export function useInteractionPhase(
|
||||||
options: UseInteractionPhaseOptions = {}
|
options: UseInteractionPhaseOptions = {}
|
||||||
): UseInteractionPhaseReturn {
|
): UseInteractionPhaseReturn {
|
||||||
const { onManualSubmitRequired } = options
|
const { initialProblem, onManualSubmitRequired } = options
|
||||||
const [phase, setPhase] = useState<InteractionPhase>({ phase: 'loading' })
|
|
||||||
|
// 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
|
// Derived State
|
||||||
|
|
@ -299,11 +405,68 @@ export function useInteractionPhase(
|
||||||
return computePrefixSums(attempt.problem.terms)
|
return computePrefixSums(attempt.problem.terms)
|
||||||
}, [attempt])
|
}, [attempt])
|
||||||
|
|
||||||
const matchedPrefixIndex = useMemo(() => {
|
const prefixMatch = useMemo((): PrefixMatchResult => {
|
||||||
if (!attempt) return -1
|
if (!attempt) return { matchedIndex: -1, isAmbiguous: false, helpTermIndex: -1 }
|
||||||
return findMatchedPrefixIndex(attempt.userAnswer, prefixSums)
|
return findMatchedPrefixIndex(attempt.userAnswer, prefixSums)
|
||||||
}, [attempt, 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(() => {
|
const canSubmit = useMemo(() => {
|
||||||
if (!attempt || !attempt.userAnswer) return false
|
if (!attempt || !attempt.userAnswer) return false
|
||||||
const answerNum = parseInt(attempt.userAnswer, 10)
|
const answerNum = parseInt(attempt.userAnswer, 10)
|
||||||
|
|
@ -514,9 +677,12 @@ export function useInteractionPhase(
|
||||||
showFeedback,
|
showFeedback,
|
||||||
inputIsFocused,
|
inputIsFocused,
|
||||||
prefixSums,
|
prefixSums,
|
||||||
|
prefixMatch,
|
||||||
matchedPrefixIndex,
|
matchedPrefixIndex,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
shouldAutoSubmit,
|
shouldAutoSubmit,
|
||||||
|
ambiguousHelpTermIndex,
|
||||||
|
ambiguousTimerElapsed,
|
||||||
loadProblem,
|
loadProblem,
|
||||||
handleDigit,
|
handleDigit,
|
||||||
handleBackspace,
|
handleBackspace,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue