feat(practice): add browse mode navigation and improve SpeedMeter timing display

Browse Mode:
- Add SessionProgressIndicator with collapsible sections for practice/browse modes
- Add BrowseModeView for reviewing problems during practice
- Navigation via clicking progress indicator slots in browse mode
- "Practice This Problem" button to exit browse mode at current problem
- Collapse non-current sections in practice mode (shows ✓count or problem count)

SpeedMeter:
- Add actual time labels (0s, ~Xs avg, Xs pause) positioned under markers
- Extend scale to 120% of threshold so threshold marker isn't always at edge
- Kid-friendly time formatting (8s, 30s, 2m)
- Label overlap detection - combines labels when mean is close to threshold
- Remove unused averageLabel/fastLabel/slowLabel props

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-13 22:05:14 -06:00
parent 6c88dcfdc5
commit 3c52e607b3
9 changed files with 1020 additions and 412 deletions

View File

@ -40,6 +40,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
const [isPaused, setIsPaused] = useState(false) const [isPaused, setIsPaused] = useState(false)
// Track timing data from ActiveSession for the sub-nav HUD // Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null) const [timingData, setTimingData] = useState<AttemptTimingData | null>(null)
// Browse mode state - lifted here so PracticeSubNav can trigger it
const [isBrowseMode, setIsBrowseMode] = useState(false)
// Browse index - lifted for navigation from SessionProgressIndicator
const [browseIndex, setBrowseIndex] = useState(0)
// Session plan mutations // Session plan mutations
const recordResult = useRecordSlotResult() const recordResult = useRecordSlotResult()
@ -116,12 +120,15 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
const sessionHud: SessionHudData | undefined = currentPart const sessionHud: SessionHudData | undefined = currentPart
? { ? {
isPaused, isPaused,
parts: currentPlan.parts,
currentPartIndex: currentPlan.currentPartIndex,
currentPart: { currentPart: {
type: currentPart.type, type: currentPart.type,
partNumber: currentPart.partNumber, partNumber: currentPart.partNumber,
totalSlots: currentPart.slots.length, totalSlots: currentPart.slots.length,
}, },
currentSlotIndex: currentPlan.currentSlotIndex, currentSlotIndex: currentPlan.currentSlotIndex,
results: currentPlan.results,
completedProblems, completedProblems,
totalProblems, totalProblems,
sessionHealth: sessionHealth sessionHealth: sessionHealth
@ -142,6 +149,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onPause: handlePause, onPause: handlePause,
onResume: handleResume, onResume: handleResume,
onEndEarly: () => handleEndEarly('Session ended'), onEndEarly: () => handleEndEarly('Session ended'),
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
onBrowseNavigate: setBrowseIndex,
} }
: undefined : undefined
@ -166,7 +176,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onResume={handleResume} onResume={handleResume}
onComplete={handleSessionComplete} onComplete={handleSessionComplete}
onTimingUpdate={setTimingData} onTimingUpdate={setTimingData}
hideHud={true} isBrowseMode={isBrowseMode}
browseIndex={browseIndex}
onBrowseIndexChange={setBrowseIndex}
onExitBrowse={() => setIsBrowseMode(false)}
/> />
</PracticeErrorBoundary> </PracticeErrorBoundary>
</main> </main>

View File

