feat(observer): add live active session item to history list

- Add active session item at top of history tab that opens observation modal
- Create useLiveSessionTimeEstimate hook for real-time WebSocket updates
- Extract shared time estimation logic to useSessionTimeEstimate hook
- Add subscribe-session-stats socket event for lightweight session updates
- Display live progress, accuracy, idle time, and estimated time remaining
- Add corner ribbon "In Progress" indicator with two-line layout
- Use inset box-shadow for border to avoid overlapping ribbon

🤖 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-29 10:05:57 -06:00
parent 98a69f1f80
commit 91d6d6a1b6
14 changed files with 1696 additions and 136 deletions

View File

@@ -349,7 +349,8 @@
"Bash(BASE_URL=http://localhost:3000 pnpm --filter @soroban/web exec playwright test:*)",
"Bash(BASE_URL=http://localhost:3000 pnpm exec playwright test:*)",
"Bash(git rebase:*)",
"Bash(GIT_EDITOR=true git rebase:*)"
"Bash(GIT_EDITOR=true git rebase:*)",
"Bash(npm run test:run:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,329 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { db, schema } from '../src/db'
import type { SessionPart, SessionSummary } from '../src/db/schema/session-plans'
import {
createSessionShare,
getSessionShare,
validateSessionShare,
incrementShareViewCount,
revokeSessionShare,
revokeSharesForSession,
getActiveSharesForSession,
isValidShareToken,
generateShareToken,
} from '../src/lib/session-share'
/**
* Session Share E2E Tests
*
* Tests the session share database operations and validation logic.
*/
// Minimal valid session parts and summary for FK constraint satisfaction
const TEST_SESSION_PARTS: SessionPart[] = [
{
partNumber: 1,
type: 'abacus',
format: 'vertical',
useAbacus: true,
slots: [],
estimatedMinutes: 5,
},
]
const TEST_SESSION_SUMMARY: SessionSummary = {
focusDescription: 'Test session',
totalProblemCount: 0,
estimatedMinutes: 5,
parts: [
{
partNumber: 1,
type: 'abacus',
description: 'Test part',
problemCount: 0,
estimatedMinutes: 5,
},
],
}
describe('Session Share API', () => {
let testUserId: string
let testPlayerId: string
let testSessionId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
// Create a test player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Student',
emoji: '🧪',
color: '#FF5733',
})
.returning()
testPlayerId = player.id
// Create a real session plan (required due to FK constraint on sessionObservationShares)
const [session] = await db
.insert(schema.sessionPlans)
.values({
playerId: testPlayerId,
targetDurationMinutes: 15,
estimatedProblemCount: 10,
avgTimePerProblemSeconds: 30,
parts: TEST_SESSION_PARTS,
summary: TEST_SESSION_SUMMARY,
status: 'in_progress',
})
.returning()
testSessionId = session.id
})
afterEach(async () => {
// Clean up all test shares first
await db
.delete(schema.sessionObservationShares)
.where(eq(schema.sessionObservationShares.createdBy, testUserId))
// Clean up session plans (before player due to FK)
await db.delete(schema.sessionPlans).where(eq(schema.sessionPlans.playerId, testPlayerId))
// Then clean up user (cascades to player)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('createSessionShare', () => {
it('creates a share with 1h expiration', async () => {
const before = Date.now()
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const after = Date.now()
expect(share.id).toHaveLength(10)
expect(isValidShareToken(share.id)).toBe(true)
expect(share.sessionId).toBe(testSessionId)
expect(share.playerId).toBe(testPlayerId)
expect(share.createdBy).toBe(testUserId)
expect(share.status).toBe('active')
expect(share.viewCount).toBe(0)
// Expiration should be ~1 hour from now
const expectedExpiry = before + 60 * 60 * 1000
const actualExpiry = share.expiresAt.getTime()
expect(actualExpiry).toBeGreaterThanOrEqual(expectedExpiry - 1000)
expect(actualExpiry).toBeLessThanOrEqual(after + 60 * 60 * 1000 + 1000)
})
it('creates a share with 24h expiration', async () => {
const before = Date.now()
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
const after = Date.now()
// Expiration should be ~24 hours from now
const expectedExpiry = before + 24 * 60 * 60 * 1000
const actualExpiry = share.expiresAt.getTime()
expect(actualExpiry).toBeGreaterThanOrEqual(expectedExpiry - 1000)
expect(actualExpiry).toBeLessThanOrEqual(after + 24 * 60 * 60 * 1000 + 1000)
})
it('generates unique tokens for each share', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share3 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share1.id).not.toBe(share2.id)
expect(share2.id).not.toBe(share3.id)
expect(share1.id).not.toBe(share3.id)
})
})
describe('getSessionShare', () => {
it('returns share for valid token', async () => {
const created = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const retrieved = await getSessionShare(created.id)
expect(retrieved).not.toBeNull()
expect(retrieved!.id).toBe(created.id)
expect(retrieved!.sessionId).toBe(testSessionId)
})
it('returns null for invalid token format', async () => {
const result = await getSessionShare('invalid!')
expect(result).toBeNull()
})
it('returns null for non-existent token', async () => {
const result = await getSessionShare('abcdef1234') // Valid format but doesn't exist
expect(result).toBeNull()
})
})
describe('validateSessionShare', () => {
it('returns valid for active non-expired share', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(true)
expect(result.share).toBeDefined()
expect(result.share!.id).toBe(share.id)
expect(result.error).toBeUndefined()
})
it('returns invalid for non-existent token', async () => {
const result = await validateSessionShare('abcdef1234')
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link not found')
expect(result.share).toBeUndefined()
})
it('returns invalid for revoked share', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share.id)
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link has been revoked')
})
it('returns invalid and marks as expired for time-expired share', async () => {
// Create share and manually set expired time in past
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await db
.update(schema.sessionObservationShares)
.set({ expiresAt: new Date(Date.now() - 1000) }) // 1 second in past
.where(eq(schema.sessionObservationShares.id, share.id))
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link has expired')
// Verify it was marked as expired in DB
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('expired')
})
})
describe('incrementShareViewCount', () => {
it('increments view count and updates lastViewedAt', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share.viewCount).toBe(0)
await incrementShareViewCount(share.id)
const updated = await getSessionShare(share.id)
expect(updated!.viewCount).toBe(1)
expect(updated!.lastViewedAt).not.toBeNull()
await incrementShareViewCount(share.id)
await incrementShareViewCount(share.id)
const final = await getSessionShare(share.id)
expect(final!.viewCount).toBe(3)
})
it('does nothing for non-existent token', async () => {
// Should not throw
await incrementShareViewCount('abcdef1234')
})
})
describe('revokeSessionShare', () => {
it('marks share as revoked', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share.status).toBe('active')
await revokeSessionShare(share.id)
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('revoked')
})
})
describe('revokeSharesForSession', () => {
it('marks all active shares for session as expired', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
await revokeSharesForSession(testSessionId)
const updated1 = await getSessionShare(share1.id)
const updated2 = await getSessionShare(share2.id)
expect(updated1!.status).toBe('expired')
expect(updated2!.status).toBe('expired')
})
it('does not affect already revoked shares', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share.id)
await revokeSharesForSession(testSessionId)
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('revoked') // Still revoked, not expired
})
it('does not affect shares for other sessions', async () => {
// Create a second session for isolation test
const [otherSession] = await db
.insert(schema.sessionPlans)
.values({
playerId: testPlayerId,
targetDurationMinutes: 15,
estimatedProblemCount: 10,
avgTimePerProblemSeconds: 30,
parts: TEST_SESSION_PARTS,
summary: TEST_SESSION_SUMMARY,
status: 'in_progress',
})
.returning()
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(otherSession.id, testPlayerId, testUserId, '1h')
await revokeSharesForSession(testSessionId)
const updated1 = await getSessionShare(share1.id)
const updated2 = await getSessionShare(share2.id)
expect(updated1!.status).toBe('expired')
expect(updated2!.status).toBe('active') // Unaffected
})
})
describe('getActiveSharesForSession', () => {
it('returns only active shares for the session', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
const share3 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share3.id) // Revoke one
const active = await getActiveSharesForSession(testSessionId)
expect(active).toHaveLength(2)
const ids = active.map((s) => s.id)
expect(ids).toContain(share1.id)
expect(ids).toContain(share2.id)
expect(ids).not.toContain(share3.id)
})
it('returns empty array for session with no shares', async () => {
const active = await getActiveSharesForSession('non-existent-session')
expect(active).toEqual([])
})
})
})

