fix(classroom): auto-transition tutorial→session observation + fix NaN display
- Auto-transition: When teacher observes a tutorial that completes,
automatically switch to observing the student's practice session
- Added polling mechanism while waiting for session to start
- Shows "Waiting for practice to start" overlay with cancel button
- 30-second timeout to prevent indefinite waiting
- Fixed NaN display in complexity tooltip "Per term (avg)":
- Added !Number.isNaN() checks in PurposeBadge and ActiveSession
- Fixed problemGenerator reduce to explicitly filter NaN values
(the ?? operator only catches null/undefined, not NaN)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Classroom } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
@@ -48,6 +48,12 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
|
||||
null
|
||||
)
|
||||
|
||||
// State for pending session observation (waiting for session to start after tutorial completes)
|
||||
const [pendingSessionObservation, setPendingSessionObservation] = useState<{
|
||||
playerId: string
|
||||
student: PresenceStudent
|
||||
} | null>(null)
|
||||
|
||||
// Fetch present students
|
||||
// Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays
|
||||
// connected even when user switches tabs
|
||||
@@ -55,7 +61,8 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
|
||||
const leaveClassroom = useLeaveClassroom()
|
||||
|
||||
// Fetch active sessions to show "Practicing" indicator
|
||||
const { data: activeSessions = [] } = useActiveSessionsInClassroom(classroom.id)
|
||||
const { data: activeSessions = [], refetch: refetchActiveSessions } =
|
||||
useActiveSessionsInClassroom(classroom.id)
|
||||
|
||||
// Listen for tutorial states via WebSocket
|
||||
const { tutorialStates } = useClassroomTutorialStates(classroom.id)
|
||||
@@ -96,6 +103,91 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
|
||||
[classroom.id, leaveClassroom]
|
||||
)
|
||||
|
||||
// Auto-transition from tutorial observation to session observation when tutorial completes
|
||||
useEffect(() => {
|
||||
if (!observingTutorialPlayerId || !observingTutorialStudent) return
|
||||
|
||||
const tutorialState = tutorialStates.get(observingTutorialPlayerId)
|
||||
|
||||
// If tutorial is complete or no longer exists, check for active session
|
||||
if (!tutorialState || tutorialState.launcherState === 'complete') {
|
||||
const activeSession = activeSessionsByPlayer.get(observingTutorialPlayerId)
|
||||
|
||||
if (activeSession) {
|
||||
console.log(
|
||||
'[ClassroomTab] Tutorial completed, switching to session observation for:',
|
||||
observingTutorialPlayerId
|
||||
)
|
||||
// Close tutorial observer and open session observer
|
||||
setObservingTutorialPlayerId(null)
|
||||
setObservingTutorialStudent(null)
|
||||
setPendingSessionObservation(null)
|
||||
setObservingSession({
|
||||
session: activeSession,
|
||||
student: observingTutorialStudent,
|
||||
})
|
||||
} else {
|
||||
// Session not ready yet, set pending observation to keep checking
|
||||
console.log(
|
||||
'[ClassroomTab] Tutorial completed, waiting for session to start for:',
|
||||
observingTutorialPlayerId
|
||||
)
|
||||
setPendingSessionObservation({
|
||||
playerId: observingTutorialPlayerId,
|
||||
student: observingTutorialStudent,
|
||||
})
|
||||
// Trigger immediate refetch to check for session sooner
|
||||
refetchActiveSessions()
|
||||
// Close tutorial observer (state is gone or complete)
|
||||
setObservingTutorialPlayerId(null)
|
||||
setObservingTutorialStudent(null)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
observingTutorialPlayerId,
|
||||
observingTutorialStudent,
|
||||
tutorialStates,
|
||||
activeSessionsByPlayer,
|
||||
refetchActiveSessions,
|
||||
])
|
||||
|
||||
// Check for pending session observation (waiting for session to start after tutorial)
|
||||
useEffect(() => {
|
||||
if (!pendingSessionObservation) return
|
||||
|
||||
const activeSession = activeSessionsByPlayer.get(pendingSessionObservation.playerId)
|
||||
|
||||
if (activeSession) {
|
||||
console.log(
|
||||
'[ClassroomTab] Session started, opening observer for:',
|
||||
pendingSessionObservation.playerId
|
||||
)
|
||||
setPendingSessionObservation(null)
|
||||
setObservingSession({
|
||||
session: activeSession,
|
||||
student: pendingSessionObservation.student,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for session while pending (every 2 seconds)
|
||||
const pollInterval = setInterval(() => {
|
||||
console.log('[ClassroomTab] Polling for session...')
|
||||
refetchActiveSessions()
|
||||
}, 2000)
|
||||
|
||||
// Timeout after 30 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
console.log('[ClassroomTab] Timeout waiting for session, cancelling pending observation')
|
||||
setPendingSessionObservation(null)
|
||||
}, 30000)
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [pendingSessionObservation, activeSessionsByPlayer, refetchActiveSessions])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="classroom-tab"
|
||||
@@ -304,6 +396,107 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
|
||||
classroomId={classroom.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Waiting for session indicator */}
|
||||
{pendingSessionObservation && (
|
||||
<div
|
||||
data-component="pending-session-overlay"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.5rem',
|
||||
margin: '0 auto 16px',
|
||||
})}
|
||||
style={{ backgroundColor: pendingSessionObservation.student.color }}
|
||||
>
|
||||
{pendingSessionObservation.student.emoji}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Waiting for practice to start...
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{pendingSessionObservation.student.name} completed the tutorial.
|
||||
<br />
|
||||
Waiting for their practice session to begin.
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'green.500',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
})}
|
||||
/>
|
||||
Connecting...
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingSessionObservation(null)}
|
||||
className={css({
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,8 @@ function ComplexitySection({
|
||||
const trace = slot.problem?.generationTrace
|
||||
const bounds = slot.complexityBounds
|
||||
const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined)
|
||||
const hasCost = trace?.totalComplexityCost !== undefined
|
||||
const hasCost =
|
||||
trace?.totalComplexityCost !== undefined && !Number.isNaN(trace?.totalComplexityCost)
|
||||
|
||||
// Don't render anything if no complexity data
|
||||
if (!hasBounds && !hasCost) {
|
||||
@@ -255,7 +256,7 @@ function ComplexitySection({
|
||||
<span className={sectionStyles.value}>{trace.totalComplexityCost}</span>
|
||||
</div>
|
||||
)}
|
||||
{trace?.steps && trace.steps.length > 0 && (
|
||||
{hasCost && trace?.steps && trace.steps.length > 0 && (
|
||||
<div className={sectionStyles.row}>
|
||||
<span>Per term (avg):</span>
|
||||
<span className={sectionStyles.value}>
|
||||
|
||||
@@ -107,7 +107,7 @@ function ComplexitySection({
|
||||
const stepCount = slot?.problem?.generationTrace?.steps?.length ?? complexity?.stepCount
|
||||
|
||||
const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined)
|
||||
const hasCost = totalCost !== undefined
|
||||
const hasCost = totalCost !== undefined && !Number.isNaN(totalCost)
|
||||
|
||||
// Don't render anything if no complexity data
|
||||
if (!hasBounds && !hasCost) {
|
||||
|
||||
@@ -582,7 +582,12 @@ function generateSequence(
|
||||
}
|
||||
|
||||
// Calculate total complexity cost from all steps
|
||||
const totalComplexityCost = steps.reduce((sum, step) => sum + (step.complexityCost ?? 0), 0)
|
||||
// Note: Use explicit NaN check since ?? only catches null/undefined, not NaN
|
||||
const totalComplexityCost = steps.reduce((sum, step) => {
|
||||
const cost = step.complexityCost
|
||||
if (cost === undefined || cost === null || Number.isNaN(cost)) return sum
|
||||
return sum + cost
|
||||
}, 0)
|
||||
|
||||
// Build skill mastery context if cost calculator is available
|
||||
const allSkills = [...new Set(steps.flatMap((s) => s.skillsUsed))]
|
||||
|
||||
Reference in New Issue
Block a user