feat(classroom): implement teacher-initiated pause and fix manual pause
- Add teacher pause/resume functionality to SessionObserverModal - Extend PauseInfo interface with 'teacher' reason and teacherMessage - Handle session-paused/session-resumed WebSocket events in student client - Update SessionPausedModal UI for teacher-initiated pauses: - Show teacher emoji (👩🏫) and custom message - Disable resume button (only teacher can resume) - Fix manual pause not showing modal when clicking HUD dropdown - Add pause columns to session_plans schema (isPaused, pausedAt, pausedBy, pauseReason) 🤖 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
apps/web/drizzle/0043_add_session_pause_columns.sql
Normal file
11
apps/web/drizzle/0043_add_session_pause_columns.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
|
||||
-- Note: These columns were manually added during development.
|
||||
-- Migration kept for consistency but columns already exist.
|
||||
-- Columns added to session_plans:
|
||||
-- is_paused integer DEFAULT 0 NOT NULL
|
||||
-- paused_at integer
|
||||
-- paused_by text
|
||||
-- paused_reason text
|
||||
|
||||
SELECT 1;
|
||||
1038
apps/web/drizzle/meta/0043_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,13 @@
|
||||
"when": 1766406120000,
|
||||
"tag": "0042_classroom-system-indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1766706763639,
|
||||
"tag": "0043_add_session_pause_columns",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
useEndSessionEarly,
|
||||
useRecordSlotResult,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { useSessionBroadcast, type ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
|
||||
import {
|
||||
useSessionBroadcast,
|
||||
type ReceivedAbacusControl,
|
||||
type TeacherPauseRequest,
|
||||
} from '@/hooks/useSessionBroadcast'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
interface PracticeClientProps {
|
||||
@@ -50,6 +54,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
const [browseIndex, setBrowseIndex] = useState(0)
|
||||
// Teacher abacus control - receives commands from observing teacher
|
||||
const [teacherControl, setTeacherControl] = useState<ReceivedAbacusControl | null>(null)
|
||||
// Teacher-initiated pause/resume requests from observing teacher
|
||||
const [teacherPauseRequest, setTeacherPauseRequest] = useState<TeacherPauseRequest | null>(null)
|
||||
const [teacherResumeRequest, setTeacherResumeRequest] = useState(false)
|
||||
// Manual pause request from HUD
|
||||
const [manualPauseRequest, setManualPauseRequest] = useState(false)
|
||||
|
||||
// Session plan mutations
|
||||
const recordResult = useRecordSlotResult()
|
||||
@@ -76,9 +85,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
return { totalProblems: total, completedProblems: completed }
|
||||
}, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex])
|
||||
|
||||
// Pause/resume handlers - just update HUD state (ActiveSession owns the modal)
|
||||
// Pause handler - triggers manual pause in ActiveSession
|
||||
const handlePause = useCallback(() => {
|
||||
setIsPaused(true)
|
||||
setManualPauseRequest(true)
|
||||
}, [])
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
@@ -125,8 +134,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
// Broadcast session state if student is in a classroom
|
||||
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
|
||||
// onAbacusControl receives control events from observing teacher
|
||||
// onTeacherPause/onTeacherResume receive pause/resume commands from teacher
|
||||
useSessionBroadcast(currentPlan.id, studentId, broadcastState, {
|
||||
onAbacusControl: setTeacherControl,
|
||||
onTeacherPause: setTeacherPauseRequest,
|
||||
onTeacherResume: () => setTeacherResumeRequest(true),
|
||||
})
|
||||
|
||||
// Build session HUD data for PracticeSubNav
|
||||
@@ -212,7 +224,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
}}
|
||||
onAnswer={handleAnswer}
|
||||
onEndEarly={handleEndEarly}
|
||||
onPause={handlePause}
|
||||
onPause={() => setIsPaused(true)}
|
||||
onResume={handleResume}
|
||||
onComplete={handleSessionComplete}
|
||||
onTimingUpdate={setTimingData}
|
||||
@@ -222,6 +234,12 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
onBrowseIndexChange={setBrowseIndex}
|
||||
teacherControl={teacherControl}
|
||||
onTeacherControlHandled={() => setTeacherControl(null)}
|
||||
teacherPauseRequest={teacherPauseRequest}
|
||||
onTeacherPauseHandled={() => setTeacherPauseRequest(null)}
|
||||
teacherResumeRequest={teacherResumeRequest}
|
||||
onTeacherResumeHandled={() => setTeacherResumeRequest(false)}
|
||||
manualPauseRequest={manualPauseRequest}
|
||||
onManualPauseHandled={() => setManualPauseRequest(false)}
|
||||
/>
|
||||
</PracticeErrorBoundary>
|
||||
</main>
|
||||
|
||||
@@ -51,11 +51,15 @@ export function SessionObserverModal({
|
||||
const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus()
|
||||
|
||||
// Subscribe to the session's socket channel
|
||||
const { state, isConnected, isObserving, error, sendControl } = useSessionObserver(
|
||||
isOpen ? session.sessionId : undefined,
|
||||
isOpen ? observerId : undefined,
|
||||
isOpen
|
||||
)
|
||||
const { state, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
|
||||
useSessionObserver(
|
||||
isOpen ? session.sessionId : undefined,
|
||||
isOpen ? observerId : undefined,
|
||||
isOpen
|
||||
)
|
||||
|
||||
// Track if we've paused the session (teacher controls resume)
|
||||
const [hasPausedSession, setHasPausedSession] = useState(false)
|
||||
|
||||
// Ref for measuring problem container height (same pattern as ActiveSession)
|
||||
const problemRef = useRef<HTMLDivElement>(null)
|
||||
@@ -98,6 +102,18 @@ export function SessionObserverModal({
|
||||
sendControl({ type: 'show-abacus' })
|
||||
}, [dock, isDockedByUser, requestDock, sendControl])
|
||||
|
||||
// Pause the student's session
|
||||
const handlePauseSession = useCallback(() => {
|
||||
sendPause('Your teacher needs your attention.')
|
||||
setHasPausedSession(true)
|
||||
}, [sendPause])
|
||||
|
||||
// Resume the student's session
|
||||
const handleResumeSession = useCallback(() => {
|
||||
sendResume()
|
||||
setHasPausedSession(false)
|
||||
}, [sendResume])
|
||||
|
||||
// Two-way sync: When student's abacus changes, sync teacher's docked abacus
|
||||
useEffect(() => {
|
||||
if (!isDockedByUser || !state?.studentAnswer) return
|
||||
@@ -372,29 +388,74 @@ export function SessionObserverModal({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dock both abaci button */}
|
||||
{state && state.phase === 'problem' && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="dock-both-abaci"
|
||||
onClick={handleDockBothAbaci}
|
||||
disabled={!isObserving}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.100',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
|
||||
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
🧮 Dock Abaci
|
||||
</button>
|
||||
)}
|
||||
{/* Teacher controls: pause/resume and dock abaci */}
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
{/* Pause/Resume button */}
|
||||
{isObserving && (
|
||||
<button
|
||||
type="button"
|
||||
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
|
||||
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: hasPausedSession
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'amber.700'
|
||||
: 'amber.100',
|
||||
color: hasPausedSession
|
||||
? isDark
|
||||
? 'green.200'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'amber.200'
|
||||
: 'amber.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: hasPausedSession
|
||||
? isDark
|
||||
? 'green.600'
|
||||
: 'green.200'
|
||||
: isDark
|
||||
? 'amber.600'
|
||||
: 'amber.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dock both abaci button */}
|
||||
{state && state.phase === 'problem' && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="dock-both-abaci"
|
||||
onClick={handleDockBothAbaci}
|
||||
disabled={!isObserving}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.100',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
|
||||
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
🧮 Dock Abaci
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -213,6 +213,10 @@ function createMockSessionPlanWithProblems(config: {
|
||||
approvedAt: new Date(Date.now() - 60000),
|
||||
startedAt: new Date(Date.now() - 30000),
|
||||
completedAt: null,
|
||||
isPaused: false,
|
||||
pausedAt: null,
|
||||
pausedBy: null,
|
||||
pauseReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,18 @@ interface ActiveSessionProps {
|
||||
teacherControl?: ReceivedAbacusControl | null
|
||||
/** Called after teacher control has been handled (to clear the state) */
|
||||
onTeacherControlHandled?: () => void
|
||||
/** Teacher-initiated pause request (from observing teacher) */
|
||||
teacherPauseRequest?: { message?: string } | null
|
||||
/** Called after teacher pause has been handled (to clear the state) */
|
||||
onTeacherPauseHandled?: () => void
|
||||
/** Teacher-initiated resume request (from observing teacher) */
|
||||
teacherResumeRequest?: boolean
|
||||
/** Called after teacher resume has been handled (to clear the state) */
|
||||
onTeacherResumeHandled?: () => void
|
||||
/** Manual pause request from parent (HUD pause button) */
|
||||
manualPauseRequest?: boolean
|
||||
/** Called after manual pause has been handled (to clear the state) */
|
||||
onManualPauseHandled?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -552,6 +564,12 @@ export function ActiveSession({
|
||||
onBrowseIndexChange,
|
||||
teacherControl,
|
||||
onTeacherControlHandled,
|
||||
teacherPauseRequest,
|
||||
onTeacherPauseHandled,
|
||||
teacherResumeRequest,
|
||||
onTeacherResumeHandled,
|
||||
manualPauseRequest,
|
||||
onManualPauseHandled,
|
||||
}: ActiveSessionProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
@@ -781,6 +799,81 @@ export function ActiveSession({
|
||||
onTeacherControlHandled,
|
||||
])
|
||||
|
||||
// Handle teacher-initiated pause requests
|
||||
// Use a ref to track if we've handled this request to prevent duplicate handling
|
||||
const handledPauseRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!teacherPauseRequest) {
|
||||
handledPauseRef.current = false
|
||||
return
|
||||
}
|
||||
if (handledPauseRef.current) return
|
||||
handledPauseRef.current = true
|
||||
|
||||
// Pause the session with teacher reason
|
||||
const newPauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(),
|
||||
reason: 'teacher',
|
||||
teacherMessage: teacherPauseRequest.message,
|
||||
}
|
||||
setPauseInfo(newPauseInfo)
|
||||
pause()
|
||||
onPause?.(newPauseInfo)
|
||||
|
||||
console.log('[ActiveSession] Teacher paused session:', teacherPauseRequest.message)
|
||||
|
||||
// Clear the request after handling
|
||||
onTeacherPauseHandled?.()
|
||||
}, [teacherPauseRequest, pause, onPause, onTeacherPauseHandled])
|
||||
|
||||
// Handle teacher-initiated resume requests
|
||||
// Use a ref to track if we've handled this request to prevent duplicate handling
|
||||
const handledResumeRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!teacherResumeRequest) {
|
||||
handledResumeRef.current = false
|
||||
return
|
||||
}
|
||||
if (handledResumeRef.current) return
|
||||
handledResumeRef.current = true
|
||||
|
||||
// Resume the session
|
||||
setPauseInfo(undefined)
|
||||
lastResumeTimeRef.current = Date.now()
|
||||
resume()
|
||||
onResume?.()
|
||||
|
||||
console.log('[ActiveSession] Teacher resumed session')
|
||||
|
||||
// Clear the request after handling
|
||||
onTeacherResumeHandled?.()
|
||||
}, [teacherResumeRequest, resume, onResume, onTeacherResumeHandled])
|
||||
|
||||
// Handle manual pause requests from parent (HUD pause button)
|
||||
const handledManualPauseRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!manualPauseRequest) {
|
||||
handledManualPauseRef.current = false
|
||||
return
|
||||
}
|
||||
if (handledManualPauseRef.current) return
|
||||
handledManualPauseRef.current = true
|
||||
|
||||
// Pause the session with manual reason
|
||||
const newPauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(),
|
||||
reason: 'manual',
|
||||
}
|
||||
setPauseInfo(newPauseInfo)
|
||||
pause()
|
||||
onPause?.(newPauseInfo)
|
||||
|
||||
console.log('[ActiveSession] Manual pause triggered from HUD')
|
||||
|
||||
// Clear the request after handling
|
||||
onManualPauseHandled?.()
|
||||
}, [manualPauseRequest, pause, onPause, onManualPauseHandled])
|
||||
|
||||
// Track which help elements have been individually dismissed
|
||||
// These reset when entering a new help session (helpContext changes)
|
||||
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
|
||||
|
||||
@@ -132,6 +132,10 @@ function createMockSessionPlan(config: {
|
||||
startedAt: new Date(Date.now() - 10 * 60 * 1000),
|
||||
completedAt: null,
|
||||
masteredSkillIds: [],
|
||||
isPaused: false,
|
||||
pausedAt: null,
|
||||
pausedBy: null,
|
||||
pauseReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ const MANUAL_PAUSE_PHRASES = [
|
||||
'Smart break!',
|
||||
]
|
||||
|
||||
// Phrases for teacher-initiated pause
|
||||
const TEACHER_PAUSE_PHRASES = [
|
||||
'Teacher called timeout!',
|
||||
'Hold on a moment!',
|
||||
'Quick pause!',
|
||||
'Wait for your teacher!',
|
||||
]
|
||||
|
||||
// Intl formatters for duration display
|
||||
const secondsFormatter = new Intl.NumberFormat('en', {
|
||||
style: 'unit',
|
||||
@@ -146,7 +154,17 @@ export function SessionPausedModal({
|
||||
|
||||
// Pick a random phrase once per pause (stable while modal is open)
|
||||
const pausePhrase = useMemo(() => {
|
||||
const phrases = pauseInfo?.reason === 'auto-timeout' ? AUTO_PAUSE_PHRASES : MANUAL_PAUSE_PHRASES
|
||||
let phrases: string[]
|
||||
switch (pauseInfo?.reason) {
|
||||
case 'auto-timeout':
|
||||
phrases = AUTO_PAUSE_PHRASES
|
||||
break
|
||||
case 'teacher':
|
||||
phrases = TEACHER_PAUSE_PHRASES
|
||||
break
|
||||
default:
|
||||
phrases = MANUAL_PAUSE_PHRASES
|
||||
}
|
||||
return phrases[Math.floor(Math.random() * phrases.length)]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pauseInfo?.pausedAt?.getTime(), pauseInfo?.reason])
|
||||
@@ -180,6 +198,7 @@ export function SessionPausedModal({
|
||||
|
||||
// Determine greeting based on pause reason
|
||||
const isAutoTimeout = pauseInfo?.reason === 'auto-timeout'
|
||||
const isTeacherPause = pauseInfo?.reason === 'teacher'
|
||||
const stats = pauseInfo?.autoPauseStats
|
||||
|
||||
return (
|
||||
@@ -256,7 +275,7 @@ export function SessionPausedModal({
|
||||
gap: '0.375rem',
|
||||
})}
|
||||
>
|
||||
<span>{isAutoTimeout ? '🤔' : '☕'}</span>
|
||||
<span>{isTeacherPause ? '👩🏫' : isAutoTimeout ? '🤔' : '☕'}</span>
|
||||
<span>{pausePhrase}</span>
|
||||
</h2>
|
||||
{pauseInfo && (
|
||||
@@ -386,6 +405,33 @@ export function SessionPausedModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teacher pause message - shown when teacher pauses the session */}
|
||||
{isTeacherPause && (
|
||||
<div
|
||||
data-element="teacher-pause-message"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
{pauseInfo?.teacherMessage ||
|
||||
'Your teacher paused the session. Please wait for them to resume.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress summary - celebratory */}
|
||||
<div
|
||||
className={css({
|
||||
@@ -474,27 +520,33 @@ export function SessionPausedModal({
|
||||
<button
|
||||
type="button"
|
||||
data-action="resume"
|
||||
onClick={onResume}
|
||||
onClick={isTeacherPause ? undefined : onResume}
|
||||
disabled={isTeacherPause}
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
|
||||
background: isTeacherPause
|
||||
? 'linear-gradient(135deg, #6b7280, #4b5563)'
|
||||
: 'linear-gradient(135deg, #22c55e, #16a34a)',
|
||||
borderRadius: '16px',
|
||||
border: '3px solid #15803d',
|
||||
cursor: 'pointer',
|
||||
border: isTeacherPause ? '3px solid #374151' : '3px solid #15803d',
|
||||
cursor: isTeacherPause ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: '0 6px 20px rgba(22, 163, 74, 0.5)',
|
||||
boxShadow: isTeacherPause
|
||||
? '0 6px 20px rgba(75, 85, 99, 0.3)'
|
||||
: '0 6px 20px rgba(22, 163, 74, 0.5)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
width: '100%',
|
||||
opacity: isTeacherPause ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<span>▶️ Keep Going!</span>
|
||||
<span>{isTeacherPause ? '⏳ Waiting for teacher...' : '▶️ Keep Going!'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -50,9 +50,11 @@ export interface PauseInfo {
|
||||
/** When the pause occurred */
|
||||
pausedAt: Date
|
||||
/** Why the session was paused */
|
||||
reason: 'manual' | 'auto-timeout'
|
||||
reason: 'manual' | 'auto-timeout' | 'teacher'
|
||||
/** Auto-pause statistics (only present for auto-timeout) */
|
||||
autoPauseStats?: AutoPauseStats
|
||||
/** Teacher's custom message (only present for teacher-initiated pause) */
|
||||
teacherMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -340,6 +340,20 @@ export const sessionPlans = sqliteTable(
|
||||
/** Results for each completed slot */
|
||||
results: text('results', { mode: 'json' }).notNull().default('[]').$type<SlotResult[]>(),
|
||||
|
||||
// ---- Pause State (for teacher observation control) ----
|
||||
|
||||
/** Whether the session is currently paused by a teacher */
|
||||
isPaused: integer('is_paused', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
/** When the session was paused */
|
||||
pausedAt: integer('paused_at', { mode: 'timestamp' }),
|
||||
|
||||
/** Observer ID (teacher user ID) who paused the session */
|
||||
pausedBy: text('paused_by'),
|
||||
|
||||
/** Optional reason for pausing (e.g., "Let's review this concept together") */
|
||||
pauseReason: text('paused_reason'),
|
||||
|
||||
// ---- Timestamps ----
|
||||
|
||||
/** When the plan was created */
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { BroadcastState } from '@/components/practice'
|
||||
import { useStudentPresence } from './useClassroom'
|
||||
import type { AbacusControlEvent, PracticeStateEvent } from '@/lib/classroom/socket-events'
|
||||
import type {
|
||||
AbacusControlEvent,
|
||||
PracticeStateEvent,
|
||||
SessionPausedEvent,
|
||||
SessionResumedEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Abacus control action received from teacher
|
||||
@@ -14,12 +19,24 @@ export type ReceivedAbacusControl =
|
||||
| { type: 'hide-abacus' }
|
||||
| { type: 'set-value'; value: number }
|
||||
|
||||
/**
|
||||
* Pause request from teacher
|
||||
*/
|
||||
export interface TeacherPauseRequest {
|
||||
/** Optional message from teacher to display on pause screen */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for useSessionBroadcast hook
|
||||
*/
|
||||
export interface UseSessionBroadcastOptions {
|
||||
/** Callback when an abacus control event is received from teacher */
|
||||
onAbacusControl?: (control: ReceivedAbacusControl) => void
|
||||
/** Callback when teacher pauses the session */
|
||||
onTeacherPause?: (request: TeacherPauseRequest) => void
|
||||
/** Callback when teacher resumes the session */
|
||||
onTeacherResume?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +171,22 @@ export function useSessionBroadcast(
|
||||
optionsRef.current?.onAbacusControl?.(control)
|
||||
})
|
||||
|
||||
// Listen for pause events from teacher
|
||||
socket.on('session-paused', (data: SessionPausedEvent) => {
|
||||
console.log('[SessionBroadcast] Received session-paused:', data)
|
||||
if (data.sessionId !== sessionId) return
|
||||
|
||||
optionsRef.current?.onTeacherPause?.({ message: data.message })
|
||||
})
|
||||
|
||||
// Listen for resume events from teacher
|
||||
socket.on('session-resumed', (data: SessionResumedEvent) => {
|
||||
console.log('[SessionBroadcast] Received session-resumed:', data)
|
||||
if (data.sessionId !== sessionId) return
|
||||
|
||||
optionsRef.current?.onTeacherResume?.()
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[SessionBroadcast] Cleaning up socket connection')
|
||||
socket.disconnect()
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { AbacusControlEvent, PracticeStateEvent } from '@/lib/classroom/socket-events'
|
||||
import type {
|
||||
AbacusControlEvent,
|
||||
PracticeStateEvent,
|
||||
SessionPausedEvent,
|
||||
SessionResumedEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Control actions a teacher can send to a student's practice session abacus
|
||||
@@ -67,6 +72,10 @@ interface UseSessionObserverResult {
|
||||
stopObserving: () => void
|
||||
/** Send a control action to the student's abacus */
|
||||
sendControl: (action: SessionAbacusControlAction) => void
|
||||
/** Pause the student's session with optional message */
|
||||
sendPause: (message?: string) => void
|
||||
/** Resume the student's session */
|
||||
sendResume: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,6 +222,41 @@ export function useSessionObserver(
|
||||
[isConnected, sessionId]
|
||||
)
|
||||
|
||||
// Send pause command to student's session
|
||||
const sendPause = useCallback(
|
||||
(message?: string) => {
|
||||
if (!socketRef.current || !isConnected || !sessionId) {
|
||||
console.warn('[SessionObserver] Cannot send pause - not connected or no sessionId')
|
||||
return
|
||||
}
|
||||
|
||||
const event: SessionPausedEvent = {
|
||||
sessionId,
|
||||
reason: 'teacher',
|
||||
message,
|
||||
}
|
||||
|
||||
socketRef.current.emit('session-pause', event)
|
||||
console.log('[SessionObserver] Sent session-pause:', { message })
|
||||
},
|
||||
[isConnected, sessionId]
|
||||
)
|
||||
|
||||
// Send resume command to student's session
|
||||
const sendResume = useCallback(() => {
|
||||
if (!socketRef.current || !isConnected || !sessionId) {
|
||||
console.warn('[SessionObserver] Cannot send resume - not connected or no sessionId')
|
||||
return
|
||||
}
|
||||
|
||||
const event: SessionResumedEvent = {
|
||||
sessionId,
|
||||
}
|
||||
|
||||
socketRef.current.emit('session-resume', event)
|
||||
console.log('[SessionObserver] Sent session-resume')
|
||||
}, [isConnected, sessionId])
|
||||
|
||||
return {
|
||||
state,
|
||||
isConnected,
|
||||
@@ -220,5 +264,7 @@ export function useSessionObserver(
|
||||
error,
|
||||
stopObserving,
|
||||
sendControl,
|
||||
sendPause,
|
||||
sendResume,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,12 @@ export interface ObserverJoinedEvent {
|
||||
export interface SessionPausedEvent {
|
||||
sessionId: string
|
||||
reason: string
|
||||
/** Optional message from teacher to show on pause screen */
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SessionResumedEvent {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,6 +312,7 @@ export interface ClassroomServerToClientEvents {
|
||||
'abacus-control': (data: AbacusControlEvent) => void
|
||||
'observer-joined': (data: ObserverJoinedEvent) => void
|
||||
'session-paused': (data: SessionPausedEvent) => void
|
||||
'session-resumed': (data: SessionResumedEvent) => void
|
||||
|
||||
// Session status events (classroom channel - for teacher's active sessions view)
|
||||
'session-started': (data: SessionStartedEvent) => void
|
||||
@@ -336,6 +343,8 @@ export interface ClassroomClientToServerEvents {
|
||||
// Observer controls
|
||||
'tutorial-control': (data: TutorialControlEvent) => void
|
||||
'abacus-control': (data: AbacusControlEvent) => void
|
||||
'session-pause': (data: SessionPausedEvent) => void
|
||||
'session-resume': (data: SessionResumedEvent) => void
|
||||
|
||||
// Skill tutorial broadcasts (from student client to classroom channel)
|
||||
'skill-tutorial-state': (data: SkillTutorialStateEvent) => void
|
||||
|
||||
@@ -844,6 +844,20 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
)
|
||||
|
||||
// Session Observation: Pause command from observer (teacher pauses student's session)
|
||||
socket.on('session-pause', (data: { sessionId: string; reason: string; message?: string }) => {
|
||||
console.log('[Socket] session-pause:', data.sessionId, data.message)
|
||||
// Forward pause command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('session-paused', data)
|
||||
})
|
||||
|
||||
// Session Observation: Resume command from observer (teacher resumes student's session)
|
||||
socket.on('session-resume', (data: { sessionId: string }) => {
|
||||
console.log('[Socket] session-resume:', data.sessionId)
|
||||
// Forward resume command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('session-resumed', data)
|
||||
})
|
||||
|
||||
// Skill Tutorial: Broadcast state from student to classroom (for teacher observation)
|
||||
// The student joins the classroom channel and emits their tutorial state
|
||||
socket.on(
|
||||
|
||||
Reference in New Issue
Block a user