@ -13,7 +13,10 @@ import type {
SlotResult, SlotResult,
} from '@/db/schema/session-plans' } from '@/db/schema/session-plans'
import { SessionPausedModal, type AutoPauseStats, type PauseInfo } from './SessionPausedModal' import { css } from '../../../styled-system/css'
import { type AutoPauseStats, calculateAutoPauseInfo, type PauseInfo } from './autoPauseCalculator'
import { BrowseModeView, getLinearIndex } from './BrowseModeView'
import { SessionPausedModal } from './SessionPausedModal'
// Re-export types for consumers // Re-export types for consumers
export type { AutoPauseStats, PauseInfo } export type { AutoPauseStats, PauseInfo }
@ -27,73 +30,6 @@ export interface StudentInfo {
color: string color: string
} }
// ============================================================================
// Auto-pause threshold calculation
// ============================================================================
const DEFAULT_PAUSE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes default
const MIN_SAMPLES_FOR_STATISTICS = 5 // Minimum problems needed for statistical calculation
/**
* Calculate mean and standard deviation of response times
*/
function calculateResponseTimeStats(results: SlotResult[]): {
mean: number
stdDev: number
count: number
} {
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.
*/
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(30_000, Math.min(threshold, DEFAULT_PAUSE_TIMEOUT_MS))
} else {
threshold = DEFAULT_PAUSE_TIMEOUT_MS
}
return {
threshold,
stats: {
meanMs: mean,
stdDevMs: stdDev,
thresholdMs: threshold,
sampleCount: count,
usedStatistics,
},
}
}
import { css } from '../../../styled-system/css'
import { AbacusDock } from '../AbacusDock' import { AbacusDock } from '../AbacusDock'
import { DecompositionProvider, DecompositionSection } from '../decomposition' import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { Tooltip, TooltipProvider } from '../ui/Tooltip' import { Tooltip, TooltipProvider } from '../ui/Tooltip'
@ -130,38 +66,16 @@ interface ActiveSessionProps {
onResume?: () => void onResume?: () => void
/** Called when session completes */ /** Called when session completes */
onComplete: () => void onComplete: () => void
/** Hide the built-in HUD (when using external HUD in PracticeSubNav) */
hideHud?: boolean
/** Called with timing data when it changes (for external timing display) */ /** Called with timing data when it changes (for external timing display) */
onTimingUpdate?: (timing: AttemptTimingData | null) => void onTimingUpdate?: (timing: AttemptTimingData | null) => void
} /** Whether browse mode is active (controlled externally via toggle in PracticeSubNav) */
isBrowseMode?: boolean
/** /** Controlled browse index (linear problem index) */
* Get the part type description for display browseIndex?: number
*/ /** Called when browse index changes (for external navigation from progress indicator) */
function getPartTypeLabel(type: SessionPart['type']): string { onBrowseIndexChange?: (index: number) => void
switch (type) { /** Called when user wants to exit browse mode and return to practice */
case 'abacus': onExitBrowse?: () => void
return 'Use Abacus'
case 'visualization':
return 'Mental Math (Visualization)'
case 'linear':
return 'Mental Math (Linear)'
}
}
/**
* Get part type emoji
*/
function getPartTypeEmoji(type: SessionPart['type']): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
}
} }
/** /**
@ -590,8 +504,11 @@ export function ActiveSession({
onPause, onPause,
onResume, onResume,
onComplete, onComplete,
hideHud = false,
onTimingUpdate, onTimingUpdate,
isBrowseMode: isBrowseModeProp = false,
browseIndex: browseIndexProp,
onBrowseIndexChange,
onExitBrowse,
}: ActiveSessionProps) { }: ActiveSessionProps) {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
@ -679,6 +596,42 @@ export function ActiveSession({
// Track pause info for displaying in the modal (single source of truth) // Track pause info for displaying in the modal (single source of truth)
const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined) const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
// Determine if browse index is controlled
const isControlledBrowseIndex = browseIndexProp !== undefined
const browseIndex = isControlledBrowseIndex ? browseIndexProp : internalBrowseIndex
// Unified setter that handles both controlled and uncontrolled modes
const setBrowseIndex = useCallback(
(index: number | ((prev: number) => number)) => {
const newIndex = typeof index === 'function' ? index(browseIndex) : index
if (isControlledBrowseIndex) {
onBrowseIndexChange?.(newIndex)
} else {
setInternalBrowseIndex(newIndex)
}
},
[browseIndex, isControlledBrowseIndex, onBrowseIndexChange]
)
// Compute current practice position as a linear index
const currentPracticeLinearIndex = useMemo(() => {
return getLinearIndex(plan.parts, plan.currentPartIndex, plan.currentSlotIndex)
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
// When entering browse mode, initialize browseIndex to current problem
const prevBrowseModeProp = useRef(isBrowseModeProp)
useEffect(() => {
if (isBrowseModeProp && !prevBrowseModeProp.current) {
// Just entered browse mode - set to current practice position
setBrowseIndex(currentPracticeLinearIndex)
}
prevBrowseModeProp.current = isBrowseModeProp
}, [isBrowseModeProp, currentPracticeLinearIndex, setBrowseIndex])
// Track last resume time to reset auto-pause timer after resuming // Track last resume time to reset auto-pause timer after resuming
const lastResumeTimeRef = useRef<number>(0) const lastResumeTimeRef = useRef<number>(0)
@ -1172,6 +1125,18 @@ export function ActiveSession({
) )
} }
// Browse mode - show the browse view instead of the practice view
if (isBrowseModeProp) {
return (
<BrowseModeView
plan={plan}
browseIndex={browseIndex}
currentPracticeIndex={currentPracticeLinearIndex}
onExitBrowse={onExitBrowse}
/>
)
}
return ( return (
<div <div
data-component="active-session" data-component="active-session"
@ -1187,212 +1152,6 @@ export function ActiveSession({
margin: '0 auto', margin: '0 auto',
})} })}
> >
{/* Practice Session HUD - Control bar with session info and tape-deck controls */}
{/* Only render if hideHud is false (default) - when using external HUD in PracticeSubNav */}
{!hideHud && (
<div
data-section="session-hud"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1rem',
backgroundColor: 'gray.900',
borderRadius: '12px',
boxShadow: 'lg',
})}
>
{/* Tape deck controls */}
<div
data-element="transport-controls"
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{/* Pause/Play button */}
<button
type="button"
data-action={isPaused ? 'resume' : 'pause'}
onClick={isPaused ? handleResume : () => handlePause()}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'white',
backgroundColor: isPaused ? 'green.500' : 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: isPaused ? 'green.400' : 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isPaused ? 'green.400' : 'gray.600',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label={isPaused ? 'Resume session' : 'Pause session'}
>
{isPaused ? '▶' : '⏸'}
</button>
{/* Stop button */}
<button
type="button"
data-action="end-early"
onClick={() => onEndEarly('Session ended')}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'red.300',
backgroundColor: 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: 'red.900',
borderColor: 'red.700',
color: 'red.200',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label="End session"
>
</button>
</div>
{/* Session info display */}
<div
data-element="session-info"
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
})}
>
{/* Part type with emoji */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '1rem',
})}
>
{getPartTypeEmoji(currentPart.type)}
</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'white',
})}
>
Part {currentPart.partNumber}: {getPartTypeLabel(currentPart.type)}
</span>
</div>
{/* Progress within part */}
<div
className={css({
fontSize: '0.75rem',
color: 'gray.400',
})}
>
Problem {currentSlotIndex + 1} of {currentPart.slots.length} in this part
</div>
</div>
{/* Overall progress and health */}
<div
data-element="progress-display"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Problem counter */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
})}
>
<div
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
fontFamily: 'monospace',
})}
>
{completedProblems + 1}/{totalProblems}
</div>
<div
className={css({
fontSize: '0.625rem',
color: 'gray.500',
textTransform: 'uppercase',
})}
>
Total
</div>
</div>
{/* Health indicator */}
{sessionHealth && (
<div
data-element="session-health"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '0.25rem 0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
})}
>
<span className={css({ fontSize: '1rem' })}>
{getHealthEmoji(sessionHealth.overall)}
</span>
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: getHealthColor(sessionHealth.overall),
})}
>
{Math.round(sessionHealth.accuracy * 100)}%
</span>
</div>
)}
</div>
</div>
)}
{/* Problem display */} {/* Problem display */}
<div <div
data-section="problem-area" data-section="problem-area"

