feat(observer): add live results panel and session progress indicator
- Add LiveResultsPanel component showing real-time problem results - Add LiveSessionReportModal for detailed session analytics view - Broadcast session structure (parts, slots, results) to observers - Display SessionProgressIndicator in observer modal (same as student view) - Show inline full report view instead of separate modal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d06048bc2c
commit
8527f892e2
|
|
@ -347,7 +347,9 @@
|
|||
"Bash(PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 npx playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 pnpm --filter @soroban/web exec playwright test:*)",
|
||||
"Bash(BASE_URL=http://localhost:3000 pnpm exec playwright test:*)"
|
||||
"Bash(BASE_URL=http://localhost:3000 pnpm exec playwright test:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(GIT_EDITOR=true git rebase:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useCallback } from 'react'
|
|||
import { SessionObserverView } from '@/components/classroom'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface ObservationClientProps {
|
||||
session: ActiveSessionInfo
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
|||
import { useSessionObserver } from '@/hooks/useSessionObserver'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { AbacusDock } from '../AbacusDock'
|
||||
import { LiveResultsPanel } from '../practice/LiveResultsPanel'
|
||||
import { LiveSessionReportInline } from '../practice/LiveSessionReportModal'
|
||||
import { PracticeFeedback } from '../practice/PracticeFeedback'
|
||||
import { PurposeBadge } from '../practice/PurposeBadge'
|
||||
import { SessionProgressIndicator } from '../practice/SessionProgressIndicator'
|
||||
import { VerticalProblem } from '../practice/VerticalProblem'
|
||||
|
||||
interface SessionObserverModalProps {
|
||||
|
|
@ -129,12 +132,15 @@ export function SessionObserverView({
|
|||
const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus()
|
||||
|
||||
// Subscribe to the session's socket channel
|
||||
const { state, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
|
||||
const { state, results, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
|
||||
useSessionObserver(session.sessionId, observerId, session.playerId, true)
|
||||
|
||||
// Track if we've paused the session (teacher controls resume)
|
||||
const [hasPausedSession, setHasPausedSession] = useState(false)
|
||||
|
||||
// Track if showing full report view (inline, not modal)
|
||||
const [showFullReport, setShowFullReport] = useState(false)
|
||||
|
||||
// Ref for measuring problem container height (same pattern as ActiveSession)
|
||||
const problemRef = useRef<HTMLDivElement>(null)
|
||||
const [problemHeight, setProblemHeight] = useState<number | null>(null)
|
||||
|
|
@ -265,29 +271,60 @@ export function SessionObserverView({
|
|||
{student.emoji}
|
||||
</span>
|
||||
<div className={css({ minWidth: 0 })}>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
fontSize: '1rem',
|
||||
margin: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
Observing {student.name}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '}
|
||||
{state?.totalProblems ?? session.totalProblems}
|
||||
</Dialog.Description>
|
||||
{/* Use Dialog.Title/Description only when inside a Dialog (modal variant) */}
|
||||
{variant === 'modal' ? (
|
||||
<>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
fontSize: '1rem',
|
||||
margin: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
Observing {student.name}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '}
|
||||
{state?.totalProblems ?? session.totalProblems}
|
||||
</Dialog.Description>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
fontSize: '1rem',
|
||||
margin: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
Observing {student.name}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '}
|
||||
{state?.totalProblems ?? session.totalProblems}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -322,6 +359,30 @@ export function SessionObserverView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Progress Indicator - same component shown to student */}
|
||||
{state?.sessionParts &&
|
||||
state.currentPartIndex !== undefined &&
|
||||
state.currentSlotIndex !== undefined && (
|
||||
<div
|
||||
data-element="progress-indicator"
|
||||
className={css({
|
||||
padding: '0 20px 12px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<SessionProgressIndicator
|
||||
parts={state.sessionParts}
|
||||
results={state.slotResults ?? []}
|
||||
currentPartIndex={state.currentPartIndex}
|
||||
currentSlotIndex={state.currentSlotIndex}
|
||||
isBrowseMode={false}
|
||||
isDark={isDark}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -378,71 +439,109 @@ export function SessionObserverView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Problem display with abacus dock - matches ActiveSession layout */}
|
||||
{state && (
|
||||
{/* Main content - either problem view or full report view */}
|
||||
{state && !showFullReport && (
|
||||
<div
|
||||
data-element="observer-content"
|
||||
data-element="observer-main-content"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
alignItems: 'flex-start',
|
||||
gap: '24px',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Purpose badge with tooltip - matches student's view */}
|
||||
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
|
||||
|
||||
{/* Problem container with absolutely positioned AbacusDock */}
|
||||
{/* Live results panel - left side */}
|
||||
<div
|
||||
data-element="problem-with-dock"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
width: '220px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Problem - ref for height measurement */}
|
||||
<div ref={problemRef}>
|
||||
<VerticalProblem
|
||||
terms={state.currentProblem.terms}
|
||||
userAnswer={state.studentAnswer}
|
||||
isFocused={state.phase === 'problem'}
|
||||
isCompleted={state.phase === 'feedback'}
|
||||
correctAnswer={state.currentProblem.answer}
|
||||
size="large"
|
||||
/>
|
||||
<LiveResultsPanel
|
||||
results={results}
|
||||
totalProblems={state.totalProblems}
|
||||
isDark={isDark}
|
||||
onExpandFullReport={() => setShowFullReport(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Problem area - center/right */}
|
||||
<div
|
||||
data-element="observer-content"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
{/* Purpose badge with tooltip - matches student's view */}
|
||||
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
|
||||
|
||||
{/* Problem container with absolutely positioned AbacusDock */}
|
||||
<div
|
||||
data-element="problem-with-dock"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
})}
|
||||
>
|
||||
{/* Problem - ref for height measurement */}
|
||||
<div ref={problemRef}>
|
||||
<VerticalProblem
|
||||
terms={state.currentProblem.terms}
|
||||
userAnswer={state.studentAnswer}
|
||||
isFocused={state.phase === 'problem'}
|
||||
isCompleted={state.phase === 'feedback'}
|
||||
correctAnswer={state.currentProblem.answer}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AbacusDock - positioned exactly like ActiveSession */}
|
||||
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
|
||||
<AbacusDock
|
||||
id="teacher-observer-dock"
|
||||
columns={abacusColumns}
|
||||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
onValueChange={handleTeacherAbacusChange}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
marginLeft: '1.5rem',
|
||||
})}
|
||||
style={{ height: problemHeight ?? undefined }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AbacusDock - positioned exactly like ActiveSession */}
|
||||
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
|
||||
<AbacusDock
|
||||
id="teacher-observer-dock"
|
||||
columns={abacusColumns}
|
||||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
onValueChange={handleTeacherAbacusChange}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
marginLeft: '1.5rem',
|
||||
})}
|
||||
style={{ height: problemHeight ?? undefined }}
|
||||
{/* Feedback message */}
|
||||
{state.studentAnswer && state.phase === 'feedback' && (
|
||||
<PracticeFeedback
|
||||
isCorrect={state.isCorrect ?? false}
|
||||
correctAnswer={state.currentProblem.answer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback message */}
|
||||
{state.studentAnswer && state.phase === 'feedback' && (
|
||||
<PracticeFeedback
|
||||
isCorrect={state.isCorrect ?? false}
|
||||
correctAnswer={state.currentProblem.answer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Report View - inline */}
|
||||
{state && showFullReport && (
|
||||
<LiveSessionReportInline
|
||||
results={results}
|
||||
totalProblems={state.totalProblems}
|
||||
sessionStartTime={state.timing.startedAt}
|
||||
isDark={isDark}
|
||||
onBack={() => setShowFullReport(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with connection status and controls */}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ export interface BroadcastState {
|
|||
currentProblemNumber: number
|
||||
/** Total problems in the session */
|
||||
totalProblems: number
|
||||
/** Session structure for progress indicator */
|
||||
sessionParts?: SessionPart[]
|
||||
/** Current part index for progress indicator */
|
||||
currentPartIndex?: number
|
||||
/** Current slot index within the part */
|
||||
currentSlotIndex?: number
|
||||
/** Accumulated results for progress indicator */
|
||||
slotResults?: SlotResult[]
|
||||
}
|
||||
|
||||
interface ActiveSessionProps {
|
||||
|
|
@ -739,6 +747,11 @@ export function ActiveSession({
|
|||
complexity: extractComplexity(prevSlot),
|
||||
currentProblemNumber: completedProblems + 1,
|
||||
totalProblems,
|
||||
// Session structure for progress indicator
|
||||
sessionParts: plan.parts,
|
||||
currentPartIndex: plan.currentPartIndex,
|
||||
currentSlotIndex: plan.currentSlotIndex,
|
||||
slotResults: plan.results,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -778,6 +791,11 @@ export function ActiveSession({
|
|||
complexity: extractComplexity(slot),
|
||||
currentProblemNumber: completedProblems + 1,
|
||||
totalProblems,
|
||||
// Session structure for progress indicator
|
||||
sessionParts: plan.parts,
|
||||
currentPartIndex: plan.currentPartIndex,
|
||||
currentSlotIndex: plan.currentSlotIndex,
|
||||
slotResults: plan.results,
|
||||
})
|
||||
}, [
|
||||
onBroadcastStateChange,
|
||||
|
|
@ -790,6 +808,7 @@ export function ActiveSession({
|
|||
plan.parts,
|
||||
plan.currentPartIndex,
|
||||
plan.currentSlotIndex,
|
||||
plan.results,
|
||||
completedProblems,
|
||||
totalProblems,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ObservedResult } from '@/hooks/useSessionObserver'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { CompactLinearProblem } from './CompactProblemDisplay'
|
||||
|
||||
interface LiveResultsPanelProps {
|
||||
/** Accumulated results from the session */
|
||||
results: ObservedResult[]
|
||||
/** Total problems in the session */
|
||||
totalProblems: number
|
||||
/** Whether dark mode */
|
||||
isDark: boolean
|
||||
/** Callback to expand to full report view */
|
||||
onExpandFullReport?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for compact problem display with status indicator
|
||||
* Reuses CompactLinearProblem from session summary
|
||||
*/
|
||||
function CompactResultItem({ result, isDark }: { result: ObservedResult; isDark: boolean }) {
|
||||
// Parse student answer to number for CompactLinearProblem
|
||||
const studentAnswerNum = parseInt(result.studentAnswer, 10)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="compact-result-item"
|
||||
data-correct={result.isCorrect}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
backgroundColor: result.isCorrect
|
||||
? isDark
|
||||
? 'green.900/40'
|
||||
: 'green.50'
|
||||
: isDark
|
||||
? 'red.900/40'
|
||||
: 'red.50',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: result.isCorrect
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.200'
|
||||
: isDark
|
||||
? 'red.700'
|
||||
: 'red.200',
|
||||
})}
|
||||
>
|
||||
{/* Problem number */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.5625rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
minWidth: '1rem',
|
||||
})}
|
||||
>
|
||||
#{result.problemNumber}
|
||||
</span>
|
||||
|
||||
{/* Status indicator */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 'bold',
|
||||
color: result.isCorrect
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
})}
|
||||
>
|
||||
{result.isCorrect ? '✓' : '✗'}
|
||||
</span>
|
||||
|
||||
{/* Problem display - reusing shared component */}
|
||||
<CompactLinearProblem
|
||||
terms={result.terms}
|
||||
answer={result.answer}
|
||||
studentAnswer={Number.isNaN(studentAnswerNum) ? undefined : studentAnswerNum}
|
||||
isCorrect={result.isCorrect}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveResultsPanel - Shows real-time accumulated results during session observation
|
||||
*
|
||||
* Always shows:
|
||||
* - Summary stats (progress, accuracy, incorrect count)
|
||||
* - Problem list (toggle between incorrect only and all)
|
||||
* - "Full Report" button to expand to comprehensive view
|
||||
*/
|
||||
export function LiveResultsPanel({
|
||||
results,
|
||||
totalProblems,
|
||||
isDark,
|
||||
onExpandFullReport,
|
||||
}: LiveResultsPanelProps) {
|
||||
const [showAllProblems, setShowAllProblems] = useState(false)
|
||||
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
const correct = results.filter((r) => r.isCorrect).length
|
||||
const incorrect = results.filter((r) => !r.isCorrect).length
|
||||
const completed = results.length
|
||||
const accuracy = completed > 0 ? correct / completed : 0
|
||||
return { correct, incorrect, completed, accuracy }
|
||||
}, [results])
|
||||
|
||||
// Get incorrect results
|
||||
const incorrectResults = useMemo(() => results.filter((r) => !r.isCorrect), [results])
|
||||
|
||||
// No results yet - show placeholder
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-component="live-results-panel"
|
||||
data-state="empty"
|
||||
className={css({
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
padding: '0.75rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
Waiting for results...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="live-results-panel"
|
||||
className={css({
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Summary stats header */}
|
||||
<div
|
||||
data-element="stats-header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
backgroundColor: isDark ? 'gray.850' : 'gray.50',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.75rem' })}>
|
||||
{/* Progress */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{stats.completed}/{totalProblems}
|
||||
</span>
|
||||
|
||||
{/* Accuracy */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
stats.accuracy >= 0.8
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: stats.accuracy >= 0.6
|
||||
? isDark
|
||||
? 'yellow.400'
|
||||
: 'yellow.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
})}
|
||||
>
|
||||
{Math.round(stats.accuracy * 100)}%
|
||||
</span>
|
||||
|
||||
{/* Incorrect count badge */}
|
||||
{stats.incorrect > 0 && (
|
||||
<span
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: isDark ? 'red.900/50' : 'red.100',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'red.300' : 'red.700',
|
||||
})}
|
||||
>
|
||||
<span>✗</span>
|
||||
<span>{stats.incorrect}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full Report button */}
|
||||
{onExpandFullReport && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="expand-full-report"
|
||||
onClick={onExpandFullReport}
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.125rem 0.25rem',
|
||||
borderRadius: '4px',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Full Report →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Problem list content */}
|
||||
<div
|
||||
data-element="results-content"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{/* Section header with toggle */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{showAllProblems ? 'All Problems' : 'Incorrect'}
|
||||
{!showAllProblems && stats.incorrect > 0 && ` (${stats.incorrect})`}
|
||||
</span>
|
||||
|
||||
{/* Toggle between incorrect only and all */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllProblems(!showAllProblems)}
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
_hover: { color: isDark ? 'blue.300' : 'blue.700' },
|
||||
})}
|
||||
>
|
||||
{showAllProblems ? 'Incorrect only' : 'Show all'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
maxHeight: '250px',
|
||||
overflowY: 'auto',
|
||||
})}
|
||||
>
|
||||
{(showAllProblems ? results : incorrectResults).length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
{showAllProblems ? 'No problems completed yet' : 'No incorrect problems yet'}
|
||||
</div>
|
||||
) : (
|
||||
(showAllProblems ? results : incorrectResults).map((result) => (
|
||||
<CompactResultItem key={result.problemNumber} result={result} isDark={isDark} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiveResultsPanel
|
||||
|
|
@ -0,0 +1,747 @@
|
|||
'use client'
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { useMemo } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import type { ObservedResult } from '@/hooks/useSessionObserver'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { CompactLinearProblem } from './CompactProblemDisplay'
|
||||
|
||||
interface LiveSessionReportContentProps {
|
||||
/** Accumulated results from the session */
|
||||
results: ObservedResult[]
|
||||
/** Total problems in the session */
|
||||
totalProblems: number
|
||||
/** Session start time (for duration calculation) */
|
||||
sessionStartTime?: number
|
||||
/** Whether dark mode */
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
interface LiveSessionReportInlineProps extends LiveSessionReportContentProps {
|
||||
/** Callback to go back to problem view */
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
interface LiveSessionReportModalProps extends LiveSessionReportContentProps {
|
||||
/** Whether the modal is open */
|
||||
isOpen: boolean
|
||||
/** Close the modal */
|
||||
onClose: () => void
|
||||
/** Student name for display */
|
||||
studentName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance message based on accuracy
|
||||
*/
|
||||
function getPerformanceMessage(accuracy: number, isComplete: boolean): string {
|
||||
if (!isComplete) {
|
||||
if (accuracy >= 0.9) return 'Excellent progress so far!'
|
||||
if (accuracy >= 0.8) return 'Great work in progress!'
|
||||
if (accuracy >= 0.7) return 'Good effort so far!'
|
||||
return 'Keep going!'
|
||||
}
|
||||
if (accuracy >= 0.95) return 'Outstanding! Math champion!'
|
||||
if (accuracy >= 0.9) return 'Excellent work!'
|
||||
if (accuracy >= 0.8) return 'Great job!'
|
||||
if (accuracy >= 0.7) return "Good effort! You're getting stronger!"
|
||||
if (accuracy >= 0.6) return 'Nice try! Practice makes perfect!'
|
||||
return "Keep practicing! You'll get better!"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get purpose display info
|
||||
*/
|
||||
function getPurposeInfo(purpose: ObservedResult['purpose']): {
|
||||
label: string
|
||||
emoji: string
|
||||
colorLight: string
|
||||
colorDark: string
|
||||
} {
|
||||
switch (purpose) {
|
||||
case 'focus':
|
||||
return { label: 'Focus', emoji: '🎯', colorLight: 'blue', colorDark: 'blue' }
|
||||
case 'reinforce':
|
||||
return { label: 'Reinforce', emoji: '💪', colorLight: 'green', colorDark: 'green' }
|
||||
case 'review':
|
||||
return { label: 'Review', emoji: '📝', colorLight: 'purple', colorDark: 'purple' }
|
||||
case 'challenge':
|
||||
return { label: 'Challenge', emoji: '⭐', colorLight: 'orange', colorDark: 'orange' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact result item for the full report
|
||||
*/
|
||||
function ReportResultItem({ result, isDark }: { result: ObservedResult; isDark: boolean }) {
|
||||
const studentAnswerNum = parseInt(result.studentAnswer, 10)
|
||||
const purposeInfo = getPurposeInfo(result.purpose)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="report-result-item"
|
||||
data-correct={result.isCorrect}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: result.isCorrect
|
||||
? isDark
|
||||
? 'green.900/40'
|
||||
: 'green.50'
|
||||
: isDark
|
||||
? 'red.900/40'
|
||||
: 'red.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: result.isCorrect
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.200'
|
||||
: isDark
|
||||
? 'red.700'
|
||||
: 'red.200',
|
||||
})}
|
||||
>
|
||||
{/* Problem number */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
minWidth: '1.5rem',
|
||||
})}
|
||||
>
|
||||
#{result.problemNumber}
|
||||
</span>
|
||||
|
||||
{/* Status indicator */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: result.isCorrect
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
})}
|
||||
>
|
||||
{result.isCorrect ? '✓' : '✗'}
|
||||
</span>
|
||||
|
||||
{/* Problem display */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<CompactLinearProblem
|
||||
terms={result.terms}
|
||||
answer={result.answer}
|
||||
studentAnswer={Number.isNaN(studentAnswerNum) ? undefined : studentAnswerNum}
|
||||
isCorrect={result.isCorrect}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Purpose badge */}
|
||||
<span
|
||||
className={css({
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '500',
|
||||
backgroundColor: isDark
|
||||
? `${purposeInfo.colorDark}.900/50`
|
||||
: `${purposeInfo.colorLight}.100`,
|
||||
color: isDark ? `${purposeInfo.colorDark}.300` : `${purposeInfo.colorLight}.700`,
|
||||
})}
|
||||
>
|
||||
{purposeInfo.emoji}
|
||||
</span>
|
||||
|
||||
{/* Response time */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
minWidth: '2.5rem',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
{(result.responseTimeMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared content component for the live session report
|
||||
*/
|
||||
function LiveSessionReportContent({
|
||||
results,
|
||||
totalProblems,
|
||||
sessionStartTime,
|
||||
isDark,
|
||||
}: LiveSessionReportContentProps) {
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
const correct = results.filter((r) => r.isCorrect).length
|
||||
const incorrect = results.filter((r) => !r.isCorrect).length
|
||||
const completed = results.length
|
||||
const accuracy = completed > 0 ? correct / completed : 0
|
||||
const totalTimeMs = results.reduce((sum, r) => sum + r.responseTimeMs, 0)
|
||||
const avgTimeMs = completed > 0 ? totalTimeMs / completed : 0
|
||||
|
||||
return { correct, incorrect, completed, accuracy, totalTimeMs, avgTimeMs }
|
||||
}, [results])
|
||||
|
||||
const isComplete = stats.completed === totalProblems
|
||||
const sessionDurationMinutes = sessionStartTime
|
||||
? (Date.now() - sessionStartTime) / 1000 / 60
|
||||
: stats.totalTimeMs / 1000 / 60
|
||||
|
||||
// Get incorrect results
|
||||
const incorrectResults = useMemo(() => results.filter((r) => !r.isCorrect), [results])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="report-content"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
{/* Celebration header */}
|
||||
<div
|
||||
data-section="celebration-header"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark
|
||||
? stats.accuracy >= 0.8
|
||||
? 'green.900/50'
|
||||
: stats.accuracy >= 0.6
|
||||
? 'yellow.900/50'
|
||||
: 'orange.900/50'
|
||||
: stats.accuracy >= 0.8
|
||||
? 'green.50'
|
||||
: stats.accuracy >= 0.6
|
||||
? 'yellow.50'
|
||||
: 'orange.50',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: isDark
|
||||
? stats.accuracy >= 0.8
|
||||
? 'green.700'
|
||||
: stats.accuracy >= 0.6
|
||||
? 'yellow.700'
|
||||
: 'orange.700'
|
||||
: stats.accuracy >= 0.8
|
||||
? 'green.200'
|
||||
: stats.accuracy >= 0.6
|
||||
? 'yellow.200'
|
||||
: 'orange.200',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2rem', marginBottom: '0.25rem' })}>
|
||||
{stats.accuracy >= 0.9
|
||||
? '🌟'
|
||||
: stats.accuracy >= 0.8
|
||||
? '🎉'
|
||||
: stats.accuracy >= 0.6
|
||||
? '👍'
|
||||
: '💪'}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{getPerformanceMessage(stats.accuracy, isComplete)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main stats grid */}
|
||||
<div
|
||||
data-section="main-stats"
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{/* Accuracy */}
|
||||
<div
|
||||
data-element="stat-accuracy"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
stats.accuracy >= 0.8
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: stats.accuracy >= 0.6
|
||||
? isDark
|
||||
? 'yellow.400'
|
||||
: 'yellow.600'
|
||||
: isDark
|
||||
? 'orange.400'
|
||||
: 'orange.600',
|
||||
})}
|
||||
>
|
||||
{Math.round(stats.accuracy * 100)}%
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Accuracy
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correct/Total */}
|
||||
<div
|
||||
data-element="stat-problems"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
{stats.correct}/{stats.completed}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div
|
||||
data-element="stat-time"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'purple.400' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
{Math.round(sessionDurationMinutes)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
data-section="progress"
|
||||
className={css({
|
||||
padding: '0.625rem 0.875rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.375rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Progress
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{stats.completed} / {totalProblems}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
height: '6px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
backgroundColor: isDark ? 'blue.500' : 'blue.500',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.3s ease',
|
||||
})}
|
||||
style={{ width: `${(stats.completed / totalProblems) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average response time */}
|
||||
<div
|
||||
data-section="timing"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.625rem 0.875rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
fontSize: '0.8125rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>Avg. response time</span>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{(stats.avgTimeMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Problems needing attention */}
|
||||
{incorrectResults.length > 0 && (
|
||||
<div
|
||||
data-section="problems-to-review"
|
||||
className={css({
|
||||
padding: '0.875rem',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.625rem',
|
||||
})}
|
||||
>
|
||||
Problems to Review ({incorrectResults.length})
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
maxHeight: '150px',
|
||||
overflowY: 'auto',
|
||||
})}
|
||||
>
|
||||
{incorrectResults.map((result) => (
|
||||
<ReportResultItem key={result.problemNumber} result={result} isDark={isDark} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All problems */}
|
||||
<div
|
||||
data-section="all-problems"
|
||||
className={css({
|
||||
padding: '0.875rem',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.625rem',
|
||||
})}
|
||||
>
|
||||
All Problems ({results.length})
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
})}
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
No problems completed yet
|
||||
</div>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<ReportResultItem key={result.problemNumber} result={result} isDark={isDark} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveSessionReportInline - Full report view shown inline (not in a modal)
|
||||
* Used in SessionObserverModal when expanding from the LiveResultsPanel
|
||||
*/
|
||||
export function LiveSessionReportInline({
|
||||
results,
|
||||
totalProblems,
|
||||
sessionStartTime,
|
||||
isDark,
|
||||
onBack,
|
||||
}: LiveSessionReportInlineProps) {
|
||||
return (
|
||||
<div
|
||||
data-component="live-session-report-inline"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="back-to-problem"
|
||||
onClick={onBack}
|
||||
className={css({
|
||||
alignSelf: 'flex-start',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
marginBottom: '1rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.900/50' : 'blue.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
← Back to Problem
|
||||
</button>
|
||||
|
||||
<LiveSessionReportContent
|
||||
results={results}
|
||||
totalProblems={totalProblems}
|
||||
sessionStartTime={sessionStartTime}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveSessionReportModal - Full session report in a modal
|
||||
* (Kept for potential future use, but inline version is preferred)
|
||||
*/
|
||||
export function LiveSessionReportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
results,
|
||||
totalProblems,
|
||||
studentName,
|
||||
sessionStartTime,
|
||||
isDark,
|
||||
}: LiveSessionReportModalProps) {
|
||||
const stats = useMemo(() => {
|
||||
const completed = results.length
|
||||
return { completed }
|
||||
}, [results])
|
||||
|
||||
const isComplete = stats.completed === totalProblems
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
data-element="live-report-modal-overlay"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
zIndex: Z_INDEX.NESTED_MODAL_BACKDROP + 10,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Dialog.Content
|
||||
data-component="live-session-report-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '90vw',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '85vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
zIndex: Z_INDEX.NESTED_MODAL + 10,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
outline: 'none',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
fontSize: '1.125rem',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
{isComplete ? 'Session Complete' : 'Session In Progress'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
{studentName}'s practice session
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-report"
|
||||
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>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<LiveSessionReportContent
|
||||
results={results}
|
||||
totalProblems={totalProblems}
|
||||
sessionStartTime={sessionStartTime}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiveSessionReportModal
|
||||
|
|
@ -94,8 +94,10 @@ export function VerticalProblem({
|
|||
|
||||
// Use numeric comparison so "09" equals 9
|
||||
const numericUserAnswer = parseInt(userAnswer, 10)
|
||||
const isCorrect = isCompleted && correctAnswer !== undefined && numericUserAnswer === correctAnswer
|
||||
const isIncorrect = isCompleted && correctAnswer !== undefined && numericUserAnswer !== correctAnswer
|
||||
const isCorrect =
|
||||
isCompleted && correctAnswer !== undefined && numericUserAnswer === correctAnswer
|
||||
const isIncorrect =
|
||||
isCompleted && correctAnswer !== undefined && numericUserAnswer !== correctAnswer
|
||||
|
||||
const fontSize = size === 'large' ? '2rem' : '1.5rem'
|
||||
const cellWidth = size === 'large' ? '1.8rem' : '1.4rem'
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export {
|
|||
useHasPhysicalKeyboard,
|
||||
useIsTouchDevice,
|
||||
} from './hooks/useDeviceDetection'
|
||||
export { LiveResultsPanel } from './LiveResultsPanel'
|
||||
export { LiveSessionReportInline, LiveSessionReportModal } from './LiveSessionReportModal'
|
||||
export { MiniStartPracticeBanner } from './MiniStartPracticeBanner'
|
||||
export { NotesModal } from './NotesModal'
|
||||
// StudentQuickLook is an alias for NotesModal (which was enhanced to serve as the QuickLook modal)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ export function useSessionBroadcast(
|
|||
complexity: currentState.complexity,
|
||||
currentProblemNumber: currentState.currentProblemNumber,
|
||||
totalProblems: currentState.totalProblems,
|
||||
// Session structure for progress indicator
|
||||
sessionParts: currentState.sessionParts,
|
||||
currentPartIndex: currentState.currentPartIndex,
|
||||
currentSlotIndex: currentState.currentSlotIndex,
|
||||
slotResults: currentState.slotResults,
|
||||
}
|
||||
|
||||
socketRef.current.emit('practice-state', event)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
|
||||
import type {
|
||||
AbacusControlEvent,
|
||||
PracticeStateEvent,
|
||||
|
|
@ -61,11 +62,43 @@ export interface ObservedSessionState {
|
|||
currentProblemNumber: number
|
||||
/** Total problems in the session */
|
||||
totalProblems: number
|
||||
/** Session structure for progress indicator */
|
||||
sessionParts?: SessionPart[]
|
||||
/** Current part index for progress indicator */
|
||||
currentPartIndex?: number
|
||||
/** Current slot index within the part */
|
||||
currentSlotIndex?: number
|
||||
/** Accumulated results for progress indicator */
|
||||
slotResults?: SlotResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded result from a completed problem during observation
|
||||
*/
|
||||
export interface ObservedResult {
|
||||
/** Problem number (1-indexed) */
|
||||
problemNumber: number
|
||||
/** The problem terms */
|
||||
terms: number[]
|
||||
/** Correct answer */
|
||||
answer: number
|
||||
/** Student's submitted answer */
|
||||
studentAnswer: string
|
||||
/** Whether correct */
|
||||
isCorrect: boolean
|
||||
/** Purpose of this problem slot */
|
||||
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
|
||||
/** Response time in ms */
|
||||
responseTimeMs: number
|
||||
/** When recorded */
|
||||
recordedAt: number
|
||||
}
|
||||
|
||||
interface UseSessionObserverResult {
|
||||
/** Current observed state (null if not yet received) */
|
||||
state: ObservedSessionState | null
|
||||
/** Accumulated results from completed problems */
|
||||
results: ObservedResult[]
|
||||
/** Whether connected to the session channel */
|
||||
isConnected: boolean
|
||||
/** Whether actively observing (connected and joined session) */
|
||||
|
|
@ -100,11 +133,14 @@ export function useSessionObserver(
|
|||
enabled = true
|
||||
): UseSessionObserverResult {
|
||||
const [state, setState] = useState<ObservedSessionState | null>(null)
|
||||
const [results, setResults] = useState<ObservedResult[]>([])
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isObserving, setIsObserving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
// Track which problem numbers we've already recorded to avoid duplicates
|
||||
const recordedProblemsRef = useRef<Set<number>>(new Set())
|
||||
|
||||
const stopObserving = useCallback(() => {
|
||||
if (socketRef.current && sessionId) {
|
||||
|
|
@ -114,6 +150,8 @@ export function useSessionObserver(
|
|||
setIsConnected(false)
|
||||
setIsObserving(false)
|
||||
setState(null)
|
||||
setResults([])
|
||||
recordedProblemsRef.current.clear()
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
|
|
@ -169,10 +207,34 @@ export function useSessionObserver(
|
|||
phase: data.phase,
|
||||
answer: data.studentAnswer,
|
||||
isCorrect: data.isCorrect,
|
||||
problemNumber: data.currentProblemNumber,
|
||||
})
|
||||
|
||||
const currentProblem = data.currentProblem as { terms: number[]; answer: number }
|
||||
|
||||
// Record result when problem is completed (feedback phase with definite answer)
|
||||
if (
|
||||
data.phase === 'feedback' &&
|
||||
data.isCorrect !== null &&
|
||||
!recordedProblemsRef.current.has(data.currentProblemNumber)
|
||||
) {
|
||||
recordedProblemsRef.current.add(data.currentProblemNumber)
|
||||
const newResult: ObservedResult = {
|
||||
problemNumber: data.currentProblemNumber,
|
||||
terms: currentProblem.terms,
|
||||
answer: currentProblem.answer,
|
||||
studentAnswer: data.studentAnswer,
|
||||
isCorrect: data.isCorrect,
|
||||
purpose: data.purpose,
|
||||
responseTimeMs: data.timing.elapsed,
|
||||
recordedAt: Date.now(),
|
||||
}
|
||||
setResults((prev) => [...prev, newResult])
|
||||
console.log('[SessionObserver] Recorded result:', newResult)
|
||||
}
|
||||
|
||||
setState({
|
||||
currentProblem: data.currentProblem as { terms: number[]; answer: number },
|
||||
currentProblem,
|
||||
phase: data.phase,
|
||||
studentAnswer: data.studentAnswer,
|
||||
isCorrect: data.isCorrect,
|
||||
|
|
@ -182,6 +244,11 @@ export function useSessionObserver(
|
|||
receivedAt: Date.now(),
|
||||
currentProblemNumber: data.currentProblemNumber,
|
||||
totalProblems: data.totalProblems,
|
||||
// Session structure for progress indicator
|
||||
sessionParts: data.sessionParts as SessionPart[] | undefined,
|
||||
currentPartIndex: data.currentPartIndex,
|
||||
currentSlotIndex: data.currentSlotIndex,
|
||||
slotResults: data.slotResults as SlotResult[] | undefined,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -274,6 +341,7 @@ export function useSessionObserver(
|
|||
|
||||
return {
|
||||
state,
|
||||
results,
|
||||
isConnected,
|
||||
isObserving,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,14 @@ export interface PracticeStateEvent {
|
|||
currentProblemNumber: number
|
||||
/** Total problems in the session */
|
||||
totalProblems: number
|
||||
/** Session structure for progress indicator */
|
||||
sessionParts?: unknown[] // SessionPart[] - sent for observer progress display
|
||||
/** Current part index for progress indicator */
|
||||
currentPartIndex?: number
|
||||
/** Current slot index within the part */
|
||||
currentSlotIndex?: number
|
||||
/** Accumulated results for progress indicator */
|
||||
slotResults?: unknown[] // SlotResult[] - for observer progress display
|
||||
}
|
||||
|
||||
export interface TutorialStateEvent {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Loading…
Reference in New Issue