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:
@@ -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": []
|
||||
|
||||
329
apps/web/__tests__/session-share.e2e.test.ts
Normal file
329
apps/web/__tests__/session-share.e2e.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,8 @@ function mockResult(responseTimeMs: number): SlotResult {
|
||||
skillsExercised: [],
|
||||
usedOnScreenAbacus: false,
|
||||
timestamp: new Date(),
|
||||
hadHelp: false,
|
||||
incorrectAttempts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
216
apps/web/src/hooks/useLiveSessionTimeEstimate.ts
Normal file
216
apps/web/src/hooks/useLiveSessionTimeEstimate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
217
apps/web/src/hooks/useSessionTimeEstimate.ts
Normal file
217
apps/web/src/hooks/useSessionTimeEstimate.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user