feat(classroom): implement real-time skill tutorial observation

Teachers can now observe students' skill tutorials in real-time:

- Added TutorialObserverModal that reuses SkillTutorialLauncher component
- Student broadcasts tutorial state via WebSocket (useSkillTutorialBroadcast)
- Teacher receives live updates via useClassroomTutorialStates
- Teacher controls work: Start, Skip, Prev, Next, Complete buttons
- Socket server forwards skill-tutorial-state and skill-tutorial-control events
- Added onControl prop to SkillTutorialLauncher and TutorialPlayer
- In observation mode, buttons send control events instead of changing local state

🤖 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 15:55:35 -06:00
parent fb73e85f2d
commit 4b7387905d
10 changed files with 1525 additions and 41 deletions

View File

@@ -11,9 +11,14 @@ import {
type ActiveSessionInfo,
type PresenceStudent,
} from '@/hooks/useClassroom'
import {
useClassroomTutorialStates,
type ClassroomTutorialState,
} from '@/hooks/useClassroomTutorialStates'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
import { SessionObserverModal } from './SessionObserverModal'
import { TutorialObserverModal } from './TutorialObserverModal'
interface ClassroomTabProps {
classroom: Classroom
@@ -37,6 +42,12 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
student: PresenceStudent
} | null>(null)
// State for tutorial observation - store just playerId to get live updates
const [observingTutorialPlayerId, setObservingTutorialPlayerId] = useState<string | null>(null)
const [observingTutorialStudent, setObservingTutorialStudent] = useState<PresenceStudent | null>(
null
)
// Fetch present students
// Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays
// connected even when user switches tabs
@@ -46,6 +57,9 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
// Fetch active sessions to show "Practicing" indicator
const { data: activeSessions = [] } = useActiveSessionsInClassroom(classroom.id)
// Listen for tutorial states via WebSocket
const { tutorialStates } = useClassroomTutorialStates(classroom.id)
// Map active sessions by playerId for quick lookup
const activeSessionsByPlayer = new Map<string, ActiveSessionInfo>(
activeSessions.map((session) => [session.playerId, session])
@@ -55,10 +69,23 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
setObservingSession({ session, student })
}, [])
const handleObserveTutorial = useCallback(
(student: PresenceStudent, _tutorialState: ClassroomTutorialState) => {
setObservingTutorialPlayerId(student.id)
setObservingTutorialStudent(student)
},
[]
)
const handleCloseObserver = useCallback(() => {
setObservingSession(null)
}, [])
const handleCloseTutorialObserver = useCallback(() => {
setObservingTutorialPlayerId(null)
setObservingTutorialStudent(null)
}, [])
const handleRemoveStudent = useCallback(
(playerId: string) => {
leaveClassroom.mutate({
@@ -137,14 +164,19 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{presentStudents.map((student) => {
const activeSession = activeSessionsByPlayer.get(student.id)
const tutorialState = tutorialStates.get(student.id)
return (
<PresentStudentCard
key={student.id}
student={student}
activeSession={activeSession}
tutorialState={tutorialState}
onObserve={
activeSession ? () => handleObserve(student, activeSession) : undefined
}
onObserveTutorial={
tutorialState ? () => handleObserveTutorial(student, tutorialState) : undefined
}
onRemove={() => handleRemoveStudent(student.id)}
isRemoving={
leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
@@ -255,6 +287,23 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
observerId={viewerId}
/>
)}
{/* Tutorial Observer Modal - look up live state from tutorialStates Map */}
{observingTutorialPlayerId &&
observingTutorialStudent &&
tutorialStates.get(observingTutorialPlayerId) && (
<TutorialObserverModal
isOpen={true}
onClose={handleCloseTutorialObserver}
tutorialState={tutorialStates.get(observingTutorialPlayerId)!}
student={{
name: observingTutorialStudent.name,
emoji: observingTutorialStudent.emoji,
color: observingTutorialStudent.color,
}}
classroomId={classroom.id}
/>
)}
</div>
)
}
@@ -266,7 +315,9 @@ export function ClassroomTab({ classroom, viewerId }: ClassroomTabProps) {
interface PresentStudentCardProps {
student: PresenceStudent
activeSession?: ActiveSessionInfo
tutorialState?: ClassroomTutorialState
onObserve?: () => void
onObserveTutorial?: () => void
onRemove: () => void
isRemoving: boolean
isDark: boolean
@@ -275,7 +326,9 @@ interface PresentStudentCardProps {
function PresentStudentCard({
student,
activeSession,
tutorialState,
onObserve,
onObserveTutorial,
onRemove,
isRemoving,
isDark,
@@ -283,11 +336,21 @@ function PresentStudentCard({
const enteredAt = new Date(student.enteredAt)
const timeAgo = getTimeAgo(enteredAt)
const isPracticing = !!activeSession
const isLearning = !!tutorialState && tutorialState.launcherState !== 'complete'
// Determine border style based on activity
const getBorderStyle = () => {
if (isPracticing) return { width: '2px', color: 'blue.500' }
if (isLearning) return { width: '2px', color: 'purple.500' }
return { width: '1px', color: isDark ? 'green.800' : 'green.200' }
}
const borderStyle = getBorderStyle()
return (
<div
data-element="present-student-card"
data-practicing={isPracticing}
data-learning={isLearning}
className={css({
display: 'flex',
alignItems: 'center',
@@ -295,8 +358,8 @@ function PresentStudentCard({
padding: '14px 16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: isPracticing ? '2px solid' : '1px solid',
borderColor: isPracticing ? 'blue.500' : isDark ? 'green.800' : 'green.200',
border: `${borderStyle.width} solid`,
borderColor: borderStyle.color,
boxShadow: isDark ? 'none' : '0 1px 3px rgba(0,0,0,0.05)',
})}
>
@@ -326,7 +389,7 @@ function PresentStudentCard({
>
{student.emoji}
</span>
{/* Online/Practicing indicator */}
{/* Online/Practicing/Learning indicator */}
<span
className={css({
position: 'absolute',
@@ -335,29 +398,29 @@ function PresentStudentCard({
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: isPracticing ? 'blue.500' : 'green.500',
backgroundColor: isPracticing ? 'blue.500' : isLearning ? 'purple.500' : 'green.500',
border: '2px solid',
borderColor: isDark ? 'gray.800' : 'white',
})}
style={
isPracticing
isPracticing || isLearning
? {
animation: 'practicing-pulse 1.5s ease-in-out infinite',
}
: undefined
}
/>
{/* Keyframes for practicing pulse animation */}
{isPracticing && (
{/* Keyframes for pulse animation */}
{(isPracticing || isLearning) && (
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes practicing-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
box-shadow: 0 0 0 0 ${isLearning && !isPracticing ? 'rgba(147, 51, 234, 0.7)' : 'rgba(59, 130, 246, 0.7)'};
}
50% {
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
box-shadow: 0 0 0 6px ${isLearning && !isPracticing ? 'rgba(147, 51, 234, 0)' : 'rgba(59, 130, 246, 0)'};
}
}
`,
@@ -396,6 +459,26 @@ function PresentStudentCard({
Practicing
</span>
)}
{isLearning && !isPracticing && (
<span
data-element="learning-badge"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '10px',
backgroundColor: isDark ? 'purple.900' : 'purple.100',
color: isDark ? 'purple.300' : 'purple.700',
fontSize: '0.6875rem',
fontWeight: 'bold',
textTransform: 'uppercase',
})}
>
<span className={css({ fontSize: '0.75rem' })}>📚</span>
Learning
</span>
)}
</p>
<p
className={css({
@@ -407,6 +490,12 @@ function PresentStudentCard({
<>
Problem {activeSession.completedProblems + 1} of {activeSession.totalProblems}
</>
) : isLearning && tutorialState ? (
<>
{tutorialState.skillTitle}
{tutorialState.tutorialState &&
` • Step ${tutorialState.tutorialState.currentStepIndex + 1} of ${tutorialState.tutorialState.totalSteps}`}
</>
) : (
<>Joined {timeAgo}</>
)}
@@ -415,7 +504,7 @@ function PresentStudentCard({
</Link>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Observe button - only show when practicing */}
{/* Observe button - show when practicing */}
{isPracticing && onObserve && (
<button
type="button"
@@ -440,6 +529,31 @@ function PresentStudentCard({
</button>
)}
{/* Observe button - show when learning (tutorial) */}
{isLearning && !isPracticing && onObserveTutorial && (
<button
type="button"
onClick={onObserveTutorial}
data-action="observe-tutorial"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'purple.700' : 'purple.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'purple.600' : 'purple.600',
},
})}
>
Observe
</button>
)}
<button
type="button"
onClick={onRemove}

View File

@@ -0,0 +1,383 @@
'use client'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import type { ClassroomTutorialState } from '@/hooks/useClassroomTutorialStates'
import { useTutorialControl } from '@/hooks/useTutorialControl'
import { SkillTutorialLauncher } from '@/components/tutorial/SkillTutorialLauncher'
import { css } from '../../../styled-system/css'
interface TutorialObserverModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Close the modal */
onClose: () => void
/** Tutorial state from the classroom tutorial states */
tutorialState: ClassroomTutorialState
/** Student info for display */
student: {
name: string
emoji: string
color: string
}
/** Classroom ID for socket channel */
classroomId: string
}
/**
* Modal for teachers to observe a student's skill tutorial in real-time.
*
* Uses the same SkillTutorialLauncher component as the student sees,
* but in observation mode (read-only, state synced via WebSocket).
*/
export function TutorialObserverModal({
isOpen,
onClose,
tutorialState,
student,
classroomId,
}: TutorialObserverModalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Tutorial control hook for sending commands to student
const { sendControl, isConnected: isControlConnected } = useTutorialControl(
classroomId,
tutorialState.playerId,
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="tutorial-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '95vw',
maxWidth: '900px',
maxHeight: '90vh',
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: '12px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
flexShrink: 0,
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.125rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<h2
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '0.9375rem',
})}
>
Observing {student.name}
</h2>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Learning: {tutorialState.skillTitle}
</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '0.6875rem',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: 'green.500',
color: 'white',
})}
>
<span
className={css({
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'white',
})}
/>
LIVE
</span>
</div>
</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>
{/* Tutorial content - uses the SAME component as student */}
<div
className={css({
flex: 1,
overflow: 'auto',
padding: '16px',
})}
>
<SkillTutorialLauncher
skillId={tutorialState.skillId}
playerId={tutorialState.playerId}
theme={isDark ? 'dark' : 'light'}
observedState={tutorialState}
onControl={sendControl}
/>
</div>
{/* Teacher controls footer */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
{/* Connection status */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '6px' })}>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
})}
style={{ backgroundColor: isControlConnected ? '#10b981' : '#ef4444' }}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isControlConnected ? 'Controls Connected' : 'Controls Disconnected'}
</span>
</div>
{/* Control buttons */}
<div className={css({ display: 'flex', gap: '8px' })}>
{/* Intro state controls */}
{tutorialState.launcherState === 'intro' && (
<>
<button
type="button"
data-action="start-tutorial"
onClick={() => sendControl({ type: 'start-tutorial' })}
disabled={!isControlConnected}
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'blue.600' : 'blue.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.500' : 'blue.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
Start Tutorial
</button>
<button
type="button"
data-action="skip-tutorial"
onClick={() => sendControl({ type: 'skip-tutorial' })}
disabled={!isControlConnected}
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
Skip Tutorial
</button>
</>
)}
{/* Tutorial state controls */}
{tutorialState.launcherState === 'tutorial' && tutorialState.tutorialState && (
<>
<button
type="button"
data-action="previous-step"
onClick={() => sendControl({ type: 'previous-step' })}
disabled={
!isControlConnected || tutorialState.tutorialState.currentStepIndex === 0
}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
Prev
</button>
<button
type="button"
data-action="next-step"
onClick={() => sendControl({ type: 'next-step' })}
disabled={
!isControlConnected ||
tutorialState.tutorialState.currentStepIndex >=
tutorialState.tutorialState.totalSteps - 1
}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
Next
</button>
<button
type="button"
data-action="set-to-target"
onClick={() =>
sendControl({
type: 'set-abacus-value',
value: tutorialState.tutorialState!.targetValue,
})
}
disabled={
!isControlConnected ||
tutorialState.tutorialState.currentValue ===
tutorialState.tutorialState.targetValue
}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'green.700' : 'green.100',
color: isDark ? 'green.200' : 'green.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'green.600' : 'green.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
Set to {tutorialState.tutorialState.targetValue}
</button>
<button
type="button"
data-action="skip-tutorial"
onClick={() => sendControl({ type: 'skip-tutorial' })}
disabled={!isControlConnected}
className={css({
padding: '8px 12px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.800' : 'gray.100',
},
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
Skip
</button>
</>
)}
</div>
</div>
</div>
</>
)
}

View File

@@ -4,6 +4,11 @@ import * as Dialog from '@radix-ui/react-dialog'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import {
useSkillTutorialBroadcast,
type SkillTutorialBroadcastState,
} from '@/hooks/useSkillTutorialBroadcast'
import type { SkillTutorialControlAction } from '@/lib/classroom/socket-events'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPlan } from '@/db/schema/session-plans'
import { DEFAULT_PLAN_CONFIG } from '@/db/schema/session-plans'
@@ -78,6 +83,32 @@ export function StartPracticeModal({
// Tutorial gate state
const [showTutorial, setShowTutorial] = useState(false)
// Tutorial broadcast state for teacher observation
const [tutorialBroadcastState, setTutorialBroadcastState] =
useState<SkillTutorialBroadcastState | null>(null)
// Control action from teacher (via WebSocket)
const [pendingControlAction, setPendingControlAction] = useState<SkillTutorialControlAction | null>(null)
// Handler for when control action is processed
const handleControlActionProcessed = useCallback(() => {
setPendingControlAction(null)
}, [])
// Handler for receiving control actions from teacher
const handleControlReceived = useCallback((action: SkillTutorialControlAction) => {
console.log('[StartPracticeModal] Received control action from teacher:', action)
setPendingControlAction(action)
}, [])
// Broadcast tutorial state to classroom observers
useSkillTutorialBroadcast(
studentId,
studentName,
showTutorial ? tutorialBroadcastState : null,
handleControlReceived
)
// Derive tutorial info from sessionMode (no separate hook needed)
const tutorialConfig = useMemo(() => {
if (sessionMode.type !== 'progression' || !sessionMode.tutorialRequired) return null
@@ -307,6 +338,9 @@ export function StartPracticeModal({
onComplete={handleTutorialComplete}
onSkip={handleTutorialSkip}
onCancel={handleTutorialCancel}
onBroadcastStateChange={setTutorialBroadcastState}
controlAction={pendingControlAction}
onControlActionProcessed={handleControlActionProcessed}
/>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -1,14 +1,15 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
import { hstack, vstack } from '../../../styled-system/patterns'
import type { Tutorial, TutorialStep } from '../../types/tutorial'
import type { Tutorial, TutorialEvent, TutorialStep } from '../../types/tutorial'
import {
type SkillTutorialConfig,
getSkillTutorialConfig,
} from '../../lib/curriculum/skill-tutorial-config'
import { TutorialPlayer } from './TutorialPlayer'
import type { SkillTutorialControlAction } from '@/lib/classroom/socket-events'
// ============================================================================
// Types
@@ -16,6 +17,32 @@ import { TutorialPlayer } from './TutorialPlayer'
type LauncherState = 'intro' | 'tutorial' | 'complete'
/**
* Broadcast state for skill tutorial observation
*/
export interface SkillTutorialBroadcastState {
/** Current launcher state */
launcherState: 'intro' | 'tutorial' | 'complete'
/** Skill being learned */
skillId: string
/** Skill display title */
skillTitle: string
/** Tutorial state details (only when in 'tutorial' state) */
tutorialState?: {
currentStepIndex: number
totalSteps: number
currentMultiStep: number
totalMultiSteps: number
currentValue: number
targetValue: number
startValue: number
isStepCompleted: boolean
problem: string
description: string
currentInstruction: string
}
}
interface SkillTutorialLauncherProps {
/** The skill ID to launch the tutorial for */
skillId: string
@@ -31,6 +58,23 @@ interface SkillTutorialLauncherProps {
theme?: 'light' | 'dark'
/** Number of columns on the abacus */
abacusColumns?: number
/** Callback when broadcast state changes (for teacher observation) */
onBroadcastStateChange?: (state: SkillTutorialBroadcastState) => void
/** Control action from teacher (optional, for remote control) */
controlAction?: SkillTutorialControlAction | null
/** Callback when control action has been processed */
onControlActionProcessed?: () => void
/**
* Observed state from WebSocket (for teacher observation mode).
* When provided, the component becomes read-only and displays this state
* instead of managing its own internal state.
*/
observedState?: SkillTutorialBroadcastState
/**
* Callback for sending control actions (used in observation mode).
* When provided, button clicks send control actions instead of changing local state.
*/
onControl?: (action: SkillTutorialControlAction) => void
}
// ============================================================================
@@ -94,12 +138,36 @@ export function SkillTutorialLauncher({
onCancel,
theme = 'light',
abacusColumns = 5,
onBroadcastStateChange,
controlAction,
onControlActionProcessed,
observedState,
onControl,
}: SkillTutorialLauncherProps) {
const [state, setState] = useState<LauncherState>('intro')
// Whether we're in observation mode (read-only, state comes from WebSocket)
const isObservationMode = !!observedState
const [localState, setLocalState] = useState<LauncherState>('intro')
const [isSubmitting, setIsSubmitting] = useState(false)
// Get the tutorial config for this skill
const config = useMemo(() => getSkillTutorialConfig(skillId), [skillId])
// In observation mode, use observed state; otherwise use local state
const state: LauncherState = isObservationMode ? observedState.launcherState : localState
const setState = setLocalState // Only used in interactive mode
// Track pending control action for TutorialPlayer
const [playerControlAction, setPlayerControlAction] = useState<SkillTutorialControlAction | null>(null)
// Track tutorial player state for broadcasting (only in interactive mode)
const [tutorialPlayerState, setTutorialPlayerState] = useState<{
currentStepIndex: number
currentMultiStep: number
currentValue: number
isStepCompleted: boolean
} | null>(null)
// Get the tutorial config for this skill (use observed skillId in observation mode)
const effectiveSkillId = isObservationMode ? observedState.skillId : skillId
const config = useMemo(() => getSkillTutorialConfig(effectiveSkillId), [effectiveSkillId])
// Generate the Tutorial object
const tutorial = useMemo(() => {
@@ -107,6 +175,50 @@ export function SkillTutorialLauncher({
return generateTutorialFromConfig(config)
}, [config])
// Broadcast state changes for teacher observation
useEffect(() => {
if (!onBroadcastStateChange || !config || !tutorial) return
// Get current step info from tutorial
const currentStep = tutorial.steps[tutorialPlayerState?.currentStepIndex ?? 0]
const broadcastState: SkillTutorialBroadcastState = {
launcherState: state,
skillId,
skillTitle: config.title,
tutorialState:
state === 'tutorial' && currentStep && tutorialPlayerState
? {
currentStepIndex: tutorialPlayerState.currentStepIndex,
totalSteps: tutorial.steps.length,
currentMultiStep: tutorialPlayerState.currentMultiStep,
totalMultiSteps: currentStep.multiStepInstructions?.length ?? 1,
currentValue: tutorialPlayerState.currentValue,
targetValue: currentStep.targetValue,
startValue: currentStep.startValue,
isStepCompleted: tutorialPlayerState.isStepCompleted,
problem: currentStep.problem,
description: currentStep.description,
currentInstruction:
currentStep.multiStepInstructions?.[tutorialPlayerState.currentMultiStep] ??
currentStep.actionDescription,
}
: undefined,
}
onBroadcastStateChange(broadcastState)
}, [
onBroadcastStateChange,
config,
tutorial,
state,
skillId,
tutorialPlayerState?.currentStepIndex,
tutorialPlayerState?.currentMultiStep,
tutorialPlayerState?.currentValue,
tutorialPlayerState?.isStepCompleted,
])
// Handle tutorial completion
const handleTutorialComplete = useCallback(
async (_score: number, _timeSpent: number) => {
@@ -168,6 +280,99 @@ export function SkillTutorialLauncher({
}
}, [playerId, skillId, onSkip])
// Handle step change from TutorialPlayer
const handleStepChange = useCallback(
(stepIndex: number, step: TutorialStep) => {
setTutorialPlayerState({
currentStepIndex: stepIndex,
currentMultiStep: 0, // Reset on new step
currentValue: step.startValue,
isStepCompleted: false,
})
},
[]
)
// Handle tutorial events to track value changes
const handleEvent = useCallback((event: TutorialEvent) => {
if (event.type === 'VALUE_CHANGED') {
setTutorialPlayerState((prev) =>
prev ? { ...prev, currentValue: event.newValue } : prev
)
} else if (event.type === 'STEP_COMPLETED') {
setTutorialPlayerState((prev) =>
prev ? { ...prev, isStepCompleted: true } : prev
)
}
}, [])
// Handle step complete from TutorialPlayer
const handleStepComplete = useCallback(
(stepIndex: number, step: TutorialStep, success: boolean) => {
setTutorialPlayerState((prev) =>
prev
? {
...prev,
isStepCompleted: success,
// Ensure the final value is captured when step completes
currentValue: success ? step.targetValue : prev.currentValue,
}
: prev
)
},
[]
)
// Handle multi-step change from TutorialPlayer (for broadcasting to observers)
const handleMultiStepChange = useCallback((multiStep: number) => {
setTutorialPlayerState((prev) =>
prev ? { ...prev, currentMultiStep: multiStep } : prev
)
}, [])
// Handle control actions from teacher
useEffect(() => {
if (!controlAction) return
console.log('[SkillTutorialLauncher] Received control action:', controlAction)
switch (controlAction.type) {
case 'start-tutorial':
// Only works from intro state
if (state === 'intro') {
setState('tutorial')
}
break
case 'skip-tutorial':
// Can skip from intro or tutorial state
if (state === 'intro' || state === 'tutorial') {
handleSkip()
}
break
case 'next-step':
case 'previous-step':
case 'go-to-step':
case 'set-abacus-value':
case 'advance-multi-step':
case 'previous-multi-step':
// Pass to TutorialPlayer
if (state === 'tutorial') {
setPlayerControlAction(controlAction)
}
break
}
// Mark action as processed
onControlActionProcessed?.()
}, [controlAction, state, handleSkip, onControlActionProcessed])
// Callback for TutorialPlayer when it processes a control action
const handlePlayerControlProcessed = useCallback(() => {
setPlayerControlAction(null)
}, [])
// No config found for this skill
if (!config || !tutorial) {
return (
@@ -283,7 +488,13 @@ export function SkillTutorialLauncher({
<div className={hstack({ gap: 4, w: 'full', justifyContent: 'center' })}>
<button
data-action="start-tutorial"
onClick={() => setState('tutorial')}
onClick={() => {
if (isObservationMode && onControl) {
onControl({ type: 'start-tutorial' })
} else {
setState('tutorial')
}
}}
disabled={isSubmitting}
className={css({
px: 6,
@@ -303,7 +514,13 @@ export function SkillTutorialLauncher({
<button
data-action="skip-tutorial"
onClick={handleSkip}
onClick={() => {
if (isObservationMode && onControl) {
onControl({ type: 'skip-tutorial' })
} else {
handleSkip()
}
}}
disabled={isSubmitting}
className={css({
px: 4,
@@ -347,10 +564,48 @@ export function SkillTutorialLauncher({
// Tutorial in progress
if (state === 'tutorial') {
// In observation mode, wait for tutorialState before rendering TutorialPlayer
if (isObservationMode && !observedState.tutorialState) {
return (
<div
data-component="skill-tutorial-launcher"
data-status="tutorial-loading"
className={css({
height: '100%',
minHeight: '600px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<p
className={css({
color: theme === 'dark' ? 'gray.400' : 'gray.500',
fontSize: '0.9375rem',
})}
>
Waiting for tutorial to load...
</p>
</div>
)
}
// Convert observed state to TutorialPlayer's observed state format
const tutorialObservedState =
isObservationMode && observedState.tutorialState
? {
currentStepIndex: observedState.tutorialState.currentStepIndex,
currentMultiStep: observedState.tutorialState.currentMultiStep,
currentValue: observedState.tutorialState.currentValue,
isStepCompleted: observedState.tutorialState.isStepCompleted,
}
: undefined
return (
<div
data-component="skill-tutorial-launcher"
data-status="tutorial"
data-observation-mode={isObservationMode ? 'true' : undefined}
className={css({
height: '100%',
minHeight: '600px',
@@ -360,7 +615,15 @@ export function SkillTutorialLauncher({
tutorial={tutorial}
theme={theme}
abacusColumns={abacusColumns}
onTutorialComplete={handleTutorialComplete}
onTutorialComplete={isObservationMode ? undefined : handleTutorialComplete}
onStepChange={isObservationMode ? undefined : handleStepChange}
onStepComplete={isObservationMode ? undefined : handleStepComplete}
onEvent={isObservationMode ? undefined : handleEvent}
onMultiStepChange={isObservationMode ? undefined : handleMultiStepChange}
controlAction={playerControlAction}
onControlActionProcessed={handlePlayerControlProcessed}
observedState={tutorialObservedState}
onControl={onControl}
/>
</div>
)

View File

@@ -25,6 +25,7 @@ import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { BeadTooltipContent } from '../shared/BeadTooltipContent'
import { TutorialProvider, useTutorialContext } from './TutorialContext'
import { TutorialUIProvider } from './TutorialUIContext'
import type { SkillTutorialControlAction } from '@/lib/classroom/socket-events'
import './CoachBar/coachbar.css'
// Reducer state and actions
@@ -170,6 +171,16 @@ function _tutorialPlayerReducer(
}
}
/**
* Observed state for teacher observation mode
*/
export interface TutorialObservedState {
currentStepIndex: number
currentMultiStep: number
currentValue: number
isStepCompleted: boolean
}
interface TutorialPlayerProps {
tutorial: Tutorial
initialStepIndex?: number
@@ -184,7 +195,23 @@ interface TutorialPlayerProps {
onStepComplete?: (stepIndex: number, step: TutorialStep, success: boolean) => void
onTutorialComplete?: (score: number, timeSpent: number) => void
onEvent?: (event: TutorialEvent) => void
/** Callback when multi-step index changes (for broadcasting to observers) */
onMultiStepChange?: (multiStep: number) => void
className?: string
/** Control action from teacher (optional, for remote control) */
controlAction?: SkillTutorialControlAction | null
/** Callback when control action has been processed */
onControlActionProcessed?: () => void
/**
* Observed state from WebSocket (for teacher observation mode).
* When set, the component becomes read-only and displays this state.
*/
observedState?: TutorialObservedState
/**
* Callback for sending control actions (used in observation mode).
* When provided, button clicks send control actions instead of local state changes.
*/
onControl?: (action: SkillTutorialControlAction) => void
}
function TutorialPlayerContent({
@@ -201,18 +228,26 @@ function TutorialPlayerContent({
onStepComplete,
onTutorialComplete,
onEvent,
onMultiStepChange,
className,
controlAction,
onControlActionProcessed,
observedState,
onControl,
}: TutorialPlayerProps) {
const t = useTranslations('tutorial.player')
const [_startTime] = useState(Date.now())
const isProgrammaticChange = useRef(false)
const [showHelpForCurrentStep, setShowHelpForCurrentStep] = useState(false)
// Whether we're in observation mode (read-only, state comes from WebSocket)
const isObservationMode = !!observedState
// Use tutorial context instead of local state
const {
state,
dispatch,
currentStep,
currentStep: contextCurrentStep,
goToStep: contextGoToStep,
goToNextStep: contextGoToNextStep,
goToPreviousStep: contextGoToPreviousStep,
@@ -227,17 +262,19 @@ function TutorialPlayerContent({
handleAbacusColumnHover,
} = useTutorialContext()
const {
currentStepIndex,
currentValue,
isStepCompleted,
error,
events,
stepStartTime,
multiStepStartTime,
uiState,
currentMultiStep,
} = state
// In observation mode, override state values with observed values
const currentStepIndex = observedState?.currentStepIndex ?? state.currentStepIndex
const currentValue = observedState?.currentValue ?? state.currentValue
const isStepCompleted = observedState?.isStepCompleted ?? state.isStepCompleted
const currentMultiStep = observedState?.currentMultiStep ?? state.currentMultiStep
// Get the current step based on the (possibly observed) step index
const currentStep = isObservationMode
? tutorial.steps[observedState.currentStepIndex]
: contextCurrentStep
// Non-observed state values (only used in interactive mode)
const { error, events, stepStartTime, multiStepStartTime, uiState } = state
// Use universal abacus display configuration
const { config: abacusConfig } = useAbacusDisplay()
@@ -248,6 +285,58 @@ function TutorialPlayerContent({
const userHasInteracted = useRef<boolean>(false)
const lastMovedBead = useRef<StepBeadHighlight | null>(null)
// Handle control actions from teacher
useEffect(() => {
if (!controlAction) return
console.log('[TutorialPlayer] Received control action:', controlAction)
switch (controlAction.type) {
case 'next-step':
contextGoToNextStep()
break
case 'previous-step':
contextGoToPreviousStep()
break
case 'go-to-step':
if ('stepIndex' in controlAction) {
contextGoToStep(controlAction.stepIndex)
}
break
case 'set-abacus-value':
if ('value' in controlAction) {
// Trigger a programmatic value change
isProgrammaticChange.current = true
contextHandleValueChange(controlAction.value)
}
break
case 'advance-multi-step':
advanceMultiStep()
break
case 'previous-multi-step':
previousMultiStep()
break
}
// Mark action as processed
onControlActionProcessed?.()
}, [
controlAction,
contextGoToNextStep,
contextGoToPreviousStep,
contextGoToStep,
contextHandleValueChange,
advanceMultiStep,
previousMultiStep,
currentValue,
onControlActionProcessed,
])
// Reset success popup when moving to new step
useEffect(() => {
setIsSuccessPopupDismissed(false)
@@ -577,11 +666,23 @@ function TutorialPlayerContent({
// Use context goToStep function instead of local one
const goToStep = contextGoToStep
// Use context goToNextStep function instead of local one
const goToNextStep = contextGoToNextStep
// Use context goToNextStep function, or send control in observation mode
const goToNextStep = useCallback(() => {
if (isObservationMode && onControl) {
onControl({ type: 'next-step' })
} else {
contextGoToNextStep()
}
}, [isObservationMode, onControl, contextGoToNextStep])
// Use context goToPreviousStep function instead of local one
const goToPreviousStep = contextGoToPreviousStep
// Use context goToPreviousStep function, or send control in observation mode
const goToPreviousStep = useCallback(() => {
if (isObservationMode && onControl) {
onControl({ type: 'previous-step' })
} else {
contextGoToPreviousStep()
}
}, [isObservationMode, onControl, contextGoToPreviousStep])
// Initialize step on mount only
useEffect(() => {
@@ -706,6 +807,21 @@ function TutorialPlayerContent({
}
}, [events, notifyEvent])
// Track previous multi-step to detect changes
const prevMultiStepRef = useRef<number>(state.currentMultiStep)
// Notify parent when multi-step changes (for broadcasting to observers)
useEffect(() => {
// Only notify in interactive mode (not observation mode)
if (isObservationMode) return
// Only notify if multi-step actually changed
if (state.currentMultiStep !== prevMultiStepRef.current) {
prevMultiStepRef.current = state.currentMultiStep
onMultiStepChange?.(state.currentMultiStep)
}
}, [state.currentMultiStep, isObservationMode, onMultiStepChange])
// Wrap context handleValueChange to track user interaction
const handleValueChange = useCallback(
(newValue: number) => {
@@ -1500,13 +1616,13 @@ function TutorialPlayerContent({
<AbacusReact
value={currentValue}
columns={abacusColumns}
interactive={true}
interactive={!isObservationMode}
animated={true}
scaleFactor={1.5}
colorScheme={abacusConfig.colorScheme}
beadShape={abacusConfig.beadShape}
hideInactiveBeads={abacusConfig.hideInactiveBeads}
soundEnabled={abacusConfig.soundEnabled}
soundEnabled={isObservationMode ? false : abacusConfig.soundEnabled}
soundVolume={abacusConfig.soundVolume}
highlightBeads={filteredHighlightBeads}
stepBeadHighlights={currentStepBeads}
@@ -1514,11 +1630,15 @@ function TutorialPlayerContent({
showDirectionIndicators={true}
customStyles={customStyles}
overlays={tooltipOverlay ? [tooltipOverlay] : []}
onValueChange={handleValueChange}
callbacks={{
onBeadClick: handleBeadClick,
onBeadRef: handleBeadRef,
}}
onValueChange={isObservationMode ? undefined : handleValueChange}
callbacks={
isObservationMode
? undefined
: {
onBeadClick: handleBeadClick,
onBeadRef: handleBeadRef,
}
}
/>
{/* Debug info */}

View File

@@ -0,0 +1,129 @@
'use client'
import { useEffect, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { SkillTutorialStateEvent } from '@/lib/classroom/socket-events'
/**
* Tutorial state for a student in the classroom
*/
export interface ClassroomTutorialState extends SkillTutorialStateEvent {
/** When this state was last updated */
lastUpdatedAt: number
}
/**
* Hook to listen for skill tutorial state events from students in a classroom
*
* Teachers use this to see which students are viewing tutorials and observe them.
*
* @param classroomId - The classroom ID to listen for tutorial events
* @param enabled - Whether to enable listening (default: true)
*/
export function useClassroomTutorialStates(
classroomId: string | undefined,
enabled = true
): {
tutorialStates: Map<string, ClassroomTutorialState>
isConnected: boolean
} {
const [tutorialStates, setTutorialStates] = useState<Map<string, ClassroomTutorialState>>(
new Map()
)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
if (!classroomId || !enabled) {
setTutorialStates(new Map())
return
}
// Create socket connection
const socket: Socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socket.on('connect', () => {
console.log('[ClassroomTutorialStates] Connected, joining classroom:', classroomId)
setIsConnected(true)
// Join the classroom channel to receive tutorial events
socket.emit('join-classroom', { classroomId })
})
socket.on('disconnect', () => {
console.log('[ClassroomTutorialStates] Disconnected')
setIsConnected(false)
})
// Listen for skill tutorial state events
socket.on('skill-tutorial-state', (data: SkillTutorialStateEvent) => {
console.log('[ClassroomTutorialStates] Received skill-tutorial-state:', {
playerId: data.playerId,
launcherState: data.launcherState,
skillId: data.skillId,
hasTutorialState: !!data.tutorialState,
tutorialStep: data.tutorialState?.currentStepIndex,
currentValue: data.tutorialState?.currentValue,
})
setTutorialStates((prev) => {
const newMap = new Map(prev)
// If tutorial is complete, remove from map after a short delay
if (data.launcherState === 'complete') {
// Keep it briefly so UI can show completion, then remove
setTimeout(() => {
setTutorialStates((current) => {
const updated = new Map(current)
updated.delete(data.playerId)
return updated
})
}, 2000)
}
// Update the state
newMap.set(data.playerId, {
...data,
lastUpdatedAt: Date.now(),
})
return newMap
})
})
// Clean up stale states (if no update for 30 seconds, assume tutorial ended)
const cleanupInterval = setInterval(() => {
const now = Date.now()
const staleThreshold = 30 * 1000 // 30 seconds
setTutorialStates((prev) => {
const newMap = new Map(prev)
let hasChanges = false
for (const [playerId, state] of newMap) {
if (now - state.lastUpdatedAt > staleThreshold) {
newMap.delete(playerId)
hasChanges = true
}
}
return hasChanges ? newMap : prev
})
}, 10000) // Check every 10 seconds
return () => {
console.log('[ClassroomTutorialStates] Cleaning up')
clearInterval(cleanupInterval)
socket.emit('leave-classroom', { classroomId })
socket.disconnect()
}
}, [classroomId, enabled])
return {
tutorialStates,
isConnected,
}
}

View File

@@ -0,0 +1,194 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import { useStudentPresence } from './useClassroom'
import type {
SkillTutorialStateEvent,
SkillTutorialControlEvent,
SkillTutorialControlAction,
} from '@/lib/classroom/socket-events'
import type { SkillTutorialBroadcastState } from '@/components/tutorial/SkillTutorialLauncher'
export type { SkillTutorialBroadcastState }
interface UseSkillTutorialBroadcastResult {
/** Whether connected to the socket */
isConnected: boolean
/** Whether actively broadcasting */
isBroadcasting: boolean
}
/**
* Hook to broadcast skill tutorial state to observers via WebSocket
*
* Only broadcasts if the student is currently present in a classroom.
* This enables teachers to observe student tutorials in real-time.
*
* @param playerId - The student's player ID
* @param playerName - The student's display name
* @param state - Current tutorial state (or null if not viewing a tutorial)
* @param onControlReceived - Callback when a control event is received from teacher
*/
export function useSkillTutorialBroadcast(
playerId: string | undefined,
playerName: string | undefined,
state: SkillTutorialBroadcastState | null,
onControlReceived?: (action: SkillTutorialControlAction) => void
): UseSkillTutorialBroadcastResult {
const socketRef = useRef<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
// Keep state in a ref so socket event handlers can access current state
const stateRef = useRef<SkillTutorialBroadcastState | null>(null)
stateRef.current = state
// Keep callback in ref to avoid effect re-runs when callback changes
const onControlReceivedRef = useRef(onControlReceived)
onControlReceivedRef.current = onControlReceived
// Keep player info in refs for stable socket event handlers
const playerIdRef = useRef(playerId)
playerIdRef.current = playerId
const playerNameRef = useRef(playerName)
playerNameRef.current = playerName
// Check if student is present in a classroom
const { data: presence } = useStudentPresence(playerId)
const isInClassroom = !!presence?.classroomId
const classroomId = presence?.classroomId
// Helper to broadcast current state (uses refs, no dependencies that change)
const broadcastState = useCallback(() => {
const currentState = stateRef.current
const pid = playerIdRef.current
const pname = playerNameRef.current
if (!socketRef.current?.connected || !pid || !pname || !currentState) {
return
}
const event: SkillTutorialStateEvent = {
playerId: pid,
playerName: pname,
launcherState: currentState.launcherState,
skillId: currentState.skillId,
skillTitle: currentState.skillTitle,
tutorialState: currentState.tutorialState,
}
socketRef.current.emit('skill-tutorial-state', event)
console.log('[SkillTutorialBroadcast] Emitted skill-tutorial-state:', {
launcherState: currentState.launcherState,
skillId: currentState.skillId,
hasTutorialState: !!currentState.tutorialState,
tutorialStep: currentState.tutorialState?.currentStepIndex,
})
}, []) // No dependencies - uses refs
// Connect to socket and join classroom channel when in classroom with active tutorial
// Only depends on: whether we have a state, whether we're in classroom, and classroom ID
const shouldConnect = !!state && !!playerId && !!playerName && isInClassroom && !!classroomId
// Store classroomId in ref for use in socket handlers
const classroomIdRef = useRef(classroomId)
classroomIdRef.current = classroomId
useEffect(() => {
// Track if this effect is still mounted
let isMounted = true
if (!shouldConnect || !classroomId) {
// Clean up if we were connected
if (socketRef.current) {
console.log('[SkillTutorialBroadcast] Disconnecting - no longer in classroom or tutorial ended')
socketRef.current.disconnect()
socketRef.current = null
setIsConnected(false)
}
return
}
// Already connected to the same classroom? Don't create a new socket
if (socketRef.current?.connected) {
console.log('[SkillTutorialBroadcast] Already connected, skipping socket creation')
return
}
// Clean up any existing socket before creating a new one
if (socketRef.current) {
console.log('[SkillTutorialBroadcast] Cleaning up existing disconnected socket')
socketRef.current.disconnect()
socketRef.current = null
}
// Create socket connection
console.log('[SkillTutorialBroadcast] Creating new socket connection for classroom:', classroomId)
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
const currentClassroomId = classroomId // Capture for closure
socket.on('connect', () => {
// Only proceed if still mounted
if (!isMounted) {
console.log('[SkillTutorialBroadcast] Connected but effect unmounted, ignoring')
return
}
console.log('[SkillTutorialBroadcast] Connected, joining classroom channel:', currentClassroomId)
setIsConnected(true)
// Join the classroom channel for skill tutorial events
socket.emit('join-classroom', { classroomId: currentClassroomId })
// Broadcast current state immediately
broadcastState()
})
socket.on('disconnect', () => {
if (!isMounted) return
console.log('[SkillTutorialBroadcast] Disconnected')
setIsConnected(false)
})
// Listen for control events from teacher
socket.on('skill-tutorial-control', (data: SkillTutorialControlEvent) => {
if (!isMounted) return
// Only handle events for this player - use ref for current value
if (data.playerId !== playerIdRef.current) return
console.log('[SkillTutorialBroadcast] Received control:', data.action)
onControlReceivedRef.current?.(data.action)
})
return () => {
isMounted = false
console.log('[SkillTutorialBroadcast] Cleaning up socket connection')
socket.emit('leave-classroom', { classroomId: currentClassroomId })
socket.disconnect()
socketRef.current = null
setIsConnected(false)
}
// Only re-run when shouldConnect or classroomId actually changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldConnect, classroomId])
// Broadcast state changes
useEffect(() => {
broadcastState()
}, [
broadcastState,
state?.launcherState,
state?.skillId,
state?.tutorialState?.currentStepIndex,
state?.tutorialState?.currentMultiStep,
state?.tutorialState?.currentValue,
state?.tutorialState?.isStepCompleted,
])
return {
isConnected,
isBroadcasting: isConnected && isInClassroom && !!state,
}
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type {
SkillTutorialControlAction,
SkillTutorialControlEvent,
} from '@/lib/classroom/socket-events'
interface UseTutorialControlResult {
/** Whether connected to the socket */
isConnected: boolean
/** Send a control action to the student */
sendControl: (action: SkillTutorialControlAction) => void
}
/**
* Hook for teachers to send control events to a student's skill tutorial
*
* @param classroomId - The classroom ID (used for the socket channel)
* @param playerId - The student's player ID
* @param enabled - Whether control is enabled (default: true)
*/
export function useTutorialControl(
classroomId: string | undefined,
playerId: string | undefined,
enabled = true
): UseTutorialControlResult {
const socketRef = useRef<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
// Connect to socket and join classroom channel
useEffect(() => {
if (!classroomId || !playerId || !enabled) {
if (socketRef.current) {
socketRef.current.disconnect()
socketRef.current = null
setIsConnected(false)
}
return
}
// Create socket connection
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
socket.on('connect', () => {
console.log('[TutorialControl] Connected, joining classroom channel:', classroomId)
setIsConnected(true)
// Join the classroom channel to send control events
socket.emit('join-classroom', { classroomId })
})
socket.on('disconnect', () => {
console.log('[TutorialControl] Disconnected')
setIsConnected(false)
})
return () => {
console.log('[TutorialControl] Cleaning up socket connection')
socket.emit('leave-classroom', { classroomId })
socket.disconnect()
socketRef.current = null
setIsConnected(false)
}
}, [classroomId, playerId, enabled])
// Send control action to student
const sendControl = useCallback(
(action: SkillTutorialControlAction) => {
if (!socketRef.current || !isConnected || !playerId) {
console.warn('[TutorialControl] Cannot send control - not connected or no playerId')
return
}
const event: SkillTutorialControlEvent = {
playerId,
action,
}
socketRef.current.emit('skill-tutorial-control', event)
console.log('[TutorialControl] Sent control:', action)
},
[isConnected, playerId]
)
return {
isConnected,
sendControl,
}
}

View File

@@ -202,6 +202,80 @@ export interface SessionEndedEvent {
reason: 'completed' | 'ended_early' | 'abandoned'
}
// ============================================================================
// Skill Tutorial Events (sent to classroom:${classroomId} channel)
// ============================================================================
/**
* Tutorial state for a specific step within a skill tutorial
*/
export interface TutorialStepState {
/** Current step index (0-based) */
currentStepIndex: number
/** Total steps in the tutorial */
totalSteps: number
/** Current multi-step index within the step (for decomposition) */
currentMultiStep: number
/** Total multi-steps in current step */
totalMultiSteps: number
/** Current abacus value */
currentValue: number
/** Target value to reach */
targetValue: number
/** Starting value for this step */
startValue: number
/** Whether the current step is completed */
isStepCompleted: boolean
/** Problem string (e.g., "0 +1 = 1") */
problem: string
/** Step description */
description: string
/** Current instruction text */
currentInstruction: string
}
/**
* Broadcast event for skill tutorial state.
* Sent when a student is viewing a skill tutorial before starting practice.
*/
export interface SkillTutorialStateEvent {
/** Player viewing the tutorial */
playerId: string
/** Player name for display */
playerName: string
/** Current launcher state */
launcherState: 'intro' | 'tutorial' | 'complete'
/** Skill being learned */
skillId: string
/** Skill display title */
skillTitle: string
/** Tutorial state details (only present when launcherState is 'tutorial') */
tutorialState?: TutorialStepState
}
/**
* Control actions a teacher can send to a student's tutorial
*/
export type SkillTutorialControlAction =
| { type: 'start-tutorial' }
| { type: 'skip-tutorial' }
| { type: 'next-step' }
| { type: 'previous-step' }
| { type: 'go-to-step'; stepIndex: number }
| { type: 'set-abacus-value'; value: number }
| { type: 'advance-multi-step' }
| { type: 'previous-multi-step' }
/**
* Control event sent from teacher to student during skill tutorial
*/
export interface SkillTutorialControlEvent {
/** Target player ID */
playerId: string
/** Control action to apply */
action: SkillTutorialControlAction
}
// ============================================================================
// Client-Side Event Map (for typed socket.io client)
// ============================================================================
@@ -236,6 +310,10 @@ export interface ClassroomServerToClientEvents {
// Session status events (classroom channel - for teacher's active sessions view)
'session-started': (data: SessionStartedEvent) => void
'session-ended': (data: SessionEndedEvent) => void
// Skill tutorial events (classroom channel - for teacher's observation)
'skill-tutorial-state': (data: SkillTutorialStateEvent) => void
'skill-tutorial-control': (data: SkillTutorialControlEvent) => void
}
/**
@@ -258,4 +336,8 @@ export interface ClassroomClientToServerEvents {
// Observer controls
'tutorial-control': (data: TutorialControlEvent) => void
'abacus-control': (data: AbacusControlEvent) => void
// Skill tutorial broadcasts (from student client to classroom channel)
'skill-tutorial-state': (data: SkillTutorialStateEvent) => void
'skill-tutorial-control': (data: SkillTutorialControlEvent) => void
}

View File

@@ -844,6 +844,75 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
)
// Skill Tutorial: Broadcast state from student to classroom (for teacher observation)
// The student joins the classroom channel and emits their tutorial state
socket.on(
'skill-tutorial-state',
(data: {
playerId: string
playerName: string
launcherState: 'intro' | 'tutorial' | 'complete'
skillId: string
skillTitle: string
tutorialState?: {
currentStepIndex: number
totalSteps: number
currentMultiStep: number
totalMultiSteps: number
currentValue: number
targetValue: number
startValue: number
isStepCompleted: boolean
problem: string
description: string
currentInstruction: string
}
}) => {
// Broadcast to all other sockets in the classroom channel (including teacher)
// The student is already in the classroom channel, so use socket.rooms to find it
for (const room of socket.rooms) {
if (room.startsWith('classroom:')) {
socket.to(room).emit('skill-tutorial-state', data)
console.log(
`📚 Skill tutorial state broadcast to ${room}:`,
data.playerId,
data.launcherState
)
}
}
}
)
// Skill Tutorial: Control from teacher to student
// Teacher sends control action, we broadcast to the classroom so the student receives it
socket.on(
'skill-tutorial-control',
(data: {
playerId: string
action:
| { type: 'start-tutorial' }
| { type: 'skip-tutorial' }
| { type: 'next-step' }
| { type: 'previous-step' }
| { type: 'go-to-step'; stepIndex: number }
| { type: 'set-abacus-value'; value: number }
| { type: 'advance-multi-step' }
| { type: 'previous-multi-step' }
}) => {
// Broadcast to all sockets in the classroom channel so the target student receives it
for (const room of socket.rooms) {
if (room.startsWith('classroom:')) {
io!.to(room).emit('skill-tutorial-control', data)
console.log(
`🎮 Skill tutorial control sent to ${room}:`,
data.playerId,
data.action.type
)
}
}
}
)
socket.on('disconnect', () => {
// Don't delete session on disconnect - it persists across devices
})