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:
@@ -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}
|
||||
|
||||
383
apps/web/src/components/classroom/TutorialObserverModal.tsx
Normal file
383
apps/web/src/components/classroom/TutorialObserverModal.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
129
apps/web/src/hooks/useClassroomTutorialStates.ts
Normal file
129
apps/web/src/hooks/useClassroomTutorialStates.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
194
apps/web/src/hooks/useSkillTutorialBroadcast.ts
Normal file
194
apps/web/src/hooks/useSkillTutorialBroadcast.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
96
apps/web/src/hooks/useTutorialControl.ts
Normal file
96
apps/web/src/hooks/useTutorialControl.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user