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:
Thomas Hallock 2025-12-28 16:41:07 -06:00
parent d06048bc2c
commit 8527f892e2
12 changed files with 1365 additions and 78 deletions

View File

@ -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": []

View File

@ -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

View File

@ -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 */}

View File

@ -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,
])

View File

@ -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

View File

@ -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}&apos;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

View File

@ -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'

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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 {

View File

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}