From 46ff5f528a3749ee068902a9e9af90b76fd3bd0a Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 9 Dec 2025 09:32:27 -0600 Subject: [PATCH] feat(practice): add prefix sum disambiguation and debug panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/practice/ActiveSession.tsx | 161 +++++++------- .../components/practice/ProblemDebugPanel.tsx | 208 ++++++++++++++++++ .../components/practice/VerticalProblem.tsx | 45 +++- .../practice/hooks/useInteractionPhase.ts | 192 ++++++++++++++-- 4 files changed, 505 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/components/practice/ProblemDebugPanel.tsx diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 67a14db5..2bedcfca 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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({ )} + + {/* Debug panel - shows current problem details when visual debug mode is on */} + {currentSlot?.problem && ( + + )} ) } - -/** - * 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 diff --git a/apps/web/src/components/practice/ProblemDebugPanel.tsx b/apps/web/src/components/practice/ProblemDebugPanel.tsx new file mode 100644 index 00000000..5f8612ca --- /dev/null +++ b/apps/web/src/components/practice/ProblemDebugPanel.tsx @@ -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 ( +
+ {/* Header */} +
setIsCollapsed(!isCollapsed)} + > + + Problem Debug {isCollapsed ? '(expand)' : ''} + +
+ {!isCollapsed && ( + + )} + {isCollapsed ? 'â–²' : 'â–¼'} +
+
+ + {/* Content */} + {!isCollapsed && ( +
+ {/* Quick summary */} +
+
+ 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,