feat(classroom): implement real-time session observation (Step 3)

Teachers can now observe students' practice sessions in real-time:
- Added SessionObserverModal with live problem display
- Created useSessionObserver hook for receiving broadcasts
- Refactored useSessionBroadcast to re-broadcast on observer join
- Added join-session socket event for session channel subscription
- Created shared PurposeBadge component with tooltip
- Created shared PracticeFeedback component
- Fixed tooltip z-index to work inside modals (15000)
- Used Z_INDEX constants throughout modal components

🤖 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 13:29:59 -06:00
parent 9636f7f44a
commit 2feb6844a4
16 changed files with 1128 additions and 121 deletions

View File

@@ -92,10 +92,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
})
} else {
// Only parent approved - notify teacher that parent approved their request
await emitEnrollmentRequestApproved(
{ ...payload, approvedBy: 'parent' },
{ classroomId }
)
await emitEnrollmentRequestApproved({ ...payload, approvedBy: 'parent' }, { classroomId })
}
}
} catch (socketError) {

View File

@@ -236,7 +236,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
onBulkArchive={undefined}
/>
<ClassroomDashboard classroom={classroom} ownChildren={players} />
<ClassroomDashboard classroom={classroom} ownChildren={players} viewerId={viewerId} />
{/* Add Student by Family Code Modal */}
<AddStudentByFamilyCodeModal

View File

@@ -6,6 +6,7 @@ import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
type AttemptTimingData,
type BroadcastState,
PracticeErrorBoundary,
PracticeSubNav,
type SessionHudData,
@@ -17,7 +18,7 @@ import {
useEndSessionEarly,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import { useSessionBroadcast, type BroadcastPracticeState } from '@/hooks/useSessionBroadcast'
import { useSessionBroadcast } from '@/hooks/useSessionBroadcast'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
@@ -41,6 +42,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
const [isPaused, setIsPaused] = useState(false)
// Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null)
// Track broadcast state for session observation (digit-by-digit updates from ActiveSession)
const [broadcastState, setBroadcastState] = useState<BroadcastState | null>(null)
// Browse mode state - lifted here so PracticeSubNav can trigger it
const [isBrowseMode, setIsBrowseMode] = useState(false)
// Browse index - lifted for navigation from SessionProgressIndicator
@@ -117,24 +120,8 @@ 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
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
useSessionBroadcast(currentPlan.id, studentId, broadcastState)
// Build session HUD data for PracticeSubNav
@@ -224,6 +211,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onResume={handleResume}
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
onBroadcastStateChange={setBroadcastState}
isBrowseMode={isBrowseMode}
browseIndex={browseIndex}
onBrowseIndexChange={setBrowseIndex}

View File

@@ -16,6 +16,8 @@ interface ClassroomDashboardProps {
classroom: Classroom
/** Teacher's own children (get special "parent access" treatment) */
ownChildren?: Player[]
/** Viewer ID for session observation (teacher's user ID) */
viewerId: string
}
/**
@@ -27,7 +29,11 @@ interface ClassroomDashboardProps {
*
* Teacher's own children appear separately with full parent access.
*/
export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDashboardProps) {
export function ClassroomDashboard({
classroom,
ownChildren = [],
viewerId,
}: ClassroomDashboardProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [activeTab, setActiveTab] = useState<TabId>('classroom')
@@ -281,7 +287,7 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
{/* Tab content */}
<main>
{activeTab === 'classroom' ? (
<ClassroomTab classroom={classroom} />
<ClassroomTab classroom={classroom} viewerId={viewerId} />
) : (
<StudentManagerTab classroom={classroom} />
)}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import type { Classroom } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
@@ -13,9 +13,12 @@ import {
} from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
import { SessionObserverModal } from './SessionObserverModal'
interface ClassroomTabProps {
classroom: Classroom
/** Viewer ID for session observation (teacher's user ID) */
viewerId: string
}
/**
@@ -24,10 +27,16 @@ interface ClassroomTabProps {
* Displays students currently "present" in the classroom.
* Teachers can see who's actively practicing and remove students when needed.
*/
export function ClassroomTab({ classroom }: ClassroomTabProps) {
export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// State for session observation
const [observingSession, setObservingSession] = useState<{
session: ActiveSessionInfo
student: PresenceStudent
} | null>(null)
// Fetch present students
// Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays
// connected even when user switches tabs
@@ -42,6 +51,14 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
activeSessions.map((session) => [session.playerId, session])
)
const handleObserve = useCallback((student: PresenceStudent, session: ActiveSessionInfo) => {
setObservingSession({ session, student })
}, [])
const handleCloseObserver = useCallback(() => {
setObservingSession(null)
}, [])
const handleRemoveStudent = useCallback(
(playerId: string) => {
leaveClassroom.mutate({
@@ -118,18 +135,24 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{presentStudents.map((student) => (
<PresentStudentCard
key={student.id}
student={student}
activeSession={activeSessionsByPlayer.get(student.id)}
onRemove={() => handleRemoveStudent(student.id)}
isRemoving={
leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
}
isDark={isDark}
/>
))}
{presentStudents.map((student) => {
const activeSession = activeSessionsByPlayer.get(student.id)
return (
<PresentStudentCard
key={student.id}
student={student}
activeSession={activeSession}
onObserve={
activeSession ? () => handleObserve(student, activeSession) : undefined
}
onRemove={() => handleRemoveStudent(student.id)}
isRemoving={
leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
}
isDark={isDark}
/>
)
})}
</div>
</section>
) : (
@@ -217,6 +240,21 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
<li>You'll see them appear here in real-time</li>
</ol>
</div>
{/* Session Observer Modal */}
{observingSession && (
<SessionObserverModal
isOpen={true}
onClose={handleCloseObserver}
session={observingSession.session}
student={{
name: observingSession.student.name,
emoji: observingSession.student.emoji,
color: observingSession.student.color,
}}
observerId={viewerId}
/>
)}
</div>
)
}
@@ -228,6 +266,7 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
interface PresentStudentCardProps {
student: PresenceStudent
activeSession?: ActiveSessionInfo
onObserve?: () => void
onRemove: () => void
isRemoving: boolean
isDark: boolean
@@ -236,6 +275,7 @@ interface PresentStudentCardProps {
function PresentStudentCard({
student,
activeSession,
onObserve,
onRemove,
isRemoving,
isDark,
@@ -256,11 +296,7 @@ function PresentStudentCard({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: isPracticing ? '2px solid' : '1px solid',
borderColor: isPracticing
? 'blue.500'
: isDark
? 'green.800'
: 'green.200',
borderColor: isPracticing ? 'blue.500' : isDark ? 'green.800' : 'green.200',
boxShadow: isDark ? 'none' : '0 1px 3px rgba(0,0,0,0.05)',
})}
>
@@ -379,6 +415,31 @@ function PresentStudentCard({
</Link>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Observe button - only show when practicing */}
{isPracticing && onObserve && (
<button
type="button"
onClick={onObserve}
data-action="observe-session"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'blue.700' : 'blue.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.600' : 'blue.600',
},
})}
>
Observe
</button>
)}
<button
type="button"
onClick={onRemove}

View File

@@ -0,0 +1,272 @@
'use client'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { useSessionObserver } from '@/hooks/useSessionObserver'
import { css } from '../../../styled-system/css'
import { PracticeFeedback } from '../practice/PracticeFeedback'
import { PurposeBadge } from '../practice/PurposeBadge'
import { VerticalProblem } from '../practice/VerticalProblem'
interface SessionObserverModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Close the modal */
onClose: () => void
/** Session info from the active sessions list */
session: ActiveSessionInfo
/** Student info for display */
student: {
name: string
emoji: string
color: string
}
/** Observer ID (e.g., teacher's user ID) */
observerId: string
}
/**
* Modal for teachers to observe a student's practice session in real-time
*
* Shows:
* - Current problem the student is working on
* - Student's answer as they type/submit
* - Feedback (correct/incorrect) when shown
* - Progress indicator
*/
export function SessionObserverModal({
isOpen,
onClose,
session,
student,
observerId,
}: SessionObserverModalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Subscribe to the session's socket channel
const { state, isConnected, isObserving, error } = useSessionObserver(
isOpen ? session.sessionId : undefined,
isOpen ? observerId : undefined,
isOpen
)
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
data-element="modal-backdrop"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.MODAL_BACKDROP,
})}
onClick={onClose}
/>
{/* Modal */}
<div
data-component="session-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '500px',
maxHeight: '85vh',
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.MODAL,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
})}
>
{/* Header */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<h2
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '1rem',
})}
>
Observing {student.name}
</h2>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Problem {session.completedProblems + 1} of {session.totalProblems}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
data-action="close-observer"
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
})}
>
Close
</button>
</div>
{/* Content */}
<div
className={css({
flex: 1,
padding: '24px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
})}
>
{/* Connection status */}
{!isConnected && !error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>Connecting...</p>
</div>
)}
{error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'red.400' : 'red.600',
padding: '16px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '8px',
})}
>
<p>{error}</p>
</div>
)}
{isObserving && !state && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>
Waiting for student activity...
</p>
<p className={css({ fontSize: '0.875rem' })}>
You&apos;ll see their problem when they start working
</p>
</div>
)}
{/* Problem display */}
{state && (
<>
{/* Purpose badge with tooltip - matches student's view */}
<PurposeBadge purpose={state.purpose} />
{/* Problem */}
<VerticalProblem
terms={state.currentProblem.terms}
userAnswer={state.studentAnswer}
isFocused={state.phase === 'problem'}
isCompleted={state.phase === 'feedback'}
correctAnswer={state.currentProblem.answer}
size="large"
/>
{/* Feedback message */}
{state.studentAnswer && state.phase === 'feedback' && (
<PracticeFeedback
isCorrect={state.isCorrect ?? false}
correctAnswer={state.currentProblem.answer}
/>
)}
</>
)}
</div>
{/* Footer with connection status */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
})}
>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: isObserving ? 'green.500' : isConnected ? 'yellow.500' : 'gray.500',
})}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isObserving ? 'Live' : isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</>
)
}

