feat(classroom): add session broadcast and active session indicators

Step 1: Student broadcasts practice state via WebSocket
- Create useSessionBroadcast hook that emits practice-state events
- Only broadcasts when student is present in a classroom
- Wired into PracticeClient to broadcast during active sessions

Step 2: Teacher sees active sessions indicator
- Add useActiveSessionsInClassroom to ClassroomTab
- Show "Practicing" badge with problem progress for active students
- Blue styling for practicing students vs green for idle

🤖 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-25 08:24:15 -06:00
parent 07f6bb7f9c
commit 9636f7f44a
3 changed files with 269 additions and 32 deletions

View File

@@ -17,6 +17,7 @@ import {
useEndSessionEarly,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import { useSessionBroadcast, type BroadcastPracticeState } from '@/hooks/useSessionBroadcast'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
@@ -116,6 +117,26 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
router.push(`/practice/${studentId}/summary`, { scroll: false })
}, [studentId, router])
// Build broadcast state for session observation
// This broadcasts the student's practice to teachers observing in real-time
const currentSlot = currentPart?.slots[currentPlan.currentSlotIndex]
const broadcastState: BroadcastPracticeState | null = useMemo(() => {
if (!currentSlot?.problem || !timingData) return null
return {
currentProblem: {
terms: currentSlot.problem.terms,
answer: currentSlot.problem.answer,
},
phase: isPaused ? 'feedback' : 'problem', // Use 'feedback' when paused as a proxy
studentAnswer: null, // We don't have access to the answer in PracticeClient
isCorrect: null,
startedAt: timingData.startTime,
}
}, [currentSlot?.problem, timingData, isPaused])
// Broadcast session state if student is in a classroom
useSessionBroadcast(currentPlan.id, studentId, broadcastState)
// Build session HUD data for PracticeSubNav
const sessionHud: SessionHudData | undefined = currentPart
? {

View File

@@ -4,7 +4,13 @@ import { useCallback } from 'react'
import Link from 'next/link'
import type { Classroom } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import { useClassroomPresence, useLeaveClassroom, type PresenceStudent } from '@/hooks/useClassroom'
import {
useActiveSessionsInClassroom,
useClassroomPresence,
useLeaveClassroom,
type ActiveSessionInfo,
type PresenceStudent,
} from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
@@ -28,6 +34,14 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
const { data: presentStudents = [], isLoading } = useClassroomPresence(classroom.id)
const leaveClassroom = useLeaveClassroom()
// Fetch active sessions to show "Practicing" indicator
const { data: activeSessions = [] } = useActiveSessionsInClassroom(classroom.id)
// Map active sessions by playerId for quick lookup
const activeSessionsByPlayer = new Map<string, ActiveSessionInfo>(
activeSessions.map((session) => [session.playerId, session])
)
const handleRemoveStudent = useCallback(
(playerId: string) => {
leaveClassroom.mutate({
@@ -108,6 +122,7 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
<PresentStudentCard
key={student.id}
student={student}
activeSession={activeSessionsByPlayer.get(student.id)}
onRemove={() => handleRemoveStudent(student.id)}
isRemoving={
leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
@@ -212,18 +227,27 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
interface PresentStudentCardProps {
student: PresenceStudent
activeSession?: ActiveSessionInfo
onRemove: () => void
isRemoving: boolean
isDark: boolean
}
function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentStudentCardProps) {
function PresentStudentCard({
student,
activeSession,
onRemove,
isRemoving,
isDark,
}: PresentStudentCardProps) {
const enteredAt = new Date(student.enteredAt)
const timeAgo = getTimeAgo(enteredAt)
const isPracticing = !!activeSession
return (
<div
data-element="present-student-card"
data-practicing={isPracticing}
className={css({
display: 'flex',
alignItems: 'center',
@@ -231,8 +255,12 @@ function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentSt
padding: '14px 16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.800' : 'green.200',
border: isPracticing ? '2px solid' : '1px solid',
borderColor: isPracticing
? 'blue.500'
: isDark
? 'green.800'
: 'green.200',
boxShadow: isDark ? 'none' : '0 1px 3px rgba(0,0,0,0.05)',
})}
>
@@ -262,7 +290,7 @@ function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentSt
>
{student.emoji}
</span>
{/* Online indicator */}
{/* Online/Practicing indicator */}
<span
className={css({
position: 'absolute',
@@ -271,20 +299,67 @@ function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentSt
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: 'green.500',
backgroundColor: isPracticing ? 'blue.500' : 'green.500',
border: '2px solid',
borderColor: isDark ? 'gray.800' : 'white',
})}
style={
isPracticing
? {
animation: 'practicing-pulse 1.5s ease-in-out infinite',
}
: undefined
}
/>
{/* Keyframes for practicing pulse animation */}
{isPracticing && (
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes practicing-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
}
}
`,
}}
/>
)}
</div>
<div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
{student.name}
{isPracticing && (
<span
data-element="practicing-badge"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '10px',
backgroundColor: isDark ? 'blue.900' : 'blue.100',
color: isDark ? 'blue.300' : 'blue.700',
fontSize: '0.6875rem',
fontWeight: 'bold',
textTransform: 'uppercase',
})}
>
<span className={css({ fontSize: '0.75rem' })}>📝</span>
Practicing
</span>
)}
</p>
<p
className={css({
@@ -292,36 +367,44 @@ function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentSt
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Joined {timeAgo}
{isPracticing ? (
<>
Problem {activeSession.completedProblems + 1} of {activeSession.totalProblems}
</>
) : (
<>Joined {timeAgo}</>
)}
</p>
</div>
</Link>
<button
type="button"
onClick={onRemove}
disabled={isRemoving}
data-action="remove-from-classroom"
className={css({
padding: '8px 14px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
color: isDark ? 'gray.300' : 'gray.700',
},
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isRemoving ? 'Removing...' : 'Remove'}
</button>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<button
type="button"
onClick={onRemove}
disabled={isRemoving}
data-action="remove-from-classroom"
className={css({
padding: '8px 14px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
color: isDark ? 'gray.300' : 'gray.700',
},
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isRemoving ? 'Removing...' : 'Remove'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { useEffect, useRef } from 'react'
import { io, type Socket } from 'socket.io-client'
import { useStudentPresence } from './useClassroom'
import type { PracticeStateEvent } from '@/lib/classroom/socket-events'
/**
* Practice state to broadcast to observers
*/
export interface BroadcastPracticeState {
/** Current problem being worked on */
currentProblem: {
terms: number[]
answer: number
}
/** Current phase of the interaction */
phase: 'problem' | 'feedback' | 'tutorial'
/** Student's current answer (null if not yet answered) */
studentAnswer: number | null
/** Whether the answer is correct (null if not yet answered) */
isCorrect: boolean | null
/** When the current attempt started (timestamp) */
startedAt: number
}
/**
* Hook to broadcast practice session state to observers via WebSocket
*
* Only broadcasts if the student is currently present in a classroom.
* This enables teachers to observe student practice in real-time.
*
* @param sessionId - The session plan ID
* @param playerId - The student's player ID
* @param state - Current practice state (or null if not in active practice)
*/
export function useSessionBroadcast(
sessionId: string | undefined,
playerId: string | undefined,
state: BroadcastPracticeState | null
): { isConnected: boolean; isBroadcasting: boolean } {
const socketRef = useRef<Socket | null>(null)
const isConnectedRef = useRef(false)
// Check if student is present in a classroom
const { data: presence } = useStudentPresence(playerId)
const isInClassroom = !!presence?.classroomId
// Connect to socket and join session channel when in classroom with active session
useEffect(() => {
// Only connect if we have a session and the student is in a classroom
if (!sessionId || !playerId || !isInClassroom) {
// Clean up if we were connected
if (socketRef.current) {
console.log('[SessionBroadcast] Disconnecting - no longer in classroom or session ended')
socketRef.current.disconnect()
socketRef.current = null
isConnectedRef.current = false
}
return
}
// Create socket connection
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
socket.on('connect', () => {
console.log('[SessionBroadcast] Connected, joining session channel:', sessionId)
isConnectedRef.current = true
// No need to explicitly join a channel - we just emit to the session channel
// The server will broadcast to observers who have joined `session:${sessionId}`
})
socket.on('disconnect', () => {
console.log('[SessionBroadcast] Disconnected')
isConnectedRef.current = false
})
// Listen for observer joined events (optional - for debugging/notification)
socket.on('observer-joined', (data: { observerId: string }) => {
console.log('[SessionBroadcast] Observer joined:', data.observerId)
})
return () => {
console.log('[SessionBroadcast] Cleaning up socket connection')
socket.disconnect()
socketRef.current = null
isConnectedRef.current = false
}
}, [sessionId, playerId, isInClassroom])
// Broadcast state changes
useEffect(() => {
if (!socketRef.current || !isConnectedRef.current || !sessionId || !state) {
return
}
const event: PracticeStateEvent = {
sessionId,
currentProblem: state.currentProblem,
phase: state.phase,
studentAnswer: state.studentAnswer,
isCorrect: state.isCorrect,
timing: {
startedAt: state.startedAt,
elapsed: Date.now() - state.startedAt,
},
}
socketRef.current.emit('practice-state', event)
console.log('[SessionBroadcast] Emitted practice-state:', {
phase: state.phase,
answer: state.studentAnswer,
isCorrect: state.isCorrect,
})
}, [
sessionId,
state?.currentProblem?.answer, // New problem
state?.phase, // Phase change
state?.studentAnswer, // Answer submitted
state?.isCorrect, // Result received
])
return {
isConnected: isConnectedRef.current,
isBroadcasting: isConnectedRef.current && isInClassroom && !!state,
}
}