diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 73743c74..6058a148 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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 - setHelpTermIndex(null) - setUserAnswer('') - } + // 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,63 +1016,31 @@ export function ActiveSession({ {currentSlot?.purpose} - {/* Problem display - vertical or linear based on part type */} - {currentPart.format === 'vertical' ? ( - = 0 && matchedPrefixIndex < prefixSums.length - 1 - ? matchedPrefixIndex - : undefined - } - autoSubmitPending={autoSubmitTriggered} - rejectedDigit={rejectedDigit} - helpOverlay={ - !isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? ( -
- {/* Term being helped indicator */} -
- Adding: - - {helpContext.term >= 0 ? '+' : ''} - {helpContext.term} - -
- - {/* Interactive abacus with arrows - just the abacus, no extra UI */} - {/* Columns = max digits between current and target values (minimum 1) */} + {/* Problem display - horizontal layout with help panel on right when in help mode */} +
+ {/* Center: Problem display */} + {currentPart.format === 'vertical' ? ( + + ) : undefined + } + /> + ) : ( + = 0 && matchedPrefixIndex < prefixSums.length - 1 + ? matchedPrefixIndex + : undefined + } + /> + )} + + {/* Right: Help panel with coach hint and decomposition (only in help mode) */} + {!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && ( +
+ {/* Coach hint */} + {(() => { + const hint = generateCoachHint(helpContext.currentValue, helpContext.targetValue) + if (!hint) return null + return ( +
+

+ {hint} +

+
+ ) + })()} + + {/* Decomposition display */} +
+
+ Step-by-Step
- ) : undefined - } - /> - ) : ( - = 0 && matchedPrefixIndex < prefixSums.length - 1 - ? matchedPrefixIndex - : undefined - } - /> - )} +
+ + + +
+
+
+ )} +
{/* Feedback message */} {feedback !== 'none' && ( diff --git a/apps/web/src/components/practice/HelpAbacus.tsx b/apps/web/src/components/practice/HelpAbacus.tsx index 000cce24..4eb4502a 100644 --- a/apps/web/src/components/practice/HelpAbacus.tsx +++ b/apps/web/src/components/practice/HelpAbacus.tsx @@ -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} diff --git a/apps/web/src/components/practice/HelpCountdown.tsx b/apps/web/src/components/practice/HelpCountdown.tsx new file mode 100644 index 00000000..f7574c30 --- /dev/null +++ b/apps/web/src/components/practice/HelpCountdown.tsx @@ -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 ( +
+ + {/* Background circle */} + + {/* Progress arc */} + + + {/* Seconds remaining in center */} +
+ {remainingSeconds} +
+
+ ) +} + +export default HelpCountdown diff --git a/apps/web/src/components/practice/VerticalProblem.tsx b/apps/web/src/components/practice/VerticalProblem.tsx index d0390aae..78e89fd9 100644 --- a/apps/web/src/components/practice/VerticalProblem.tsx +++ b/apps/web/src/components/practice/VerticalProblem.tsx @@ -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 (
- {/* Checkmark for confirmed terms */} - {isConfirmed && ( -
- ✓ -
- )} - - {/* Preview checkmark for detected prefix terms (shown in muted color with subtle pulse) */} - {isPreviewConfirmed && ( -
- ✓ -
- )} - - {/* Arrow indicator for current help term */} + {/* Arrow indicator for current help term (the term being added) */} {isCurrentHelp && (
)} - {/* Preview arrow for next term after detected prefix */} - {isPreviewNext && ( -
- → -
- )} - {/* Operator column (only show minus for negative) */}
Perfect!
)} - {/* Equals sign column - show "..." for prefix sums (mathematically incomplete), "=" for final answer */} + {/* Equals column */}
- {detectedPrefixIndex !== undefined ? '…' : '='} + =
{/* Answer digit cells - show maxDigits cells total */} diff --git a/apps/web/src/components/practice/coachHintGenerator.ts b/apps/web/src/components/practice/coachHintGenerator.ts new file mode 100644 index 00000000..73f1eda0 --- /dev/null +++ b/apps/web/src/components/practice/coachHintGenerator.ts @@ -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 +}