+ Part {part.partNumber} ({part.type}) - Slot {slotIndex + 1}
+
+
+ {problem.terms
+ .map((t, i) => (i === 0 ? t : t >= 0 ? `+ ${t}` : `- ${Math.abs(t)}`))
+ .join(' ')}{' '}
+ = {problem.answer}
+
+
+ Skills: {problem.skillsRequired.join(', ')}
+
+
+ Phase: {phaseName} | Input: "{userInput}"
+
+
+
+ {/* Full JSON */}
+
+ {debugJson}
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/practice/VerticalProblem.tsx b/apps/web/src/components/practice/VerticalProblem.tsx
index 38af4804..c325ecd1 100644
--- a/apps/web/src/components/practice/VerticalProblem.tsx
+++ b/apps/web/src/components/practice/VerticalProblem.tsx
@@ -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 (
+ {/* "Need help?" prompt for ambiguous prefix case */}
+ {showNeedHelp && (
+
+ need help?
+
+ →
+
+
+ )}
+
{/* Arrow indicator for current help term (the term being added) */}
{isCurrentHelp && (
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({ phase: 'loading' })
+ const { initialProblem, onManualSubmitRequired } = options
+
+ // Initialize state with problem if provided (for SSR hydration)
+ const [phase, setPhase] = useState(() => {
+ 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(null)
+ const lastAmbiguousKeyRef = useRef(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,