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:
parent
18ce1f41af
commit
2fca17a58b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
export { ActiveSession } from './ActiveSession'
|
||||
export type { AttemptTimingData } from './ActiveSession'
|
||||
export { ContinueSessionCard } from './ContinueSessionCard'
|
||||
// Hooks
|
||||
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'
|
||||
|
|
|
|||
Loading…
Reference in New Issue