View File

@@ -14,6 +14,8 @@ interface PublicObservationClientProps {
color: string
}
expiresAt: number
/** If set, the current user can observe this student directly (without share link) */
authenticatedObserveUrl?: string
}
function formatTimeRemaining(ms: number): string {
@@ -31,6 +33,7 @@ export function PublicObservationClient({
shareToken,
student,
expiresAt,
authenticatedObserveUrl,
}: PublicObservationClientProps) {
const [navHeight, setNavHeight] = useState(20) // Minimal padding for public page (no nav)
const [timeRemaining, setTimeRemaining] = useState(expiresAt - Date.now())
@@ -59,12 +62,49 @@ export function PublicObservationClient({
paddingTop: `${navHeight}px`,
}}
>
{/* Authenticated observer recommendation banner */}
{authenticatedObserveUrl && (
<div
data-element="authenticated-recommend-banner"
className={css({
backgroundColor: 'green.50',
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
fontSize: '0.875rem',
color: 'green.700',
borderBottom: '1px solid',
borderColor: 'green.200',
_dark: {
backgroundColor: 'green.900',
color: 'green.200',
borderColor: 'green.800',
},
})}
>
<span> You have full access to observe {student.name}.</span>
<a
href={authenticatedObserveUrl}
className={css({
fontWeight: 'semibold',
textDecoration: 'underline',
color: 'green.800',
_dark: { color: 'green.100' },
_hover: { color: 'green.900', _dark: { color: 'white' } },
})}
>
Switch to full observation mode
</a>
</div>
)}
{/* Expiration banner */}
<div
data-element="expiration-banner"
className={css({
backgroundColor: timeRemaining > 0 ? 'blue.50' : 'red.50',
_dark: { backgroundColor: timeRemaining > 0 ? 'blue.900' : 'red.900' },
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
@@ -72,10 +112,13 @@ export function PublicObservationClient({
gap: '8px',
fontSize: '0.875rem',
color: timeRemaining > 0 ? 'blue.700' : 'red.700',
_dark: { color: timeRemaining > 0 ? 'blue.200' : 'red.200' },
borderBottom: '1px solid',
borderColor: timeRemaining > 0 ? 'blue.200' : 'red.200',
_dark: { borderColor: timeRemaining > 0 ? 'blue.800' : 'red.800' },
_dark: {
backgroundColor: timeRemaining > 0 ? 'blue.900' : 'red.900',
color: timeRemaining > 0 ? 'blue.200' : 'red.200',
borderColor: timeRemaining > 0 ? 'blue.800' : 'red.800',
},
})}
>
<span>👁 View-only access</span>

View File

@@ -2,7 +2,9 @@ import { notFound } from 'next/navigation'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { players, sessionPlans } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { validateSessionShare } from '@/lib/session-share'
import { getDbUserId } from '@/lib/viewer'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { PublicObservationClient } from './PublicObservationClient'
@@ -61,6 +63,20 @@ export default async function PublicObservationPage({ params }: PublicObservatio
}
completedProblems += session.currentSlotIndex
// Check if the current user can observe this player directly (without the share link)
let authenticatedObserveUrl: string | undefined
try {
const userId = await getDbUserId()
if (userId) {
const canObserve = await canPerformAction(userId, share.playerId, 'observe')
if (canObserve) {
authenticatedObserveUrl = `/practice/${share.playerId}/observe`
}
}
} catch {
// Not logged in or error checking permissions - that's fine, just don't show the banner
}
const sessionInfo: ActiveSessionInfo = {
sessionId: session.id,
playerId: session.playerId,
@@ -87,6 +103,7 @@ export default async function PublicObservationPage({ params }: PublicObservatio
expiresAt={
share.expiresAt instanceof Date ? share.expiresAt.getTime() : Number(share.expiresAt)
}
authenticatedObserveUrl={authenticatedObserveUrl}
/>
)
}

View File

@@ -2181,7 +2181,17 @@ function SkillsTab({
)
}
function HistoryTab({ isDark, studentId }: { isDark: boolean; studentId: string }) {
function HistoryTab({
isDark,
studentId,
activeSession,
onOpenActiveSession,
}: {
isDark: boolean
studentId: string
activeSession?: SessionPlan | null
onOpenActiveSession?: () => void
}) {
return (
<div data-tab-content="history">
<div
@@ -2225,7 +2235,13 @@ function HistoryTab({ isDark, studentId }: { isDark: boolean; studentId: string
</p>
</div>
<VirtualizedSessionList studentId={studentId} isDark={isDark} height={400} />
<VirtualizedSessionList
studentId={studentId}
isDark={isDark}
height={400}
activeSession={activeSession}
onOpenActiveSession={onOpenActiveSession}
/>
</div>
</div>
)
@@ -2796,7 +2812,14 @@ export function DashboardClient({
/>
)}
{activeTab === 'history' && <HistoryTab isDark={isDark} studentId={studentId} />}
{activeTab === 'history' && (
<HistoryTab
isDark={isDark}
studentId={studentId}
activeSession={activeSession}
onOpenActiveSession={() => setIsObserving(true)}
/>
)}
{activeTab === 'notes' && (
<NotesTab

View File

@@ -100,7 +100,8 @@ export function SessionShareButton({ sessionId, isDark }: SessionShareButtonProp
const data = await response.json()
const share: ShareInfo = {
token: data.token,
url: data.url,
// Always generate URL client-side using window.location.origin (server can't know the correct hostname)
url: getShareUrl('observe', data.token),
expiresAt: data.expiresAt,
createdAt: Date.now(),
viewCount: 0,

View File

@@ -3,7 +3,9 @@
import { useMemo, useState } from 'react'
import type { ObservedResult } from '@/hooks/useSessionObserver'
import { css } from '../../../styled-system/css'
import { formatMs } from './autoPauseCalculator'
import { CompactLinearProblem } from './CompactProblemDisplay'
import { getPurposeColors, getPurposeConfig } from './purposeExplanations'
interface LiveResultsPanelProps {
/** Accumulated results from the session */
@@ -17,77 +19,294 @@ interface LiveResultsPanelProps {
}
/**
* Wrapper for compact problem display with status indicator
* Reuses CompactLinearProblem from session summary
* Observed Result Item - expandable card like ProblemToReview
*
* Shows problem in collapsed mode, expands to show full details when clicked.
* Similar pattern to ProblemToReview but adapted for ObservedResult data.
*/
function CompactResultItem({ result, isDark }: { result: ObservedResult; isDark: boolean }) {
// Parse student answer to number for CompactLinearProblem
function ObservedResultItem({ result, isDark }: { result: ObservedResult; isDark: boolean }) {
const [isExpanded, setIsExpanded] = useState(false)
const studentAnswerNum = parseInt(result.studentAnswer, 10)
const purposeConfig = getPurposeConfig(result.purpose)
const purposeColors = getPurposeColors(result.purpose, isDark)
const isIncorrect = !result.isCorrect
return (
<div
data-element="compact-result-item"
data-component="observed-result-item"
data-problem-number={result.problemNumber}
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',
flexDirection: 'column',
borderRadius: '8px',
border: '1px solid',
borderColor: result.isCorrect
borderColor: isIncorrect
? isDark
? 'green.700'
: 'green.200'
: isDark
? 'red.700'
: 'red.200',
: 'red.200'
: isDark
? 'green.700'
: 'green.200',
backgroundColor: isIncorrect
? isDark
? 'red.900/30'
: 'red.50'
: isDark
? 'green.900/30'
: 'green.50',
overflow: 'hidden',
})}
>
{/* Problem number */}
<span
{/* Header row - clickable to expand/collapse */}
<button
type="button"
data-element="result-header"
onClick={() => setIsExpanded(!isExpanded)}
className={css({
fontSize: '0.5625rem',
fontWeight: 'bold',
color: isDark ? 'gray.500' : 'gray.400',
minWidth: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.375rem 0.625rem',
backgroundColor: isDark ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
border: 'none',
borderBottom: isExpanded ? '1px solid' : 'none',
borderColor: isDark ? 'gray.700/50' : 'gray.200/50',
width: '100%',
cursor: 'pointer',
textAlign: 'left',
_hover: {
backgroundColor: isDark ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
},
})}
>
#{result.problemNumber}
</span>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flex: 1,
})}
>
{/* Problem number */}
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'gray.500' : 'gray.400',
minWidth: '1.25rem',
})}
>
#{result.problemNumber}
</span>
{/* Status indicator */}
<span
{/* Status indicator */}
<span
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: result.isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
})}
>
{result.isCorrect ? '✓' : '✗'}
</span>
{/* Compact 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>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Purpose emoji badge */}
<span
className={css({
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.5625rem',
fontWeight: '500',
backgroundColor: purposeColors.background,
color: purposeColors.text,
})}
>
{purposeConfig.emoji}
</span>
{/* Expand/collapse indicator */}
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1rem',
height: '1rem',
fontSize: '0.5rem',
color: isDark ? 'gray.400' : 'gray.500',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.25s ease-out',
})}
>
</span>
</div>
</button>
{/* Expanded details with smooth animation */}
<div
data-element="expanded-details-wrapper"
className={css({
fontSize: '0.6875rem',
fontWeight: 'bold',
color: result.isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
transition: 'grid-template-rows 0.25s ease-out',
})}
>
{result.isCorrect ? '✓' : '✗'}
</span>
<div
data-element="expanded-details"
className={css({
overflow: 'hidden',
})}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.5rem 0.625rem',
opacity: isExpanded ? 1 : 0,
transition: 'opacity 0.2s ease-out',
})}
>
{/* Full problem display */}
<div
className={css({
fontFamily: 'var(--font-mono, monospace)',
fontSize: '1rem',
fontWeight: 'bold',
textAlign: 'center',
padding: '0.5rem',
borderRadius: '6px',
backgroundColor: result.isCorrect
? isDark
? 'green.900/50'
: 'green.100'
: isDark
? 'red.900/50'
: 'red.100',
})}
>
<span className={css({ color: isDark ? 'gray.200' : 'gray.800' })}>
{result.terms
.map((t, i) => (i === 0 ? String(t) : t < 0 ? ` ${Math.abs(t)}` : ` + ${t}`))
.join('')}{' '}
={' '}
</span>
<span
className={css({
color: result.isCorrect
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'red.300'
: 'red.700',
})}
>
{result.answer}
</span>
</div>
{/* Problem display - reusing shared component */}
<CompactLinearProblem
terms={result.terms}
answer={result.answer}
studentAnswer={Number.isNaN(studentAnswerNum) ? undefined : studentAnswerNum}
isCorrect={result.isCorrect}
isDark={isDark}
/>
{/* If incorrect, show student's answer */}
{!result.isCorrect && (
<div
className={css({
padding: '0.375rem',
borderRadius: '6px',
backgroundColor: isDark ? 'red.900/40' : 'red.50',
textAlign: 'center',
fontSize: '0.75rem',
color: isDark ? 'red.300' : 'red.700',
})}
>
Student answered: <strong>{result.studentAnswer}</strong>
</div>
)}
{/* Purpose explanation */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem 0.5rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: '500',
backgroundColor: purposeColors.background,
color: purposeColors.text,
width: 'fit-content',
})}
>
{purposeConfig.emoji} {purposeConfig.shortLabel}
</span>
<span
className={css({
fontSize: '0.625rem',
color: isDark ? 'gray.400' : 'gray.500',
fontStyle: 'italic',
})}
>
{purposeConfig.shortExplanation}
</span>
</div>
{/* Response time */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Response time:</span>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
{formatMs(result.responseTimeMs)}
</span>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -319,7 +538,7 @@ export function LiveResultsPanel({
</div>
) : (
(showAllProblems ? results : incorrectResults).map((result) => (
<CompactResultItem key={result.problemNumber} result={result} isDark={isDark} />
<ObservedResultItem key={result.problemNumber} result={result} isDark={isDark} />
))
)}
</div>

View File

@@ -866,6 +866,45 @@ export function PracticeSubNav({
<span></span>
<span>End Session</span>
</DropdownMenu.Item>
{/* Observe session - for parents/teachers to open observation page */}
{(viewerRelationship?.type === 'parent' ||
viewerRelationship?.type === 'teacher') && (
<>
<DropdownMenu.Separator
className={css({
height: '1px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
margin: '0.375rem 0',
})}
/>
<DropdownMenu.Item
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '4px',
fontSize: '0.875rem',
cursor: 'pointer',
outline: 'none',
color: isDark ? 'blue.400' : 'blue.600',
_hover: {
backgroundColor: isDark ? 'blue.900/50' : 'blue.50',
},
_focus: {
backgroundColor: isDark ? 'blue.900/50' : 'blue.50',
},
})}
onSelect={() => {
window.open(`/practice/${student.id}/observe`, '_blank')
}}
>
<span>👁</span>
<span>Observe Session</span>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

View File

@@ -3,6 +3,11 @@
import * as Popover from '@radix-ui/react-popover'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useMemo, useState } from 'react'
import {
calculateEstimatedTimeRemainingMs,
formatEstimatedTimeRemaining,
type TimingStats,
} from '@/hooks/useSessionTimeEstimate'
import { css } from '../../../styled-system/css'
import { useIsTouchDevice } from './hooks/useDeviceDetection'
import { SpeedMeter } from './SpeedMeter'
@@ -175,15 +180,7 @@ function formatTimeKid(ms: number): string {
return `${seconds}s`
}
/**
* Format estimated time remaining
*/
function formatEstimate(ms: number): string {
const minutes = Math.round(ms / 60000)
if (minutes < 1) return 'less than a minute'
if (minutes === 1) return 'about 1 minute'
return `about ${minutes} minutes`
}
// formatEstimatedTimeRemaining is imported from useSessionTimeEstimate
// Animation names (defined in GlobalStyles below)
const ANIM = {
@@ -433,7 +430,7 @@ function MoodContent({
color: isDark ? 'gray.100' : 'gray.900',
})}
>
~{formatEstimate(estimatedTimeMs)}
{formatEstimatedTimeRemaining(estimatedTimeMs)}
</div>
</div>
<div
@@ -657,13 +654,23 @@ function MoodContent({
* Tooltip (desktop) or Popover (touch) reveals the detailed data in a kid-friendly layout.
*/
export function SessionMoodIndicator(props: SessionMoodIndicatorProps) {
const { problemsRemaining, recentResults, isDark, meanMs, hasEnoughData } = props
const { problemsRemaining, recentResults, isDark, meanMs, stdDevMs, thresholdMs, hasEnoughData } =
props
const isTouchDevice = useIsTouchDevice()
const [popoverOpen, setPopoverOpen] = useState(false)
const mood = getMood(props)
const estimatedTimeMs = hasEnoughData ? meanMs * problemsRemaining : problemsRemaining * 10000
// Use shared time estimation function
const timingStats: TimingStats = {
mean: meanMs,
stdDev: stdDevMs,
count: hasEnoughData ? 5 : 0, // We don't have exact count, but hasEnoughData tells us if >= 5
hasEnoughData,
threshold: thresholdMs,
}
const estimatedTimeMs = calculateEstimatedTimeRemainingMs(timingStats, problemsRemaining)
// Calculate streak
const streak = useMemo(() => calculateStreak(recentResults), [recentResults])

View File

@@ -6,12 +6,17 @@
* Uses @tanstack/react-virtual for virtualization to efficiently render
* large numbers of sessions. Automatically loads more sessions as the
* user scrolls near the bottom.
*
* Supports showing an "in progress" session at the top of the list.
*/
import { useVirtualizer } from '@tanstack/react-virtual'
import Link from 'next/link'
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useLiveSessionTimeEstimate } from '@/hooks/useLiveSessionTimeEstimate'
import { useSessionHistory } from '@/hooks/useSessionHistory'
import { css } from '../../../styled-system/css'
@@ -24,10 +29,231 @@ interface VirtualizedSessionListProps {
isDark: boolean
/** Height of the scrollable container */
height?: number | string
/** Active session (in progress) to show at the top */
activeSession?: SessionPlan | null
/** Callback when user clicks on the active session */
onOpenActiveSession?: () => void
}
// ============================================================================
// Session Item Component
// Shared Session Card Components
// ============================================================================
/**
* Status badge - shows session state (completed accuracy or in progress)
*/
function StatusBadge({
variant,
correct,
total,
isDark,
}: {
variant: 'completed' | 'in-progress'
correct?: number
total?: number
isDark: boolean
}) {
if (variant === 'in-progress') {
return (
<span
data-element="status-badge"
data-status="in-progress"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'medium',
backgroundColor: isDark ? 'blue.900' : 'blue.100',
color: isDark ? 'blue.300' : 'blue.700',
})}
>
<span
className={css({
width: '0.5rem',
height: '0.5rem',
borderRadius: '50%',
backgroundColor: isDark ? 'green.400' : 'green.500',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
In Progress
</span>
)
}
// Completed - show accuracy badge with same format as active session
const accuracy = total && total > 0 ? (correct ?? 0) / total : 0
const isHighAccuracy = accuracy >= 0.8
return (
<span
data-element="status-badge"
data-status="completed"
className={css({
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'medium',
backgroundColor: isHighAccuracy
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'yellow.900'
: 'yellow.100',
color: isHighAccuracy
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'yellow.300'
: 'yellow.700',
})}
>
{correct}/{total} · {Math.round(accuracy * 100)}%
</span>
)
}
/**
* Progress bar background for active sessions
* Positioned absolutely as a subtle full-height background indicator
*/
function ProgressBarBackground({
completed,
total,
isDark,
}: {
completed: number
total: number
isDark: boolean
}) {
const progress = total > 0 ? completed / total : 0
return (
<div
data-element="progress-bar-background"
className={css({
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: '6px',
pointerEvents: 'none',
})}
>
{/* Progress fill - visible but not overwhelming */}
<div
className={css({
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: isDark ? 'rgba(34, 197, 94, 0.25)' : 'rgba(34, 197, 94, 0.20)',
transition: 'width 0.3s ease',
})}
style={{ width: `${Math.round(progress * 100)}%` }}
/>
{/* Progress edge indicator - vertical line at progress point */}
{progress > 0 && progress < 1 && (
<div
className={css({
position: 'absolute',
top: 0,
bottom: 0,
width: '2px',
backgroundColor: isDark ? 'rgba(74, 222, 128, 0.6)' : 'rgba(22, 163, 74, 0.5)',
transition: 'left 0.3s ease',
})}
style={{ left: `${Math.round(progress * 100)}%` }}
/>
)}
</div>
)
}
/**
* Stats row - shows session metrics
*/
function StatsRow({ children, isDark }: { children: ReactNode; isDark: boolean }) {
return (
<div
data-element="stats-row"
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
display: 'flex',
gap: '1rem',
marginTop: '0.5rem',
})}
>
{children}
</div>
)
}
/**
* Stat item for the stats row
*/
function StatItem({
children,
highlight,
isDark,
}: {
children: ReactNode
highlight?: boolean
isDark: boolean
}) {
return (
<span
className={css({
color: highlight ? (isDark ? 'blue.300' : 'blue.600') : undefined,
fontWeight: highlight ? 'medium' : undefined,
})}
>
{children}
</span>
)
}
/**
* Header row - date/time on left, status badge on right
*/
function SessionHeader({
date,
statusBadge,
isDark,
}: {
date: string
statusBadge: ReactNode
isDark: boolean
}) {
return (
<div
data-element="session-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{date}
</span>
{statusBadge}
</div>
)
}
// ============================================================================
// Completed Session Item
// ============================================================================
function SessionItem({
@@ -41,7 +267,8 @@ function SessionItem({
}) {
const accuracy =
session.problemsAttempted > 0 ? session.problemsCorrect / session.problemsAttempted : 0
const isHighAccuracy = accuracy >= 0.8
const displayDate = new Date(session.completedAt || session.startedAt).toLocaleDateString()
const durationMinutes = Math.round((session.totalTimeMs || 0) / 60000)
return (
<Link
@@ -50,13 +277,16 @@ function SessionItem({
data-session-id={session.id}
className={css({
display: 'block',
width: '100%',
padding: '1rem',
borderRadius: '8px',
backgroundColor: isDark ? 'gray.700' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
textAlign: 'left',
textDecoration: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
border: '1px solid',
backgroundColor: isDark ? 'gray.700' : 'white',
borderColor: isDark ? 'gray.600' : 'gray.200',
_hover: {
backgroundColor: isDark ? 'gray.650' : 'gray.50',
borderColor: isDark ? 'gray.500' : 'gray.300',
@@ -65,62 +295,243 @@ function SessionItem({
},
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{new Date(session.completedAt || session.startedAt).toLocaleDateString()}
</span>
<span
className={css({
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'medium',
backgroundColor: isHighAccuracy
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'yellow.900'
: 'yellow.100',
color: isHighAccuracy
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'yellow.300'
: 'yellow.700',
})}
>
{session.problemsCorrect}/{session.problemsAttempted} correct
</span>
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
display: 'flex',
gap: '1rem',
})}
>
<span>{Math.round((session.totalTimeMs || 0) / 60000)} min</span>
<span>{Math.round(accuracy * 100)}% accuracy</span>
</div>
<SessionHeader
date={displayDate}
statusBadge={
<StatusBadge
variant="completed"
correct={session.problemsCorrect}
total={session.problemsAttempted}
isDark={isDark}
/>
}
isDark={isDark}
/>
<StatsRow isDark={isDark}>
<StatItem isDark={isDark}>{durationMinutes} min</StatItem>
</StatsRow>
</Link>
)
}
// ============================================================================
// Active Session Item (In Progress)
// ============================================================================
/**
* Format elapsed time as human-readable duration (e.g., "2m", "1h 30m")
*/
function formatElapsedTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
if (seconds < 10) return 'just now'
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
if (remainingMinutes === 0) return `${hours}h`
return `${hours}h ${remainingMinutes}m`
}
/**
* Format idle time as human-readable string with "ago" suffix
*/
function formatIdleTime(idleMs: number): string {
const elapsed = formatElapsedTime(idleMs)
if (elapsed === 'just now') return 'Just now'
return `${elapsed} ago`
}
/**
* Hook to track idle time since last activity, updating every second
*/
function useIdleTime(lastActivityTime: Date | null): number {
const [idleMs, setIdleMs] = useState(() =>
lastActivityTime ? Date.now() - lastActivityTime.getTime() : 0
)
useEffect(() => {
if (!lastActivityTime) return
// Update immediately
setIdleMs(Date.now() - lastActivityTime.getTime())
// Update every second
const interval = setInterval(() => {
setIdleMs(Date.now() - lastActivityTime.getTime())
}, 1000)
return () => clearInterval(interval)
}, [lastActivityTime])
return idleMs
}
function ActiveSessionItem({
session,
isDark,
onClick,
}: {
session: SessionPlan
isDark: boolean
onClick: () => void
}) {
// Use live time estimation hook with WebSocket subscription
const timeEstimate = useLiveSessionTimeEstimate({
sessionId: session.id,
initialResults: session.results ?? [],
initialParts: session.parts ?? [],
enabled: true,
})
const {
totalProblems,
completedProblems,
accuracy,
estimatedTimeRemainingFormatted,
isLive,
lastActivityAt,
} = timeEstimate
// Get session start time (memoize to avoid creating new Date objects on every render)
const sessionStartTime = useMemo(
() => (session.startedAt ? new Date(session.startedAt) : new Date(session.createdAt)),
[session.startedAt, session.createdAt]
)
// Get last activity time from initial results (memoized)
const lastActivityTimeFromResults = useMemo(() => {
if (session.results && session.results.length > 0) {
return new Date(session.results[session.results.length - 1].timestamp)
}
return sessionStartTime
}, [session.results, sessionStartTime])
// Prefer live data when available, fall back to initial data
const lastActivityTime = isLive && lastActivityAt ? lastActivityAt : lastActivityTimeFromResults
const idleMs = useIdleTime(lastActivityTime)
const elapsedMs = useIdleTime(sessionStartTime)
return (
<button
type="button"
data-element="active-session-item"
data-session-id={session.id}
onClick={onClick}
className={css({
display: 'block',
width: '100%',
padding: '1rem',
borderRadius: '8px',
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.15s ease',
border: 'none',
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.5)' : 'rgba(219, 234, 254, 1)',
boxShadow: isDark
? 'inset 0 0 0 1px var(--colors-blue-500)'
: 'inset 0 0 0 1px var(--colors-blue-300)',
position: 'relative',
overflow: 'hidden',
_hover: {
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.7)' : 'rgba(191, 219, 254, 1)',
boxShadow: isDark
? 'inset 0 0 0 1px var(--colors-blue-400), 0 2px 8px rgba(0,0,0,0.1)'
: 'inset 0 0 0 1px var(--colors-blue-400), 0 2px 8px rgba(0,0,0,0.1)',
transform: 'translateY(-1px)',
},
})}
>
{/* Progress bar as subtle background */}
<ProgressBarBackground completed={completedProblems} total={totalProblems} isDark={isDark} />
{/* Corner ribbon - "In Progress" indicator */}
<div
data-element="in-progress-ribbon"
className={css({
position: 'absolute',
top: 0,
left: 0,
overflow: 'hidden',
width: '5rem',
height: '5rem',
pointerEvents: 'none',
zIndex: 2,
})}
>
<div
className={css({
position: 'absolute',
top: '0.125rem',
left: '-2.125rem',
width: '6rem',
transform: 'rotate(-45deg)',
backgroundColor: isDark ? 'green.600' : 'green.500',
color: 'white',
fontSize: '0.5rem',
fontWeight: 'bold',
textAlign: 'center',
padding: '0.125rem 0',
textTransform: 'uppercase',
letterSpacing: '0.025em',
lineHeight: '1.2',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
})}
>
In
<br />
Progress
</div>
</div>
{/* Content - positioned above the progress background */}
<div className={css({ position: 'relative', zIndex: 1 })}>
{/* Header: Idle time (left) | Stats badge (right) */}
<div
data-element="active-session-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
{/* Left: Idle time since last activity */}
<span
className={css({
color: isDark ? 'gray.300' : 'gray.600',
fontSize: '0.75rem',
})}
>
{formatElapsedTime(idleMs)} since last activity
</span>
{/* Right: Stats badge */}
<span
className={css({
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'medium',
backgroundColor: isDark ? 'rgba(30, 64, 175, 0.6)' : 'rgba(191, 219, 254, 1)',
color: isDark ? 'blue.200' : 'blue.800',
})}
>
{completedProblems}/{totalProblems}
{completedProblems > 0 && ` · ${Math.round(accuracy * 100)}%`}
</span>
</div>
{/* Footer: Session duration and estimated time remaining */}
<StatsRow isDark={isDark}>
<StatItem isDark={isDark}>Started {formatIdleTime(elapsedMs)}</StatItem>
<StatItem isDark={isDark}>{estimatedTimeRemainingFormatted} left</StatItem>
</StatsRow>
</div>
</button>
)
}
// ============================================================================
// Loading Indicator
// ============================================================================
@@ -163,8 +574,11 @@ export function VirtualizedSessionList({
studentId,
isDark,
height = 500,
activeSession,
onOpenActiveSession,
}: VirtualizedSessionListProps) {
const parentRef = useRef<HTMLDivElement>(null)
const hasActiveSession = activeSession != null && activeSession.completedAt == null
const {
sessions,
@@ -182,10 +596,12 @@ export function VirtualizedSessionList({
)
// Virtualizer configuration
// +1 for active session at top (if any), +1 for loading indicator at bottom (if any)
const activeSessionOffset = hasActiveSession ? 1 : 0
const rowVirtualizer = useVirtualizer({
count: sessions.length + (hasNextPage ? 1 : 0), // +1 for loading indicator
count: activeSessionOffset + sessions.length + (hasNextPage ? 1 : 0),
getScrollElement: () => parentRef.current,
estimateSize: () => 90, // Estimated height of each session item
estimateSize: () => 90, // All items are same height now
overscan: 5, // Render 5 extra items above/below viewport
})
@@ -248,8 +664,8 @@ export function VirtualizedSessionList({
)
}
// Empty state
if (sessions.length === 0) {
// Empty state (only if no active session either)
if (sessions.length === 0 && !hasActiveSession) {
return (
<p
className={css({
@@ -282,7 +698,10 @@ export function VirtualizedSessionList({
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const isLoadingRow = virtualItem.index === sessions.length
// Determine what to render based on index
const isActiveSessionRow = hasActiveSession && virtualItem.index === 0
const sessionIndex = virtualItem.index - activeSessionOffset
const isLoadingRow = sessionIndex === sessions.length
return (
<div
@@ -296,11 +715,17 @@ export function VirtualizedSessionList({
}}
>
<div className={css({ paddingBottom: '0.75rem' })}>
{isLoadingRow ? (
{isActiveSessionRow && activeSession ? (
<ActiveSessionItem
session={activeSession}
isDark={isDark}
onClick={onOpenActiveSession ?? (() => {})}
/>
) : isLoadingRow ? (
<LoadingIndicator isDark={isDark} />
) : (
<SessionItem
session={sessions[virtualItem.index]}
session={sessions[sessionIndex]}
studentId={studentId}
isDark={isDark}
/>

View File

@@ -26,6 +26,8 @@ function mockResult(responseTimeMs: number): SlotResult {
skillsExercised: [],
usedOnScreenAbacus: false,
timestamp: new Date(),
hadHelp: false,
incorrectAttempts: 0,
}
}

View File

@@ -0,0 +1,216 @@
'use client'
/**
* Hook for real-time session time estimates via WebSocket
*
* Subscribes to session state updates and computes time estimates
* using the shared calculation functions. Falls back to static data
* when WebSocket isn't connected.
*/
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 { PracticeStateEvent } from '@/lib/classroom/socket-events'
import {
calculateTimingStats,
calculateEstimatedTimeRemainingMs,
formatEstimatedTimeRemaining,
type SessionTimeEstimate,
} from './useSessionTimeEstimate'
// ============================================================================
// Types
// ============================================================================
export interface LiveSessionTimeEstimateOptions {
/** Session ID to subscribe to */
sessionId: string | undefined
/** Initial results (used before WebSocket connects) */
initialResults?: SlotResult[]
/** Initial parts (used before WebSocket connects) */
initialParts?: SessionPart[]
/** Whether to enable the WebSocket subscription */
enabled?: boolean
}
export interface LiveSessionTimeEstimateResult extends SessionTimeEstimate {
/** Number of correct answers */
correctCount: number
/** Accuracy as a decimal (0-1) */
accuracy: number
/** Whether connected to WebSocket */
isConnected: boolean
/** Whether receiving live updates */
isLive: boolean
/** Last activity timestamp from live updates */
lastActivityAt: Date | null
/** Error if connection failed */
error: string | null
}
// ============================================================================
// Hook
// ============================================================================
/**
* Hook to get real-time session time estimates via WebSocket
*
* Connects to the session's socket channel and receives practice state updates.
* Computes time estimates using the same functions as the student's practice view.
*
* @example
* ```tsx
* const estimate = useLiveSessionTimeEstimate({
* sessionId: session.id,
* initialResults: session.results,
* initialParts: session.parts,
* })
*
* return (
* <span>
* {estimate.isLive ? '🔴 Live: ' : ''}
* {estimate.estimatedTimeRemainingFormatted} left
* </span>
* )
* ```
*/
export function useLiveSessionTimeEstimate({
sessionId,
initialResults = [],
initialParts = [],
enabled = true,
}: LiveSessionTimeEstimateOptions): LiveSessionTimeEstimateResult {
// State for live data
const [liveResults, setLiveResults] = useState<SlotResult[]>(initialResults)
const [liveParts, setLiveParts] = useState<SessionPart[]>(initialParts)
const [lastActivityAt, setLastActivityAt] = useState<Date | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [isLive, setIsLive] = useState(false)
const [error, setError] = useState<string | null>(null)
const socketRef = useRef<Socket | null>(null)
// Update initial data when props change (before WebSocket connects)
useEffect(() => {
if (!isLive) {
setLiveResults(initialResults)
setLiveParts(initialParts)
}
}, [initialResults, initialParts, isLive])
// Cleanup function
const cleanup = useCallback(() => {
if (socketRef.current) {
socketRef.current.disconnect()
socketRef.current = null
}
setIsConnected(false)
setIsLive(false)
}, [])
// WebSocket subscription
useEffect(() => {
if (!sessionId || !enabled) {
cleanup()
return
}
// Create socket connection
const socket = io({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
socketRef.current = socket
socket.on('connect', () => {
console.log('[LiveSessionTimeEstimate] Connected, subscribing to session:', sessionId)
setIsConnected(true)
setError(null)
// Subscribe to session updates (read-only, no observer auth needed)
socket.emit('subscribe-session-stats', { sessionId })
})
socket.on('disconnect', () => {
console.log('[LiveSessionTimeEstimate] Disconnected')
setIsConnected(false)
setIsLive(false)
})
socket.on('connect_error', (err) => {
console.error('[LiveSessionTimeEstimate] Connection error:', err)
setError('Failed to connect')
setIsConnected(false)
})
// Listen for practice state updates
socket.on('practice-state', (data: PracticeStateEvent) => {
console.log('[LiveSessionTimeEstimate] Received practice-state:', {
problemNumber: data.currentProblemNumber,
totalProblems: data.totalProblems,
resultsCount: (data.slotResults as SlotResult[] | undefined)?.length ?? 0,
})
// Update parts if provided
if (data.sessionParts) {
setLiveParts(data.sessionParts as SessionPart[])
}
// Update results if provided
if (data.slotResults) {
setLiveResults(data.slotResults as SlotResult[])
}
// Update last activity time
setLastActivityAt(new Date())
setIsLive(true)
})
// Listen for session ended
socket.on('session-ended', () => {
console.log('[LiveSessionTimeEstimate] Session ended')
setIsLive(false)
})
return () => {
console.log('[LiveSessionTimeEstimate] Cleaning up')
socket.emit('unsubscribe-session-stats', { sessionId })
socket.disconnect()
socketRef.current = null
}
}, [sessionId, enabled, cleanup])
// Compute time estimates from current data
const results = liveResults
const parts = liveParts
const totalProblems = parts.reduce((sum, p) => sum + (p.slots?.length ?? 0), 0)
const completedProblems = results.length
const problemsRemaining = totalProblems - completedProblems
// Calculate correctness stats
const correctCount = results.filter((r) => r.isCorrect).length
const accuracy = completedProblems > 0 ? correctCount / completedProblems : 0
const timingStats = calculateTimingStats(results, parts)
const estimatedTimeRemainingMs = calculateEstimatedTimeRemainingMs(timingStats, problemsRemaining)
const estimatedTimeRemainingFormatted = formatEstimatedTimeRemaining(estimatedTimeRemainingMs)
return {
timingStats,
problemsRemaining,
totalProblems,
completedProblems,
correctCount,
accuracy,
estimatedTimeRemainingMs,
estimatedTimeRemainingFormatted,
isConnected,
isLive,
lastActivityAt,
error,
}
}

View File

@@ -0,0 +1,217 @@
'use client'
import { useMemo } from 'react'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
// ============================================================================
// Constants
// ============================================================================
/** Minimum samples needed for reliable statistical estimates */
export const MIN_SAMPLES_FOR_STATS = 5
/** Default time per problem in ms when not enough data (10 seconds) */
export const DEFAULT_TIME_PER_PROBLEM_MS = 10_000
// ============================================================================
// Types
// ============================================================================
export interface TimingStats {
/** Mean response time in milliseconds */
mean: number
/** Standard deviation of response times */
stdDev: number
/** Number of samples used */
count: number
/** Whether we have enough data for reliable estimates */
hasEnoughData: boolean
/** Auto-pause threshold in milliseconds */
threshold: number
}
export interface SessionTimeEstimate {
/** Timing statistics from results */
timingStats: TimingStats
/** Number of problems remaining */
problemsRemaining: number
/** Total problems in session */
totalProblems: number
/** Number of completed problems */
completedProblems: number
/** Estimated time remaining in milliseconds */
estimatedTimeRemainingMs: number
/** Formatted estimated time remaining (e.g., "~5 min") */
estimatedTimeRemainingFormatted: string
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Calculate mean and standard deviation from an array of numbers
*/
function calculateStats(times: number[]): { mean: number; stdDev: number; count: number } {
const count = times.length
if (count === 0) return { mean: 0, stdDev: 0, count: 0 }
const mean = times.reduce((sum, t) => sum + t, 0) / count
if (count < 2) return { mean, stdDev: 0, count }
const variance = times.reduce((sum, t) => sum + (t - mean) ** 2, 0) / (count - 1)
const stdDev = Math.sqrt(variance)
return { mean, stdDev, count }
}
/**
* Calculate timing stats from session results
*
* Can optionally filter by part type for more accurate estimates
* when the student is working on a specific part.
*/
export function calculateTimingStats(
results: SlotResult[],
parts?: SessionPart[],
currentPartType?: SessionPart['type']
): TimingStats {
let times: number[]
if (currentPartType && parts) {
// Filter results by part type for current-part-specific estimates
times = results
.filter((r) => {
const partIndex = parts.findIndex((p) => p.partNumber === r.partNumber)
return partIndex >= 0 && parts[partIndex].type === currentPartType
})
.map((r) => r.responseTimeMs)
} else {
// Use all results
times = results.map((r) => r.responseTimeMs)
}
const stats = calculateStats(times)
const hasEnoughData = stats.count >= MIN_SAMPLES_FOR_STATS
// Calculate auto-pause threshold: mean + 2*stdDev, clamped between 30s and 5min
const threshold = hasEnoughData
? Math.max(30_000, Math.min(stats.mean + 2 * stats.stdDev, 5 * 60 * 1000))
: 60_000 // Default 1 minute when not enough data
return {
...stats,
hasEnoughData,
threshold,
}
}
/**
* Format estimated time remaining as human-readable string
*/
export function formatEstimatedTimeRemaining(ms: number): string {
const minutes = Math.round(ms / 60_000)
if (minutes < 1) return '< 1 min'
if (minutes === 1) return '~1 min'
return `~${minutes} min`
}
/**
* Calculate estimated time remaining in milliseconds
*/
export function calculateEstimatedTimeRemainingMs(
timingStats: TimingStats,
problemsRemaining: number
): number {
const timePerProblem = timingStats.hasEnoughData ? timingStats.mean : DEFAULT_TIME_PER_PROBLEM_MS
return timePerProblem * problemsRemaining
}
// ============================================================================
// Hook
// ============================================================================
export interface UseSessionTimeEstimateOptions {
/** Session results array */
results: SlotResult[]
/** Session parts array */
parts: SessionPart[]
/** Optional: current part type to filter stats by (for more accurate current-part estimates) */
currentPartType?: SessionPart['type']
}
/**
* Hook to calculate session time estimates
*
* Provides timing statistics and estimated time remaining based on
* the student's response times during the session.
*
* @example
* ```tsx
* const estimate = useSessionTimeEstimate({
* results: session.results,
* parts: session.parts,
* })
*
* return <span>{estimate.estimatedTimeRemainingFormatted} left</span>
* ```
*/
export function useSessionTimeEstimate({
results,
parts,
currentPartType,
}: UseSessionTimeEstimateOptions): SessionTimeEstimate {
return useMemo(() => {
// Calculate total and completed problems
const totalProblems = parts.reduce((sum, p) => sum + (p.slots?.length ?? 0), 0)
const completedProblems = results.length
const problemsRemaining = totalProblems - completedProblems
// Calculate timing stats
const timingStats = calculateTimingStats(results, parts, currentPartType)
// Calculate estimated time remaining
const estimatedTimeRemainingMs = calculateEstimatedTimeRemainingMs(
timingStats,
problemsRemaining
)
return {
timingStats,
problemsRemaining,
totalProblems,
completedProblems,
estimatedTimeRemainingMs,
estimatedTimeRemainingFormatted: formatEstimatedTimeRemaining(estimatedTimeRemainingMs),
}
}, [results, parts, currentPartType])
}
/**
* Standalone function version for use outside React components
*
* Useful for computing time estimates from raw session data without hooks.
*/
export function getSessionTimeEstimate(
results: SlotResult[],
parts: SessionPart[],
currentPartType?: SessionPart['type']
): SessionTimeEstimate {
const totalProblems = parts.reduce((sum, p) => sum + (p.slots?.length ?? 0), 0)
const completedProblems = results.length
const problemsRemaining = totalProblems - completedProblems
const timingStats = calculateTimingStats(results, parts, currentPartType)
const estimatedTimeRemainingMs = calculateEstimatedTimeRemainingMs(timingStats, problemsRemaining)
return {
timingStats,
problemsRemaining,
totalProblems,
completedProblems,
estimatedTimeRemainingMs,
estimatedTimeRemainingFormatted: formatEstimatedTimeRemaining(estimatedTimeRemainingMs),
}
}

View File

@@ -772,6 +772,27 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
})
// Session Stats: Subscribe to session updates (read-only, for time estimates in history list)
// This is a lightweight alternative to full observation - just receives practice-state events
socket.on('subscribe-session-stats', async ({ sessionId }: { sessionId: string }) => {
try {
await socket.join(`session:${sessionId}`)
console.log(`📊 Stats subscriber joined session channel: ${sessionId}`)
} catch (error) {
console.error('Error subscribing to session stats:', error)
}
})
// Session Stats: Unsubscribe from session updates
socket.on('unsubscribe-session-stats', async ({ sessionId }: { sessionId: string }) => {
try {
await socket.leave(`session:${sessionId}`)
console.log(`📊 Stats subscriber left session channel: ${sessionId}`)
} catch (error) {
console.error('Error unsubscribing from session stats:', error)
}
})
// Session Observation: Start observing a practice session
// Supports both authenticated observers (parent/teacher) and token-based shared observers
socket.on(