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:
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
133
apps/web/src/hooks/useSessionBroadcast.ts
Normal file
133
apps/web/src/hooks/useSessionBroadcast.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user