feat(abacus-react): add defaultValue prop for uncontrolled mode
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 <noreply@anthropic.com>
This commit is contained in:
parent
373ec34e46
commit
3ce12c59fc
|
|
@ -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<number | null>(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}
|
||||
</div>
|
||||
|
||||
{/* Problem display - vertical or linear based on part type */}
|
||||
{currentPart.format === 'vertical' ? (
|
||||
<VerticalProblem
|
||||
terms={currentProblem.problem.terms}
|
||||
userAnswer={userAnswer}
|
||||
isFocused={!isPaused && !isSubmitting}
|
||||
isCompleted={feedback !== 'none'}
|
||||
correctAnswer={currentProblem.problem.answer}
|
||||
size="large"
|
||||
/>
|
||||
) : (
|
||||
<LinearProblem
|
||||
terms={currentProblem.problem.terms}
|
||||
userAnswer={userAnswer}
|
||||
isFocused={!isPaused && !isSubmitting}
|
||||
isCompleted={feedback !== 'none'}
|
||||
correctAnswer={currentProblem.problem.answer}
|
||||
/>
|
||||
)}
|
||||
{/* Problem and Help Abacus - side by side layout */}
|
||||
<div
|
||||
data-section="problem-and-help"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{/* Problem display - vertical or linear based on part type */}
|
||||
{currentPart.format === 'vertical' ? (
|
||||
<VerticalProblem
|
||||
terms={currentProblem.problem.terms}
|
||||
userAnswer={userAnswer}
|
||||
isFocused={!isPaused && !isSubmitting}
|
||||
isCompleted={feedback !== 'none'}
|
||||
correctAnswer={currentProblem.problem.answer}
|
||||
size="large"
|
||||
confirmedTermCount={confirmedTermCount}
|
||||
currentHelpTermIndex={helpTermIndex ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<LinearProblem
|
||||
terms={currentProblem.problem.terms}
|
||||
userAnswer={userAnswer}
|
||||
isFocused={!isPaused && !isSubmitting}
|
||||
isCompleted={feedback !== 'none'}
|
||||
correctAnswer={currentProblem.problem.answer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Per-term help with HelpAbacus - shown when helpTermIndex is set */}
|
||||
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
|
||||
<div
|
||||
data-section="term-help"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: 'purple.50',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.200',
|
||||
minWidth: '200px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
})}
|
||||
>
|
||||
{helpContext.term >= 0 ? '+' : ''}
|
||||
{helpContext.term}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissHelp}
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: 'gray.500',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
_hover: { color: 'gray.700' },
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HelpAbacus
|
||||
currentValue={helpContext.currentValue}
|
||||
targetValue={helpContext.targetValue}
|
||||
columns={3}
|
||||
scaleFactor={0.9}
|
||||
interactive={true}
|
||||
onTargetReached={handleTargetReached}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback message */}
|
||||
{feedback !== 'none' && (
|
||||
|
|
@ -653,23 +825,59 @@ export function ActiveSession({
|
|||
: `The answer was ${currentProblem.problem.answer}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help panel - show when not submitting and feedback hasn't been shown yet */}
|
||||
{!isSubmitting && feedback === 'none' && (
|
||||
<div data-section="help-area" className={css({ width: '100%' })}>
|
||||
<PracticeHelpPanel
|
||||
helpState={helpState}
|
||||
onRequestHelp={helpActions.requestHelp}
|
||||
onDismissHelp={helpActions.dismissHelp}
|
||||
isAbacusPart={currentPart.type === 'abacus'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
{!isPaused && feedback === 'none' && (
|
||||
<div data-section="input-area">
|
||||
{/* Dynamic action button */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action={buttonState}
|
||||
onClick={buttonState === 'help' ? handleGetHelp : handleSubmit}
|
||||
disabled={buttonState === 'disabled' || isSubmitting}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: buttonState === 'disabled' ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
// Different styles based on button state
|
||||
backgroundColor:
|
||||
buttonState === 'help'
|
||||
? 'purple.500'
|
||||
: buttonState === 'submit'
|
||||
? 'blue.500'
|
||||
: 'gray.300',
|
||||
color: buttonState === 'disabled' ? 'gray.500' : 'white',
|
||||
opacity: buttonState === 'disabled' ? 0.5 : 1,
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
buttonState === 'help'
|
||||
? 'purple.600'
|
||||
: buttonState === 'submit'
|
||||
? 'blue.600'
|
||||
: 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{buttonState === 'help'
|
||||
? 'Get Help →'
|
||||
: buttonState === 'submit'
|
||||
? 'Submit ✓'
|
||||
: 'Enter Total'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Physical keyboard hint */}
|
||||
{hasPhysicalKeyboard && (
|
||||
<div
|
||||
|
|
@ -680,7 +888,8 @@ export function ActiveSession({
|
|||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
Type your answer and press Enter
|
||||
Type your abacus total
|
||||
{buttonState === 'help' ? ' to get help, or your final answer' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -689,7 +898,7 @@ export function ActiveSession({
|
|||
<NumericKeypad
|
||||
onDigit={handleDigit}
|
||||
onBackspace={handleBackspace}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={buttonState === 'help' ? handleGetHelp : handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
currentValue={userAnswer}
|
||||
/>
|
||||
|
|
@ -697,58 +906,6 @@ export function ActiveSession({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Abacus toggle (only for abacus part) */}
|
||||
{currentPart.type === 'abacus' && (
|
||||
<div data-section="abacus-tools">
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-abacus"
|
||||
onClick={() => setShowAbacus(!showAbacus)}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
color: showAbacus ? 'blue.700' : 'gray.600',
|
||||
backgroundColor: showAbacus ? 'blue.50' : 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: showAbacus ? 'blue.200' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: showAbacus ? 'blue.100' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{showAbacus ? 'Hide On-Screen Abacus' : 'Need Help? Show Abacus'}
|
||||
</button>
|
||||
|
||||
{showAbacus && (
|
||||
<div
|
||||
data-element="abacus-reminder"
|
||||
className={css({
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'yellow.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.200',
|
||||
fontSize: '0.875rem',
|
||||
color: 'yellow.700',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Try using your physical abacus first! The on-screen version is for checking only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: Add AbacusReact component here when showAbacus is true */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teacher controls */}
|
||||
<div
|
||||
data-section="teacher-controls"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
AbacusReact,
|
||||
calculateBeadDiffFromValues,
|
||||
type StepBeadHighlight,
|
||||
useAbacusDisplay,
|
||||
} from '@soroban/abacus-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
/** Bead change from calculateBeadDiffFromValues */
|
||||
interface BeadChange {
|
||||
placeValue: number
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
direction: 'up' | 'down' | 'activate' | 'deactivate' | 'none'
|
||||
order?: number
|
||||
}
|
||||
|
||||
interface HelpAbacusProps {
|
||||
/** Initial value to start the abacus at */
|
||||
currentValue: number
|
||||
/** Target value we want to reach */
|
||||
targetValue: number
|
||||
/** Number of columns to display (default: 3) */
|
||||
columns?: number
|
||||
/** Scale factor for the abacus (default: 1.2) */
|
||||
scaleFactor?: number
|
||||
/** Callback when target is reached */
|
||||
onTargetReached?: () => 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 (
|
||||
<div
|
||||
data-component="help-abacus"
|
||||
data-status="complete"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '1rem',
|
||||
color: 'green.600',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
✓ Already at target value
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="help-abacus"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{/* Summary instruction */}
|
||||
{summary && (
|
||||
<div
|
||||
data-element="help-summary"
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: 'blue.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
color: 'blue.700',
|
||||
fontWeight: 'medium',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
💡 {summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The abacus with bead arrows - uses defaultValue for uncontrolled mode */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: 'blue.200',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
defaultValue={currentValue}
|
||||
columns={columns}
|
||||
interactive={interactive}
|
||||
animated={true}
|
||||
scaleFactor={scaleFactor}
|
||||
colorScheme={abacusConfig.colorScheme}
|
||||
beadShape={abacusConfig.beadShape}
|
||||
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
||||
soundEnabled={false} // Disable sound in help mode
|
||||
stepBeadHighlights={isAtTarget ? undefined : stepBeadHighlights}
|
||||
currentStep={currentStep}
|
||||
showDirectionIndicators={!isAtTarget}
|
||||
customStyles={customStyles}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value labels */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ color: isAtTarget ? 'green.600' : 'gray.600' })}>
|
||||
Current:{' '}
|
||||
<span
|
||||
className={css({ fontWeight: 'bold', color: isAtTarget ? 'green.700' : 'gray.800' })}
|
||||
>
|
||||
{displayedValue}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ color: isAtTarget ? 'green.600' : 'blue.600' })}>
|
||||
Target:{' '}
|
||||
<span
|
||||
className={css({ fontWeight: 'bold', color: isAtTarget ? 'green.700' : 'blue.800' })}
|
||||
>
|
||||
{targetValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success feedback when target reached */}
|
||||
{isAtTarget && (
|
||||
<div
|
||||
data-element="target-reached"
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: 'green.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
color: 'green.700',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
✓ Perfect! Moving to next term...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpAbacus
|
||||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div
|
||||
data-element="bead-steps"
|
||||
data-element="help-abacus"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.100',
|
||||
borderColor: 'purple.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
|
@ -314,43 +321,20 @@ export function PracticeHelpPanel({
|
|||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.600',
|
||||
marginBottom: '0.5rem',
|
||||
marginBottom: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Bead Movements
|
||||
🧮 Follow the Arrows
|
||||
</div>
|
||||
<ol
|
||||
className={css({
|
||||
listStyle: 'decimal',
|
||||
paddingLeft: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{content.beadSteps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
})}
|
||||
>
|
||||
{step.mathematicalTerm}
|
||||
</span>
|
||||
{step.englishInstruction && (
|
||||
<span className={css({ color: 'gray.600' })}> — {step.englishInstruction}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<HelpAbacus
|
||||
currentValue={currentValue}
|
||||
targetValue={targetValue}
|
||||
columns={3}
|
||||
scaleFactor={1.0}
|
||||
/>
|
||||
|
||||
{isAbacusPart && (
|
||||
<div
|
||||
|
|
@ -364,12 +348,72 @@ export function PracticeHelpPanel({
|
|||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Try following these steps on your abacus
|
||||
Try following these movements on your physical abacus
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Text bead steps if abacus values not provided */}
|
||||
{currentLevel >= 3 &&
|
||||
(currentValue === undefined || targetValue === undefined) &&
|
||||
content?.beadSteps &&
|
||||
content.beadSteps.length > 0 && (
|
||||
<div
|
||||
data-element="bead-steps-text"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.100',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.600',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
})}
|
||||
>
|
||||
Bead Movements
|
||||
</div>
|
||||
<ol
|
||||
className={css({
|
||||
listStyle: 'decimal',
|
||||
paddingLeft: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{content.beadSteps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
})}
|
||||
>
|
||||
{step.mathematicalTerm}
|
||||
</span>
|
||||
{step.englishInstruction && (
|
||||
<span className={css({ color: 'gray.600' })}> — {step.englishInstruction}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More help button (if not at max level) */}
|
||||
{currentLevel < 3 && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ interface VerticalProblemProps {
|
|||
correctAnswer?: number
|
||||
/** Size variant */
|
||||
size?: 'normal' | 'large'
|
||||
/** Index of terms that have been confirmed (0 = first term done, 1 = first two terms done, etc.) */
|
||||
confirmedTermCount?: number
|
||||
/** Index of the term currently being helped with (highlighted) */
|
||||
currentHelpTermIndex?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,6 +37,8 @@ export function VerticalProblem({
|
|||
isCompleted = false,
|
||||
correctAnswer,
|
||||
size = 'normal',
|
||||
confirmedTermCount = 0,
|
||||
currentHelpTermIndex,
|
||||
}: VerticalProblemProps) {
|
||||
// Calculate max digits needed for alignment
|
||||
const maxDigits = Math.max(
|
||||
|
|
@ -86,16 +92,60 @@ export function VerticalProblem({
|
|||
const absValue = Math.abs(term)
|
||||
const digits = absValue.toString().padStart(maxDigits, ' ').split('')
|
||||
|
||||
// Term status for highlighting
|
||||
const isConfirmed = index < confirmedTermCount
|
||||
const isCurrentHelp = index === currentHelpTermIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-element="term-row"
|
||||
data-term-status={isConfirmed ? 'confirmed' : isCurrentHelp ? 'current' : 'pending'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
position: 'relative',
|
||||
// Confirmed terms are dimmed with checkmark
|
||||
opacity: isConfirmed ? 0.5 : 1,
|
||||
// Current help term is highlighted
|
||||
backgroundColor: isCurrentHelp ? 'purple.100' : 'transparent',
|
||||
borderRadius: isCurrentHelp ? '4px' : '0',
|
||||
padding: isCurrentHelp ? '2px 4px' : '0',
|
||||
marginLeft: isCurrentHelp ? '-4px' : '0',
|
||||
marginRight: isCurrentHelp ? '-4px' : '0',
|
||||
})}
|
||||
>
|
||||
{/* Checkmark for confirmed terms */}
|
||||
{isConfirmed && (
|
||||
<div
|
||||
data-element="confirmed-check"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '-1.5rem',
|
||||
color: 'green.500',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrow indicator for current help term */}
|
||||
{isCurrentHelp && (
|
||||
<div
|
||||
data-element="current-arrow"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '-1.5rem',
|
||||
color: 'purple.600',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator column (only show minus for negative) */}
|
||||
<div
|
||||
data-element="operator"
|
||||
|
|
@ -122,7 +172,8 @@ export function VerticalProblem({
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'gray.800',
|
||||
color: isCurrentHelp ? 'purple.800' : 'gray.800',
|
||||
fontWeight: isCurrentHelp ? 'bold' : 'inherit',
|
||||
})}
|
||||
>
|
||||
{digit}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue