feat(practice): consolidate nav with transport dropdown and mood indicator

- Add SessionMoodIndicator with streak animations and touch-friendly popover
- Consolidate transport controls (pause/resume/browse/end) into dropdown menu
- Remove summary section from SessionProgressIndicator (moved to mood tooltip)
- Add DetailedProblemCard with skill annotations and complexity breakdown
- Add autoPauseCalculator for timing threshold calculations
- Add 0.75 opacity to zero-cost skill pills for reduced visual noise
- Clean up unused timing/estimate code from progress indicator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-13 23:34:21 -06:00
parent c0764ccd85
commit 8851be5948
18 changed files with 3293 additions and 604 deletions

View File

@@ -179,7 +179,6 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
isBrowseMode={isBrowseMode}
browseIndex={browseIndex}
onBrowseIndexChange={setBrowseIndex}
onExitBrowse={() => setIsBrowseMode(false)}
/>
</PracticeErrorBoundary>
</main>

View File

@@ -3,7 +3,12 @@
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { PracticeSubNav, SessionSummary, StartPracticeModal } from '@/components/practice'
import {
PracticeSubNav,
SessionOverview,
SessionSummary,
StartPracticeModal,
} from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan } from '@/db/schema/session-plans'
@@ -37,6 +42,7 @@ export function SummaryClient({
const isDark = resolvedTheme === 'dark'
const [showStartPracticeModal, setShowStartPracticeModal] = useState(false)
const [viewMode, setViewMode] = useState<'summary' | 'debug'>('summary')
const isInProgress = session?.startedAt && !session?.completedAt
@@ -107,13 +113,75 @@ export function SummaryClient({
</p>
</header>
{/* Session Summary or Empty State */}
{/* View Mode Toggle (only show when there's a session) */}
{session && (
<div
data-element="view-mode-toggle"
className={css({
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
marginBottom: '1.5rem',
})}
>
<button
type="button"
data-action="view-summary"
onClick={() => setViewMode('summary')}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: viewMode === 'summary' ? 'bold' : 'normal',
color: viewMode === 'summary' ? 'white' : isDark ? 'gray.300' : 'gray.600',
backgroundColor:
viewMode === 'summary' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
borderRadius: '6px 0 0 6px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor:
viewMode === 'summary' ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Summary
</button>
<button
type="button"
data-action="view-debug"
onClick={() => setViewMode('debug')}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: viewMode === 'debug' ? 'bold' : 'normal',
color: viewMode === 'debug' ? 'white' : isDark ? 'gray.300' : 'gray.600',
backgroundColor:
viewMode === 'debug' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
borderRadius: '0 6px 6px 0',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor:
viewMode === 'debug' ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Debug View
</button>
</div>
)}
{/* Session Summary/Overview or Empty State */}
{session ? (
<SessionSummary
plan={session}
studentName={player.name}
onPracticeAgain={handlePracticeAgain}
/>
viewMode === 'summary' ? (
<SessionSummary
plan={session}
studentName={player.name}
onPracticeAgain={handlePracticeAgain}
/>
) : (
<SessionOverview plan={session} studentName={player.name} />
)
) : (
<div
className={css({

View File

@@ -74,8 +74,6 @@ interface ActiveSessionProps {
browseIndex?: number
/** Called when browse index changes (for external navigation from progress indicator) */
onBrowseIndexChange?: (index: number) => void
/** Called when user wants to exit browse mode and return to practice */
onExitBrowse?: () => void
}
/**
@@ -508,7 +506,6 @@ export function ActiveSession({
isBrowseMode: isBrowseModeProp = false,
browseIndex: browseIndexProp,
onBrowseIndexChange,
onExitBrowse,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -1132,7 +1129,6 @@ export function ActiveSession({
plan={plan}
browseIndex={browseIndex}
currentPracticeIndex={currentPracticeLinearIndex}
onExitBrowse={onExitBrowse}
/>
)
}

View File

@@ -0,0 +1,197 @@
/**
* Compact problem display components for session summary and overview
*
* These components show problems in a small, reviewable format:
* - CompactVerticalProblem: Stacked format like a workbook problem
* - CompactLinearProblem: Horizontal equation format
*/
import { css } from '../../../styled-system/css'
/**
* Props for compact problem components
*/
export interface CompactProblemProps {
/** Problem terms (positive for add, negative for subtract) */
terms: number[]
/** Correct answer */
answer: number
/** Student's submitted answer (if available) */
studentAnswer?: number
/** Whether the student got it correct (if available) */
isCorrect?: boolean
/** Whether dark mode is active */
isDark: boolean
}
/**
* Compact vertical problem display for review
* Shows terms stacked with answer below, like a mini workbook problem
*/
export function CompactVerticalProblem({
terms,
answer,
studentAnswer,
isCorrect,
isDark,
}: CompactProblemProps) {
// Calculate max digits for alignment
const maxDigits = Math.max(
...terms.map((t) => Math.abs(t).toString().length),
answer.toString().length
)
return (
<div
data-element="compact-vertical-problem"
className={css({
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'flex-end',
fontFamily: 'monospace',
fontSize: '0.75rem',
lineHeight: 1.2,
})}
>
{terms.map((term, i) => {
const isNegative = term < 0
const absValue = Math.abs(term)
const digits = absValue.toString()
const padding = maxDigits - digits.length
return (
<div
key={i}
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{/* Sign: show for negative terms, + for additions after first (but usually omit +) */}
<span
className={css({
width: '0.75em',
textAlign: 'center',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
{i === 0 ? '' : isNegative ? '' : ''}
</span>
{/* Padding for alignment */}
{padding > 0 && (
<span className={css({ visibility: 'hidden' })}>{' '.repeat(padding)}</span>
)}
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{digits}</span>
</div>
)
})}
{/* Answer line */}
<div
className={css({
display: 'flex',
alignItems: 'center',
borderTop: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
paddingTop: '0.125rem',
marginTop: '0.125rem',
})}
>
<span className={css({ width: '0.75em' })} />
<span
className={css({
color:
isCorrect === undefined
? isDark
? 'gray.400'
: 'gray.600'
: isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
fontWeight: 'bold',
})}
>
{answer}
</span>
{/* Show wrong answer if incorrect */}
{isCorrect === false && studentAnswer !== undefined && (
<span
className={css({
marginLeft: '0.25rem',
color: isDark ? 'red.400' : 'red.500',
textDecoration: 'line-through',
fontSize: '0.625rem',
})}
>
{studentAnswer}
</span>
)}
</div>
</div>
)
}
/**
* Compact linear problem display for review
* Shows equation in horizontal format: "5 + 3 - 2 = 6"
*/
export function CompactLinearProblem({
terms,
answer,
studentAnswer,
isCorrect,
isDark,
}: CompactProblemProps) {
const equation = terms
.map((term, i) => {
if (i === 0) return String(term)
return term < 0 ? ` ${Math.abs(term)}` : ` + ${term}`
})
.join('')
return (
<span
data-element="compact-linear-problem"
className={css({
fontFamily: 'monospace',
fontSize: '0.8125rem',
})}
>
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{equation} = </span>
<span
className={css({
color:
isCorrect === undefined
? isDark
? 'gray.400'
: 'gray.600'
: isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
fontWeight: 'bold',
})}
>
{answer}
</span>
{/* Show wrong answer if incorrect */}
{isCorrect === false && studentAnswer !== undefined && (
<span
className={css({
marginLeft: '0.375rem',
color: isDark ? 'red.400' : 'red.500',
textDecoration: 'line-through',
})}
>
{studentAnswer}
</span>
)}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import type {
} from '@/db/schema/session-plans'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import { SessionOverview } from './SessionOverview'
interface PlanReviewProps {
plan: SessionPlan
@@ -72,6 +73,7 @@ export function PlanReview({ plan, studentName, onApprove, onCancel }: PlanRevie
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [showConfig, setShowConfig] = useState(false)
const [showFullProblems, setShowFullProblems] = useState(false)
const summary = plan.summary as SessionSummary
const parts = plan.parts as SessionPart[]
@@ -485,6 +487,46 @@ export function PlanReview({ plan, studentName, onApprove, onCancel }: PlanRevie
</div>
)}
{/* Full Problems Toggle */}
<button
type="button"
data-action="toggle-full-problems"
onClick={() => setShowFullProblems(!showFullProblems)}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1rem',
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
backgroundColor: 'transparent',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '6px',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.800' : 'gray.50',
},
})}
>
<span>{showFullProblems ? '' : ''}</span>
Full Problem Details (Skills + Costs)
</button>
{/* Full Problems Panel */}
{showFullProblems && (
<div
data-section="full-problems"
className={css({
width: '100%',
maxWidth: '100%',
overflow: 'auto',
})}
>
<SessionOverview plan={plan} studentName={studentName} />
</div>
)}
{/* Action Buttons */}
<div
className={css({

View File

@@ -1,12 +1,13 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { SessionMoodIndicator } from './SessionMoodIndicator'
import { SessionProgressIndicator } from './SessionProgressIndicator'
import { SpeedMeter } from './SpeedMeter'
/**
* Timing data for the current problem attempt
@@ -80,50 +81,6 @@ interface PracticeSubNavProps {
onStartPractice?: () => void
}
function getPartTypeEmoji(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
}
}
function getPartTypeLabel(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return 'Use Abacus'
case 'visualization':
return 'Visualization'
case 'linear':
return 'Mental Math'
}
}
function getHealthEmoji(overall: 'good' | 'warning' | 'struggling'): string {
switch (overall) {
case 'good':
return '🟢'
case 'warning':
return '🟡'
case 'struggling':
return '🔴'
}
}
function getHealthColor(overall: 'good' | 'warning' | 'struggling'): string {
switch (overall) {
case 'good':
return 'green.500'
case 'warning':
return 'yellow.500'
case 'struggling':
return 'red.500'
}
}
// Minimum samples needed for statistical display
const MIN_SAMPLES_FOR_STATS = 3
@@ -153,21 +110,6 @@ function calculateStats(times: number[]): {
return { mean, stdDev, count }
}
/**
* Format seconds as a compact time string
*/
function formatTimeCompact(ms: number): string {
if (ms < 0) return '0s'
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes > 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
return `${seconds}s`
}
/**
* Practice Sub-Navigation Bar
*
@@ -231,6 +173,12 @@ export function PracticeSubNav({
})()
: null
// Extract recent correctness results for mood indicator (last N)
const recentResults = useMemo(() => {
if (!sessionHud?.results) return []
return sessionHud.results.slice(-10).map((r) => r.isCorrect)
}, [sessionHud?.results])
return (
<nav
data-component="practice-sub-nav"
@@ -336,140 +284,179 @@ export function PracticeSubNav({
gap: { base: '0.375rem', md: '0.75rem' },
})}
>
{/* Transport controls */}
<div
data-element="transport-controls"
className={css({
display: 'flex',
gap: '0.25rem',
flexShrink: 0,
})}
>
{/* Pause/Play button */}
<button
type="button"
data-action={sessionHud.isPaused ? 'resume' : 'pause'}
onClick={sessionHud.isPaused ? sessionHud.onResume : sessionHud.onPause}
className={css({
width: { base: '32px', md: '36px' },
height: { base: '32px', md: '36px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: { base: '1rem', md: '1.125rem' },
color: 'white',
backgroundColor: sessionHud.isPaused
? 'green.500'
: isDark
? 'gray.600'
: 'gray.500',
borderRadius: '6px',
border: '2px solid',
borderColor: sessionHud.isPaused ? 'green.400' : isDark ? 'gray.500' : 'gray.400',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: sessionHud.isPaused
? 'green.400'
: isDark
? 'gray.500'
: 'gray.400',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label={sessionHud.isPaused ? 'Resume session' : 'Pause session'}
>
{sessionHud.isPaused ? '▶' : '⏸'}
</button>
{/* Transport controls dropdown */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
data-element="transport-controls"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.375rem 0.625rem',
fontSize: '0.75rem',
fontWeight: '500',
color: isDark ? 'gray.200' : 'gray.700',
backgroundColor: isDark ? 'gray.700' : 'white',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
cursor: 'pointer',
transition: 'all 0.15s ease',
flexShrink: 0,
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.50',
borderColor: isDark ? 'gray.500' : 'gray.400',
},
_active: {
backgroundColor: isDark ? 'gray.650' : 'gray.100',
},
})}
aria-label="Session controls"
>
{/* Status indicator dot */}
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: sessionHud.isBrowseMode
? 'blue.500'
: sessionHud.isPaused
? 'yellow.500'
: 'green.500',
})}
/>
<span>
{sessionHud.isBrowseMode
? 'Browse'
: sessionHud.isPaused
? 'Paused'
: 'Active'}
</span>
<span
className={css({
fontSize: '0.5rem',
color: isDark ? 'gray.400' : 'gray.500',
marginLeft: '0.125rem',
})}
>
</span>
</button>
</DropdownMenu.Trigger>
{/* Stop button */}
<button
type="button"
data-action="end-early"
onClick={sessionHud.onEndEarly}
className={css({
width: { base: '32px', md: '36px' },
height: { base: '32px', md: '36px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: { base: '1rem', md: '1.125rem' },
color: isDark ? 'red.400' : 'red.500',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '6px',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'red.900' : 'red.100',
borderColor: isDark ? 'red.700' : 'red.300',
color: isDark ? 'red.300' : 'red.600',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label="End session"
>
</button>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
minWidth: '160px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
padding: '0.375rem',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
zIndex: 1000,
})}
sideOffset={5}
>
{/* Play/Pause item */}
<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 ? 'gray.100' : 'gray.900',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_focus: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})}
onSelect={sessionHud.isPaused ? sessionHud.onResume : sessionHud.onPause}
>
<span>{sessionHud.isPaused ? '▶' : '⏸'}</span>
<span>{sessionHud.isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenu.Item>
{/* Browse mode toggle button */}
<button
type="button"
data-action="toggle-browse"
data-active={sessionHud.isBrowseMode}
onClick={sessionHud.onToggleBrowse}
className={css({
width: { base: '32px', md: '36px' },
height: { base: '32px', md: '36px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: { base: '1rem', md: '1.125rem' },
color: sessionHud.isBrowseMode ? 'white' : isDark ? 'blue.400' : 'blue.500',
backgroundColor: sessionHud.isBrowseMode
? 'blue.500'
: isDark
? 'gray.700'
: 'gray.200',
borderRadius: '6px',
border: '2px solid',
borderColor: sessionHud.isBrowseMode
? 'blue.400'
: isDark
? 'gray.600'
: 'gray.300',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: sessionHud.isBrowseMode
? 'blue.600'
: isDark
? 'blue.900'
: 'blue.100',
borderColor: sessionHud.isBrowseMode
? 'blue.500'
: isDark
? 'blue.700'
: 'blue.300',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label={sessionHud.isBrowseMode ? 'Exit browse mode' : 'Browse all problems'}
title={sessionHud.isBrowseMode ? 'Exit browse mode' : 'Browse all problems'}
>
🔢
</button>
</div>
{/* Browse mode item */}
<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: sessionHud.isBrowseMode
? isDark
? 'blue.300'
: 'blue.600'
: isDark
? 'gray.100'
: 'gray.900',
backgroundColor: sessionHud.isBrowseMode
? isDark
? 'blue.900/50'
: 'blue.50'
: 'transparent',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_focus: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})}
onSelect={sessionHud.onToggleBrowse}
>
<span>🔢</span>
<span>{sessionHud.isBrowseMode ? 'Exit Browse' : 'Browse'}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator
className={css({
height: '1px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
margin: '0.375rem 0',
})}
/>
{/* End session item */}
<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 ? 'red.400' : 'red.600',
_hover: {
backgroundColor: isDark ? 'red.900/50' : 'red.50',
},
_focus: {
backgroundColor: isDark ? 'red.900/50' : 'red.50',
},
})}
onSelect={sessionHud.onEndEarly}
>
<span></span>
<span>End Session</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/* Session Progress Indicator - discrete problem slots */}
<div
@@ -492,103 +479,22 @@ export function PracticeSubNav({
/>
</div>
{/* Timing display */}
{sessionHud.timing && timingStats && (
<div
data-element="timing-display"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
padding: { base: '0.125rem 0.375rem', md: '0.25rem 0.5rem' },
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: { base: '6px', md: '8px' },
flexShrink: 0,
minWidth: { base: '60px', md: '100px' },
})}
>
{/* Current timer */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.125rem',
})}
>
<span
className={css({ fontSize: '0.625rem', display: { base: 'none', sm: 'inline' } })}
>
</span>
<span
className={css({
fontFamily: 'monospace',
fontSize: { base: '0.875rem', md: '1rem' },
fontWeight: 'bold',
color:
currentElapsedMs > timingStats.threshold
? isDark
? 'red.400'
: 'red.500'
: currentElapsedMs > timingStats.mean + timingStats.stdDev
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'green.400'
: 'green.600',
})}
>
{formatTimeCompact(currentElapsedMs)}
</span>
</div>
{/* Mini speed meter - hidden on very small screens */}
{timingStats.hasEnoughData && (
<div className={css({ display: { base: 'none', sm: 'block' } })}>
<SpeedMeter
meanMs={timingStats.mean}
stdDevMs={timingStats.stdDev}
thresholdMs={timingStats.threshold}
currentTimeMs={currentElapsedMs}
isDark={isDark}
compact={true}
/>
</div>
)}
</div>
)}
{/* Health indicator - hidden on very small screens */}
{sessionHud.sessionHealth && (
<div
data-element="session-health"
className={css({
display: { base: 'none', sm: 'flex' },
alignItems: 'center',
gap: '0.125rem',
padding: { base: '0.25rem 0.375rem', md: '0.375rem 0.5rem' },
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: { base: '6px', md: '8px' },
flexShrink: 0,
})}
>
<span className={css({ fontSize: { base: '0.875rem', md: '1rem' } })}>
{getHealthEmoji(sessionHud.sessionHealth.overall)}
</span>
<span
className={css({
fontSize: { base: '0.75rem', md: '0.875rem' },
fontWeight: 'bold',
})}
style={{
color: `var(--colors-${getHealthColor(sessionHud.sessionHealth.overall).replace('.', '-')})`,
}}
>
{Math.round(sessionHud.sessionHealth.accuracy * 100)}%
</span>
</div>
{/* Session Mood Indicator - unified timing + health display */}
{timingStats && (
<SessionMoodIndicator
currentElapsedMs={currentElapsedMs}
meanMs={timingStats.mean}
stdDevMs={timingStats.stdDev}
thresholdMs={timingStats.threshold}
hasEnoughData={timingStats.hasEnoughData}
problemsRemaining={sessionHud.totalProblems - sessionHud.completedProblems}
totalProblems={sessionHud.totalProblems}
recentResults={recentResults}
accuracy={sessionHud.sessionHealth?.accuracy ?? 1}
healthStatus={sessionHud.sessionHealth?.overall ?? 'good'}
isPaused={sessionHud.isPaused}
isDark={isDark}
/>
)}
</div>
)}

View File

@@ -0,0 +1,811 @@
'use client'
import * as Popover from '@radix-ui/react-popover'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useMemo, useState } from 'react'
import { css } from '../../../styled-system/css'
import { useIsTouchDevice } from './hooks/useDeviceDetection'
import { SpeedMeter } from './SpeedMeter'
export interface SessionMoodIndicatorProps {
/** Current elapsed time on this problem in ms */
currentElapsedMs: number
/** Mean response time for this part type */
meanMs: number
/** Standard deviation of response times */
stdDevMs: number
/** Threshold for "too slow" */
thresholdMs: number
/** Whether we have enough data for stats */
hasEnoughData: boolean
/** Number of problems remaining */
problemsRemaining: number
/** Total problems in session */
totalProblems: number
/** Recent results for accuracy display (last N) */
recentResults: boolean[]
/** Overall accuracy (0-1) */
accuracy: number
/** Session health status */
healthStatus: 'good' | 'warning' | 'struggling'
/** Is session paused */
isPaused: boolean
/** Dark mode */
isDark: boolean
}
/**
* Calculate current streak from results
*/
function calculateStreak(results: boolean[]): number {
let streak = 0
for (let i = results.length - 1; i >= 0; i--) {
if (results[i]) {
streak++
} else {
break
}
}
return streak
}
/**
* Get streak message based on length
*/
function getStreakMessage(streak: number): { text: string; fires: string } {
if (streak >= 10) return { text: 'LEGENDARY!', fires: '🔥🔥🔥🔥' }
if (streak >= 7) return { text: 'Unstoppable!', fires: '🔥🔥🔥' }
if (streak >= 5) return { text: "You're on fire!", fires: '🔥🔥' }
if (streak >= 3) return { text: 'Nice streak!', fires: '🔥' }
return { text: '', fires: '' }
}
/**
* Determine the mood based on current state
*/
function getMood(props: SessionMoodIndicatorProps): {
emoji: string
label: string
description: string
} {
const {
currentElapsedMs,
meanMs,
stdDevMs,
hasEnoughData,
healthStatus,
isPaused,
recentResults,
} = props
// Check for streak
const streak = calculateStreak(recentResults)
const lastFiveCorrect = recentResults.slice(-5)
const recentAccuracy =
lastFiveCorrect.length > 0 ? lastFiveCorrect.filter(Boolean).length / lastFiveCorrect.length : 1
// Paused state
if (isPaused) {
return {
emoji: '⏸️',
label: 'Paused',
description: "Taking a break - that's fine!",
}
}
// Calculate how slow they are on current problem
const slowThreshold = hasEnoughData ? meanMs + stdDevMs : 15000
const verySlowThreshold = hasEnoughData ? meanMs + 2 * stdDevMs : 30000
const isSlowOnCurrent = currentElapsedMs > slowThreshold
const isVerySlowOnCurrent = currentElapsedMs > verySlowThreshold
// Very stuck
if (isVerySlowOnCurrent) {
return {
emoji: '🤔',
label: 'Thinking hard',
description: "This one's tricky! Take your time.",
}
}
// On fire - fast and accurate with streak
if (streak >= 3 && healthStatus === 'good' && !isSlowOnCurrent) {
return {
emoji: '🔥',
label: 'On fire!',
description: "You're crushing it!",
}
}
// Good overall
if (healthStatus === 'good' && !isSlowOnCurrent) {
return {
emoji: '😊',
label: 'Cruising',
description: "You're doing great!",
}
}
// Bit slow but accurate
if (isSlowOnCurrent && recentAccuracy >= 0.7) {
return {
emoji: '🐢',
label: 'Slow and steady',
description: 'Taking your time - accuracy is good!',
}
}
// Warning - some mistakes or slow
if (healthStatus === 'warning') {
return {
emoji: '😌',
label: 'Hanging in there',
description: 'Keep going, you got this!',
}
}
// Struggling
if (healthStatus === 'struggling') {
return {
emoji: '💪',
label: 'Tough stretch',
description: "It's hard right now, but you're learning!",
}
}
// Default
return {
emoji: '😊',
label: 'Doing well',
description: 'Keep it up!',
}
}
/**
* Format milliseconds as kid-friendly time
*/
function formatTimeKid(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes >= 1) {
return `${minutes}m ${remainingSeconds}s`
}
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`
}
// Animation names (defined in GlobalStyles below)
const ANIM = {
pulse: 'streak-pulse',
glow: 'streak-glow',
shake: 'streak-shake',
rainbow: 'streak-rainbow',
}
/**
* Global styles for streak animations
* Injected once into the document
*/
function GlobalStreakStyles() {
return (
<style>{`
@keyframes ${ANIM.pulse} {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes ${ANIM.glow} {
0%, 100% { box-shadow: 0 0 5px rgba(251, 146, 60, 0.5); }
50% { box-shadow: 0 0 20px rgba(251, 146, 60, 0.8), 0 0 30px rgba(251, 146, 60, 0.4); }
}
@keyframes ${ANIM.shake} {
0%, 100% { transform: translateX(0) rotate(0deg); }
25% { transform: translateX(-2px) rotate(-2deg); }
75% { transform: translateX(2px) rotate(2deg); }
}
@keyframes ${ANIM.rainbow} {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
`}</style>
)
}
/**
* Streak Display Component with animations
*/
function StreakDisplay({ streak, isDark }: { streak: number; isDark: boolean }) {
if (streak < 2) return null
const { text, fires } = getStreakMessage(streak)
// Animation intensity based on streak
const animationDuration = Math.max(0.3, 1 - streak * 0.08) // Faster as streak grows
const isLegendary = streak >= 10
const isUnstoppable = streak >= 7
const isOnFire = streak >= 5
return (
<div
data-element="streak-display"
data-streak={streak}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
marginTop: '0.5rem',
borderRadius: '8px',
backgroundColor: isDark ? 'orange.900' : 'orange.50',
border: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.200',
})}
style={{
animation: isLegendary
? `${ANIM.glow} ${animationDuration}s ease-in-out infinite, ${ANIM.shake} ${animationDuration * 0.5}s ease-in-out infinite`
: isOnFire
? `${ANIM.glow} ${animationDuration}s ease-in-out infinite`
: undefined,
}}
>
{/* Fire emojis with animation */}
<span
className={css({
fontSize: isLegendary ? '1.5rem' : isUnstoppable ? '1.25rem' : '1rem',
})}
style={{
animation:
streak >= 3 ? `${ANIM.pulse} ${animationDuration}s ease-in-out infinite` : undefined,
}}
>
<span
style={{
animation: isLegendary ? `${ANIM.rainbow} 2s linear infinite` : undefined,
display: 'inline-block',
}}
>
{fires}
</span>
</span>
{/* Streak count and message */}
<div className={css({ textAlign: 'center' })}>
<div
className={css({
fontSize: isLegendary ? '1.25rem' : '1rem',
fontWeight: 'bold',
color: isDark ? 'orange.300' : 'orange.600',
})}
style={{
animation: isUnstoppable
? `${ANIM.pulse} ${animationDuration}s ease-in-out infinite`
: undefined,
}}
>
{streak} in a row!
</div>
{text && (
<div
className={css({
fontSize: '0.75rem',
fontWeight: '600',
color: isDark ? 'orange.400' : 'orange.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
{text}
</div>
)}
</div>
{/* Fire emojis on the right too for symmetry on big streaks */}
{isOnFire && (
<span
className={css({
fontSize: isLegendary ? '1.5rem' : isUnstoppable ? '1.25rem' : '1rem',
})}
style={{
animation: `${ANIM.pulse} ${animationDuration}s ease-in-out infinite`,
animationDelay: `${animationDuration / 2}s`,
}}
>
<span
style={{
animation: isLegendary ? `${ANIM.rainbow} 2s linear infinite` : undefined,
animationDelay: '1s',
display: 'inline-block',
}}
>
{fires}
</span>
</span>
)}
</div>
)
}
/**
* The tooltip/popover content
*/
function MoodContent({
props,
mood,
streak,
lastFive,
correctCount,
estimatedTimeMs,
}: {
props: SessionMoodIndicatorProps
mood: { emoji: string; label: string; description: string }
streak: number
lastFive: boolean[]
correctCount: number
estimatedTimeMs: number
}) {
const { currentElapsedMs, meanMs, stdDevMs, thresholdMs, hasEnoughData, isDark } = props
return (
<div
className={css({
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
boxShadow: 'lg',
minWidth: '280px',
maxWidth: '320px',
})}
>
{/* Header with mood */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem',
paddingBottom: '0.75rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<span className={css({ fontSize: '2.5rem' })}>{mood.emoji}</span>
<div>
<div
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{mood.label}
</div>
<div
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{mood.description}
</div>
</div>
</div>
{/* Time Remaining */}
<div
data-section="time-remaining"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
borderRadius: '8px',
})}
>
<div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '0.125rem',
})}
>
Time left
</div>
<div
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
~{formatEstimate(estimatedTimeMs)}
</div>
</div>
<div
className={css({
fontSize: '2rem',
opacity: 0.5,
})}
>
</div>
</div>
{/* Current Problem Speed */}
<div
data-section="current-speed"
className={css({
marginBottom: '1rem',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
borderRadius: '8px',
})}
>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '0.5rem',
})}
>
This problem
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '0.5rem',
})}
>
<span
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
fontFamily: 'monospace',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{formatTimeKid(currentElapsedMs)}
</span>
{hasEnoughData && (
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
(you usually take ~{formatTimeKid(meanMs)})
</span>
)}
</div>
{/* Speed Meter */}
{hasEnoughData && (
<SpeedMeter
meanMs={meanMs}
stdDevMs={stdDevMs}
thresholdMs={thresholdMs}
currentTimeMs={currentElapsedMs}
isDark={isDark}
compact={false}
averageLabel="your usual"
fastLabel="🐇 fast"
slowLabel="🐢 slow"
/>
)}
</div>
{/* Accuracy - Last 5 with Streak */}
<div
data-section="accuracy"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
borderRadius: '8px',
})}
>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '0.5rem',
})}
>
Last 5 answers
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Dots for last 5 */}
<div
className={css({
display: 'flex',
gap: '0.375rem',
})}
>
{lastFive.length === 0 ? (
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.500' : 'gray.400',
fontStyle: 'italic',
})}
>
No answers yet
</span>
) : (
lastFive.map((correct, i) => {
// Is this dot part of the current streak?
const isInStreak =
correct && i >= lastFive.length - Math.min(streak, lastFive.length)
const streakPosition = isInStreak ? lastFive.length - i : 0
return (
<span
key={i}
className={css({
width: '24px',
height: '24px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
backgroundColor: correct
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'red.900'
: 'red.100',
color: correct
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'red.300'
: 'red.700',
border: '2px solid',
borderColor: correct
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'red.700'
: 'red.300',
transition: 'all 0.2s ease',
})}
style={
isInStreak && streak >= 3
? {
animation: `${ANIM.pulse} ${Math.max(0.4, 0.8 - streakPosition * 0.1)}s ease-in-out infinite`,
animationDelay: `${streakPosition * 0.1}s`,
boxShadow:
streak >= 5
? '0 0 8px rgba(34, 197, 94, 0.6)'
: '0 0 4px rgba(34, 197, 94, 0.4)',
}
: undefined
}
>
{correct ? '✓' : '✗'}
</span>
)
})
)}
</div>
{/* Count */}
{lastFive.length > 0 && (
<span
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color:
correctCount >= 4
? isDark
? 'green.400'
: 'green.600'
: correctCount >= 2
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'red.400'
: 'red.600',
})}
>
{correctCount} right!
</span>
)}
</div>
{/* Streak Display */}
<StreakDisplay streak={streak} isDark={isDark} />
</div>
</div>
)
}
/**
* Session Mood Indicator
*
* A big emoji that synthesizes speed + accuracy into an at-a-glance mood.
* 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 isTouchDevice = useIsTouchDevice()
const [popoverOpen, setPopoverOpen] = useState(false)
const mood = getMood(props)
const estimatedTimeMs = hasEnoughData ? meanMs * problemsRemaining : problemsRemaining * 10000
// Calculate streak
const streak = useMemo(() => calculateStreak(recentResults), [recentResults])
// Last 5 results for dot display
const lastFive = recentResults.slice(-5)
const correctCount = lastFive.filter(Boolean).length
// Trigger button styles
const triggerClassName = css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'gray.100',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderColor: isDark ? 'gray.600' : 'gray.300',
transform: 'scale(1.02)',
},
})
// Animation for streak on the main button
const buttonAnimation =
streak >= 10
? `${ANIM.glow} 0.5s ease-in-out infinite`
: streak >= 5
? `${ANIM.glow} 0.8s ease-in-out infinite`
: undefined
const TriggerButton = (
<button
type="button"
data-element="session-mood-indicator"
data-streak={streak}
className={triggerClassName}
style={{
animation: buttonAnimation,
borderColor: streak >= 5 ? (isDark ? 'orange.600' : 'orange.300') : undefined,
}}
>
{/* Big emoji */}
<span
className={css({
fontSize: '2rem',
lineHeight: 1,
})}
style={{
animation:
streak >= 3
? `${ANIM.pulse} ${Math.max(0.5, 1 - streak * 0.05)}s ease-in-out infinite`
: undefined,
}}
>
{mood.emoji}
</span>
{/* Problems left + time estimate + streak indicator */}
<span
className={css({
fontSize: '0.75rem',
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.600',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
})}
>
{problemsRemaining} left · ~{Math.max(1, Math.round(estimatedTimeMs / 60000))}m
{streak >= 3 && (
<span
style={{
animation: `${ANIM.pulse} ${Math.max(0.4, 0.8 - streak * 0.05)}s ease-in-out infinite`,
}}
>
🔥
</span>
)}
</span>
</button>
)
const contentProps = {
props,
mood,
streak,
lastFive,
correctCount,
estimatedTimeMs,
}
// Use Popover for touch devices, Tooltip for desktop
if (isTouchDevice) {
return (
<>
<GlobalStreakStyles />
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>{TriggerButton}</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="bottom"
sideOffset={8}
className={css({ zIndex: 1000, outline: 'none' })}
>
<MoodContent {...contentProps} />
<Popover.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</>
)
}
return (
<>
<GlobalStreakStyles />
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{TriggerButton}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={8} className={css({ zIndex: 1000 })}>
<MoodContent {...contentProps} />
<Tooltip.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</>
)
}

View File

@@ -0,0 +1,302 @@
/**
* Session Overview Component
*
* Shows all problems in a session with detailed debugging information:
* - Auto-pause timing summary
* - Problems grouped by part (abacus, visualization, linear)
* - Per-term skill annotations with effective costs
* - Problem constraints and timing
*/
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { calculateAutoPauseInfo, formatMs, getAutoPauseExplanation } from './autoPauseCalculator'
import { DetailedProblemCard } from './DetailedProblemCard'
export interface SessionOverviewProps {
/** The session plan with all problems */
plan: SessionPlan
/** Student name for the header */
studentName: string
}
/**
* Get display label for part type
*/
function getPartTypeLabel(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return 'Part 1: Use Abacus'
case 'visualization':
return 'Part 2: Mental Math (Visualization)'
case 'linear':
return 'Part 3: Mental Math (Linear)'
}
}
/**
* Get emoji for part type
*/
function getPartTypeEmoji(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '📝'
}
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
/**
* Calculate auto-pause stats at a specific position in results
* (what the threshold would have been when this problem was presented)
*/
function calculateAutoPauseAtPosition(
allResults: SlotResult[],
position: number
): ReturnType<typeof calculateAutoPauseInfo> {
const previousResults = allResults.slice(0, position)
return calculateAutoPauseInfo(previousResults)
}
/**
* Build a mapping from (partNumber, slotIndex) to position in overall results
*/
function buildResultMap(
results: SlotResult[]
): Map<string, { result: SlotResult; position: number }> {
const map = new Map<string, { result: SlotResult; position: number }>()
results.forEach((result, position) => {
const key = `${result.partNumber}-${result.slotIndex}`
map.set(key, { result, position })
})
return map
}
export function SessionOverview({ plan, studentName }: SessionOverviewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Build result map for quick lookup
const resultMap = buildResultMap(plan.results)
// Calculate current auto-pause stats (based on all results)
const currentAutoPause = calculateAutoPauseInfo(plan.results)
// Calculate total problems and duration
const totalProblems = plan.parts.reduce((sum, part) => sum + part.slots.length, 0)
const sessionDate = plan.startedAt ?? plan.createdAt
// Track problem number across all parts
let globalProblemNumber = 0
return (
<div
data-component="session-overview"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
padding: '1.5rem',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
paddingBottom: '1rem',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
marginBottom: '0.25rem',
})}
>
Session Overview for {studentName}
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{formatDate(sessionDate)} {totalProblems} problems {plan.targetDurationMinutes}{' '}
minutes
</p>
</header>
{/* Auto-Pause Timing Summary */}
<section
data-section="auto-pause-summary"
className={css({
padding: '1rem',
borderRadius: '8px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h2
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '0.5rem',
})}
>
Auto-Pause Timing
</h2>
<div
className={css({
display: 'grid',
gap: '0.5rem',
fontSize: '0.875rem',
})}
>
<div className={css({ display: 'flex', justifyContent: 'space-between' })}>
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>
Current threshold:
</span>
<span className={css({ fontWeight: 'bold', color: isDark ? 'gray.200' : 'gray.800' })}>
{formatMs(currentAutoPause.threshold)}
</span>
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
{getAutoPauseExplanation(currentAutoPause.stats)}
</div>
{currentAutoPause.stats.sampleCount > 0 && (
<div
className={css({
display: 'flex',
gap: '1rem',
marginTop: '0.25rem',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Mean: {formatMs(currentAutoPause.stats.meanMs)}</span>
<span>Std Dev: {formatMs(currentAutoPause.stats.stdDevMs)}</span>
<span>Samples: {currentAutoPause.stats.sampleCount}</span>
</div>
)}
</div>
</section>
{/* Problems by Part */}
{plan.parts.map((part, partIndex) => (
<section
key={part.partNumber}
data-section={`part-${part.partNumber}`}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
{/* Part Header */}
<h2
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.800',
paddingBottom: '0.5rem',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
})}
>
<span>{getPartTypeEmoji(part.type)}</span>
<span>{getPartTypeLabel(part.type)}</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'normal',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
({part.slots.length} problems)
</span>
</h2>
{/* Problems */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
{part.slots.map((slot, slotIndex) => {
globalProblemNumber++
const key = `${part.partNumber}-${slotIndex}`
const resultInfo = resultMap.get(key)
// Calculate what the auto-pause would have been at this position
const position = resultInfo?.position ?? plan.results.length
const autoPauseAtPosition = calculateAutoPauseAtPosition(plan.results, position)
return (
<DetailedProblemCard
key={key}
slot={slot}
part={part}
result={resultInfo?.result}
autoPauseStats={autoPauseAtPosition.stats}
isDark={isDark}
problemNumber={globalProblemNumber}
/>
)
})}
</div>
</section>
))}
{/* Footer */}
<footer
className={css({
textAlign: 'center',
paddingTop: '1rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
Session ID: {plan.id}
</footer>
</div>
)
}
export default SessionOverview

View File

@@ -4,35 +4,11 @@ import { useEffect, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import type { AutoPauseStats, PauseInfo } from './autoPauseCalculator'
import { SpeedMeter } from './SpeedMeter'
/**
* Statistics about response times used for auto-pause threshold
*/
export interface AutoPauseStats {
/** Mean response time in milliseconds */
meanMs: number
/** Standard deviation of response times in milliseconds */
stdDevMs: number
/** Calculated threshold (mean + 2*stdDev) in milliseconds */
thresholdMs: number
/** Number of samples used to calculate stats */
sampleCount: number
/** Whether statistical calculation was used (vs default timeout) */
usedStatistics: boolean
}
/**
* Information about why and when the session was paused
*/
export interface PauseInfo {
/** When the pause occurred */
pausedAt: Date
/** Why the session was paused */
reason: 'manual' | 'auto-timeout'
/** Auto-pause statistics (only present for auto-timeout) */
autoPauseStats?: AutoPauseStats
}
// Re-export types for backwards compatibility
export type { AutoPauseStats, PauseInfo }
function getPartTypeLabel(type: SessionPart['type']): string {
switch (type) {

View File

@@ -33,8 +33,6 @@ export interface SessionProgressIndicatorProps {
isBrowseMode: boolean
/** Callback when clicking a problem in browse mode */
onNavigate?: (linearIndex: number) => void
/** Average response time in ms (for time estimate) */
averageResponseTimeMs?: number
/** Dark mode */
isDark: boolean
/** Compact mode for smaller screens */
@@ -74,17 +72,6 @@ function getSlotResult(
return results.find((r) => r.partNumber === partNumber && r.slotIndex === slotIndex)
}
/**
* Format time as "X min" or "X sec"
*/
function formatTimeEstimate(ms: number): string {
if (ms < 60000) {
return `${Math.round(ms / 1000)}s`
}
const minutes = Math.round(ms / 60000)
return `~${minutes} min`
}
/**
* Collapsed section summary - shows section as compact badge
* For completed sections: shows ✓count (green if all correct)
@@ -322,7 +309,6 @@ export function SessionProgressIndicator({
currentSlotIndex,
isBrowseMode,
onNavigate,
averageResponseTimeMs,
isDark,
compact = false,
}: SessionProgressIndicatorProps) {
@@ -335,23 +321,6 @@ export function SessionProgressIndicator({
return index + currentSlotIndex
}, [parts, currentPartIndex, currentSlotIndex])
// Calculate totals
const { totalProblems, completedProblems, remainingProblems } = useMemo(() => {
const total = parts.reduce((sum, part) => sum + part.slots.length, 0)
const completed = results.length
return {
totalProblems: total,
completedProblems: completed,
remainingProblems: total - completed,
}
}, [parts, results])
// Time estimate
const timeEstimate = useMemo(() => {
if (!averageResponseTimeMs || remainingProblems === 0) return null
return averageResponseTimeMs * remainingProblems
}, [averageResponseTimeMs, remainingProblems])
// Track linear offset for each part
let linearOffset = 0
@@ -441,45 +410,6 @@ export function SessionProgressIndicator({
)
})}
</div>
{/* Summary stats */}
<div
data-element="summary"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexShrink: 0,
paddingLeft: '0.5rem',
borderLeft: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
})}
>
{/* Progress count */}
<span
className={css({
fontSize: compact ? '0.6875rem' : '0.75rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.600',
whiteSpace: 'nowrap',
})}
>
{completedProblems}/{totalProblems}
</span>
{/* Time estimate */}
{timeEstimate && !isBrowseMode && (
<span
className={css({
fontSize: compact ? '0.625rem' : '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
whiteSpace: 'nowrap',
})}
>
{formatTimeEstimate(timeEstimate)}
</span>
)}
</div>
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { CompactLinearProblem, CompactVerticalProblem } from './CompactProblemDisplay'
interface SessionSummaryProps {
plan: SessionPlan
@@ -856,188 +857,4 @@ function isVerticalPart(type: SessionPart['type']): boolean {
return type === 'abacus' || type === 'visualization'
}
/**
* Compact vertical problem display for review
* Shows terms stacked with answer below, like a mini workbook problem
*/
function CompactVerticalProblem({
terms,
answer,
studentAnswer,
isCorrect,
isDark,
}: {
terms: number[]
answer: number
studentAnswer?: number
isCorrect?: boolean
isDark: boolean
}) {
// Calculate max digits for alignment
const maxDigits = Math.max(
...terms.map((t) => Math.abs(t).toString().length),
answer.toString().length
)
return (
<div
data-element="compact-vertical-problem"
className={css({
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'flex-end',
fontFamily: 'monospace',
fontSize: '0.75rem',
lineHeight: 1.2,
})}
>
{terms.map((term, i) => {
const isNegative = term < 0
const absValue = Math.abs(term)
const digits = absValue.toString()
const padding = maxDigits - digits.length
return (
<div
key={i}
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{/* Sign: show for negative terms, + for additions after first (but usually omit +) */}
<span
className={css({
width: '0.75em',
textAlign: 'center',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
{i === 0 ? '' : isNegative ? '' : ''}
</span>
{/* Padding for alignment */}
{padding > 0 && (
<span className={css({ visibility: 'hidden' })}>{' '.repeat(padding)}</span>
)}
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{digits}</span>
</div>
)
})}
{/* Answer line */}
<div
className={css({
display: 'flex',
alignItems: 'center',
borderTop: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
paddingTop: '0.125rem',
marginTop: '0.125rem',
})}
>
<span className={css({ width: '0.75em' })} />
<span
className={css({
color:
isCorrect === undefined
? isDark
? 'gray.400'
: 'gray.600'
: isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
fontWeight: 'bold',
})}
>
{answer}
</span>
{/* Show wrong answer if incorrect */}
{isCorrect === false && studentAnswer !== undefined && (
<span
className={css({
marginLeft: '0.25rem',
color: isDark ? 'red.400' : 'red.500',
textDecoration: 'line-through',
fontSize: '0.625rem',
})}
>
{studentAnswer}
</span>
)}
</div>
</div>
)
}
/**
* Compact linear problem display for review
* Shows equation in horizontal format: "5 + 3 - 2 = 6"
*/
function CompactLinearProblem({
terms,
answer,
studentAnswer,
isCorrect,
isDark,
}: {
terms: number[]
answer: number
studentAnswer?: number
isCorrect?: boolean
isDark: boolean
}) {
const equation = terms
.map((term, i) => {
if (i === 0) return String(term)
return term < 0 ? ` ${Math.abs(term)}` : ` + ${term}`
})
.join('')
return (
<span
data-element="compact-linear-problem"
className={css({
fontFamily: 'monospace',
fontSize: '0.8125rem',
})}
>
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{equation} = </span>
<span
className={css({
color:
isCorrect === undefined
? isDark
? 'gray.400'
: 'gray.600'
: isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
fontWeight: 'bold',
})}
>
{answer}
</span>
{/* Show wrong answer if incorrect */}
{isCorrect === false && studentAnswer !== undefined && (
<span
className={css({
marginLeft: '0.375rem',
color: isDark ? 'red.400' : 'red.500',
textDecoration: 'line-through',
})}
>
{studentAnswer}
</span>
)}
</span>
)
}
export default SessionSummary

View File

@@ -0,0 +1,317 @@
/**
* Term Skill Annotation Component
*
* Shows skills used for a single term with their effective costs.
* Effective cost = baseCost × masteryMultiplier
*
* Display format:
* - shortName: effectiveCost (base × multiplier masteryState)
* - e.g., "9=10-1: 4 (2×2 fluent)"
*/
import type { GenerationTraceStep, SkillMasteryDisplay } from '@/db/schema/session-plans'
import { getBaseComplexity } from '@/utils/skillComplexity'
import { css } from '../../../styled-system/css'
export interface TermSkillAnnotationProps {
/** The generation trace step for this term */
step: GenerationTraceStep
/** Per-skill mastery context (from GenerationTrace.skillMasteryContext) */
skillMasteryContext?: Record<string, SkillMasteryDisplay>
/** Maximum complexity budget per term (for color coding) */
maxBudget?: number
/** Minimum complexity budget per term (for color coding) */
minBudget?: number
/** Whether this is the first term (exempt from min budget) */
isFirstTerm: boolean
/** Dark mode */
isDark: boolean
/** Compact mode - just show total cost, not per-skill breakdown */
compact?: boolean
}
/**
* Format a skill ID for human-readable display
*/
function formatSkillId(skillId: string): { shortName: string; fullName: string } {
// "fiveComplements.4=5-1" → { shortName: "4=5-1", fullName: "5-complement for 4" }
// "tenComplements.9=10-1" → { shortName: "9=10-1", fullName: "10-complement for 9" }
// "basic.directAddition" → { shortName: "direct", fullName: "Direct addition" }
const parts = skillId.split('.')
const category = parts[0]
const specific = parts[1] || skillId
// For complement skills, the specific part is already descriptive
if (category === 'fiveComplements' || category === 'tenComplements') {
return { shortName: specific, fullName: `${category}: ${specific}` }
}
if (category === 'fiveComplementsSub' || category === 'tenComplementsSub') {
return { shortName: specific, fullName: `${category}: ${specific}` }
}
if (category === 'basic') {
const shortNames: Record<string, string> = {
directAddition: 'direct+',
heavenBead: 'heaven',
simpleCombinations: 'simple',
directSubtraction: 'direct-',
heavenBeadSubtraction: 'heaven-',
simpleCombinationsSub: 'simple-',
}
return {
shortName: shortNames[specific] || specific,
fullName: `Basic: ${specific}`,
}
}
return { shortName: specific, fullName: skillId }
}
/**
* Get mastery state abbreviation
*/
function getMasteryAbbrev(state: SkillMasteryDisplay['masteryState']): string {
switch (state) {
case 'effortless':
return '×1'
case 'fluent':
return '×2'
case 'rusty':
return '×3'
case 'practicing':
return '×3'
case 'not_practicing':
return '×4'
default:
return ''
}
}
/**
* Determine cost status for color coding
*/
function getCostStatus(
cost: number | undefined,
maxBudget: number | undefined,
minBudget: number | undefined,
isFirstTerm: boolean
): 'over' | 'under' | 'ok' | 'unknown' {
if (cost === undefined) return 'unknown'
if (maxBudget !== undefined && cost > maxBudget) {
return 'over'
}
if (minBudget !== undefined && cost < minBudget && !isFirstTerm) {
return 'under'
}
return 'ok'
}
/**
* Get color for cost status
*/
function getCostColor(status: 'over' | 'under' | 'ok' | 'unknown', isDark: boolean): string {
switch (status) {
case 'over':
return isDark ? '#f87171' : '#dc2626' // red
case 'under':
return isDark ? '#fbbf24' : '#d97706' // yellow/amber
case 'ok':
return isDark ? '#4ade80' : '#16a34a' // green
case 'unknown':
return isDark ? '#9ca3af' : '#6b7280' // gray
}
}
/**
* Get status indicator icon
*/
function getCostStatusIcon(status: 'over' | 'under' | 'ok' | 'unknown'): string {
switch (status) {
case 'over':
return '✗'
case 'under':
return '⚠'
case 'ok':
return '✓'
case 'unknown':
return ''
}
}
export function TermSkillAnnotation({
step,
skillMasteryContext,
maxBudget,
minBudget,
isFirstTerm,
isDark,
compact = false,
}: TermSkillAnnotationProps) {
const { skillsUsed, complexityCost } = step
// No skills = nothing to show
if (skillsUsed.length === 0) {
return (
<span
className={css({
color: isDark ? 'gray.500' : 'gray.400',
fontSize: '0.75rem',
fontStyle: 'italic',
})}
>
(no skills)
</span>
)
}
const costStatus = getCostStatus(complexityCost, maxBudget, minBudget, isFirstTerm)
const costColor = getCostColor(costStatus, isDark)
const statusIcon = getCostStatusIcon(costStatus)
// Compact mode: just show total cost with color
if (compact) {
return (
<span
data-element="term-skill-annotation-compact"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
fontSize: '0.75rem',
fontFamily: 'monospace',
})}
style={{ color: costColor }}
>
[{complexityCost ?? '?'}]{statusIcon && <span>{statusIcon}</span>}
{isFirstTerm && minBudget !== undefined && (
<span className={css({ fontSize: '0.625rem', color: isDark ? 'gray.500' : 'gray.400' })}>
*
</span>
)}
</span>
)
}
// Full mode: show per-skill breakdown
return (
<div
data-element="term-skill-annotation"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
fontSize: '0.6875rem',
fontFamily: 'monospace',
lineHeight: 1.3,
})}
>
{skillsUsed.map((skillId, i) => {
const { shortName } = formatSkillId(skillId)
const masteryInfo = skillMasteryContext?.[skillId]
// Use mastery context if available, otherwise fall back to base cost
const baseCost = masteryInfo?.baseCost ?? getBaseComplexity(skillId)
const effectiveCost = masteryInfo?.effectiveCost ?? baseCost
const masteryState = masteryInfo?.masteryState
return (
<span
key={i}
className={css({
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{shortName}: <span style={{ color: costColor, fontWeight: 500 }}>{effectiveCost}</span>
{masteryState && (
<span className={css({ color: isDark ? 'gray.500' : 'gray.400' })}>
{' '}
({baseCost}
{getMasteryAbbrev(masteryState)})
</span>
)}
</span>
)
})}
{/* Total cost line */}
<span
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
marginTop: '0.125rem',
paddingTop: '0.125rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
fontWeight: 'bold',
})}
style={{ color: costColor }}
>
= {complexityCost ?? '?'}
{statusIcon && <span>{statusIcon}</span>}
{isFirstTerm && minBudget !== undefined && (
<span
className={css({
fontSize: '0.5625rem',
color: isDark ? 'gray.500' : 'gray.400',
fontWeight: 'normal',
})}
>
(1st exempt)
</span>
)}
</span>
</div>
)
}
/**
* Inline version for use next to terms in vertical/linear problems
* Shows skills in a single line with abbreviated format
*/
export function InlineTermSkillAnnotation({
step,
skillMasteryContext,
isDark,
}: {
step: GenerationTraceStep
skillMasteryContext?: Record<string, SkillMasteryDisplay>
isDark: boolean
}) {
const { skillsUsed, complexityCost } = step
if (skillsUsed.length === 0) {
return null
}
const skillsText = skillsUsed
.map((skillId) => {
const { shortName } = formatSkillId(skillId)
const masteryInfo = skillMasteryContext?.[skillId]
const effectiveCost = masteryInfo?.effectiveCost ?? getBaseComplexity(skillId)
return `${shortName}(${effectiveCost})`
})
.join(', ')
return (
<span
data-element="inline-term-skill-annotation"
className={css({
fontSize: '0.625rem',
fontFamily: 'monospace',
color: isDark ? 'gray.500' : 'gray.400',
whiteSpace: 'nowrap',
})}
>
{skillsText}
{complexityCost !== undefined && (
<span className={css({ marginLeft: '0.25rem', fontWeight: 'bold' })}>
={complexityCost}
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,162 @@
/**
* Auto-pause threshold calculation utilities
*
* Calculates when to auto-pause based on the student's response times.
* Uses mean + 2*stdDev with clamping between 30s and 5 minutes.
*/
import type { SlotResult } from '@/db/schema/session-plans'
// ============================================================================
// Constants
// ============================================================================
/** Default timeout when not enough samples for statistics (5 minutes) */
export const DEFAULT_PAUSE_TIMEOUT_MS = 5 * 60 * 1000
/** Minimum problems needed for statistical calculation */
export const MIN_SAMPLES_FOR_STATISTICS = 5
/** Minimum clamp for the auto-pause threshold (30 seconds) */
export const MIN_PAUSE_THRESHOLD_MS = 30_000
/** Maximum clamp for the auto-pause threshold (5 minutes) */
export const MAX_PAUSE_THRESHOLD_MS = DEFAULT_PAUSE_TIMEOUT_MS
// ============================================================================
// Types
// ============================================================================
/**
* Auto-pause statistics for display and debugging
*/
export interface AutoPauseStats {
/** Mean response time in milliseconds */
meanMs: number
/** Standard deviation of response times in milliseconds */
stdDevMs: number
/** Calculated threshold (mean + 2*stdDev) in milliseconds */
thresholdMs: number
/** Number of samples used to calculate stats */
sampleCount: number
/** Whether statistical calculation was used (vs default timeout) */
usedStatistics: boolean
}
/**
* Information about why a session was paused
*/
export interface PauseInfo {
/** When the pause occurred */
pausedAt: Date
/** Why the session was paused */
reason: 'manual' | 'auto-timeout'
/** Auto-pause statistics (only present for auto-timeout) */
autoPauseStats?: AutoPauseStats
}
/**
* Response time statistics
*/
export interface ResponseTimeStats {
/** Mean response time in milliseconds */
mean: number
/** Standard deviation of response times in milliseconds */
stdDev: number
/** Number of samples */
count: number
}
// ============================================================================
// Functions
// ============================================================================
/**
* Calculate mean and standard deviation of response times
*/
export function calculateResponseTimeStats(results: SlotResult[]): ResponseTimeStats {
if (results.length === 0) {
return { mean: 0, stdDev: 0, count: 0 }
}
const times = results.map((r) => r.responseTimeMs)
const count = times.length
const mean = times.reduce((sum, t) => sum + t, 0) / count
if (count < 2) {
return { mean, stdDev: 0, count }
}
const squaredDiffs = times.map((t) => (t - mean) ** 2)
const variance = squaredDiffs.reduce((sum, d) => sum + d, 0) / (count - 1) // Sample std dev
const stdDev = Math.sqrt(variance)
return { mean, stdDev, count }
}
/**
* Calculate the auto-pause threshold and full stats for display.
*
* @returns threshold in ms and stats for debugging/display
*/
export function calculateAutoPauseInfo(results: SlotResult[]): {
threshold: number
stats: AutoPauseStats
} {
const { mean, stdDev, count } = calculateResponseTimeStats(results)
const usedStatistics = count >= MIN_SAMPLES_FOR_STATISTICS
let threshold: number
if (usedStatistics) {
// Use mean + 2 standard deviations
threshold = mean + 2 * stdDev
// Clamp between 30 seconds and 5 minutes
threshold = Math.max(MIN_PAUSE_THRESHOLD_MS, Math.min(threshold, MAX_PAUSE_THRESHOLD_MS))
} else {
threshold = DEFAULT_PAUSE_TIMEOUT_MS
}
return {
threshold,
stats: {
meanMs: mean,
stdDevMs: stdDev,
thresholdMs: threshold,
sampleCount: count,
usedStatistics,
},
}
}
/**
* Get a human-readable explanation of how the auto-pause threshold was calculated.
* Used in the SessionOverview component to explain the timing.
*/
export function getAutoPauseExplanation(stats: AutoPauseStats): string {
if (!stats.usedStatistics) {
return `Default timeout (${formatMs(stats.thresholdMs)}) - need ${MIN_SAMPLES_FOR_STATISTICS}+ problems for statistical calculation`
}
const rawThreshold = stats.meanMs + 2 * stats.stdDevMs
const wasClamped = rawThreshold < MIN_PAUSE_THRESHOLD_MS || rawThreshold > MAX_PAUSE_THRESHOLD_MS
let explanation = `mean (${formatMs(stats.meanMs)}) + 2×stdDev (${formatMs(stats.stdDevMs)}) = ${formatMs(rawThreshold)}`
if (wasClamped) {
explanation += ` → clamped to ${formatMs(stats.thresholdMs)}`
}
return explanation
}
/**
* Format milliseconds as a human-readable time string
*/
export function formatMs(ms: number): string {
if (ms >= 60_000) {
const minutes = ms / 60_000
return `${minutes.toFixed(1)}m`
}
const seconds = ms / 1000
return `${seconds.toFixed(1)}s`
}

View File

@@ -8,8 +8,8 @@
* - SessionSummary: Results after completing session
*/
export { ActiveSession } from './ActiveSession'
export type { AttemptTimingData, StudentInfo } from './ActiveSession'
export { ActiveSession } from './ActiveSession'
export { ContinueSessionCard } from './ContinueSessionCard'
// Hooks
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'
@@ -17,14 +17,16 @@ export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export type { SessionHudData } from './PracticeSubNav'
export { PracticeSubNav } from './PracticeSubNav'
export type { SessionProgressIndicatorProps } from './SessionProgressIndicator'
export { SessionProgressIndicator } from './SessionProgressIndicator'
export { PracticeTimingDisplay } from './PracticeTimingDisplay'
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
export { ProgressDashboard } from './ProgressDashboard'
export type { SessionMoodIndicatorProps } from './SessionMoodIndicator'
export { SessionMoodIndicator } from './SessionMoodIndicator'
export { SessionOverview } from './SessionOverview'
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
export { SessionPausedModal } from './SessionPausedModal'
export { SessionOverview } from './SessionOverview'
export type { SessionProgressIndicatorProps } from './SessionProgressIndicator'
export { SessionProgressIndicator } from './SessionProgressIndicator'
export { SessionSummary } from './SessionSummary'
export { SkillPerformanceReports } from './SkillPerformanceReports'
export type { SpeedMeterProps } from './SpeedMeter'

View File

@@ -141,7 +141,6 @@ export const FLUENCY_CONFIG = {
...FLUENCY_RECENCY,
} as const
/**
* Check if a student has achieved fluency in a skill based on their practice history.
* Fluency = high accuracy + consistent performance (consecutive correct answers)

View File

@@ -1,7 +1,5 @@
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import type { SkillSet } from '@/types/tutorial'
import { players } from './players'
import {
DEFAULT_SECONDS_PER_PROBLEM,
PART_TIME_WEIGHTS,
@@ -11,6 +9,8 @@ import {
SESSION_TIMEOUT_HOURS,
TERM_COUNT_RANGES,
} from '@/lib/curriculum/config'
import type { SkillSet } from '@/types/tutorial'
import { players } from './players'
// ============================================================================
// Types for JSON fields
@@ -106,12 +106,13 @@ export interface GenerationTraceStep {
}
/**
* Skill mastery context for a single skill - captured at generation time
* Skill mastery context for a single skill - captured at generation time.
* This matches the MasteryState type from @/lib/curriculum/config/skill-costs.ts
*/
export interface SkillMasteryDisplay {
/** Mastery level at generation time */
masteryLevel: 'effortless' | 'fluent' | 'rusty' | 'practicing' | 'learning'
/** Base complexity cost (intrinsic to skill) */
/** Mastery state at generation time (matches MasteryState) */
masteryState: 'effortless' | 'fluent' | 'rusty' | 'practicing' | 'not_practicing'
/** Base complexity cost (intrinsic to skill, 0-3) */
baseCost: number
/** Effective cost for this student (baseCost × masteryMultiplier) */
effectiveCost: number

View File

@@ -11,8 +11,9 @@
import type { GeneratedProblem, ProblemConstraints } from '@/db/schema/session-plans'
import { createBasicSkillSet, type SkillSet } from '@/types/tutorial'
import {
type GenerationDiagnostics,
type ProblemConstraints as GeneratorConstraints,
generateSingleProblem,
generateSingleProblemWithDiagnostics,
} from '@/utils/problemGenerator'
import type { SkillCostCalculator } from '@/utils/skillComplexity'
@@ -22,13 +23,58 @@ import type { SkillCostCalculator } from '@/utils/skillComplexity'
export class ProblemGenerationError extends Error {
constructor(
message: string,
public readonly constraints: ProblemConstraints
public readonly constraints: ProblemConstraints,
public readonly diagnostics?: GenerationDiagnostics
) {
super(message)
this.name = 'ProblemGenerationError'
}
}
/**
* Format diagnostics into an actionable error message
*/
function formatDiagnosticsMessage(diagnostics: GenerationDiagnostics): string {
const lines: string[] = []
// Identify the main failure mode
if (diagnostics.sequenceFailures === diagnostics.totalAttempts) {
lines.push('CAUSE: All attempts failed during sequence generation.')
lines.push(
'This means no valid sequence of terms could be built with the given skill/budget constraints.'
)
if (diagnostics.enabledRequiredSkills.length === 0) {
lines.push('FIX: No required skills are enabled - enable at least some basic skills.')
} else {
lines.push(`Enabled skills: ${diagnostics.enabledRequiredSkills.slice(0, 5).join(', ')}...`)
}
} else if (diagnostics.skillMatchFailures > 0) {
lines.push(
`CAUSE: ${diagnostics.skillMatchFailures}/${diagnostics.totalAttempts} attempts generated problems but they didn't match skill requirements.`
)
if (diagnostics.lastGeneratedSkills) {
lines.push(`Last problem used skills: ${diagnostics.lastGeneratedSkills.join(', ')}`)
}
if (diagnostics.enabledTargetSkills.length > 0) {
lines.push(
`Target skills required: ${diagnostics.enabledTargetSkills.slice(0, 5).join(', ')}`
)
lines.push('FIX: Generated problems may not naturally use the target skills.')
}
} else if (diagnostics.sumConstraintFailures > 0) {
lines.push(`CAUSE: ${diagnostics.sumConstraintFailures} attempts failed sum constraints.`)
lines.push('FIX: Adjust min/max sum constraints or number range.')
}
// Add stats
lines.push('')
lines.push(
`Stats: ${diagnostics.totalAttempts} attempts = ${diagnostics.sequenceFailures} seq failures + ${diagnostics.sumConstraintFailures} sum failures + ${diagnostics.skillMatchFailures} skill failures`
)
return lines.join('\n')
}
/**
* Generate a problem from slot constraints using the skill-based algorithm.
*
@@ -79,7 +125,7 @@ export function generateProblemFromConstraints(
maxComplexityBudgetPerTerm: constraints.maxComplexityBudgetPerTerm,
}
const generatedProblem = generateSingleProblem({
const { problem: generatedProblem, diagnostics } = generateSingleProblemWithDiagnostics({
constraints: generatorConstraints,
requiredSkills,
targetSkills: constraints.targetSkills,
@@ -96,11 +142,15 @@ export function generateProblemFromConstraints(
}
}
// No fallback - surface the error so it can be addressed
throw new ProblemGenerationError(
`Failed to generate problem with constraints: termCount=${constraints.termCount?.min}-${constraints.termCount?.max}, ` +
`digitRange=${constraints.digitRange?.min}-${constraints.digitRange?.max}, ` +
`requiredSkills=${Object.keys(constraints.requiredSkills || {}).length} categories`,
constraints
)
// Build actionable error message
const basicInfo =
`Failed to generate problem with constraints:\n` +
` termCount: ${constraints.termCount?.min}-${constraints.termCount?.max}\n` +
` digitRange: ${constraints.digitRange?.min}-${constraints.digitRange?.max}\n` +
` minComplexityBudget: ${constraints.minComplexityBudgetPerTerm ?? 'none'}\n` +
` maxComplexityBudget: ${constraints.maxComplexityBudgetPerTerm ?? 'none'}\n`
const diagnosticsMessage = formatDiagnosticsMessage(diagnostics)
throw new ProblemGenerationError(`${basicInfo}\n${diagnosticsMessage}`, constraints, diagnostics)
}