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:
@@ -179,7 +179,6 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
isBrowseMode={isBrowseMode}
|
||||
browseIndex={browseIndex}
|
||||
onBrowseIndexChange={setBrowseIndex}
|
||||
onExitBrowse={() => setIsBrowseMode(false)}
|
||||
/>
|
||||
</PracticeErrorBoundary>
|
||||
</main>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
197
apps/web/src/components/practice/CompactProblemDisplay.tsx
Normal file
197
apps/web/src/components/practice/CompactProblemDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1114
apps/web/src/components/practice/DetailedProblemCard.tsx
Normal file
1114
apps/web/src/components/practice/DetailedProblemCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
811
apps/web/src/components/practice/SessionMoodIndicator.tsx
Normal file
811
apps/web/src/components/practice/SessionMoodIndicator.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
302
apps/web/src/components/practice/SessionOverview.tsx
Normal file
302
apps/web/src/components/practice/SessionOverview.tsx
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
317
apps/web/src/components/practice/TermSkillAnnotation.tsx
Normal file
317
apps/web/src/components/practice/TermSkillAnnotation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
apps/web/src/components/practice/autoPauseCalculator.ts
Normal file
162
apps/web/src/components/practice/autoPauseCalculator.ts
Normal 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`
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user