diff --git a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx index 09eee42a..525efc15 100644 --- a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx +++ b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx @@ -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(undefined) + // Track timing data from ActiveSession for the sub-nav HUD + const [timingData, setTimingData] = useState(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} /> diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 102a222b..cab6266c 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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({ )} - {/* Timing Display - shows current problem timer, average, and per-part-type breakdown */} - {/* Always shown regardless of hideHud - timing info is always useful */} - {attempt && ( - - )} - {/* Problem display */}
= { + title: 'Practice/PracticeSubNav', + component: PracticeSubNav, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +// ============================================================================= +// 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 ( + +
+ {/* Fake main nav placeholder */} +
+ + Main Navigation Bar + +
+ {children} + {/* Content placeholder */} +
+
+ Page content goes here... +
+
+
+
+ ) +} + +// ============================================================================= +// Dashboard States (No Active Session) +// ============================================================================= + +export const DashboardDefault: Story = { + render: () => ( + + + + ), +} + +export const DashboardWithStartButton: Story = { + render: () => ( + + alert('Start Practice clicked!')} + /> + + ), +} + +export const DashboardLongName: Story = { + render: () => ( + + alert('Start Practice clicked!')} + /> + + ), +} + +export const ConfigurePage: Story = { + render: () => ( + + + + ), +} + +export const SummaryPage: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Active Session - Part Types +// ============================================================================= + +export const SessionAbacusPart: Story = { + render: () => ( + + + + ), +} + +export const SessionVisualizationPart: Story = { + render: () => ( + + + + ), +} + +export const SessionLinearPart: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Active Session - Progress States +// ============================================================================= + +export const SessionJustStarted: Story = { + render: () => ( + + + + ), +} + +export const SessionMidway: Story = { + render: () => ( + + + + ), +} + +export const SessionNearEnd: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Active Session - Timing Display States +// ============================================================================= + +export const TimingNoData: Story = { + name: 'Timing: No Prior Data (First Problem)', + render: () => ( + + + + ), +} + +export const TimingFewSamples: Story = { + name: 'Timing: Few Samples (No SpeedMeter)', + render: () => ( + + + + ), +} + +export const TimingWithSpeedMeter: Story = { + name: 'Timing: With SpeedMeter (3+ Samples)', + render: () => ( + + + + ), +} + +export const TimingManyDataPoints: Story = { + name: 'Timing: Many Data Points', + render: () => ( + + + + ), +} + +// ============================================================================= +// Active Session - Health States +// ============================================================================= + +export const HealthGood: Story = { + render: () => ( + + + + ), +} + +export const HealthWarning: Story = { + render: () => ( + + + + ), +} + +export const HealthStruggling: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Paused State +// ============================================================================= + +export const SessionPaused: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Dark Mode Variants +// ============================================================================= + +export const DarkModeDashboard: Story = { + render: () => ( + + alert('Start Practice clicked!')} + /> + + ), +} + +export const DarkModeSession: Story = { + render: () => ( + + + + ), +} + +export const DarkModeWithWarning: Story = { + render: () => ( + + + + ), +} + +// ============================================================================= +// Mobile Viewport Stories +// ============================================================================= + +export const MobileSession: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + render: () => ( + + + + ), +} + +export const MobileSessionLongName: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + render: () => ( + + + + ), +} + +export const MobileDashboard: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + render: () => ( + + alert('Start Practice clicked!')} + /> + + ), +} + +export const MobileDarkMode: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + render: () => ( + + + + ), +} + +// ============================================================================= +// Tablet Viewport Stories +// ============================================================================= + +export const TabletSession: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + }, + render: () => ( + + + + ), +} + +// ============================================================================= +// 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 ( +
+ {students.map((student, i) => ( + + + + ))} +
+ ) + }, +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +export const NoTimingData: Story = { + name: 'Edge: No Timing Data', + render: () => ( + + + + ), +} + +export const NoHealthData: Story = { + name: 'Edge: No Health Data', + render: () => ( + + + + ), +} + +export const LargeSessionCount: Story = { + name: 'Edge: Large Problem Count', + render: () => ( + + + + ), +} diff --git a/apps/web/src/components/practice/PracticeSubNav.tsx b/apps/web/src/components/practice/PracticeSubNav.tsx index 3458f39e..681074e2 100644 --- a/apps/web/src/components/practice/PracticeSubNav.tsx +++ b/apps/web/src/components/practice/PracticeSubNav.tsx @@ -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 (
- {/* Name + context */} + {/* Name + context - hidden on mobile during session */}
{/* 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', })} > - + {getPartTypeEmoji(sessionHud.currentPart.type)}
- {/* Health indicator */} + {/* Timing display */} + {sessionHud.timing && timingStats && ( +
+ {/* Current timer */} +
+ + ⏱️ + + timingStats.threshold + ? isDark + ? 'red.400' + : 'red.500' + : currentElapsedMs > timingStats.mean + timingStats.stdDev + ? isDark + ? 'yellow.400' + : 'yellow.600' + : isDark + ? 'green.400' + : 'green.600', + })} + > + {formatTimeCompact(currentElapsedMs)} + +
+ + {/* Mini speed meter - hidden on very small screens */} + {timingStats.hasEnoughData && ( +
+ +
+ )} +
+ )} + + {/* Health indicator - hidden on very small screens */} {sessionHud.sessionHealth && (
- + {getHealthEmoji(sessionHud.sessionHealth.overall)}