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:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
251
apps/web/src/components/practice/BrowseModeView.tsx
Normal file
251
apps/web/src/components/practice/BrowseModeView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,87 +418,78 @@ 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 */}
|
||||||
@@ -551,9 +554,6 @@ export function PracticeSubNav({
|
|||||||
currentTimeMs={currentElapsedMs}
|
currentTimeMs={currentElapsedMs}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
compact={true}
|
compact={true}
|
||||||
averageLabel=""
|
|
||||||
fastLabel=""
|
|
||||||
slowLabel=""
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
487
apps/web/src/components/practice/SessionProgressIndicator.tsx
Normal file
487
apps/web/src/components/practice/SessionProgressIndicator.tsx
Normal 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
|
||||||
@@ -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({
|
||||||
color: isDark ? 'blue.300' : 'blue.600',
|
position: 'absolute',
|
||||||
fontWeight: 'bold',
|
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>
|
||||||
<span>{slowLabel}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user