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:
parent
b31aba7aa3
commit
c0e63ff68b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,28 +91,10 @@ 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',
|
||||
|
|
@ -130,7 +108,6 @@ function getActiveSessionConfig(activity: StudentActivity, isTeacher: boolean):
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue