fix(practice): real-time progress in observer modal + numeric answer comparison

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-28 12:18:09 -06:00
parent b31aba7aa3
commit c0e63ff68b
8 changed files with 88 additions and 80 deletions

View File

@ -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}
</Dialog.Description>
</div>
</div>

View File

@ -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 (
<div
data-component="linear-problem"
@ -502,7 +531,7 @@ function LinearProblem({
borderRadius: '8px',
textAlign: 'center',
backgroundColor: isCompleted
? userAnswer === String(correctAnswer)
? answeredCorrectly
? isDark
? 'green.900'
: 'green.100'
@ -513,7 +542,7 @@ function LinearProblem({
? 'gray.800'
: 'gray.100',
color: isCompleted
? userAnswer === String(correctAnswer)
? answeredCorrectly
? isDark
? 'green.200'
: 'green.700'
@ -647,6 +676,20 @@ export function ActiveSession({
}
}, [onTimingUpdate, attempt?.startTime, attempt?.accumulatedPauseMs])
// Calculate total progress across all parts (needed for broadcast state)
const totalProblems = useMemo(() => {
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 && (
<AbacusDock
id="practice-abacus"
columns={String(Math.abs(attempt.problem.answer)).length}
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}

View File

@ -14,14 +14,10 @@ interface MiniStartPracticeBannerProps {
sessionMode: SessionMode | null
/** Current activity status */
activity: StudentActivity | null
/** Whether the viewer is a teacher (affects "Watch" vs "Resume" for active sessions) */
isTeacher?: boolean
/** Called when "Start" is clicked - should open StartPracticeModal */
onStartPractice: () => 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({
<div
data-component="mini-start-practice-banner"
data-mode={isPracticing ? 'active' : sessionMode?.type}
data-variant={isPracticing ? (isTeacher ? 'watch' : 'resume') : 'start'}
data-variant={isPracticing ? 'resume' : 'start'}
className={css({
display: 'flex',
alignItems: 'center',
@ -246,9 +220,7 @@ export function MiniStartPracticeBanner({
{/* Action button */}
<button
type="button"
data-action={
isPracticing ? (isTeacher ? 'watch-session' : 'resume-practice') : 'start-practice'
}
data-action={isPracticing ? 'resume-practice' : 'start-practice'}
onClick={handleClick}
className={css({
display: 'flex',

View File

@ -9,7 +9,6 @@ import { FamilyCodeDisplay } from '@/components/family'
import { Z_INDEX } from '@/constants/zIndex'
import { usePageTransition } from '@/contexts/PageTransitionContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useMyClassroom } from '@/hooks/useClassroom'
import { usePlayerCurriculumQuery } from '@/hooks/usePlayerCurriculum'
import { useSessionMode } from '@/hooks/useSessionMode'
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
@ -159,8 +158,6 @@ export function NotesModal({
// ========== Additional data for Overview tab ==========
const { data: curriculumData } = usePlayerCurriculumQuery(student.id)
const { data: sessionMode } = useSessionMode(student.id)
const { data: classroom } = useMyClassroom()
const isTeacher = !!classroom
const updatePlayer = useUpdatePlayer() // For notes only
// ========== Stakeholder data for Relationships tab ==========
@ -215,15 +212,6 @@ export function NotesModal({
router.push(`/practice/${student.id}`)
}, [onClose, router, student.id])
const handleBannerWatchSession = useCallback(() => {
// 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({
<MiniStartPracticeBanner
sessionMode={sessionMode ?? null}
activity={activity}
isTeacher={isTeacher}
onStartPractice={handleBannerStartPractice}
onResumePractice={handleBannerResumePractice}
onWatchSession={handleBannerWatchSession}
/>
{/* Tab bar - show if Overview has content or if we have stakeholder data */}

View File

@ -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'

View File

@ -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)

View File

@ -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,
})
})

View File

@ -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 {