feat(practice): integrate timing display into sub-nav with mobile support

- Move timing display from ActiveSession to PracticeSubNav
- Add per-part-type timing stats (abacus/visualization/linear calculated separately)
- Pass timing data from PracticeClient through sessionHud
- Add responsive mobile styles:
  - Smaller padding and gaps on mobile
  - Hide student name during session on small screens
  - Hide part type text label (keep emoji)
  - Compact timing display with hidden SpeedMeter on very small screens
  - Hide health indicator on small screens
- Add comprehensive Storybook stories for PracticeSubNav covering:
  - Dashboard states, session part types, progress states
  - Timing display states, health indicators
  - Dark mode, mobile/tablet viewports, edge cases

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-12 07:07:14 -06:00
parent 18ce1f41af
commit 2fca17a58b
5 changed files with 1033 additions and 38 deletions

View File

@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
type AttemptTimingData,
PracticeErrorBoundary,
PracticeSubNav,
type SessionHudData,
@ -42,6 +43,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
const [isPaused, setIsPaused] = useState(false)
// Track pause info for displaying details in the modal
const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined)
// Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null)
// Session plan mutations
const recordResult = useRecordSlotResult()
@ -134,6 +137,15 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
accuracy: sessionHealth.accuracy,
}
: undefined,
// Pass timing data for the current problem
timing: timingData
? {
startTime: timingData.startTime,
accumulatedPauseMs: timingData.accumulatedPauseMs,
results: currentPlan.results,
parts: currentPlan.parts,
}
: undefined,
onPause: () =>
handlePause({
pausedAt: new Date(),
@ -164,6 +176,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onPause={handlePause}
onResume={handleResume}
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
hideHud={true}
/>
</PracticeErrorBoundary>

View File

@ -93,10 +93,19 @@ import { useInteractionPhase } from './hooks/useInteractionPhase'
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
import { NumericKeypad } from './NumericKeypad'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { PracticeTimingDisplay } from './PracticeTimingDisplay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
/**
* Timing data for the current problem attempt
*/
export interface AttemptTimingData {
/** When the current attempt started */
startTime: number
/** Accumulated pause time in ms */
accumulatedPauseMs: number
}
interface ActiveSessionProps {
plan: SessionPlan
studentName: string
@ -112,6 +121,8 @@ interface ActiveSessionProps {
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
}
/**
@ -268,6 +279,7 @@ export function ActiveSession({
onResume,
onComplete,
hideHud = false,
onTimingUpdate,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -331,6 +343,20 @@ export function ActiveSession({
onManualSubmitRequired: () => playSound('womp_womp'),
})
// Notify parent of timing data changes for external timing display
useEffect(() => {
if (onTimingUpdate) {
if (attempt) {
onTimingUpdate({
startTime: attempt.startTime,
accumulatedPauseMs: attempt.accumulatedPauseMs,
})
} else {
onTimingUpdate(null)
}
}
}, [onTimingUpdate, attempt?.startTime, attempt?.accumulatedPauseMs])
// Track which help elements have been individually dismissed
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
@ -1039,20 +1065,6 @@ export function ActiveSession({
</div>
)}
{/* Timing Display - shows current problem timer, average, and per-part-type breakdown */}
{/* Always shown regardless of hideHud - timing info is always useful */}
{attempt && (
<PracticeTimingDisplay
results={plan.results}
parts={plan.parts}
attemptStartTime={attempt.startTime}
accumulatedPauseMs={attempt.accumulatedPauseMs}
isPaused={isPaused}
currentPartType={currentPart.type}
isDark={isDark}
/>
)}
{/* Problem display */}
<div
data-section="problem-area"

View File

@ -0,0 +1,789 @@
import type { Meta, StoryObj } from '@storybook/react'
import { ThemeProvider } from '@/contexts/ThemeContext'
import type { ProblemSlot, SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { PracticeSubNav, type SessionHudData, type TimingData } from './PracticeSubNav'
const meta: Meta<typeof PracticeSubNav> = {
title: 'Practice/PracticeSubNav',
component: PracticeSubNav,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof PracticeSubNav>
// =============================================================================
// Mock Data Helpers
// =============================================================================
const mockStudent = {
id: 'student-1',
name: 'Sonia',
emoji: '🦄',
color: '#E879F9',
}
const mockStudentLongName = {
id: 'student-2',
name: 'Alexander the Great',
emoji: '👑',
color: '#60A5FA',
}
function createMockSlots(count: number, purpose: ProblemSlot['purpose']): ProblemSlot[] {
return Array.from({ length: count }, (_, i) => ({
index: i,
purpose,
constraints: {},
}))
}
function createMockParts(): SessionPart[] {
return [
{
partNumber: 1,
type: 'abacus',
format: 'vertical',
useAbacus: true,
slots: createMockSlots(5, 'focus'),
estimatedMinutes: 5,
},
{
partNumber: 2,
type: 'visualization',
format: 'vertical',
useAbacus: false,
slots: createMockSlots(5, 'reinforce'),
estimatedMinutes: 4,
},
{
partNumber: 3,
type: 'linear',
format: 'linear',
useAbacus: false,
slots: createMockSlots(5, 'review'),
estimatedMinutes: 3,
},
]
}
function createMockResults(
count: number,
partType: 'abacus' | 'visualization' | 'linear'
): SlotResult[] {
const partNumber = partType === 'abacus' ? 1 : partType === 'visualization' ? 2 : 3
return Array.from({ length: count }, (_, i) => ({
partNumber: partNumber as 1 | 2 | 3,
slotIndex: i % 5,
problem: {
terms: [3, 4, 2],
answer: 9,
skillsRequired: ['basic.directAddition'],
},
studentAnswer: 9,
isCorrect: Math.random() > 0.15,
responseTimeMs: 2500 + Math.random() * 3000,
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: partType === 'abacus',
timestamp: new Date(Date.now() - (count - i) * 30000),
helpLevelUsed: 0,
incorrectAttempts: 0,
}))
}
function createTimingData(
resultCount: number,
partType: 'abacus' | 'visualization' | 'linear'
): TimingData {
return {
startTime: Date.now() - 5000, // Started 5 seconds ago
accumulatedPauseMs: 0,
results: createMockResults(resultCount, partType),
parts: createMockParts(),
}
}
function createSessionHud(config: {
isPaused?: boolean
partType: 'abacus' | 'visualization' | 'linear'
completedProblems: number
totalProblems: number
timing?: TimingData
health?: { overall: 'good' | 'warning' | 'struggling'; accuracy: number }
}): SessionHudData {
const partNumber = config.partType === 'abacus' ? 1 : config.partType === 'visualization' ? 2 : 3
return {
isPaused: config.isPaused ?? false,
currentPart: {
type: config.partType,
partNumber,
totalSlots: 5,
},
currentSlotIndex: config.completedProblems % 5,
completedProblems: config.completedProblems,
totalProblems: config.totalProblems,
sessionHealth: config.health,
timing: config.timing,
onPause: () => console.log('Pause clicked'),
onResume: () => console.log('Resume clicked'),
onEndEarly: () => console.log('End early clicked'),
}
}
// =============================================================================
// Wrapper Component
// =============================================================================
function NavWrapper({
children,
darkMode = false,
}: {
children: React.ReactNode
darkMode?: boolean
}) {
return (
<ThemeProvider forcedTheme={darkMode ? 'dark' : 'light'}>
<div
className={css({
minHeight: '300px',
backgroundColor: darkMode ? '#1a1a2e' : 'gray.50',
paddingTop: '80px', // Space for fake main nav
})}
>
{/* Fake main nav placeholder */}
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '80px',
backgroundColor: darkMode ? 'gray.900' : 'white',
borderBottom: '1px solid',
borderColor: darkMode ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
})}
>
<span
className={css({ color: darkMode ? 'gray.400' : 'gray.500', fontSize: '0.875rem' })}
>
Main Navigation Bar
</span>
</div>
{children}
{/* Content placeholder */}
<div className={css({ padding: '2rem' })}>
<div
className={css({
padding: '2rem',
backgroundColor: darkMode ? 'gray.800' : 'white',
borderRadius: '8px',
color: darkMode ? 'gray.300' : 'gray.600',
})}
>
Page content goes here...
</div>
</div>
</div>
</ThemeProvider>
)
}
// =============================================================================
// Dashboard States (No Active Session)
// =============================================================================
export const DashboardDefault: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav student={mockStudent} pageContext="dashboard" />
</NavWrapper>
),
}
export const DashboardWithStartButton: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="dashboard"
onStartPractice={() => alert('Start Practice clicked!')}
/>
</NavWrapper>
),
}
export const DashboardLongName: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudentLongName}
pageContext="dashboard"
onStartPractice={() => alert('Start Practice clicked!')}
/>
</NavWrapper>
),
}
export const ConfigurePage: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav student={mockStudent} pageContext="configure" />
</NavWrapper>
),
}
export const SummaryPage: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav student={mockStudent} pageContext="summary" />
</NavWrapper>
),
}
// =============================================================================
// Active Session - Part Types
// =============================================================================
export const SessionAbacusPart: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 2,
totalProblems: 15,
timing: createTimingData(2, 'abacus'),
})}
/>
</NavWrapper>
),
}
export const SessionVisualizationPart: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 7,
totalProblems: 15,
timing: createTimingData(7, 'visualization'),
})}
/>
</NavWrapper>
),
}
export const SessionLinearPart: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'linear',
completedProblems: 12,
totalProblems: 15,
timing: createTimingData(12, 'linear'),
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Active Session - Progress States
// =============================================================================
export const SessionJustStarted: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 0,
totalProblems: 15,
timing: createTimingData(0, 'abacus'),
})}
/>
</NavWrapper>
),
}
export const SessionMidway: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 8,
totalProblems: 15,
timing: createTimingData(8, 'visualization'),
health: { overall: 'good', accuracy: 0.88 },
})}
/>
</NavWrapper>
),
}
export const SessionNearEnd: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'linear',
completedProblems: 14,
totalProblems: 15,
timing: createTimingData(14, 'linear'),
health: { overall: 'good', accuracy: 0.93 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Active Session - Timing Display States
// =============================================================================
export const TimingNoData: Story = {
name: 'Timing: No Prior Data (First Problem)',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 0,
totalProblems: 15,
timing: createTimingData(0, 'abacus'),
})}
/>
</NavWrapper>
),
}
export const TimingFewSamples: Story = {
name: 'Timing: Few Samples (No SpeedMeter)',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 2,
totalProblems: 15,
timing: createTimingData(2, 'abacus'),
})}
/>
</NavWrapper>
),
}
export const TimingWithSpeedMeter: Story = {
name: 'Timing: With SpeedMeter (3+ Samples)',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 5,
totalProblems: 15,
timing: createTimingData(5, 'abacus'),
})}
/>
</NavWrapper>
),
}
export const TimingManyDataPoints: Story = {
name: 'Timing: Many Data Points',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 10,
totalProblems: 15,
timing: createTimingData(10, 'visualization'),
health: { overall: 'good', accuracy: 0.9 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Active Session - Health States
// =============================================================================
export const HealthGood: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 6,
totalProblems: 15,
timing: createTimingData(6, 'abacus'),
health: { overall: 'good', accuracy: 0.92 },
})}
/>
</NavWrapper>
),
}
export const HealthWarning: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={{ ...mockStudent, emoji: '🤔', color: '#FBBF24' }}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 8,
totalProblems: 15,
timing: createTimingData(8, 'visualization'),
health: { overall: 'warning', accuracy: 0.75 },
})}
/>
</NavWrapper>
),
}
export const HealthStruggling: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={{ ...mockStudent, emoji: '😅', color: '#F87171' }}
pageContext="session"
sessionHud={createSessionHud({
partType: 'linear',
completedProblems: 5,
totalProblems: 15,
timing: createTimingData(5, 'linear'),
health: { overall: 'struggling', accuracy: 0.55 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Paused State
// =============================================================================
export const SessionPaused: Story = {
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
isPaused: true,
partType: 'abacus',
completedProblems: 4,
totalProblems: 15,
timing: createTimingData(4, 'abacus'),
health: { overall: 'good', accuracy: 0.85 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Dark Mode Variants
// =============================================================================
export const DarkModeDashboard: Story = {
render: () => (
<NavWrapper darkMode>
<PracticeSubNav
student={mockStudent}
pageContext="dashboard"
onStartPractice={() => alert('Start Practice clicked!')}
/>
</NavWrapper>
),
}
export const DarkModeSession: Story = {
render: () => (
<NavWrapper darkMode>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 7,
totalProblems: 15,
timing: createTimingData(7, 'visualization'),
health: { overall: 'good', accuracy: 0.88 },
})}
/>
</NavWrapper>
),
}
export const DarkModeWithWarning: Story = {
render: () => (
<NavWrapper darkMode>
<PracticeSubNav
student={{ ...mockStudent, emoji: '🌙', color: '#818CF8' }}
pageContext="session"
sessionHud={createSessionHud({
partType: 'linear',
completedProblems: 10,
totalProblems: 15,
timing: createTimingData(10, 'linear'),
health: { overall: 'warning', accuracy: 0.72 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Mobile Viewport Stories
// =============================================================================
export const MobileSession: Story = {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 5,
totalProblems: 15,
timing: createTimingData(5, 'abacus'),
health: { overall: 'good', accuracy: 0.9 },
})}
/>
</NavWrapper>
),
}
export const MobileSessionLongName: Story = {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudentLongName}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 8,
totalProblems: 15,
timing: createTimingData(8, 'visualization'),
health: { overall: 'warning', accuracy: 0.78 },
})}
/>
</NavWrapper>
),
}
export const MobileDashboard: Story = {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="dashboard"
onStartPractice={() => alert('Start Practice clicked!')}
/>
</NavWrapper>
),
}
export const MobileDarkMode: Story = {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
render: () => (
<NavWrapper darkMode>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'linear',
completedProblems: 12,
totalProblems: 15,
timing: createTimingData(12, 'linear'),
health: { overall: 'good', accuracy: 0.92 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Tablet Viewport Stories
// =============================================================================
export const TabletSession: Story = {
parameters: {
viewport: {
defaultViewport: 'tablet',
},
},
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 6,
totalProblems: 15,
timing: createTimingData(6, 'abacus'),
health: { overall: 'good', accuracy: 0.88 },
})}
/>
</NavWrapper>
),
}
// =============================================================================
// Different Students
// =============================================================================
export const DifferentStudents: Story = {
render: () => {
const students = [
{ id: '1', name: 'Luna', emoji: '🌙', color: '#818CF8' },
{ id: '2', name: 'Max', emoji: '🚀', color: '#60A5FA' },
{ id: '3', name: 'Kai', emoji: '🌊', color: '#2DD4BF' },
{ id: '4', name: 'Nova', emoji: '✨', color: '#FBBF24' },
]
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0' })}>
{students.map((student, i) => (
<NavWrapper key={student.id}>
<PracticeSubNav
student={student}
pageContext="session"
sessionHud={createSessionHud({
partType: i === 0 ? 'abacus' : i === 1 ? 'visualization' : 'linear',
completedProblems: 3 + i * 3,
totalProblems: 15,
timing: createTimingData(
3 + i * 3,
i === 0 ? 'abacus' : i === 1 ? 'visualization' : 'linear'
),
health: {
overall: i < 2 ? 'good' : i === 2 ? 'warning' : 'good',
accuracy: 0.85 - i * 0.05,
},
})}
/>
</NavWrapper>
))}
</div>
)
},
}
// =============================================================================
// Edge Cases
// =============================================================================
export const NoTimingData: Story = {
name: 'Edge: No Timing Data',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 5,
totalProblems: 15,
// No timing data
})}
/>
</NavWrapper>
),
}
export const NoHealthData: Story = {
name: 'Edge: No Health Data',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'abacus',
completedProblems: 5,
totalProblems: 15,
timing: createTimingData(5, 'abacus'),
// No health data
})}
/>
</NavWrapper>
),
}
export const LargeSessionCount: Story = {
name: 'Edge: Large Problem Count',
render: () => (
<NavWrapper>
<PracticeSubNav
student={mockStudent}
pageContext="session"
sessionHud={createSessionHud({
partType: 'visualization',
completedProblems: 47,
totalProblems: 100,
timing: createTimingData(20, 'visualization'),
health: { overall: 'good', accuracy: 0.94 },
})}
/>
</NavWrapper>
),
}

