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:
parent
6c88dcfdc5
commit
3c52e607b3
|
|
@ -40,6 +40,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
|||
const [isPaused, setIsPaused] = useState(false)
|
||||
// Track timing data from ActiveSession for the sub-nav HUD
|
||||
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
|
||||
const recordResult = useRecordSlotResult()
|
||||
|
|
@ -116,12 +120,15 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
|||
const sessionHud: SessionHudData | undefined = currentPart
|
||||
? {
|
||||
isPaused,
|
||||
parts: currentPlan.parts,
|
||||
currentPartIndex: currentPlan.currentPartIndex,
|
||||
currentPart: {
|
||||
type: currentPart.type,
|
||||
partNumber: currentPart.partNumber,
|
||||
totalSlots: currentPart.slots.length,
|
||||
},
|
||||
currentSlotIndex: currentPlan.currentSlotIndex,
|
||||
results: currentPlan.results,
|
||||
completedProblems,
|
||||
totalProblems,
|
||||
sessionHealth: sessionHealth
|
||||
|
|
@ -142,6 +149,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
|||
onPause: handlePause,
|
||||
onResume: handleResume,
|
||||
onEndEarly: () => handleEndEarly('Session ended'),
|
||||
isBrowseMode,
|
||||
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
|
||||
onBrowseNavigate: setBrowseIndex,
|
||||
}
|
||||
: undefined
|
||||
|
||||
|
|
@ -166,7 +176,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
|||
onResume={handleResume}
|
||||
onComplete={handleSessionComplete}
|
||||
onTimingUpdate={setTimingData}
|
||||
hideHud={true}
|
||||
isBrowseMode={isBrowseMode}
|
||||
browseIndex={browseIndex}
|
||||
onBrowseIndexChange={setBrowseIndex}
|
||||
onExitBrowse={() => setIsBrowseMode(false)}
|
||||
/>
|
||||
</PracticeErrorBoundary>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import type {
|
|||
SlotResult,
|
||||
} 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
|
||||
export type { AutoPauseStats, PauseInfo }
|
||||
|
|
@ -27,73 +30,6 @@ export interface StudentInfo {
|
|||
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 { DecompositionProvider, DecompositionSection } from '../decomposition'
|
||||
import { Tooltip, TooltipProvider } from '../ui/Tooltip'
|
||||
|
|
@ -130,38 +66,16 @@ interface ActiveSessionProps {
|
|||
onResume?: () => void
|
||||
/** Called when session completes */
|
||||
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) */
|
||||
onTimingUpdate?: (timing: AttemptTimingData | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the part type description for display
|
||||
*/
|
||||
function getPartTypeLabel(type: SessionPart['type']): string {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
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 '💭'
|
||||
}
|
||||
/** Whether browse mode is active (controlled externally via toggle in PracticeSubNav) */
|
||||
isBrowseMode?: boolean
|
||||
/** Controlled browse index (linear problem index) */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -590,8 +504,11 @@ export function ActiveSession({
|
|||
onPause,
|
||||
onResume,
|
||||
onComplete,
|
||||
hideHud = false,
|
||||
onTimingUpdate,
|
||||
isBrowseMode: isBrowseModeProp = false,
|
||||
browseIndex: browseIndexProp,
|
||||
onBrowseIndexChange,
|
||||
onExitBrowse,
|
||||
}: ActiveSessionProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
|
@ -679,6 +596,42 @@ export function ActiveSession({
|
|||
// Track pause info for displaying in the modal (single source of truth)
|
||||
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
|
||||
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 (
|
||||
<div
|
||||
data-component="active-session"
|
||||
|
|
@ -1187,212 +1152,6 @@ export function ActiveSession({
|
|||
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 */}
|
||||
<div
|
||||
data-section="problem-area"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -114,23 +114,57 @@ function createSessionHud(config: {
|
|||
totalProblems: number
|
||||
timing?: TimingData
|
||||
health?: { overall: 'good' | 'warning' | 'struggling'; accuracy: number }
|
||||
isBrowseMode?: boolean
|
||||
}): SessionHudData {
|
||||
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 {
|
||||
isPaused: config.isPaused ?? false,
|
||||
parts,
|
||||
currentPartIndex,
|
||||
currentPart: {
|
||||
type: config.partType,
|
||||
partNumber,
|
||||
totalSlots: 5,
|
||||
},
|
||||
currentSlotIndex: config.completedProblems % 5,
|
||||
results,
|
||||
completedProblems: config.completedProblems,
|
||||
totalProblems: config.totalProblems,
|
||||
sessionHealth: config.health,
|
||||
timing: config.timing,
|
||||
isBrowseMode: config.isBrowseMode ?? false,
|
||||
onPause: () => console.log('Pause clicked'),
|
||||
onResume: () => console.log('Resume clicked'),
|
||||
onEndEarly: () => console.log('End early clicked'),
|
||||
onToggleBrowse: () => console.log('Toggle browse clicked'),
|
||||
onBrowseNavigate: (index) => console.log(`Navigate to problem ${index}`),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
|
|||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { SessionProgressIndicator } from './SessionProgressIndicator'
|
||||
import { SpeedMeter } from './SpeedMeter'
|
||||
|
||||
/**
|
||||
|
|
@ -27,6 +28,10 @@ export interface TimingData {
|
|||
export interface SessionHudData {
|
||||
/** Is the session currently paused? */
|
||||
isPaused: boolean
|
||||
/** All session parts */
|
||||
parts: SessionPart[]
|
||||
/** Current part index */
|
||||
currentPartIndex: number
|
||||
/** Current part info */
|
||||
currentPart: {
|
||||
type: 'abacus' | 'visualization' | 'linear'
|
||||
|
|
@ -35,6 +40,8 @@ export interface SessionHudData {
|
|||
}
|
||||
/** Current slot index within the part */
|
||||
currentSlotIndex: number
|
||||
/** All results so far */
|
||||
results: SlotResult[]
|
||||
/** Total problems completed so far */
|
||||
completedProblems: number
|
||||
/** Total problems in session */
|
||||
|
|
@ -46,10 +53,15 @@ export interface SessionHudData {
|
|||
}
|
||||
/** Timing data for current problem (optional) */
|
||||
timing?: TimingData
|
||||
/** Whether browse mode is active */
|
||||
isBrowseMode: boolean
|
||||
/** Callbacks for transport controls */
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onEndEarly: () => void
|
||||
onToggleBrowse: () => void
|
||||
/** Navigate to specific problem in browse mode */
|
||||
onBrowseNavigate?: (linearIndex: number) => void
|
||||
}
|
||||
|
||||
interface PracticeSubNavProps {
|
||||
|
|
@ -406,87 +418,78 @@ export function PracticeSubNav({
|
|||
>
|
||||
⏹
|
||||
</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>
|
||||
|
||||
{/* BIG Progress bar - takes up remaining width */}
|
||||
{/* Session Progress Indicator - discrete problem slots */}
|
||||
<div
|
||||
data-element="progress-bar"
|
||||
data-element="progress-indicator"
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
minWidth: 0, // Allow shrinking
|
||||
})}
|
||||
>
|
||||
{/* Labels row */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Mode label on left */}
|
||||
<div
|
||||
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>
|
||||
<SessionProgressIndicator
|
||||
parts={sessionHud.parts}
|
||||
results={sessionHud.results}
|
||||
currentPartIndex={sessionHud.currentPartIndex}
|
||||
currentSlotIndex={sessionHud.currentSlotIndex}
|
||||
isBrowseMode={sessionHud.isBrowseMode}
|
||||
onNavigate={sessionHud.onBrowseNavigate}
|
||||
averageResponseTimeMs={timingStats?.hasEnoughData ? timingStats.mean : undefined}
|
||||
isDark={isDark}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timing display */}
|
||||
|
|
@ -551,9 +554,6 @@ export function PracticeSubNav({
|
|||
currentTimeMs={currentElapsedMs}
|
||||
isDark={isDark}
|
||||
compact={true}
|
||||
averageLabel=""
|
||||
fastLabel=""
|
||||
slowLabel=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -228,9 +228,6 @@ export function PracticeTimingDisplay({
|
|||
currentTimeMs={currentElapsedMs}
|
||||
isDark={isDark}
|
||||
compact={true}
|
||||
averageLabel={`Avg: ${formatSecondsDecimal(overallStats.mean)}`}
|
||||
fastLabel=""
|
||||
slowLabel=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,16 +15,22 @@ export interface SpeedMeterProps {
|
|||
currentTimeMs?: number
|
||||
/** Optional compact mode for inline display */
|
||||
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
|
||||
*/
|
||||
export function SpeedMeter({
|
||||
|
|
@ -34,14 +40,11 @@ export function SpeedMeter({
|
|||
isDark,
|
||||
currentTimeMs,
|
||||
compact = false,
|
||||
averageLabel = 'Your usual speed',
|
||||
fastLabel = 'Fast',
|
||||
slowLabel = 'Pause',
|
||||
}: SpeedMeterProps) {
|
||||
// Scale so the mean is around 50% and threshold is at 100%
|
||||
// This ensures the visualization is always meaningful regardless of absolute values
|
||||
const scaleMax = thresholdMs
|
||||
// Scale so threshold is at ~83% instead of 100%, giving visual room beyond it
|
||||
const scaleMax = thresholdMs * 1.2
|
||||
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
|
||||
const rawVariationPercent = (stdDevMs / scaleMax) * 100
|
||||
|
|
@ -52,9 +55,14 @@ export function SpeedMeter({
|
|||
? Math.min(110, Math.max(0, (currentTimeMs / scaleMax) * 100))
|
||||
: 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 markerTop = compact ? '-2px' : '-4px'
|
||||
const markerHeight = compact ? '20px' : '32px'
|
||||
const labelFontSize = compact ? '0.625rem' : '0.75rem'
|
||||
const smallLabelFontSize = compact ? '0.5rem' : '0.625rem'
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -117,7 +125,7 @@ export function SpeedMeter({
|
|||
position: 'absolute',
|
||||
width: '4px',
|
||||
backgroundColor:
|
||||
currentPercent > 100
|
||||
currentPercent > thresholdPercent
|
||||
? isDark
|
||||
? 'red.400'
|
||||
: 'red.500'
|
||||
|
|
@ -152,31 +160,87 @@ export function SpeedMeter({
|
|||
borderRadius: '2px',
|
||||
})}
|
||||
style={{
|
||||
left: 'calc(100% - 2px)',
|
||||
left: `calc(${thresholdPercent}% - 2px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{/* Time labels positioned under their markers */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: compact ? '0.25rem' : '0.5rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
position: 'relative',
|
||||
height: compact ? '1.75rem' : '2.25rem',
|
||||
marginTop: compact ? '0.25rem' : '0.375rem',
|
||||
})}
|
||||
style={{ fontSize: compact ? '0.625rem' : '0.6875rem' }}
|
||||
>
|
||||
<span>{fastLabel}</span>
|
||||
{/* 0s label at left */}
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
fontWeight: 'bold',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
style={{ fontSize: labelFontSize }}
|
||||
>
|
||||
{averageLabel}
|
||||
0s
|
||||
</span>
|
||||
|
||||
{/* Average time label - positioned at mean marker */}
|
||||
{!labelsWouldOverlap && (
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
style={{
|
||||
left: `${meanPercent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: labelFontSize,
|
||||
}}
|
||||
>
|
||||
~{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>{slowLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ 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 { AutoPauseStats, PauseInfo } from './SessionPausedModal'
|
||||
export { SessionPausedModal } from './SessionPausedModal'
|
||||
export { SessionOverview } from './SessionOverview'
|
||||
export { SessionSummary } from './SessionSummary'
|
||||
export { SkillPerformanceReports } from './SkillPerformanceReports'
|
||||
export type { SpeedMeterProps } from './SpeedMeter'
|
||||
|
|
|
|||
Loading…
Reference in New Issue