View File

@ -0,0 +1,251 @@
/**
* Browse Mode View Component
*
* Allows browsing through all problems in a session during practice.
* Shows problems using DetailedProblemCard.
* Navigation is handled via SessionProgressIndicator in the nav bar.
* Does not affect actual session progress - just for viewing.
*/
'use client'
import { useMemo } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { ProblemSlot, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { calculateAutoPauseInfo } from './autoPauseCalculator'
import { DetailedProblemCard } from './DetailedProblemCard'
/**
* Flattened problem item with all context needed for display
*/
export interface LinearProblemItem {
partNumber: number
slotIndex: number
slot: ProblemSlot
part: SessionPart
linearIndex: number
}
/**
* Build a flattened list of all problems for navigation
*/
export function buildLinearProblemList(parts: SessionPart[]): LinearProblemItem[] {
const items: LinearProblemItem[] = []
let linearIndex = 0
for (const part of parts) {
for (let slotIndex = 0; slotIndex < part.slots.length; slotIndex++) {
items.push({
partNumber: part.partNumber,
slotIndex,
slot: part.slots[slotIndex],
part,
linearIndex,
})
linearIndex++
}
}
return items
}
/**
* Convert current part/slot indices to linear index
*/
export function getLinearIndex(
parts: SessionPart[],
currentPartIndex: number,
currentSlotIndex: number
): number {
let index = 0
for (let i = 0; i < currentPartIndex; i++) {
index += parts[i].slots.length
}
return index + currentSlotIndex
}
export interface BrowseModeViewProps {
/** The session plan with all problems */
plan: SessionPlan
/** Current browse index (linear) */
browseIndex: number
/** The actual current practice problem index (to highlight) */
currentPracticeIndex: number
/** Called when user wants to exit browse mode and practice the current problem */
onExitBrowse?: () => void
}
/**
* Get result for a specific problem if it exists
*/
function getResultForProblem(
results: SlotResult[],
partNumber: number,
slotIndex: number
): SlotResult | undefined {
return results.find((r) => r.partNumber === partNumber && r.slotIndex === slotIndex)
}
export function BrowseModeView({
plan,
browseIndex,
currentPracticeIndex,
onExitBrowse,
}: BrowseModeViewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Build linear problem list
const linearProblems = useMemo(() => buildLinearProblemList(plan.parts), [plan.parts])
const currentItem = linearProblems[browseIndex]
// Get result for current browse item
const result = useMemo(() => {
if (!currentItem) return undefined
return getResultForProblem(plan.results, currentItem.partNumber, currentItem.slotIndex)
}, [plan.results, currentItem])
// Calculate auto-pause stats at this position
const autoPauseStats = useMemo(() => {
if (!currentItem) return undefined
// Find the position in results where this problem would be
const resultsUpToHere = plan.results.filter((r) => {
const rLinear = linearProblems.findIndex(
(p) => p.partNumber === r.partNumber && p.slotIndex === r.slotIndex
)
return rLinear < browseIndex
})
return calculateAutoPauseInfo(resultsUpToHere).stats
}, [plan.results, linearProblems, browseIndex, currentItem])
// Is this the current practice problem?
const isCurrentPractice = browseIndex === currentPracticeIndex
const isCompleted = browseIndex < currentPracticeIndex
const isUpcoming = browseIndex > currentPracticeIndex
if (!currentItem) {
return (
<div
className={css({
padding: '2rem',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
No problems to display
</div>
)
}
return (
<div
data-component="browse-mode-view"
data-browse-index={browseIndex}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1rem',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Current Practice Indicator */}
{isCurrentPractice && (
<div
className={css({
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.200',
textAlign: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'yellow.200' : 'yellow.700',
})}
>
This is your current practice problem
</div>
)}
{/* Problem Display */}
<DetailedProblemCard
slot={currentItem.slot}
part={currentItem.part}
result={result}
autoPauseStats={autoPauseStats}
isDark={isDark}
problemNumber={browseIndex + 1}
/>
{/* Action Button */}
<div
data-element="browse-action"
className={css({
display: 'flex',
justifyContent: 'center',
padding: '0.5rem 0',
})}
>
{isCurrentPractice && onExitBrowse && (
<button
type="button"
data-action="practice-this-problem"
onClick={onExitBrowse}
className={css({
padding: '0.75rem 1.5rem',
fontSize: '1rem',
fontWeight: 'bold',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
backgroundColor: isDark ? 'green.600' : 'green.500',
color: 'white',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'green.500' : 'green.600',
transform: 'scale(1.02)',
},
_active: {
transform: 'scale(0.98)',
},
})}
>
Practice This Problem
</button>
)}
{isCompleted && (
<div
data-status="completed"
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
borderRadius: '6px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
This problem was already completed
</div>
)}
{isUpcoming && (
<div
data-status="upcoming"
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
borderRadius: '6px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
This problem hasn't been reached yet
</div>
)}
</div>
</div>
)
}

View File

@ -114,23 +114,57 @@ function createSessionHud(config: {
totalProblems: number totalProblems: number
timing?: TimingData timing?: TimingData
health?: { overall: 'good' | 'warning' | 'struggling'; accuracy: number } health?: { overall: 'good' | 'warning' | 'struggling'; accuracy: number }
isBrowseMode?: boolean
}): SessionHudData { }): SessionHudData {
const partNumber = config.partType === 'abacus' ? 1 : config.partType === 'visualization' ? 2 : 3 const partNumber = config.partType === 'abacus' ? 1 : config.partType === 'visualization' ? 2 : 3
const parts = createMockParts()
const currentPartIndex = partNumber - 1
// Create mock results based on completedProblems
const results: SlotResult[] = []
let remaining = config.completedProblems
for (let pIdx = 0; pIdx < parts.length && remaining > 0; pIdx++) {
const part = parts[pIdx]
const slotsToFill = Math.min(remaining, part.slots.length)
for (let sIdx = 0; sIdx < slotsToFill; sIdx++) {
results.push({
partNumber: (pIdx + 1) as 1 | 2 | 3,
slotIndex: sIdx,
problem: { terms: [3, 4], answer: 7, skillsRequired: ['basic.directAddition'] },
studentAnswer: 7,
isCorrect: Math.random() > 0.15,
responseTimeMs: 2500 + Math.random() * 3000,
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: pIdx === 0,
timestamp: new Date(),
helpLevelUsed: 0,
incorrectAttempts: 0,
})
}
remaining -= slotsToFill
}
return { return {
isPaused: config.isPaused ?? false, isPaused: config.isPaused ?? false,
parts,
currentPartIndex,
currentPart: { currentPart: {
type: config.partType, type: config.partType,
partNumber, partNumber,
totalSlots: 5, totalSlots: 5,
}, },
currentSlotIndex: config.completedProblems % 5, currentSlotIndex: config.completedProblems % 5,
results,
completedProblems: config.completedProblems, completedProblems: config.completedProblems,
totalProblems: config.totalProblems, totalProblems: config.totalProblems,
sessionHealth: config.health, sessionHealth: config.health,
timing: config.timing, timing: config.timing,
isBrowseMode: config.isBrowseMode ?? false,
onPause: () => console.log('Pause clicked'), onPause: () => console.log('Pause clicked'),
onResume: () => console.log('Resume clicked'), onResume: () => console.log('Resume clicked'),
onEndEarly: () => console.log('End early clicked'), onEndEarly: () => console.log('End early clicked'),
onToggleBrowse: () => console.log('Toggle browse clicked'),
onBrowseNavigate: (index) => console.log(`Navigate to problem ${index}`),
} }
} }

View File

@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans' import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css' import { css } from '../../../styled-system/css'
import { SessionProgressIndicator } from './SessionProgressIndicator'
import { SpeedMeter } from './SpeedMeter' import { SpeedMeter } from './SpeedMeter'
/** /**
@ -27,6 +28,10 @@ export interface TimingData {
export interface SessionHudData { export interface SessionHudData {
/** Is the session currently paused? */ /** Is the session currently paused? */
isPaused: boolean isPaused: boolean
/** All session parts */
parts: SessionPart[]
/** Current part index */
currentPartIndex: number
/** Current part info */ /** Current part info */
currentPart: { currentPart: {
type: 'abacus' | 'visualization' | 'linear' type: 'abacus' | 'visualization' | 'linear'
@ -35,6 +40,8 @@ export interface SessionHudData {
} }
/** Current slot index within the part */ /** Current slot index within the part */
currentSlotIndex: number currentSlotIndex: number
/** All results so far */
results: SlotResult[]
/** Total problems completed so far */ /** Total problems completed so far */
completedProblems: number completedProblems: number
/** Total problems in session */ /** Total problems in session */
@ -46,10 +53,15 @@ export interface SessionHudData {
} }
/** Timing data for current problem (optional) */ /** Timing data for current problem (optional) */
timing?: TimingData timing?: TimingData
/** Whether browse mode is active */
isBrowseMode: boolean
/** Callbacks for transport controls */ /** Callbacks for transport controls */
onPause: () => void onPause: () => void
onResume: () => void onResume: () => void
onEndEarly: () => void onEndEarly: () => void
onToggleBrowse: () => void
/** Navigate to specific problem in browse mode */
onBrowseNavigate?: (linearIndex: number) => void
} }
interface PracticeSubNavProps { interface PracticeSubNavProps {
@ -406,88 +418,79 @@ export function PracticeSubNav({
> >
</button> </button>
{/* 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> </div>
{/* BIG Progress bar - takes up remaining width */} {/* Session Progress Indicator - discrete problem slots */}
<div <div
data-element="progress-bar" data-element="progress-indicator"
className={css({ className={css({
flex: 1, flex: 1,
display: 'flex', minWidth: 0, // Allow shrinking
flexDirection: 'column',
gap: '0.25rem',
})} })}
> >
{/* Labels row */} <SessionProgressIndicator
<div parts={sessionHud.parts}
className={css({ results={sessionHud.results}
display: 'flex', currentPartIndex={sessionHud.currentPartIndex}
justifyContent: 'space-between', currentSlotIndex={sessionHud.currentSlotIndex}
alignItems: 'center', isBrowseMode={sessionHud.isBrowseMode}
})} onNavigate={sessionHud.onBrowseNavigate}
> averageResponseTimeMs={timingStats?.hasEnoughData ? timingStats.mean : undefined}
{/* Mode label on left */} isDark={isDark}
<div compact={true}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
})}
>
<span
className={css({ fontSize: { base: '1rem', md: '1.125rem' }, lineHeight: 1 })}
>
{getPartTypeEmoji(sessionHud.currentPart.type)}
</span>
<span
className={css({
display: { base: 'none', sm: 'inline' },
fontSize: '0.875rem',
fontWeight: '600',
color: isDark ? 'gray.100' : 'gray.700',
})}
>
{getPartTypeLabel(sessionHud.currentPart.type)}
</span>
</div>
{/* "X left" on right */}
<span
className={css({
fontSize: { base: '0.75rem', md: '0.875rem' },
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
{sessionHud.totalProblems - sessionHud.completedProblems} left
</span>
</div>
{/* Big chunky progress bar */}
<div
className={css({
width: '100%',
height: { base: '12px', md: '16px' },
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: { base: '6px', md: '8px' },
overflow: 'hidden',
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.1)',
})}
>
<div
className={css({
height: '100%',
backgroundColor: 'green.500',
borderRadius: { base: '6px', md: '8px' },
transition: 'width 0.3s ease',
boxShadow: '0 2px 4px rgba(34, 197, 94, 0.3)',
})}
style={{
width: `${Math.round((sessionHud.completedProblems / sessionHud.totalProblems) * 100)}%`,
}}
/> />
</div> </div>
</div>
{/* Timing display */} {/* Timing display */}
{sessionHud.timing && timingStats && ( {sessionHud.timing && timingStats && (
@ -551,9 +554,6 @@ export function PracticeSubNav({
currentTimeMs={currentElapsedMs} currentTimeMs={currentElapsedMs}
isDark={isDark} isDark={isDark}
compact={true} compact={true}
averageLabel=""
fastLabel=""
slowLabel=""
/> />
</div> </div>
)} )}

View File

@ -228,9 +228,6 @@ export function PracticeTimingDisplay({
currentTimeMs={currentElapsedMs} currentTimeMs={currentElapsedMs}
isDark={isDark} isDark={isDark}
compact={true} compact={true}
averageLabel={`Avg: ${formatSecondsDecimal(overallStats.mean)}`}
fastLabel=""
slowLabel=""
/> />
</div> </div>
)} )}

View File

@ -0,0 +1,487 @@
/**
* Session Progress Indicator Component
*
* A unified progress display that shows:
* - Discrete problem slots grouped by section (abacus, visualization, linear)
* - Completion status for each problem (correct/incorrect/pending)
* - Current position in the session
* - Time remaining estimate
*
* Features:
* - Collapsed mode: completed sections show as compact summaries
* - Expanded mode (browse): all sections show individual slots for navigation
* - Smooth transitions between states
* - Click-to-navigate in browse mode
*/
'use client'
import { useMemo } from 'react'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
export interface SessionProgressIndicatorProps {
/** Session parts with their slots */
parts: SessionPart[]
/** Completed results */
results: SlotResult[]
/** Current part index */
currentPartIndex: number
/** Current slot index within the part */
currentSlotIndex: number
/** Whether browse mode is active (enables navigation) */
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 */
compact?: boolean
}
function getPartEmoji(type: SessionPart['type']): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
}
}
function getPartLabel(type: SessionPart['type']): string {
switch (type) {
case 'abacus':
return 'Abacus'
case 'visualization':
return 'Visual'
case 'linear':
return 'Mental'
}
}
/**
* Get result for a specific slot
*/
function getSlotResult(
results: SlotResult[],
partNumber: number,
slotIndex: number
): SlotResult | undefined {
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)
* For future sections: shows just the count (gray)
*/
function CollapsedSection({
part,
results,
linearOffset,
isDark,
isBrowseMode,
onNavigate,
isCompleted,
}: {
part: SessionPart
results: SlotResult[]
linearOffset: number
isDark: boolean
isBrowseMode: boolean
onNavigate?: (linearIndex: number) => void
isCompleted: boolean
}) {
const completedCount = part.slots.filter((_, i) =>
getSlotResult(results, part.partNumber, i)
).length
const correctCount = part.slots.filter((_, i) => {
const result = getSlotResult(results, part.partNumber, i)
return result?.isCorrect
}).length
const allCorrect = isCompleted && correctCount === part.slots.length
const totalCount = part.slots.length
// In browse mode, expand to show individual slots
if (isBrowseMode) {
return (
<ExpandedSection
part={part}
results={results}
linearOffset={linearOffset}
currentLinearIndex={-1}
isDark={isDark}
isBrowseMode={true}
onNavigate={onNavigate}
/>
)
}
return (
<div
data-element="collapsed-section"
data-part-type={part.type}
data-status={isCompleted ? 'completed' : 'future'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '6px',
backgroundColor: allCorrect
? isDark
? 'green.900/60'
: 'green.100'
: isDark
? 'gray.700'
: 'gray.200',
border: '1px solid',
borderColor: allCorrect
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'gray.600'
: 'gray.300',
flexShrink: 0,
transition: 'all 0.2s ease',
})}
>
<span className={css({ fontSize: '0.875rem' })}>{getPartEmoji(part.type)}</span>
<span
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: allCorrect
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.600',
})}
>
{isCompleted ? `${completedCount}` : totalCount}
</span>
</div>
)
}
/**
* Expanded section - shows individual problem slots
*/
function ExpandedSection({
part,
results,
linearOffset,
currentLinearIndex,
isDark,
isBrowseMode,
onNavigate,
}: {
part: SessionPart
results: SlotResult[]
linearOffset: number
currentLinearIndex: number
isDark: boolean
isBrowseMode: boolean
onNavigate?: (linearIndex: number) => void
}) {
return (
<div
data-element="expanded-section"
data-part-type={part.type}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem',
borderRadius: '6px',
backgroundColor: isDark ? 'gray.800/50' : 'gray.100/50',
transition: 'all 0.2s ease',
})}
>
{/* Section emoji */}
<span
className={css({
fontSize: '0.75rem',
padding: '0 0.25rem',
flexShrink: 0,
})}
title={getPartLabel(part.type)}
>
{getPartEmoji(part.type)}
</span>
{/* Individual slots */}
{part.slots.map((_, slotIndex) => {
const linearIndex = linearOffset + slotIndex
const result = getSlotResult(results, part.partNumber, slotIndex)
const isCurrent = linearIndex === currentLinearIndex
const isCompleted = result !== undefined
const isCorrect = result?.isCorrect
const isClickable = isBrowseMode && onNavigate
return (
<button
key={slotIndex}
type="button"
data-slot-index={slotIndex}
data-linear-index={linearIndex}
data-status={
isCurrent
? 'current'
: isCompleted
? isCorrect
? 'correct'
: 'incorrect'
: 'pending'
}
onClick={isClickable ? () => onNavigate(linearIndex) : undefined}
disabled={!isClickable}
className={css({
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: isCurrent ? 'bold' : 'normal',
borderRadius: '4px',
border: '1px solid',
cursor: isClickable ? 'pointer' : 'default',
transition: 'all 0.15s ease',
// Current problem
...(isCurrent && {
backgroundColor: isDark ? 'yellow.600' : 'yellow.400',
borderColor: isDark ? 'yellow.500' : 'yellow.500',
color: isDark ? 'yellow.100' : 'yellow.900',
boxShadow: `0 0 0 2px ${isDark ? 'rgba(234, 179, 8, 0.3)' : 'rgba(234, 179, 8, 0.4)'}`,
}),
// Completed correct
...(!isCurrent &&
isCompleted &&
isCorrect && {
backgroundColor: isDark ? 'green.900' : 'green.100',
borderColor: isDark ? 'green.700' : 'green.300',
color: isDark ? 'green.300' : 'green.700',
}),
// Completed incorrect
...(!isCurrent &&
isCompleted &&
!isCorrect && {
backgroundColor: isDark ? 'red.900' : 'red.100',
borderColor: isDark ? 'red.700' : 'red.300',
color: isDark ? 'red.300' : 'red.700',
}),
// Pending
...(!isCurrent &&
!isCompleted && {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.400' : 'gray.500',
}),
// Hover effect in browse mode
...(isClickable && {
_hover: {
transform: 'scale(1.15)',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
}),
})}
title={isBrowseMode ? `Go to problem ${linearIndex + 1}` : undefined}
>
{isBrowseMode ? linearIndex + 1 : isCompleted ? (isCorrect ? '✓' : '✗') : '○'}
</button>
)
})}
</div>
)
}
export function SessionProgressIndicator({
parts,
results,
currentPartIndex,
currentSlotIndex,
isBrowseMode,
onNavigate,
averageResponseTimeMs,
isDark,
compact = false,
}: SessionProgressIndicatorProps) {
// Calculate linear index for current position
const currentLinearIndex = useMemo(() => {
let index = 0
for (let i = 0; i < currentPartIndex; i++) {
index += parts[i].slots.length
}
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
return (
<div
data-component="session-progress-indicator"
data-browse-mode={isBrowseMode}
className={css({
display: 'flex',
alignItems: 'center',
gap: compact ? '0.375rem' : '0.5rem',
padding: compact ? '0.25rem' : '0.375rem',
backgroundColor: isDark ? 'gray.800' : 'gray.100',
borderRadius: '8px',
overflow: 'hidden',
minHeight: '36px',
})}
>
{/* Section indicators */}
<div
data-element="sections"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
flex: 1,
overflowX: 'auto',
overflowY: 'hidden',
// Hide scrollbar but allow scrolling
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
display: 'none',
},
})}
>
{parts.map((part, partIndex) => {
const partLinearOffset = linearOffset
linearOffset += part.slots.length
const isCurrentPart = partIndex === currentPartIndex
const isCompletedPart = partIndex < currentPartIndex
const isFuturePart = partIndex > currentPartIndex
// In browse mode: always expanded
// In practice mode: collapse non-current sections (both completed and future)
const shouldCollapse = !isBrowseMode && !isCurrentPart
return (
<div
key={part.partNumber}
className={css({ display: 'flex', alignItems: 'center', gap: '0.25rem' })}
>
{/* Part separator for non-first parts */}
{partIndex > 0 && (
<div
className={css({
width: '1px',
height: '20px',
backgroundColor: isDark ? 'gray.600' : 'gray.300',
flexShrink: 0,
})}
/>
)}
{shouldCollapse ? (
<CollapsedSection
part={part}
results={results}
linearOffset={partLinearOffset}
isDark={isDark}
isBrowseMode={isBrowseMode}
onNavigate={onNavigate}
isCompleted={isCompletedPart}
/>
) : (
<ExpandedSection
part={part}
results={results}
linearOffset={partLinearOffset}
currentLinearIndex={currentLinearIndex}
isDark={isDark}
isBrowseMode={isBrowseMode}
onNavigate={onNavigate}
/>
)}
</div>
)
})}
</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>
)
}
export default SessionProgressIndicator

