feat(practice): improve help UX with coach hints and simplified UI

- Add coach hint generator using same hints as tutorial (segment.readable.summary)
- Hide abacus numerals in help mode for cleaner display
- Restructure layout: abacus centered, coaching panel on right side
- Exit help mode completely when student finishes adding term to abacus
- Remove checkmarks from term indicators, keep only arrow for current term
- Clean up unused props (confirmedTermCount, detectedPrefixIndex, countdownElement)
- Add HelpCountdown component (pie chart timer, not currently used)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-07 20:41:33 -06:00
parent 9a4ab8296e
commit 19169ad9fe
5 changed files with 319 additions and 192 deletions

View File

@ -20,6 +20,8 @@ import {
generateSingleProblem,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { generateCoachHint } from './coachHintGenerator'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
@ -529,24 +531,16 @@ export function ActiveSession({
}, [helpActions])
// Handle when student reaches the target value on the help abacus
// This exits help mode completely and resets the problem to normal state
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
// Brief delay so user sees the success feedback, then exit help mode completely
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
// Reset all help-related state - problem returns to as if they never entered a prefix
setHelpTermIndex(null)
setConfirmedTermCount(0)
setUserAnswer('')
}
}, 800) // 800ms delay to show "Perfect!" feedback
}, [helpTermIndex, currentProblem])
@ -1022,7 +1016,18 @@ export function ActiveSession({
{currentSlot?.purpose}
</div>
{/* Problem display - vertical or linear based on part type */}
{/* Problem display - horizontal layout with help panel on right when in help mode */}
<div
data-element="problem-with-help"
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
gap: '1.5rem',
width: '100%',
})}
>
{/* Center: Problem display */}
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={currentProblem.problem.terms}
@ -1031,54 +1036,11 @@ export function ActiveSession({
isCompleted={feedback !== 'none'}
correctAnswer={currentProblem.problem.answer}
size="large"
confirmedTermCount={confirmedTermCount}
currentHelpTermIndex={helpTermIndex ?? undefined}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
autoSubmitPending={autoSubmitTriggered}
rejectedDigit={rejectedDigit}
helpOverlay={
!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
backgroundColor: isDark
? 'rgba(30, 58, 138, 0.95)'
: 'rgba(219, 234, 254, 0.95)',
borderRadius: '12px',
padding: '0.5rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
})}
>
{/* Term being helped indicator */}
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.25rem 0.75rem',
backgroundColor: isDark ? 'purple.900' : 'purple.100',
borderRadius: '20px',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'purple.200' : 'purple.700',
})}
>
<span>Adding:</span>
<span className={css({ fontFamily: 'monospace', fontSize: '1rem' })}>
{helpContext.term >= 0 ? '+' : ''}
{helpContext.term}
</span>
</div>
{/* Interactive abacus with arrows - just the abacus, no extra UI */}
{/* Columns = max digits between current and target values (minimum 1) */}
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
@ -1088,7 +1050,6 @@ export function ActiveSession({
)}
onTargetReached={handleTargetReached}
/>
</div>
) : undefined
}
/>
@ -1108,6 +1069,98 @@ export function ActiveSession({
/>
)}
{/* Right: Help panel with coach hint and decomposition (only in help mode) */}
{!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && (
<div
data-element="help-panel"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
minWidth: '200px',
maxWidth: '280px',
})}
>
{/* Coach hint */}
{(() => {
const hint = generateCoachHint(helpContext.currentValue, helpContext.targetValue)
if (!hint) return null
return (
<div
data-element="coach-hint"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.4',
margin: 0,
})}
>
{hint}
</p>
</div>
)
})()}
{/* Decomposition display */}
<div
data-element="decomposition-display"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<div
className={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.25rem',
textTransform: 'uppercase',
})}
>
Step-by-Step
</div>
<div
className={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
color: isDark ? 'gray.100' : 'gray.800',
})}
>
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
currentStepIndex={0}
abacusColumns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
>
<DecompositionDisplay />
</DecompositionProvider>
</div>
</div>
</div>
)}
</div>
{/* Feedback message */}
{feedback !== 'none' && (
<div

View File

@ -1,6 +1,5 @@
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import {
type AbacusOverlay,
AbacusReact,
@ -9,6 +8,7 @@ import {
useAbacusDisplay,
} from '@soroban/abacus-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
/** Bead change from calculateBeadDiffFromValues */
@ -213,6 +213,7 @@ export function HelpAbacus({
colorScheme={abacusConfig.colorScheme}
beadShape={abacusConfig.beadShape}
hideInactiveBeads={abacusConfig.hideInactiveBeads}
showNumbers={false} // Hide numerals to keep display tight
soundEnabled={false} // Disable sound in help mode
stepBeadHighlights={isAtTarget ? undefined : stepBeadHighlights}
currentStep={currentStep}

View File

@ -0,0 +1,129 @@
'use client'
/**
* HelpCountdown - Pie chart style countdown timer for help escalation
*
* Shows a circular countdown that depletes as time passes, with seconds
* remaining displayed in the center. Positioned in place of the "=" sign
* in the VerticalProblem component.
*/
import { css } from '../../../styled-system/css'
export interface HelpCountdownProps {
/** Time elapsed since problem started (ms) */
elapsedTimeMs: number
/** Target time for next help level (ms) */
targetTimeMs: number
/** Size of the countdown circle (matches cell size) */
size?: string
/** Whether dark mode is active */
isDark?: boolean
}
/**
* Get color based on percentage remaining
* Green (>66%) Yellow (33-66%) Orange (15-33%) Red (<15%)
*/
function getColor(percentRemaining: number, isDark: boolean): string {
if (percentRemaining > 66) {
return isDark ? '#22c55e' : '#16a34a' // green
} else if (percentRemaining > 33) {
return isDark ? '#eab308' : '#ca8a04' // yellow
} else if (percentRemaining > 15) {
return isDark ? '#f97316' : '#ea580c' // orange
} else {
return isDark ? '#ef4444' : '#dc2626' // red
}
}
/**
* HelpCountdown - Pie chart countdown timer
*
* Renders a circular progress indicator that depletes clockwise,
* with the seconds remaining shown in the center.
*/
export function HelpCountdown({
elapsedTimeMs,
targetTimeMs,
size = '2.4rem',
isDark = false,
}: HelpCountdownProps) {
const remainingMs = Math.max(0, targetTimeMs - elapsedTimeMs)
const remainingSeconds = Math.ceil(remainingMs / 1000)
const percentRemaining = (remainingMs / targetTimeMs) * 100
// SVG parameters
const viewBoxSize = 100
const center = viewBoxSize / 2
const radius = 40
const circumference = 2 * Math.PI * radius
// Calculate stroke dash for remaining time (pie depletes as time passes)
const strokeDashoffset = circumference * (1 - percentRemaining / 100)
const color = getColor(percentRemaining, isDark)
// Background color for the circle
const bgColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
return (
<div
data-element="help-countdown"
data-seconds-remaining={remainingSeconds}
className={css({
width: size,
height: size,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<svg
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
className={css({
width: '100%',
height: '100%',
transform: 'rotate(-90deg)', // Start from top
})}
>
{/* Background circle */}
<circle cx={center} cy={center} r={radius} fill="none" stroke={bgColor} strokeWidth="12" />
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={color}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className={css({
transition: 'stroke-dashoffset 0.3s ease-out, stroke 0.3s ease',
})}
/>
</svg>
{/* Seconds remaining in center */}
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '0.75rem',
fontWeight: 'bold',
fontFamily: 'var(--font-mono, monospace)',
color: color,
lineHeight: 1,
})}
>
{remainingSeconds}
</div>
</div>
)
}
export default HelpCountdown

View File

@ -17,12 +17,8 @@ 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) */
/** Index of the term currently being helped with (shows arrow indicator) */
currentHelpTermIndex?: number
/** Detected prefix index - shows preview checkmarks/arrow before user clicks "Get Help" */
detectedPrefixIndex?: number
/** Whether auto-submit is about to trigger (shows celebration animation) */
autoSubmitPending?: boolean
/** Rejected digit to show as red X (null = no rejection) */
@ -47,9 +43,7 @@ export function VerticalProblem({
isCompleted = false,
correctAnswer,
size = 'normal',
confirmedTermCount = 0,
currentHelpTermIndex,
detectedPrefixIndex,
autoSubmitPending = false,
rejectedDigit = null,
helpOverlay,
@ -125,89 +119,23 @@ export function VerticalProblem({
const absValue = Math.abs(term)
const digits = absValue.toString().padStart(maxDigits, ' ').split('')
// Term status for highlighting
const isConfirmed = index < confirmedTermCount
// Check if this term row should show the help overlay
const isCurrentHelp = index === currentHelpTermIndex
// Preview states - shown when user's input matches a prefix sum (before clicking "Get Help")
const isPreviewConfirmed =
detectedPrefixIndex !== undefined && index <= detectedPrefixIndex && !isConfirmed
const isPreviewNext =
detectedPrefixIndex !== undefined &&
index === detectedPrefixIndex + 1 &&
!isCurrentHelp &&
detectedPrefixIndex < terms.length - 1 // Don't show if prefix is the full answer
return (
<div
key={index}
data-element="term-row"
data-term-status={
isConfirmed
? 'confirmed'
: isCurrentHelp
? 'current'
: isPreviewConfirmed
? 'preview-confirmed'
: isPreviewNext
? 'preview-next'
: 'pending'
}
data-term-status={isCurrentHelp ? 'current' : 'pending'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
// Confirmed terms are dimmed with checkmark
opacity: isConfirmed ? 0.5 : isPreviewConfirmed ? 0.7 : 1,
// Current help term is highlighted
backgroundColor: isCurrentHelp
? isDark
? 'purple.800'
: 'purple.100'
: isPreviewNext
? isDark
? 'yellow.900'
: 'yellow.50'
: 'transparent',
borderRadius: isCurrentHelp || isPreviewNext ? '4px' : '0',
padding: isCurrentHelp || isPreviewNext ? '2px 4px' : '0',
marginLeft: isCurrentHelp || isPreviewNext ? '-4px' : '0',
marginRight: isCurrentHelp || isPreviewNext ? '-4px' : '0',
transition: 'all 0.2s ease',
})}
>
{/* Checkmark for confirmed terms */}
{isConfirmed && (
<div
data-element="confirmed-check"
className={css({
position: 'absolute',
left: '-1.5rem',
color: isDark ? 'green.400' : 'green.500',
fontSize: '0.875rem',
})}
>
</div>
)}
{/* Preview checkmark for detected prefix terms (shown in muted color with subtle pulse) */}
{isPreviewConfirmed && (
<div
data-element="preview-check"
className={css({
position: 'absolute',
left: '-1.5rem',
color: isDark ? 'yellow.400' : 'yellow.600',
fontSize: '0.875rem',
opacity: 0.8,
})}
>
</div>
)}
{/* Arrow indicator for current help term */}
{/* Arrow indicator for current help term (the term being added) */}
{isCurrentHelp && (
<div
data-element="current-arrow"
@ -222,21 +150,6 @@ export function VerticalProblem({
</div>
)}
{/* Preview arrow for next term after detected prefix */}
{isPreviewNext && (
<div
data-element="preview-arrow"
className={css({
position: 'absolute',
left: '-1.5rem',
color: isDark ? 'yellow.400' : 'yellow.600',
fontSize: '0.875rem',
})}
>
</div>
)}
{/* Operator column (only show minus for negative) */}
<div
data-element="operator"
@ -352,27 +265,19 @@ export function VerticalProblem({
<span>Perfect!</span>
</div>
)}
{/* Equals sign column - show "..." for prefix sums (mathematically incomplete), "=" for final answer */}
{/* Equals column */}
<div
data-element="equals"
data-prefix-mode={detectedPrefixIndex !== undefined ? 'true' : undefined}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color:
detectedPrefixIndex !== undefined
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'gray.400'
: 'gray.500',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{detectedPrefixIndex !== undefined ? '…' : '='}
=
</div>
{/* Answer digit cells - show maxDigits cells total */}

View File

@ -0,0 +1,39 @@
/**
* Coach hint generator for practice help system
*
* Uses the same readable.summary from unifiedStepGenerator that the
* tutorial CoachBar uses, ensuring consistent hints across the app.
*/
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
/**
* Generate a coach hint based on the current step
*
* Returns the segment's readable.summary if available, or null if not.
* This matches the tutorial CoachBar behavior which only renders when
* readable.summary exists.
*/
export function generateCoachHint(
startValue: number,
targetValue: number,
currentStepIndex: number = 0
): string | null {
const sequence = generateUnifiedInstructionSequence(startValue, targetValue)
if (!sequence || sequence.steps.length === 0) {
return null
}
// Get the current step
const currentStep = sequence.steps[currentStepIndex]
if (!currentStep) {
return null
}
// Find the segment this step belongs to
const segment = sequence.segments.find((s) => s.id === currentStep.segmentId)
// Return the segment's readable summary if available (same as tutorial CoachBar)
return segment?.readable?.summary ?? null
}