View File

@ -1,8 +1,25 @@
'use client'
import Link from 'next/link'
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 { SpeedMeter } from './SpeedMeter'
/**
* Timing data for the current problem attempt
*/
export interface TimingData {
/** When the current attempt started */
startTime: number
/** Accumulated pause time in ms */
accumulatedPauseMs: number
/** Session results so far (for calculating averages) */
results: SlotResult[]
/** Session parts (to map result partNumber to part type) */
parts: SessionPart[]
}
/**
* Session HUD data for active practice sessions
@ -27,6 +44,8 @@ export interface SessionHudData {
overall: 'good' | 'warning' | 'struggling'
accuracy: number
}
/** Timing data for current problem (optional) */
timing?: TimingData
/** Callbacks for transport controls */
onPause: () => void
onResume: () => void
@ -93,6 +112,50 @@ function getHealthColor(overall: 'good' | 'warning' | 'struggling'): string {
}
}
// Minimum samples needed for statistical display
const MIN_SAMPLES_FOR_STATS = 3
/**
* Calculate mean and standard deviation of response times
*/
function calculateStats(times: number[]): {
mean: number
stdDev: number
count: number
} {
if (times.length === 0) {
return { mean: 0, stdDev: 0, count: 0 }
}
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)
const stdDev = Math.sqrt(variance)
return { mean, stdDev, count }
}
/**
* Format seconds as a compact time string
*/
function formatTimeCompact(ms: number): string {
if (ms < 0) return '0s'
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes > 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
return `${seconds}s`
}
/**
* Practice Sub-Navigation Bar
*
@ -113,6 +176,49 @@ export function PracticeSubNav({
const isOnDashboard = pageContext === 'dashboard'
// Live-updating current problem timer
const [currentElapsedMs, setCurrentElapsedMs] = useState(0)
// Update current timer every 100ms when timing data is available
useEffect(() => {
if (!sessionHud?.timing || sessionHud.isPaused) {
return
}
const { startTime, accumulatedPauseMs } = sessionHud.timing
const updateTimer = () => {
const elapsed = Date.now() - startTime - accumulatedPauseMs
setCurrentElapsedMs(Math.max(0, elapsed))
}
updateTimer()
const interval = setInterval(updateTimer, 100)
return () => clearInterval(interval)
}, [sessionHud?.timing?.startTime, sessionHud?.timing?.accumulatedPauseMs, sessionHud?.isPaused])
// Calculate timing stats from results - filtered by current part type
const timingStats = sessionHud?.timing
? (() => {
const currentPartType = sessionHud.currentPart.type
const { results, parts } = sessionHud.timing
// Map each result to its part type and filter for current type only
const timesForCurrentType = results
.filter((r) => {
const partIndex = parts.findIndex((p) => p.partNumber === r.partNumber)
return partIndex >= 0 && parts[partIndex].type === currentPartType
})
.map((r) => r.responseTimeMs)
const stats = calculateStats(timesForCurrentType)
const hasEnoughData = stats.count >= MIN_SAMPLES_FOR_STATS
const threshold = hasEnoughData
? Math.max(30_000, Math.min(stats.mean + 2 * stats.stdDev, 5 * 60 * 1000))
: 60_000
return { ...stats, hasEnoughData, threshold, partType: currentPartType }
})()
: null
return (
<nav
data-component="practice-sub-nav"
@ -125,8 +231,8 @@ export function PracticeSubNav({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '0.75rem 1.5rem',
gap: { base: '0.5rem', md: '1rem' },
padding: { base: '0.5rem 0.75rem', md: '0.75rem 1.5rem' },
backgroundColor: isDark ? 'gray.900' : 'gray.100',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.800' : 'gray.200',
@ -170,10 +276,10 @@ export function PracticeSubNav({
>
{student.emoji}
</div>
{/* Name + context */}
{/* Name + context - hidden on mobile during session */}
<div
className={css({
display: 'flex',
display: sessionHud ? { base: 'none', sm: 'flex' } : 'flex',
flexDirection: 'column',
gap: '0',
minWidth: 0,
@ -215,7 +321,7 @@ export function PracticeSubNav({
flex: 1,
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
gap: { base: '0.375rem', md: '0.75rem' },
})}
>
{/* Transport controls */}
@ -233,12 +339,12 @@ export function PracticeSubNav({
data-action={sessionHud.isPaused ? 'resume' : 'pause'}
onClick={sessionHud.isPaused ? sessionHud.onResume : sessionHud.onPause}
className={css({
width: '36px',
height: '36px',
width: { base: '32px', md: '36px' },
height: { base: '32px', md: '36px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.125rem',
fontSize: { base: '1rem', md: '1.125rem' },
color: 'white',
backgroundColor: sessionHud.isPaused ? 'green.500' : 'gray.600',
borderRadius: '6px',
@ -265,12 +371,12 @@ export function PracticeSubNav({
data-action="end-early"
onClick={sessionHud.onEndEarly}
className={css({
width: '36px',
height: '36px',
width: { base: '32px', md: '36px' },
height: { base: '32px', md: '36px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.125rem',
fontSize: { base: '1rem', md: '1.125rem' },
color: 'red.300',
backgroundColor: 'gray.600',
borderRadius: '6px',
@ -320,11 +426,14 @@ export function PracticeSubNav({
gap: '0.375rem',
})}
>
<span className={css({ fontSize: '1.125rem', lineHeight: 1 })}>
<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',
@ -337,7 +446,7 @@ export function PracticeSubNav({
{/* "X left" on right */}
<span
className={css({
fontSize: '0.875rem',
fontSize: { base: '0.75rem', md: '0.875rem' },
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.600',
})}
@ -350,9 +459,9 @@ export function PracticeSubNav({
<div
className={css({
width: '100%',
height: '16px',
height: { base: '12px', md: '16px' },
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '8px',
borderRadius: { base: '6px', md: '8px' },
overflow: 'hidden',
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.1)',
})}
@ -361,7 +470,7 @@ export function PracticeSubNav({
className={css({
height: '100%',
backgroundColor: 'green.500',
borderRadius: '8px',
borderRadius: { base: '6px', md: '8px' },
transition: 'width 0.3s ease',
boxShadow: '0 2px 4px rgba(34, 197, 94, 0.3)',
})}
@ -372,26 +481,97 @@ export function PracticeSubNav({
</div>
</div>
{/* Health indicator */}
{/* Timing display */}
{sessionHud.timing && timingStats && (
<div
data-element="timing-display"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
padding: { base: '0.125rem 0.375rem', md: '0.25rem 0.5rem' },
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: { base: '6px', md: '8px' },
flexShrink: 0,
minWidth: { base: '60px', md: '100px' },
})}
>
{/* Current timer */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.125rem',
})}
>
<span
className={css({ fontSize: '0.625rem', display: { base: 'none', sm: 'inline' } })}
>
</span>
<span
className={css({
fontFamily: 'monospace',
fontSize: { base: '0.875rem', md: '1rem' },
fontWeight: 'bold',
color:
currentElapsedMs > timingStats.threshold
? isDark
? 'red.400'
: 'red.500'
: currentElapsedMs > timingStats.mean + timingStats.stdDev
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'green.400'
: 'green.600',
})}
>
{formatTimeCompact(currentElapsedMs)}
</span>
</div>
{/* Mini speed meter - hidden on very small screens */}
{timingStats.hasEnoughData && (
<div className={css({ display: { base: 'none', sm: 'block' } })}>
<SpeedMeter
meanMs={timingStats.mean}
stdDevMs={timingStats.stdDev}
thresholdMs={timingStats.threshold}
currentTimeMs={currentElapsedMs}
isDark={isDark}
compact={true}
averageLabel=""
fastLabel=""
slowLabel=""
/>
</div>
)}
</div>
)}
{/* Health indicator - hidden on very small screens */}
{sessionHud.sessionHealth && (
<div
data-element="session-health"
className={css({
display: 'flex',
display: { base: 'none', sm: 'flex' },
alignItems: 'center',
gap: '0.25rem',
padding: '0.375rem 0.5rem',
gap: '0.125rem',
padding: { base: '0.25rem 0.375rem', md: '0.375rem 0.5rem' },
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
borderRadius: { base: '6px', md: '8px' },
flexShrink: 0,
})}
>
<span className={css({ fontSize: '1rem' })}>
<span className={css({ fontSize: { base: '0.875rem', md: '1rem' } })}>
{getHealthEmoji(sessionHud.sessionHealth.overall)}
</span>
<span
className={css({
fontSize: '0.875rem',
fontSize: { base: '0.75rem', md: '0.875rem' },
fontWeight: 'bold',
})}
style={{

View File

@ -9,6 +9,7 @@
*/
export { ActiveSession } from './ActiveSession'
export type { AttemptTimingData } from './ActiveSession'
export { ContinueSessionCard } from './ContinueSessionCard'
// Hooks
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'