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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
272
apps/web/src/components/classroom/SessionObserverModal.tsx
Normal file
272
apps/web/src/components/classroom/SessionObserverModal.tsx
Normal 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'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
54
apps/web/src/components/practice/PracticeFeedback.tsx
Normal file
54
apps/web/src/components/practice/PracticeFeedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
372
apps/web/src/components/practice/PurposeBadge.tsx
Normal file
372
apps/web/src/components/practice/PurposeBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
158
apps/web/src/hooks/useSessionObserver.ts
Normal file
158
apps/web/src/hooks/useSessionObserver.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user