View File

@@ -38,6 +38,7 @@ import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { useInteractionPhase } from './hooks/useInteractionPhase'
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
import { NumericKeypad } from './NumericKeypad'
import { PracticeFeedback } from './PracticeFeedback'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
@@ -52,6 +53,28 @@ export interface AttemptTimingData {
accumulatedPauseMs: number
}
/**
* Broadcast state for session observation
* This is sent to teachers observing the session in real-time
*/
export interface BroadcastState {
/** Current problem being worked on */
currentProblem: {
terms: number[]
answer: number
}
/** Current phase of the interaction */
phase: 'problem' | 'feedback' | 'tutorial'
/** Student's current answer (empty string if not yet started typing) */
studentAnswer: string
/** Whether the answer is correct (null if not yet submitted) */
isCorrect: boolean | null
/** When the current attempt started (timestamp) */
startedAt: number
/** Purpose of this problem slot (why this problem was selected) */
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
}
interface ActiveSessionProps {
plan: SessionPlan
/** Student info for display in pause modal */
@@ -68,6 +91,8 @@ interface ActiveSessionProps {
onComplete: () => void
/** Called with timing data when it changes (for external timing display) */
onTimingUpdate?: (timing: AttemptTimingData | null) => void
/** Called with broadcast state when it changes (for session observation) */
onBroadcastStateChange?: (state: BroadcastState | null) => void
/** Whether browse mode is active (controlled externally via toggle in PracticeSubNav) */
isBrowseMode?: boolean
/** Controlled browse index (linear problem index) */
@@ -499,6 +524,7 @@ export function ActiveSession({
onResume,
onComplete,
onTimingUpdate,
onBroadcastStateChange,
isBrowseMode: isBrowseModeProp = false,
browseIndex: browseIndexProp,
onBrowseIndexChange,
@@ -579,6 +605,83 @@ export function ActiveSession({
}
}, [onTimingUpdate, attempt?.startTime, attempt?.accumulatedPauseMs])
// Notify parent of broadcast state changes for session observation
useEffect(() => {
if (!onBroadcastStateChange) return
// Get current slot's purpose from plan
const currentPart = plan.parts[plan.currentPartIndex]
const slot = currentPart?.slots[plan.currentSlotIndex]
const purpose = slot?.purpose ?? 'focus'
// During transitioning, we show the outgoing (completed) problem, not the incoming one
// But we use the PREVIOUS slot's purpose since we're still showing feedback for it
if (phase.phase === 'transitioning' && outgoingAttempt) {
// During transition, use the previous slot's purpose
const prevSlotIndex = plan.currentSlotIndex > 0 ? plan.currentSlotIndex - 1 : 0
const prevSlot = currentPart?.slots[prevSlotIndex]
const prevPurpose = prevSlot?.purpose ?? purpose
onBroadcastStateChange({
currentProblem: {
terms: outgoingAttempt.problem.terms,
answer: outgoingAttempt.problem.answer,
},
phase: 'feedback',
studentAnswer: outgoingAttempt.userAnswer,
isCorrect: outgoingAttempt.result === 'correct',
startedAt: attempt?.startTime ?? Date.now(),
purpose: prevPurpose,
})
return
}
if (!attempt) {
onBroadcastStateChange(null)
return
}
// Map internal phase to broadcast phase
let broadcastPhase: 'problem' | 'feedback' | 'tutorial'
if (phase.phase === 'helpMode') {
broadcastPhase = 'tutorial'
} else if (phase.phase === 'showingFeedback') {
broadcastPhase = 'feedback'
} else {
broadcastPhase = 'problem'
}
// Determine if answer is correct (only known in feedback phase)
let isCorrect: boolean | null = null
if (phase.phase === 'showingFeedback') {
// Use the result stored in the phase, not calculated from attempt
isCorrect = phase.result === 'correct'
}
onBroadcastStateChange({
currentProblem: {
terms: attempt.problem.terms,
answer: attempt.problem.answer,
},
phase: broadcastPhase,
studentAnswer: attempt.userAnswer,
isCorrect,
startedAt: attempt.startTime,
purpose,
})
}, [
onBroadcastStateChange,
attempt?.problem?.terms,
attempt?.problem?.answer,
attempt?.userAnswer,
attempt?.startTime,
phase,
outgoingAttempt,
plan.parts,
plan.currentPartIndex,
plan.currentSlotIndex,
])
// Track which help elements have been individually dismissed
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
@@ -1273,25 +1376,18 @@ export function ActiveSession({
generationTrace={outgoingAttempt.problem.generationTrace}
/>
{/* Feedback stays with outgoing problem */}
<div
data-element="outgoing-feedback"
<PracticeFeedback
isCorrect={true}
correctAnswer={outgoingAttempt.problem.answer}
className={css({
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
padding: '0.5rem 1rem',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
backgroundColor: isDark ? 'green.900' : 'green.100',
color: isDark ? 'green.200' : 'green.700',
whiteSpace: 'nowrap',
})}
>
Correct!
</div>
/>
</animated.div>
)}
@@ -1510,19 +1606,7 @@ export function ActiveSession({
{/* Feedback message - only show for incorrect */}
{showFeedback && (
<div
data-element="feedback"
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '8px',
fontSize: '1.25rem',
fontWeight: 'bold',
backgroundColor: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.200' : 'red.700',
})}
>
The answer was {attempt.problem.answer}
</div>
<PracticeFeedback isCorrect={false} correctAnswer={attempt.problem.answer} />
)}
</div>

View File

@@ -0,0 +1,54 @@
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import { css, cx } from '../../../styled-system/css'
interface PracticeFeedbackProps {
/** Whether the answer was correct */
isCorrect: boolean
/** The correct answer (shown when incorrect) */
correctAnswer: number
/** Optional className for additional styling/positioning */
className?: string
}
/**
* Shared feedback component for practice sessions
*
* Shows:
* - "Correct!" in green for correct answers
* - "The answer was X" in red for incorrect answers
*
* Used by both ActiveSession (student view) and SessionObserverModal (teacher view)
*/
export function PracticeFeedback({ isCorrect, correctAnswer, className }: PracticeFeedbackProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const baseStyles = css({
padding: '0.75rem 1.5rem',
borderRadius: '8px',
fontSize: '1.25rem',
fontWeight: 'bold',
backgroundColor: isCorrect
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'red.900'
: 'red.100',
color: isCorrect
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'red.200'
: 'red.700',
})
return (
<div data-element="practice-feedback" data-correct={isCorrect} className={cx(baseStyles, className)}>
{isCorrect ? 'Correct!' : `The answer was ${correctAnswer}`}
</div>
)
}

View File

@@ -0,0 +1,372 @@
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import type { ProblemSlot } from '@/db/schema/session-plans'
import { Tooltip, TooltipProvider } from '../ui/Tooltip'
import { css } from '../../../styled-system/css'
type Purpose = 'focus' | 'reinforce' | 'review' | 'challenge'
interface PurposeBadgeProps {
/** The purpose type */
purpose: Purpose
/** Optional slot for detailed tooltip (when available) */
slot?: ProblemSlot
}
/**
* Extract the primary skill from constraints for display
*/
function extractTargetSkillName(slot: ProblemSlot): string | null {
const targetSkills = slot.constraints?.targetSkills
if (!targetSkills) return null
// Look for specific skill in targetSkills
for (const [category, skills] of Object.entries(targetSkills)) {
if (skills && typeof skills === 'object') {
const skillKeys = Object.keys(skills)
if (skillKeys.length === 1) {
// Single skill - this is a targeted reinforce/review
return formatSkillName(category, skillKeys[0])
}
}
}
return null
}
/**
* Format a skill ID into a human-readable name
*/
function formatSkillName(category: string, skillKey: string): string {
// Categories: basic, fiveComplements, tenComplements
if (category === 'basic') {
// Format "+3" or "-5" into "add 3" or "subtract 5"
if (skillKey.startsWith('+')) {
return `add ${skillKey.slice(1)}`
}
if (skillKey.startsWith('-')) {
return `subtract ${skillKey.slice(1)}`
}
return skillKey
}
if (category === 'fiveComplements') {
// Format "4=5-1" into "5-complement for 4"
const match = skillKey.match(/^(\d+)=/)
if (match) {
return `5-complement for ${match[1]}`
}
return skillKey
}
if (category === 'tenComplements') {
// Format "9=10-1" into "10-complement for 9"
const match = skillKey.match(/^(\d+)=/)
if (match) {
return `10-complement for ${match[1]}`
}
return skillKey
}
return `${category}: ${skillKey}`
}
/**
* Complexity section for purpose tooltip - shows complexity bounds and actual costs
*/
function ComplexitySection({
slot,
showBounds = true,
}: {
slot: ProblemSlot
showBounds?: boolean
}) {
const trace = slot.problem?.generationTrace
const bounds = slot.complexityBounds
const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined)
const hasCost = trace?.totalComplexityCost !== undefined
// Don't render anything if no complexity data
if (!hasBounds && !hasCost) {
return null
}
const sectionStyles = {
container: css({
marginTop: '0.5rem',
padding: '0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
fontSize: '0.8125rem',
}),
header: css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
color: 'gray.400',
fontWeight: '500',
marginBottom: '0.375rem',
}),
row: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'gray.300',
paddingY: '0.125rem',
}),
value: css({
fontFamily: 'mono',
color: 'white',
}),
boundsLabel: css({
color: 'gray.500',
fontSize: '0.75rem',
}),
}
// Format bounds string
let boundsText = ''
if (bounds?.min !== undefined && bounds?.max !== undefined) {
boundsText = `${bounds.min} ${bounds.max}`
} else if (bounds?.min !== undefined) {
boundsText = `${bounds.min}`
} else if (bounds?.max !== undefined) {
boundsText = `${bounds.max}`
}
return (
<div className={sectionStyles.container} data-element="complexity-section">
<div className={sectionStyles.header}>
<span>📊</span>
<span>Complexity</span>
</div>
{showBounds && hasBounds && (
<div className={sectionStyles.row}>
<span className={sectionStyles.boundsLabel}>Required range:</span>
<span className={sectionStyles.value}>{boundsText}</span>
</div>
)}
{hasCost && (
<div className={sectionStyles.row}>
<span>Total cost:</span>
<span className={sectionStyles.value}>{trace.totalComplexityCost}</span>
</div>
)}
{trace?.steps && trace.steps.length > 0 && (
<div className={sectionStyles.row}>
<span>Per term (avg):</span>
<span className={sectionStyles.value}>
{(trace.totalComplexityCost! / trace.steps.length).toFixed(1)}
</span>
</div>
)}
</div>
)
}
const tooltipStyles = {
container: css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}),
header: css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontWeight: 'bold',
fontSize: '0.9375rem',
}),
emoji: css({
fontSize: '1.125rem',
}),
description: css({
color: 'gray.300',
lineHeight: '1.5',
}),
detail: css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.375rem 0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
fontSize: '0.8125rem',
}),
detailLabel: css({
color: 'gray.400',
fontWeight: '500',
}),
detailValue: css({
color: 'white',
fontFamily: 'mono',
}),
}
const purposeInfo: Record<
Purpose,
{ emoji: string; title: string; description: string }
> = {
focus: {
emoji: '🎯',
title: 'Focus Practice',
description:
"Building mastery of your current curriculum skills. These problems are at the heart of what you're learning right now.",
},
reinforce: {
emoji: '💪',
title: 'Reinforcement',
description:
'Extra practice for skills identified as needing more work. These problems target areas where mastery is still developing.',
},
review: {
emoji: '🔄',
title: 'Spaced Review',
description:
'Keeping mastered skills fresh through spaced repetition. Regular review prevents forgetting and strengthens long-term memory.',
},
challenge: {
emoji: '⭐',
title: 'Challenge',
description:
'Harder problems that require complement techniques for every term. These push your skills and build deeper fluency.',
},
}
/**
* Purpose tooltip content - rich explanatory content for each purpose
*/
function PurposeTooltipContent({ purpose, slot }: { purpose: Purpose; slot?: ProblemSlot }) {
const info = purposeInfo[purpose]
const skillName = slot ? extractTargetSkillName(slot) : null
return (
<div className={tooltipStyles.container}>
<div className={tooltipStyles.header}>
<span className={tooltipStyles.emoji}>{info.emoji}</span>
<span>{info.title}</span>
</div>
<p className={tooltipStyles.description}>{info.description}</p>
{/* Purpose-specific details */}
{purpose === 'focus' && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Distribution:</span>
<span className={tooltipStyles.detailValue}>60% of session</span>
</div>
)}
{purpose === 'reinforce' && skillName && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Targeting:</span>
<span className={tooltipStyles.detailValue}>{skillName}</span>
</div>
)}
{purpose === 'review' && (
<>
{skillName && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Reviewing:</span>
<span className={tooltipStyles.detailValue}>{skillName}</span>
</div>
)}
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Schedule:</span>
<span className={tooltipStyles.detailValue}>
Mastered: 14 days Practicing: 7 days
</span>
</div>
</>
)}
{purpose === 'challenge' && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Requirement:</span>
<span className={tooltipStyles.detailValue}>Every term uses complements</span>
</div>
)}
{/* Complexity section only when slot is available */}
{slot && <ComplexitySection slot={slot} />}
</div>
)
}
/**
* Shared purpose badge component with tooltip
*
* Shows the slot's purpose (focus/reinforce/review/challenge) with
* appropriate styling and a tooltip explaining what the purpose means.
*
* Used by both the student's ActiveSession and the teacher's SessionObserverModal.
*/
export function PurposeBadge({ purpose, slot }: PurposeBadgeProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const badgeStyles = css({
position: 'relative',
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
cursor: 'help',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
_hover: {
transform: 'scale(1.05)',
boxShadow: 'sm',
},
backgroundColor:
purpose === 'focus'
? isDark
? 'blue.900'
: 'blue.100'
: purpose === 'reinforce'
? isDark
? 'orange.900'
: 'orange.100'
: purpose === 'review'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'purple.900'
: 'purple.100',
color:
purpose === 'focus'
? isDark
? 'blue.200'
: 'blue.700'
: purpose === 'reinforce'
? isDark
? 'orange.200'
: 'orange.700'
: purpose === 'review'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'purple.200'
: 'purple.700',
})
return (
<TooltipProvider>
<Tooltip
content={<PurposeTooltipContent purpose={purpose} slot={slot} />}
side="bottom"
delayDuration={300}
>
<div
data-element="problem-purpose"
data-purpose={purpose}
className={badgeStyles}
>
{purpose}
</div>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -8,7 +8,7 @@
* - SessionSummary: Results after completing session
*/
export type { AttemptTimingData, StudentInfo } from './ActiveSession'
export type { AttemptTimingData, BroadcastState, StudentInfo } from './ActiveSession'
export { ActiveSession } from './ActiveSession'
export { ContinueSessionCard } from './ContinueSessionCard'
// Hooks
@@ -19,6 +19,8 @@ export {
export { NotesModal } from './NotesModal'
export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export { PracticeFeedback } from './PracticeFeedback'
export { PurposeBadge } from './PurposeBadge'
export type { SessionHudData } from './PracticeSubNav'
export { PracticeSubNav } from './PracticeSubNav'
export { PracticeTimingDisplay } from './PracticeTimingDisplay'

View File

@@ -24,7 +24,8 @@ export interface TooltipProps {
}
const contentStyles = css({
zIndex: 50,
// Must be above modals (10001) so tooltips inside modals are visible
zIndex: 15000,
overflow: 'hidden',
borderRadius: '8px',
padding: '0.75rem 1rem',

View File

@@ -22,13 +22,15 @@ export const Z_INDEX = {
// Overlays and dropdowns (1000-9999)
DROPDOWN: 1000,
TOOLTIP: 1000,
POPOVER: 1000,
// Modal and dialog layers (10000-19999)
MODAL_BACKDROP: 10000,
MODAL: 10001,
// Tooltips must be above modals so they work inside modals
TOOLTIP: 15000,
// Top-level overlays (20000+)
TOAST: 20000,

View File

@@ -1,29 +1,11 @@
'use client'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { BroadcastState } from '@/components/practice'
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
*
@@ -37,15 +19,46 @@ export interface BroadcastPracticeState {
export function useSessionBroadcast(
sessionId: string | undefined,
playerId: string | undefined,
state: BroadcastPracticeState | null
state: BroadcastState | null
): { isConnected: boolean; isBroadcasting: boolean } {
const socketRef = useRef<Socket | null>(null)
const isConnectedRef = useRef(false)
// Keep state in a ref so socket event handlers can access current state
const stateRef = useRef<BroadcastState | null>(null)
stateRef.current = state
// Check if student is present in a classroom
const { data: presence } = useStudentPresence(playerId)
const isInClassroom = !!presence?.classroomId
// Helper to broadcast current state
const broadcastState = useCallback(() => {
const currentState = stateRef.current
if (!socketRef.current || !isConnectedRef.current || !sessionId || !currentState) {
return
}
const event: PracticeStateEvent = {
sessionId,
currentProblem: currentState.currentProblem,
phase: currentState.phase,
studentAnswer: currentState.studentAnswer,
isCorrect: currentState.isCorrect,
timing: {
startedAt: currentState.startedAt,
elapsed: Date.now() - currentState.startedAt,
},
purpose: currentState.purpose,
}
socketRef.current.emit('practice-state', event)
console.log('[SessionBroadcast] Emitted practice-state:', {
phase: currentState.phase,
answer: currentState.studentAnswer,
isCorrect: currentState.isCorrect,
})
}, [sessionId])
// 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
@@ -72,8 +85,10 @@ export function useSessionBroadcast(
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}`
// Join the session channel so we can receive 'observer-joined' events
socket.emit('join-session', { sessionId })
// Broadcast current state immediately so any waiting observers get it
broadcastState()
})
socket.on('disconnect', () => {
@@ -81,9 +96,10 @@ export function useSessionBroadcast(
isConnectedRef.current = false
})
// Listen for observer joined events (optional - for debugging/notification)
// When an observer joins, re-broadcast current state so they see it immediately
socket.on('observer-joined', (data: { observerId: string }) => {
console.log('[SessionBroadcast] Observer joined:', data.observerId)
console.log('[SessionBroadcast] Observer joined:', data.observerId, '- re-broadcasting state')
broadcastState()
})
return () => {
@@ -92,38 +108,18 @@ export function useSessionBroadcast(
socketRef.current = null
isConnectedRef.current = false
}
}, [sessionId, playerId, isInClassroom])
}, [sessionId, playerId, isInClassroom, broadcastState])
// 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,
})
broadcastState()
}, [
sessionId,
broadcastState,
state?.currentProblem?.answer, // New problem
state?.phase, // Phase change
state?.studentAnswer, // Answer submitted
state?.isCorrect, // Result received
state?.purpose, // Purpose change
])
return {

View File

@@ -0,0 +1,158 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { PracticeStateEvent } from '@/lib/classroom/socket-events'
/**
* State of an observed practice session
*/
export interface ObservedSessionState {
/** Current problem being worked on */
currentProblem: {
terms: number[]
answer: number
}
/** Current phase of the interaction */
phase: 'problem' | 'feedback' | 'tutorial'
/** Student's current typed answer (digit by digit, empty string if not started) */
studentAnswer: string
/** Whether the answer is correct (null if not yet submitted) */
isCorrect: boolean | null
/** Timing information */
timing: {
startedAt: number
elapsed: number
}
/** Purpose of this problem slot (why it was selected) */
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
/** When this state was received */
receivedAt: number
}
interface UseSessionObserverResult {
/** Current observed state (null if not yet received) */
state: ObservedSessionState | null
/** Whether connected to the session channel */
isConnected: boolean
/** Whether actively observing (connected and joined session) */
isObserving: boolean
/** Error message if connection failed */
error: string | null
/** Stop observing the session */
stopObserving: () => void
}
/**
* Hook to observe a student's practice session in real-time
*
* Connects to the session's socket channel and receives practice state updates.
* Use this in a teacher's observation modal to see what the student is doing.
*
* @param sessionId - The session plan ID to observe
* @param observerId - Unique identifier for this observer (e.g., teacher's user ID)
* @param enabled - Whether to start observing (default: true)
*/
export function useSessionObserver(
sessionId: string | undefined,
observerId: string | undefined,
enabled = true
): UseSessionObserverResult {
const [state, setState] = useState<ObservedSessionState | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [isObserving, setIsObserving] = useState(false)
const [error, setError] = useState<string | null>(null)
const socketRef = useRef<Socket | null>(null)
const stopObserving = useCallback(() => {
if (socketRef.current && sessionId) {
socketRef.current.emit('stop-observing', { sessionId })
socketRef.current.disconnect()
socketRef.current = null
setIsConnected(false)
setIsObserving(false)
setState(null)
}
}, [sessionId])
useEffect(() => {
if (!sessionId || !observerId || !enabled) {
// Clean up if disabled
if (socketRef.current) {
stopObserving()
}
return
}
// Create socket connection
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
socket.on('connect', () => {
console.log('[SessionObserver] Connected, joining session:', sessionId)
setIsConnected(true)
setError(null)
// Join the session channel as an observer
socket.emit('observe-session', { sessionId, observerId })
setIsObserving(true)
})
socket.on('disconnect', () => {
console.log('[SessionObserver] Disconnected')
setIsConnected(false)
setIsObserving(false)
})
socket.on('connect_error', (err) => {
console.error('[SessionObserver] Connection error:', err)
setError('Failed to connect to session')
})
// Listen for practice state updates from the student
socket.on('practice-state', (data: PracticeStateEvent) => {
console.log('[SessionObserver] Received practice-state:', {
phase: data.phase,
answer: data.studentAnswer,
isCorrect: data.isCorrect,
})
setState({
currentProblem: data.currentProblem as { terms: number[]; answer: number },
phase: data.phase,
studentAnswer: data.studentAnswer,
isCorrect: data.isCorrect,
timing: data.timing,
purpose: data.purpose,
receivedAt: Date.now(),
})
})
// Listen for session ended event
socket.on('session-ended', () => {
console.log('[SessionObserver] Session ended')
stopObserving()
})
return () => {
console.log('[SessionObserver] Cleaning up')
socket.emit('stop-observing', { sessionId })
socket.disconnect()
socketRef.current = null
}
}, [sessionId, observerId, enabled, stopObserving])
return {
state,
isConnected,
isObserving,
error,
stopObserving,
}
}

View File

@@ -126,12 +126,15 @@ export interface PracticeStateEvent {
sessionId: string
currentProblem: unknown // GeneratedProblem type from curriculum
phase: 'problem' | 'feedback' | 'tutorial'
studentAnswer: number | null
/** Student's current typed answer (digit by digit) */
studentAnswer: string
isCorrect: boolean | null
timing: {
startedAt: number
elapsed: number
}
/** Purpose of this problem slot (why it was selected) */
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
}
export interface TutorialStateEvent {
@@ -228,6 +231,7 @@ export interface ClassroomClientToServerEvents {
'leave-classroom': (data: { classroomId: string }) => void
'join-player': (data: { playerId: string }) => void
'leave-player': (data: { playerId: string }) => void
'join-session': (data: { sessionId: string }) => void
'observe-session': (data: { sessionId: string; observerId: string }) => void
'stop-observing': (data: { sessionId: string }) => void

View File

@@ -760,6 +760,16 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
})
// Session Observation: Join session channel (for students to receive observer-joined events)
socket.on('join-session', async ({ sessionId }: { sessionId: string }) => {
try {
await socket.join(`session:${sessionId}`)
console.log(`📝 Student joined session channel: ${sessionId}`)
} catch (error) {
console.error('Error joining session channel:', error)
}
})
// Session Observation: Start observing a practice session
socket.on(
'observe-session',