View File

@ -15,16 +15,22 @@ export interface SpeedMeterProps {
currentTimeMs?: number currentTimeMs?: number
/** Optional compact mode for inline display */ /** Optional compact mode for inline display */
compact?: boolean compact?: boolean
/** Label for the average marker (default: "Your usual speed") */
averageLabel?: string
/** Label for the fast end (default: "Fast") */
fastLabel?: string
/** Label for the slow/threshold end (default: "Pause") */
slowLabel?: string
} }
/** /**
* Speed visualization bar - shows average speed vs variation * Format milliseconds to a kid-friendly short time string
* Under 60s: "Xs" (e.g., "8s", "30s")
* 60s+: "Xm" (e.g., "2m")
*/
function formatTimeShort(ms: number): string {
const seconds = Math.round(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.round(seconds / 60)
return `${minutes}m`
}
/**
* Speed visualization bar - shows average speed vs variation with actual time values
* Used in session pause modal and timing displays to visualize response time patterns * Used in session pause modal and timing displays to visualize response time patterns
*/ */
export function SpeedMeter({ export function SpeedMeter({
@ -34,14 +40,11 @@ export function SpeedMeter({
isDark, isDark,
currentTimeMs, currentTimeMs,
compact = false, compact = false,
averageLabel = 'Your usual speed',
fastLabel = 'Fast',
slowLabel = 'Pause',
}: SpeedMeterProps) { }: SpeedMeterProps) {
// Scale so the mean is around 50% and threshold is at 100% // Scale so threshold is at ~83% instead of 100%, giving visual room beyond it
// This ensures the visualization is always meaningful regardless of absolute values const scaleMax = thresholdMs * 1.2
const scaleMax = thresholdMs
const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100)) const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100))
const thresholdPercent = (thresholdMs / scaleMax) * 100 // ~83%
// Variation should be visible but proportional - minimum 8% width for visibility // Variation should be visible but proportional - minimum 8% width for visibility
const rawVariationPercent = (stdDevMs / scaleMax) * 100 const rawVariationPercent = (stdDevMs / scaleMax) * 100
@ -52,9 +55,14 @@ export function SpeedMeter({
? Math.min(110, Math.max(0, (currentTimeMs / scaleMax) * 100)) ? Math.min(110, Math.max(0, (currentTimeMs / scaleMax) * 100))
: null : null
// Check if mean and threshold labels would overlap (within 15% of each other)
const labelsWouldOverlap = thresholdPercent - meanPercent < 15
const barHeight = compact ? '16px' : '24px' const barHeight = compact ? '16px' : '24px'
const markerTop = compact ? '-2px' : '-4px' const markerTop = compact ? '-2px' : '-4px'
const markerHeight = compact ? '20px' : '32px' const markerHeight = compact ? '20px' : '32px'
const labelFontSize = compact ? '0.625rem' : '0.75rem'
const smallLabelFontSize = compact ? '0.5rem' : '0.625rem'
return ( return (
<div <div
@ -117,7 +125,7 @@ export function SpeedMeter({
position: 'absolute', position: 'absolute',
width: '4px', width: '4px',
backgroundColor: backgroundColor:
currentPercent > 100 currentPercent > thresholdPercent
? isDark ? isDark
? 'red.400' ? 'red.400'
: 'red.500' : 'red.500'
@ -152,31 +160,87 @@ export function SpeedMeter({
borderRadius: '2px', borderRadius: '2px',
})} })}
style={{ style={{
left: 'calc(100% - 2px)', left: `calc(${thresholdPercent}% - 2px)`,
}} }}
/> />
</div> </div>
{/* Labels */} {/* Time labels positioned under their markers */}
<div <div
className={css({ className={css({
display: 'flex', position: 'relative',
justifyContent: 'space-between', height: compact ? '1.75rem' : '2.25rem',
marginTop: compact ? '0.25rem' : '0.5rem', marginTop: compact ? '0.25rem' : '0.375rem',
color: isDark ? 'gray.400' : 'gray.500',
})} })}
style={{ fontSize: compact ? '0.625rem' : '0.6875rem' }}
> >
<span>{fastLabel}</span> {/* 0s label at left */}
<span <span
className={css({ className={css({
position: 'absolute',
left: 0,
color: isDark ? 'gray.500' : 'gray.400',
})}
style={{ fontSize: labelFontSize }}
>
0s
</span>
{/* Average time label - positioned at mean marker */}
{!labelsWouldOverlap && (
<span
className={css({
position: 'absolute',
textAlign: 'center',
color: isDark ? 'blue.300' : 'blue.600', color: isDark ? 'blue.300' : 'blue.600',
fontWeight: 'bold', fontWeight: 'bold',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
lineHeight: 1.2,
})} })}
style={{
left: `${meanPercent}%`,
transform: 'translateX(-50%)',
fontSize: labelFontSize,
}}
> >
{averageLabel} ~{formatTimeShort(meanMs)}
<span
className={css({ fontWeight: 'normal', color: isDark ? 'gray.400' : 'gray.500' })}
style={{ fontSize: smallLabelFontSize }}
>
avg
</span>
</span>
)}
{/* Threshold time label - positioned at threshold marker */}
<span
className={css({
position: 'absolute',
textAlign: 'center',
color: isDark ? 'yellow.300' : 'yellow.700',
fontWeight: 'bold',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
lineHeight: 1.2,
})}
style={{
left: `${thresholdPercent}%`,
transform: 'translateX(-50%)',
fontSize: labelFontSize,
}}
>
{labelsWouldOverlap ? `~${formatTimeShort(meanMs)} / ` : ''}
{formatTimeShort(thresholdMs)}
<span
className={css({ fontWeight: 'normal', color: isDark ? 'gray.400' : 'gray.500' })}
style={{ fontSize: smallLabelFontSize }}
>
{labelsWouldOverlap ? 'avg / pause' : 'pause'}
</span>
</span> </span>
<span>{slowLabel}</span>
</div> </div>
</div> </div>
) )

View File

@ -17,11 +17,14 @@ export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary' export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export type { SessionHudData } from './PracticeSubNav' export type { SessionHudData } from './PracticeSubNav'
export { PracticeSubNav } from './PracticeSubNav' export { PracticeSubNav } from './PracticeSubNav'
export type { SessionProgressIndicatorProps } from './SessionProgressIndicator'
export { SessionProgressIndicator } from './SessionProgressIndicator'
export { PracticeTimingDisplay } from './PracticeTimingDisplay' export { PracticeTimingDisplay } from './PracticeTimingDisplay'
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard' export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
export { ProgressDashboard } from './ProgressDashboard' export { ProgressDashboard } from './ProgressDashboard'
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal' export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
export { SessionPausedModal } from './SessionPausedModal' export { SessionPausedModal } from './SessionPausedModal'
export { SessionOverview } from './SessionOverview'
export { SessionSummary } from './SessionSummary' export { SessionSummary } from './SessionSummary'
export { SkillPerformanceReports } from './SkillPerformanceReports' export { SkillPerformanceReports } from './SkillPerformanceReports'
export type { SpeedMeterProps } from './SpeedMeter' export type { SpeedMeterProps } from './SpeedMeter'