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 {