From 3ce12c59fc3f8306bb21b19876e6a820e6ddedda Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 6 Dec 2025 19:11:43 -0600 Subject: [PATCH] feat(abacus-react): add defaultValue prop for uncontrolled mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add standard React controlled/uncontrolled component pattern to AbacusReact: - Add `defaultValue` prop to support uncontrolled mode (component owns state) - When `value` is provided, component operates in controlled mode (syncs to prop) - When only `defaultValue` is provided, component operates in uncontrolled mode - Update HelpAbacus to use defaultValue for interactive help This enables interactive abacus in help mode where the component tracks its own state while parent monitors via onValueChange callback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/components/practice/ActiveSession.tsx | 339 +- .../src/components/practice/HelpAbacus.tsx | 245 + .../components/practice/PracticeHelpPanel.tsx | 122 +- .../components/practice/VerticalProblem.tsx | 53 +- packages/abacus-react/src/AbacusReact.tsx | 4385 +++++++++-------- 5 files changed, 2829 insertions(+), 2315 deletions(-) create mode 100644 apps/web/src/components/practice/HelpAbacus.tsx diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index f269904a..4b4f8e3c 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { GeneratedProblem, - HelpLevel, ProblemConstraints, ProblemSlot, SessionHealth, @@ -12,7 +11,7 @@ import type { SlotResult, } from '@/db/schema/session-plans' import type { StudentHelpSettings } from '@/db/schema/players' -import { usePracticeHelp, type TermContext } from '@/hooks/usePracticeHelp' +import { usePracticeHelp } from '@/hooks/usePracticeHelp' import { createBasicSkillSet, type SkillSet } from '@/types/tutorial' import { analyzeRequiredSkills, @@ -20,9 +19,9 @@ import { generateSingleProblem, } from '@/utils/problemGenerator' import { css } from '../../../styled-system/css' +import { HelpAbacus } from './HelpAbacus' import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection' import { NumericKeypad } from './NumericKeypad' -import { PracticeHelpPanel } from './PracticeHelpPanel' import { VerticalProblem } from './VerticalProblem' interface ActiveSessionProps { @@ -187,10 +186,13 @@ export function ActiveSession({ const [userAnswer, setUserAnswer] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [isPaused, setIsPaused] = useState(false) - const [showAbacus, setShowAbacus] = useState(false) const [feedback, setFeedback] = useState<'none' | 'correct' | 'incorrect'>('none') const [currentTermIndex, setCurrentTermIndex] = useState(0) const [incorrectAttempts, setIncorrectAttempts] = useState(0) + // Help mode: which terms have been confirmed correct so far + const [confirmedTermCount, setConfirmedTermCount] = useState(0) + // Which term we're currently showing help for (null = not showing help) + const [helpTermIndex, setHelpTermIndex] = useState(null) const hasPhysicalKeyboard = useHasPhysicalKeyboard() @@ -217,6 +219,56 @@ export function ActiveSession({ return currentProblem.problem.terms[currentTermIndex] }, [currentProblem, currentTermIndex]) + // Compute all prefix sums for the current problem + // prefixSums[i] = sum of terms[0..i] (inclusive) + // e.g., for [23, 45, 12]: prefixSums = [23, 68, 80] + const prefixSums = useMemo(() => { + if (!currentProblem) return [] + const terms = currentProblem.problem.terms + const sums: number[] = [] + let total = 0 + for (const term of terms) { + total += term + sums.push(total) + } + return sums + }, [currentProblem]) + + // Check if user's input matches any prefix sum + // Returns the index of the matched prefix, or -1 if no match + const matchedPrefixIndex = useMemo(() => { + const answerNum = parseInt(userAnswer, 10) + if (Number.isNaN(answerNum)) return -1 + return prefixSums.indexOf(answerNum) + }, [userAnswer, prefixSums]) + + // Determine button state based on input + const buttonState = useMemo((): 'help' | 'submit' | 'disabled' => { + if (!userAnswer) return 'disabled' + const answerNum = parseInt(userAnswer, 10) + if (Number.isNaN(answerNum)) return 'disabled' + + // If it matches a prefix sum (but not the final answer), offer help + if (matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1) { + return 'help' + } + + // Otherwise it's a submit (final answer or wrong answer) + return 'submit' + }, [userAnswer, matchedPrefixIndex, prefixSums.length]) + + // Compute context for help abacus when showing help + const helpContext = useMemo(() => { + if (helpTermIndex === null || !currentProblem) return null + const terms = currentProblem.problem.terms + // Current value is the prefix sum up to helpTermIndex (exclusive) + const currentValue = helpTermIndex === 0 ? 0 : prefixSums[helpTermIndex - 1] + // Target is the prefix sum including this term + const targetValue = prefixSums[helpTermIndex] + const term = terms[helpTermIndex] + return { currentValue, targetValue, term } + }, [helpTermIndex, currentProblem, prefixSums]) + // Initialize help system const [helpState, helpActions] = usePracticeHelp({ settings: helpSettings || { @@ -326,6 +378,50 @@ export function ActiveSession({ setUserAnswer((prev) => prev.slice(0, -1)) }, []) + // Handle "Get Help" button - show help for the next term + const handleGetHelp = useCallback(() => { + if (matchedPrefixIndex < 0) return + + // User has confirmed up through matchedPrefixIndex (inclusive) + // Set confirmed count and show help for the NEXT term + const newConfirmedCount = matchedPrefixIndex + 1 + setConfirmedTermCount(newConfirmedCount) + + // If there's a next term to help with, show it + if (newConfirmedCount < (currentProblem?.problem.terms.length || 0)) { + setHelpTermIndex(newConfirmedCount) + // Clear the input so they can continue + setUserAnswer('') + } + }, [matchedPrefixIndex, currentProblem?.problem.terms.length]) + + // Handle dismissing help (continue without visual assistance) + const handleDismissHelp = useCallback(() => { + setHelpTermIndex(null) + }, []) + + // Handle when student reaches the target value on the help abacus + const handleTargetReached = useCallback(() => { + if (helpTermIndex === null || !currentProblem) return + + // Current term is now confirmed + const newConfirmedCount = helpTermIndex + 1 + setConfirmedTermCount(newConfirmedCount) + + // Brief delay so user sees the success feedback + setTimeout(() => { + // If there's another term after this one, move to it + if (newConfirmedCount < currentProblem.problem.terms.length) { + setHelpTermIndex(newConfirmedCount) + setUserAnswer('') + } else { + // This was the last term - hide help and let them type the final answer + setHelpTermIndex(null) + setUserAnswer('') + } + }, 800) // 800ms delay to show "Perfect!" feedback + }, [helpTermIndex, currentProblem]) + const handleSubmit = useCallback(async () => { if (!currentProblem || isSubmitting || !userAnswer) return @@ -353,7 +449,7 @@ export function ActiveSession({ isCorrect, responseTimeMs, skillsExercised: currentProblem.problem.skillsRequired, - usedOnScreenAbacus: showAbacus, + usedOnScreenAbacus: confirmedTermCount > 0 || helpTermIndex !== null, // Help tracking fields helpLevelUsed: helpState.maxLevelUsed, incorrectAttempts, @@ -368,6 +464,8 @@ export function ActiveSession({ setCurrentProblem(null) setCurrentTermIndex(0) setIncorrectAttempts(0) + setConfirmedTermCount(0) + setHelpTermIndex(null) setIsSubmitting(false) }, isCorrect ? 500 : 1500 @@ -376,7 +474,8 @@ export function ActiveSession({ currentProblem, isSubmitting, userAnswer, - showAbacus, + confirmedTermCount, + helpTermIndex, onAnswer, helpState.maxLevelUsed, helpState.trigger, @@ -615,25 +714,98 @@ export function ActiveSession({ {currentSlot?.purpose} - {/* Problem display - vertical or linear based on part type */} - {currentPart.format === 'vertical' ? ( - - ) : ( - - )} + {/* Problem and Help Abacus - side by side layout */} +
+ {/* Problem display - vertical or linear based on part type */} + {currentPart.format === 'vertical' ? ( + + ) : ( + + )} + + {/* Per-term help with HelpAbacus - shown when helpTermIndex is set */} + {!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && ( +
+
+
+ {helpContext.term >= 0 ? '+' : ''} + {helpContext.term} +
+ +
+ + +
+ )} +
{/* Feedback message */} {feedback !== 'none' && ( @@ -653,23 +825,59 @@ export function ActiveSession({ : `The answer was ${currentProblem.problem.answer}`} )} - - {/* Help panel - show when not submitting and feedback hasn't been shown yet */} - {!isSubmitting && feedback === 'none' && ( -
- -
- )} {/* Input area */} {!isPaused && feedback === 'none' && (
+ {/* Dynamic action button */} +
+ +
+ {/* Physical keyboard hint */} {hasPhysicalKeyboard && (
- Type your answer and press Enter + Type your abacus total + {buttonState === 'help' ? ' to get help, or your final answer' : ''}
)} @@ -689,7 +898,7 @@ export function ActiveSession({ @@ -697,58 +906,6 @@ export function ActiveSession({
)} - {/* Abacus toggle (only for abacus part) */} - {currentPart.type === 'abacus' && ( -
- - - {showAbacus && ( -
- Try using your physical abacus first! The on-screen version is for checking only. -
- )} - - {/* TODO: Add AbacusReact component here when showAbacus is true */} -
- )} - {/* Teacher controls */}
void + /** Optional callback when value changes (if interactive) */ + onValueChange?: (value: number) => void + /** Whether the abacus is interactive (default: false for help mode) */ + interactive?: boolean +} + +/** + * HelpAbacus - Shows an abacus with bead movement arrows + * + * Uses AbacusReact in uncontrolled mode (defaultValue) so interactions + * work automatically. Tracks value changes via onValueChange to update + * the bead diff arrows and detect when target is reached. + */ +export function HelpAbacus({ + currentValue, + targetValue, + columns = 3, + scaleFactor = 1.2, + onTargetReached, + onValueChange, + interactive = false, +}: HelpAbacusProps) { + const { config: abacusConfig } = useAbacusDisplay() + const [currentStep] = useState(0) + + // Track the displayed value for bead diff calculations + // This is updated via onValueChange from AbacusReact + const [displayedValue, setDisplayedValue] = useState(currentValue) + + // Handle value changes from user interaction + const handleValueChange = useCallback( + (newValue: number | bigint) => { + const numValue = typeof newValue === 'bigint' ? Number(newValue) : newValue + setDisplayedValue(numValue) + onValueChange?.(numValue) + + // Check if target reached + if (numValue === targetValue) { + onTargetReached?.() + } + }, + [targetValue, onTargetReached, onValueChange] + ) + + // Check if currently at target (for showing success state) + const isAtTarget = displayedValue === targetValue + + // Generate bead movement highlights using the bead diff algorithm + // Updates as user moves beads closer to (or away from) the target + const { stepBeadHighlights, hasChanges, summary } = useMemo(() => { + try { + const beadDiff = calculateBeadDiffFromValues(displayedValue, targetValue) + + if (!beadDiff.hasChanges) { + return { stepBeadHighlights: undefined, hasChanges: false, summary: '' } + } + + // Convert bead diff to StepBeadHighlight format + // Filter to only columns that exist in our display + const highlights: StepBeadHighlight[] = (beadDiff.changes as BeadChange[]) + .filter((change: BeadChange) => change.placeValue < columns) + .map((change: BeadChange) => ({ + placeValue: change.placeValue, + beadType: change.beadType, + position: change.position, + direction: change.direction, + stepIndex: 0, // All in step 0 for now (could be multi-step later) + order: change.order, + })) + + return { + stepBeadHighlights: highlights.length > 0 ? highlights : undefined, + hasChanges: true, + summary: beadDiff.summary, + } + } catch (error) { + console.error('HelpAbacus: Error generating bead diff:', error) + return { stepBeadHighlights: undefined, hasChanges: false, summary: '' } + } + }, [displayedValue, targetValue, columns]) + + // Custom styles for help mode - highlight the arrows more prominently + const customStyles = useMemo(() => { + return { + // Subtle background to indicate this is a help visualization + frame: { + fill: 'rgba(59, 130, 246, 0.05)', + }, + } + }, []) + + if (!hasChanges) { + return ( +
+ ✓ Already at target value +
+ ) + } + + return ( +
+ {/* Summary instruction */} + {summary && ( +
+ 💡 {summary} +
+ )} + + {/* The abacus with bead arrows - uses defaultValue for uncontrolled mode */} +
+ +
+ + {/* Value labels */} +
+
+ Current:{' '} + + {displayedValue} + +
+
+ Target:{' '} + + {targetValue} + +
+
+ + {/* Success feedback when target reached */} + {isAtTarget && ( +
+ ✓ Perfect! Moving to next term... +
+ )} +
+ ) +} + +export default HelpAbacus diff --git a/apps/web/src/components/practice/PracticeHelpPanel.tsx b/apps/web/src/components/practice/PracticeHelpPanel.tsx index 3264c3d3..4201e5d7 100644 --- a/apps/web/src/components/practice/PracticeHelpPanel.tsx +++ b/apps/web/src/components/practice/PracticeHelpPanel.tsx @@ -2,8 +2,9 @@ import { useCallback, useState } from 'react' import type { HelpLevel } from '@/db/schema/session-plans' -import type { HelpContent, PracticeHelpState } from '@/hooks/usePracticeHelp' +import type { PracticeHelpState } from '@/hooks/usePracticeHelp' import { css } from '../../../styled-system/css' +import { HelpAbacus } from './HelpAbacus' interface PracticeHelpPanelProps { /** Current help state from usePracticeHelp hook */ @@ -14,6 +15,10 @@ interface PracticeHelpPanelProps { onDismissHelp: () => void /** Whether this is the abacus part (enables bead arrows at L3) */ isAbacusPart?: boolean + /** Current value on the abacus (for bead arrows at L3) */ + currentValue?: number + /** Target value to reach (for bead arrows at L3) */ + targetValue?: number } /** @@ -50,6 +55,8 @@ export function PracticeHelpPanel({ onRequestHelp, onDismissHelp, isAbacusPart = false, + currentValue, + targetValue, }: PracticeHelpPanelProps) { const { currentLevel, content, isAvailable, maxLevelUsed } = helpState const [isExpanded, setIsExpanded] = useState(false) @@ -297,16 +304,16 @@ export function PracticeHelpPanel({
)} - {/* Level 3: Bead steps */} - {currentLevel >= 3 && content?.beadSteps && content.beadSteps.length > 0 && ( + {/* Level 3: Visual abacus with bead arrows */} + {currentLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
- Bead Movements + 🧮 Follow the Arrows
-
    - {content.beadSteps.map((step, index) => ( -
  1. - - {step.mathematicalTerm} - - {step.englishInstruction && ( - — {step.englishInstruction} - )} -
  2. - ))} -
+ + {isAbacusPart && (
- Try following these steps on your abacus + Try following these movements on your physical abacus
)}
)} + {/* Fallback: Text bead steps if abacus values not provided */} + {currentLevel >= 3 && + (currentValue === undefined || targetValue === undefined) && + content?.beadSteps && + content.beadSteps.length > 0 && ( +
+
+ Bead Movements +
+
    + {content.beadSteps.map((step, index) => ( +
  1. + + {step.mathematicalTerm} + + {step.englishInstruction && ( + — {step.englishInstruction} + )} +
  2. + ))} +
+
+ )} + {/* More help button (if not at max level) */} {currentLevel < 3 && (