From c0e63ff68b26fd37eedd657504f7f79e5ce40a10 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 28 Dec 2025 12:18:09 -0600 Subject: [PATCH] fix(practice): real-time progress in observer modal + numeric answer comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add currentProblemNumber and totalProblems to broadcast state - Observer modal now shows live "Problem X of Y" updates as student progresses - Fix answer validation to use numeric comparison (parseInt) instead of string comparison, so "09" correctly equals 9 (fixes red background when correct) - Simplify MiniStartPracticeBanner to always show "Resume" for active sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../classroom/SessionObserverModal.tsx | 3 +- .../src/components/practice/ActiveSession.tsx | 69 ++++++++++++++----- .../practice/MiniStartPracticeBanner.tsx | 64 +++++------------ .../src/components/practice/NotesModal.tsx | 14 ---- .../components/practice/VerticalProblem.tsx | 6 +- apps/web/src/hooks/useSessionBroadcast.ts | 2 + apps/web/src/hooks/useSessionObserver.ts | 6 ++ apps/web/src/lib/classroom/socket-events.ts | 4 ++ 8 files changed, 88 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/classroom/SessionObserverModal.tsx b/apps/web/src/components/classroom/SessionObserverModal.tsx index 0e89a04f..f35b0929 100644 --- a/apps/web/src/components/classroom/SessionObserverModal.tsx +++ b/apps/web/src/components/classroom/SessionObserverModal.tsx @@ -208,7 +208,8 @@ export function SessionObserverModal({ margin: 0, })} > - Problem {session.completedProblems + 1} of {session.totalProblems} + Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '} + {state?.totalProblems ?? session.totalProblems} diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 55fe48a6..381ebf02 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -90,6 +90,10 @@ export interface BroadcastState { purpose: 'focus' | 'reinforce' | 'review' | 'challenge' /** Complexity data for tooltip display */ complexity?: BroadcastComplexity + /** Current problem number (1-indexed for display) */ + currentProblemNumber: number + /** Total problems in the session */ + totalProblems: number } interface ActiveSessionProps { @@ -191,6 +195,27 @@ function formatSkillName(category: string, skillKey: string): string { return `${category}: ${skillKey}` } +/** + * Calculate the number of abacus columns needed to compute a problem. + * + * This considers all intermediate running totals as we progress through the terms, + * not just the final answer. For subtraction (e.g., 87 - 45 = 42), we need + * enough columns for the minuend (87), not just the answer (42). + */ +function calculateAbacusColumns(terms: number[]): number { + if (terms.length === 0) return 1 + + let runningTotal = 0 + let maxAbsValue = 0 + + for (const term of terms) { + runningTotal += term + maxAbsValue = Math.max(maxAbsValue, Math.abs(runningTotal)) + } + + return Math.max(1, String(maxAbsValue).length) +} + /** * Complexity section for purpose tooltip - shows complexity bounds and actual costs */ @@ -465,6 +490,10 @@ function LinearProblem({ const isPrefixSum = detectedPrefixIndex !== undefined const operator = isPrefixSum ? '…' : '=' + // Use numeric comparison so "09" equals 9 + const numericUserAnswer = parseInt(userAnswer, 10) + const answeredCorrectly = isCompleted && numericUserAnswer === correctAnswer + return (
{ + return plan.parts.reduce((sum, part) => sum + part.slots.length, 0) + }, [plan.parts]) + + const completedProblems = useMemo(() => { + let count = 0 + for (let i = 0; i < plan.currentPartIndex; i++) { + count += plan.parts[i].slots.length + } + count += plan.currentSlotIndex + return count + }, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex]) + // Notify parent of broadcast state changes for session observation useEffect(() => { if (!onBroadcastStateChange) return @@ -694,6 +737,8 @@ export function ActiveSession({ startedAt: attempt?.startTime ?? Date.now(), purpose: prevPurpose, complexity: extractComplexity(prevSlot), + currentProblemNumber: completedProblems + 1, + totalProblems, }) return } @@ -731,6 +776,8 @@ export function ActiveSession({ startedAt: attempt.startTime, purpose, complexity: extractComplexity(slot), + currentProblemNumber: completedProblems + 1, + totalProblems, }) }, [ onBroadcastStateChange, @@ -743,6 +790,8 @@ export function ActiveSession({ plan.parts, plan.currentPartIndex, plan.currentSlotIndex, + completedProblems, + totalProblems, ]) // Handle teacher abacus control events @@ -1050,20 +1099,6 @@ export function ActiveSession({ const currentSlot = currentPart?.slots[currentSlotIndex] as ProblemSlot | undefined const sessionHealth = plan.sessionHealth as SessionHealth | null - // Calculate total progress across all parts - const totalProblems = useMemo(() => { - return parts.reduce((sum, part) => sum + part.slots.length, 0) - }, [parts]) - - const completedProblems = useMemo(() => { - let count = 0 - for (let i = 0; i < currentPartIndex; i++) { - count += parts[i].slots.length - } - count += currentSlotIndex - return count - }, [parts, currentPartIndex, currentSlotIndex]) - // Check for session completion useEffect(() => { if (currentPartIndex >= parts.length) { @@ -1777,7 +1812,7 @@ export function ActiveSession({ {currentPart.type === 'abacus' && !showHelpOverlay && (problemHeight ?? 0) > 0 && ( void /** Called when "Resume" is clicked - navigates to active session */ onResumePractice: () => void - /** Called when "Watch" is clicked - opens session observer */ - onWatchSession: () => void } // ============================================================================ @@ -95,41 +91,22 @@ function getIdleModeConfig(sessionMode: SessionMode): ModeConfig { } } -function getActiveSessionConfig(activity: StudentActivity, isTeacher: boolean): ModeConfig { +function getActiveSessionConfig(activity: StudentActivity): ModeConfig { const progress = activity.sessionProgress const progressText = progress ? `${progress.current}/${progress.total} problems` : 'In progress' - if (isTeacher) { - // Teacher sees "Watch" option - return { - icon: '👁', - label: 'Practicing now', - sublabel: progressText, - buttonLabel: 'Watch', - bgGradient: { - light: - 'linear-gradient(135deg, rgba(139, 92, 246, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)', - dark: 'linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)', - }, - borderColor: { light: '#8b5cf6', dark: '#7c3aed' }, - textColor: { light: '#6d28d9', dark: '#c4b5fd' }, - buttonGradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)', - } - } else { - // Parent/student sees "Resume" option - return { - icon: '▶️', - label: 'Session in progress', - sublabel: progressText, - buttonLabel: 'Resume', - bgGradient: { - light: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(16, 185, 129, 0.04) 100%)', - dark: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 185, 129, 0.08) 100%)', - }, - borderColor: { light: '#22c55e', dark: '#16a34a' }, - textColor: { light: '#166534', dark: '#86efac' }, - buttonGradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', - } + return { + icon: '▶️', + label: 'Session in progress', + sublabel: progressText, + buttonLabel: 'Resume', + bgGradient: { + light: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(16, 185, 129, 0.04) 100%)', + dark: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 185, 129, 0.08) 100%)', + }, + borderColor: { light: '#22c55e', dark: '#16a34a' }, + textColor: { light: '#166534', dark: '#86efac' }, + buttonGradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', } } @@ -142,18 +119,15 @@ function getActiveSessionConfig(activity: StudentActivity, isTeacher: boolean): * * Shows different content based on state: * - Idle + session mode: Shows session mode info with "Start" CTA - * - Active session (teacher): Shows "Watch" to observe - * - Active session (parent): Shows "Resume" to continue + * - Active session: Shows "Resume" to continue/observe the session * * Designed to fit above the Overview/Notes tabs. */ export function MiniStartPracticeBanner({ sessionMode, activity, - isTeacher = false, onStartPractice, onResumePractice, - onWatchSession, }: MiniStartPracticeBannerProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -165,8 +139,8 @@ export function MiniStartPracticeBanner({ let handleClick: () => void if (isPracticing) { - config = getActiveSessionConfig(activity, isTeacher) - handleClick = isTeacher ? onWatchSession : onResumePractice + config = getActiveSessionConfig(activity) + handleClick = onResumePractice } else if (sessionMode) { config = getIdleModeConfig(sessionMode) handleClick = onStartPractice @@ -179,7 +153,7 @@ export function MiniStartPracticeBanner({
{ - // Session observer is rendered at parent level to avoid z-index issues - // Close this modal first so the observer modal appears on top - if (onObserveSession && student.activity?.sessionId) { - onClose() - onObserveSession(student.activity.sessionId) - } - }, [onObserveSession, student.activity?.sessionId, onClose]) - // ========== Effects ========== // Reset state when modal opens/closes or student changes @@ -646,10 +634,8 @@ export function NotesModal({ {/* Tab bar - show if Overview has content or if we have stakeholder data */} diff --git a/apps/web/src/components/practice/VerticalProblem.tsx b/apps/web/src/components/practice/VerticalProblem.tsx index 56ef75a4..c11def26 100644 --- a/apps/web/src/components/practice/VerticalProblem.tsx +++ b/apps/web/src/components/practice/VerticalProblem.tsx @@ -92,8 +92,10 @@ export function VerticalProblem({ correctAnswer?.toString().length || 1 ) - const isCorrect = isCompleted && userAnswer === correctAnswer?.toString() - const isIncorrect = isCompleted && userAnswer !== correctAnswer?.toString() + // Use numeric comparison so "09" equals 9 + const numericUserAnswer = parseInt(userAnswer, 10) + const isCorrect = isCompleted && correctAnswer !== undefined && numericUserAnswer === correctAnswer + const isIncorrect = isCompleted && correctAnswer !== undefined && numericUserAnswer !== correctAnswer const fontSize = size === 'large' ? '2rem' : '1.5rem' const cellWidth = size === 'large' ? '1.8rem' : '1.4rem' diff --git a/apps/web/src/hooks/useSessionBroadcast.ts b/apps/web/src/hooks/useSessionBroadcast.ts index 0b0d3e48..769e0129 100644 --- a/apps/web/src/hooks/useSessionBroadcast.ts +++ b/apps/web/src/hooks/useSessionBroadcast.ts @@ -84,6 +84,8 @@ export function useSessionBroadcast( }, purpose: currentState.purpose, complexity: currentState.complexity, + currentProblemNumber: currentState.currentProblemNumber, + totalProblems: currentState.totalProblems, } socketRef.current.emit('practice-state', event) diff --git a/apps/web/src/hooks/useSessionObserver.ts b/apps/web/src/hooks/useSessionObserver.ts index c2cad61a..d072980a 100644 --- a/apps/web/src/hooks/useSessionObserver.ts +++ b/apps/web/src/hooks/useSessionObserver.ts @@ -57,6 +57,10 @@ export interface ObservedSessionState { complexity?: ObservedComplexity /** When this state was received */ receivedAt: number + /** Current problem number (1-indexed for display) */ + currentProblemNumber: number + /** Total problems in the session */ + totalProblems: number } interface UseSessionObserverResult { @@ -176,6 +180,8 @@ export function useSessionObserver( purpose: data.purpose, complexity: data.complexity, receivedAt: Date.now(), + currentProblemNumber: data.currentProblemNumber, + totalProblems: data.totalProblems, }) }) diff --git a/apps/web/src/lib/classroom/socket-events.ts b/apps/web/src/lib/classroom/socket-events.ts index da4f20bf..85988867 100644 --- a/apps/web/src/lib/classroom/socket-events.ts +++ b/apps/web/src/lib/classroom/socket-events.ts @@ -151,6 +151,10 @@ export interface PracticeStateEvent { purpose: 'focus' | 'reinforce' | 'review' | 'challenge' /** Complexity data for tooltip display */ complexity?: BroadcastComplexity + /** Current problem number (1-indexed for display) */ + currentProblemNumber: number + /** Total problems in the session */ + totalProblems: number } export interface TutorialStateEvent {