feat(practice): add response time tracking and live timing display

- Fix response time bug: exclude pause duration from calculations
- Add global per-kid stats tracking with new DB columns
- Create SkillPerformanceReports component for dashboard
- Add PracticeTimingDisplay with live problem timer and speed meter
- Extract SpeedMeter to shared component
- Add defensive handling for empty JSON in abacus-settings API

New features:
- Live timer showing elapsed time on current problem
- Speed visualization bar showing position vs average
- Per-part-type timing breakdown (abacus/visualize/linear)
- Skill performance analysis on dashboard (fast/slow/weak skills)

🤖 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-11 18:23:03 -06:00
parent 0c40dd5c42
commit 18ce1f41af
16 changed files with 2338 additions and 129 deletions

View File

@ -0,0 +1,6 @@
-- Custom SQL migration file, put your code below! --
-- Add response time tracking columns to player_skill_mastery table
ALTER TABLE `player_skill_mastery` ADD `total_response_time_ms` integer DEFAULT 0 NOT NULL;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` ADD `response_time_count` integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -204,6 +204,13 @@
"when": 1765331044112,
"tag": "0028_medical_wolfsbane",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1765496987070,
"tag": "0029_first_black_tarantula",
"breakpoints": true
}
]
}

View File

@ -41,7 +41,14 @@ export async function GET() {
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Handle empty or invalid JSON body gracefully
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid or empty request body' }, { status: 400 })
}
// Security: Strip userId from request body - it must come from session only
const { userId: _, ...updates } = body

View File

@ -0,0 +1,22 @@
import { type NextRequest, NextResponse } from 'next/server'
import { analyzeSkillPerformance } from '@/lib/curriculum/progress-manager'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET /api/curriculum/[playerId]/skills/performance
* Get skill performance analysis for a player (response times, strengths/weaknesses)
*/
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { playerId } = await params
try {
const analysis = await analyzeSkillPerformance(playerId)
return NextResponse.json({ analysis })
} catch (error) {
console.error('Error fetching skill performance:', error)
return NextResponse.json({ error: 'Failed to fetch skill performance' }, { status: 500 })
}
}

View File

@ -8,6 +8,7 @@ import {
type CurrentPhaseInfo,
PracticeSubNav,
ProgressDashboard,
SkillPerformanceReports,
type SkillProgress,
StartPracticeModal,
type StudentWithProgress,
@ -301,6 +302,11 @@ export function DashboardClient({
onSetSkillsManually={handleSetSkillsManually}
onRecordOfflinePractice={handleRecordOfflinePractice}
/>
{/* Skill Performance Reports - shows response time analysis */}
<div className={css({ marginTop: '24px' })}>
<SkillPerformanceReports playerId={studentId} isDark={isDark} />
</div>
</div>
{/* Manual Skill Selector Modal */}

View File

@ -93,6 +93,7 @@ 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'
@ -586,7 +587,8 @@ export function ActiveSession({
// Transition to submitting phase
startSubmit()
const responseTimeMs = Date.now() - attemptData.startTime
// Subtract accumulated pause time to get actual response time
const responseTimeMs = Date.now() - attemptData.startTime - attemptData.accumulatedPauseMs
const isCorrect = answerNum === attemptData.problem.answer
// Record the result
@ -715,8 +717,8 @@ export function ActiveSession({
// Calculate the threshold and stats from historical results
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
// Calculate remaining time until auto-pause
const elapsedMs = Date.now() - attempt.startTime
// Calculate remaining time until auto-pause (using actual working time, not total elapsed)
const elapsedMs = Date.now() - attempt.startTime - attempt.accumulatedPauseMs
const remainingMs = threshold - elapsedMs
// Create pause info for auto-timeout
@ -742,7 +744,15 @@ export function ActiveSession({
}, remainingMs)
return () => clearTimeout(timeoutId)
}, [phase.phase, isPaused, attempt?.startTime, plan.results, pause, onPause])
}, [
phase.phase,
isPaused,
attempt?.startTime,
attempt?.accumulatedPauseMs,
plan.results,
pause,
onPause,
])
const handlePause = useCallback(() => {
const pauseInfo: PauseInfo = {
@ -1029,6 +1039,20 @@ 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,330 @@
'use client'
import { useEffect, useState } from 'react'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { SpeedMeter } from './SpeedMeter'
interface PracticeTimingDisplayProps {
/** Session results so far */
results: SlotResult[]
/** Session parts (to map result partNumber to part type) */
parts: SessionPart[]
/** Current attempt start time (for live timer) */
attemptStartTime: number
/** Accumulated pause time for current attempt */
accumulatedPauseMs: number
/** Whether session is paused */
isPaused: boolean
/** Current part type */
currentPartType: 'abacus' | 'visualization' | 'linear'
/** Whether dark mode is enabled */
isDark: boolean
}
// Minimum samples needed for statistical display
const MIN_SAMPLES_FOR_STATS = 3
/**
* Format milliseconds as a human-readable duration
*/
function formatTime(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`
}
/**
* Format milliseconds for display as decimal seconds
*/
function formatSecondsDecimal(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
}
/**
* 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 }
}
function getPartTypeLabel(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return 'Abacus'
case 'visualization':
return 'Visualize'
case 'linear':
return 'Linear'
}
}
function getPartTypeEmoji(type: 'abacus' | 'visualization' | 'linear'): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
}
}
/**
* PracticeTimingDisplay - Shows timing stats during practice
*
* Displays:
* - Live timer for current problem
* - Average time per problem with SpeedMeter visualization
* - Breakdown by part type (abacus/visualization/linear)
*/
export function PracticeTimingDisplay({
results,
parts,
attemptStartTime,
accumulatedPauseMs,
isPaused,
currentPartType,
isDark,
}: PracticeTimingDisplayProps) {
// Live-updating current problem timer
const [currentElapsedMs, setCurrentElapsedMs] = useState(0)
// Update current timer every 100ms
useEffect(() => {
if (isPaused) return
const updateTimer = () => {
const elapsed = Date.now() - attemptStartTime - accumulatedPauseMs
setCurrentElapsedMs(Math.max(0, elapsed))
}
updateTimer()
const interval = setInterval(updateTimer, 100)
return () => clearInterval(interval)
}, [attemptStartTime, accumulatedPauseMs, isPaused])
// Calculate overall stats
const allTimes = results.map((r) => r.responseTimeMs)
const overallStats = calculateStats(allTimes)
const hasEnoughData = overallStats.count >= MIN_SAMPLES_FOR_STATS
// Calculate per-part-type stats
const partTypeStats = (['abacus', 'visualization', 'linear'] as const).map((type) => {
const typeTimes = results
.filter((r) => {
const part = parts[r.partNumber - 1]
return part && part.type === type
})
.map((r) => r.responseTimeMs)
return {
type,
...calculateStats(typeTimes),
}
})
// Filter to only part types with data
const activePartTypes = partTypeStats.filter((s) => s.count > 0)
// Calculate threshold for SpeedMeter (mean + 2 stddev, clamped)
const threshold = hasEnoughData
? Math.max(30_000, Math.min(overallStats.mean + 2 * overallStats.stdDev, 5 * 60 * 1000))
: 60_000 // Default 1 minute if not enough data
return (
<div
data-component="practice-timing-display"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Current problem timer - prominent display */}
<div
data-element="current-timer"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
<span className={css({ fontSize: '1rem' })}>{getPartTypeEmoji(currentPartType)}</span>
<span
className={css({
fontSize: '0.75rem',
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.600',
textTransform: 'uppercase',
})}
>
This problem
</span>
</div>
<span
className={css({
fontFamily: 'monospace',
fontSize: '1.25rem',
fontWeight: 'bold',
color:
currentElapsedMs > threshold
? isDark
? 'red.400'
: 'red.500'
: currentElapsedMs > overallStats.mean + overallStats.stdDev
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'green.400'
: 'green.600',
})}
>
{formatTime(currentElapsedMs)}
</span>
</div>
{/* Speed meter - shows where current time falls relative to average */}
{hasEnoughData && (
<div data-element="speed-visualization">
<SpeedMeter
meanMs={overallStats.mean}
stdDevMs={overallStats.stdDev}
thresholdMs={threshold}
currentTimeMs={currentElapsedMs}
isDark={isDark}
compact={true}
averageLabel={`Avg: ${formatSecondsDecimal(overallStats.mean)}`}
fastLabel=""
slowLabel=""
/>
</div>
)}
{/* Per-part-type breakdown */}
{activePartTypes.length > 1 && (
<div
data-element="part-type-breakdown"
className={css({
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
})}
>
{activePartTypes.map((stats) => (
<div
key={stats.type}
data-part-type={stats.type}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.25rem 0.5rem',
backgroundColor:
stats.type === currentPartType
? isDark
? 'blue.900'
: 'blue.100'
: isDark
? 'gray.700'
: 'gray.100',
borderRadius: '6px',
border: stats.type === currentPartType ? '1px solid' : 'none',
borderColor: isDark ? 'blue.700' : 'blue.300',
})}
>
<span className={css({ fontSize: '0.875rem' })}>{getPartTypeEmoji(stats.type)}</span>
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{getPartTypeLabel(stats.type)}
</span>
<span
className={css({
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{formatSecondsDecimal(stats.mean)}
</span>
<span
className={css({
fontSize: '0.625rem',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
({stats.count})
</span>
</div>
))}
</div>
)}
{/* Simple average display when not enough data for full visualization */}
{!hasEnoughData && overallStats.count > 0 && (
<div
data-element="simple-average"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Avg so far:</span>
<span
className={css({
fontFamily: 'monospace',
fontWeight: 'bold',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{formatSecondsDecimal(overallStats.mean)}
</span>
<span>({overallStats.count} problems)</span>
</div>
)}
</div>
)
}

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { SpeedMeter } from './SpeedMeter'
/**
* Statistics about response times used for auto-pause threshold
@ -125,122 +126,6 @@ function formatSecondsFriendly(ms: number): string {
return `about ${secondsFormatter.format(Math.round(seconds))}`
}
/**
* Speed visualization - shows average speed vs variation
*/
function SpeedMeter({
meanMs,
stdDevMs,
thresholdMs,
isDark,
}: {
meanMs: number
stdDevMs: number
thresholdMs: number
isDark: boolean
}) {
// Scale so the mean is around 50% and threshold is at 100%
// This ensures the visualization is always meaningful regardless of absolute values
const scaleMax = thresholdMs
const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100))
// Variation should be visible but proportional - minimum 8% width for visibility
const rawVariationPercent = (stdDevMs / scaleMax) * 100
const variationPercent = Math.max(8, Math.min(40, rawVariationPercent))
return (
<div
data-element="speed-meter"
className={css({
width: '100%',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
})}
>
{/* Speed bar container */}
<div
className={css({
position: 'relative',
height: '24px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
overflow: 'visible',
})}
>
{/* Variation range (the "wiggle room") */}
<div
className={css({
position: 'absolute',
height: '100%',
backgroundColor: isDark ? 'blue.800' : 'blue.100',
borderRadius: '12px',
transition: 'all 0.5s ease',
})}
style={{
left: `${Math.max(0, meanPercent - variationPercent)}%`,
width: `${variationPercent * 2}%`,
}}
/>
{/* Average marker */}
<div
className={css({
position: 'absolute',
top: '-4px',
width: '8px',
height: '32px',
backgroundColor: isDark ? 'blue.400' : 'blue.500',
borderRadius: '4px',
transition: 'all 0.5s ease',
zIndex: 1,
})}
style={{
left: `calc(${meanPercent}% - 4px)`,
}}
/>
{/* Threshold marker */}
<div
className={css({
position: 'absolute',
top: '0',
width: '3px',
height: '100%',
backgroundColor: isDark ? 'yellow.500' : 'yellow.600',
borderRadius: '2px',
})}
style={{
left: 'calc(100% - 2px)',
}}
/>
</div>
{/* Labels */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginTop: '0.5rem',
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Fast</span>
<span
className={css({
color: isDark ? 'blue.300' : 'blue.600',
fontWeight: 'bold',
})}
>
Your usual speed
</span>
<span>Pause</span>
</div>
</div>
)
}
export interface SessionPausedModalProps {
/** Whether the modal is visible */
isOpen: boolean

View File

@ -0,0 +1,449 @@
'use client'
import { useEffect, useState } from 'react'
import { css } from '../../../styled-system/css'
interface SkillPerformance {
skillId: string
masteryLevel: 'learning' | 'practicing' | 'mastered'
attempts: number
accuracy: number
avgResponseTimeMs: number | null
responseTimeCount: number
}
interface SkillPerformanceAnalysis {
skills: SkillPerformance[]
overallAvgResponseTimeMs: number | null
fastSkills: SkillPerformance[]
slowSkills: SkillPerformance[]
lowAccuracySkills: SkillPerformance[]
reinforcementSkills: SkillPerformance[]
}
interface SkillPerformanceReportsProps {
playerId: string
isDark?: boolean
}
// Format skill ID to human-readable name
function formatSkillName(skillId: string): string {
// Examples:
// "basic.directAddition" -> "Direct Addition"
// "fiveComplements.4=5-1" -> "5s: 4=5-1"
// "tenComplements.9=10-1" -> "10s: 9=10-1"
if (skillId.startsWith('basic.')) {
const skill = skillId.replace('basic.', '')
if (skill === 'directAddition') return 'Direct Addition'
if (skill === 'heavenBead') return 'Heaven Bead'
if (skill === 'simpleCombinations') return 'Simple Combos'
if (skill === 'directSubtraction') return 'Direct Subtraction'
if (skill === 'heavenBeadSubtraction') return 'Heaven Bead Sub'
if (skill === 'simpleCombinationsSub') return 'Simple Combos Sub'
return skill
}
if (skillId.startsWith('fiveComplements.')) {
return `5s: ${skillId.replace('fiveComplements.', '')}`
}
if (skillId.startsWith('tenComplements.')) {
return `10s: ${skillId.replace('tenComplements.', '')}`
}
if (skillId.startsWith('fiveComplementsSub.')) {
return `5s Sub: ${skillId.replace('fiveComplementsSub.', '')}`
}
if (skillId.startsWith('tenComplementsSub.')) {
return `10s Sub: ${skillId.replace('tenComplementsSub.', '')}`
}
return skillId
}
// Format milliseconds to readable duration
function formatTime(ms: number): string {
if (ms < 1000) return `${ms}ms`
const seconds = ms / 1000
if (seconds < 60) return `${seconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.round(seconds % 60)
return `${minutes}m ${remainingSeconds}s`
}
// Get mastery level badge style
function getMasteryBadgeStyle(level: string, isDark: boolean) {
const baseStyle = {
display: 'inline-block',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.75rem',
fontWeight: 'bold',
}
switch (level) {
case 'mastered':
return {
...baseStyle,
backgroundColor: isDark ? 'green.800' : 'green.100',
color: isDark ? 'green.200' : 'green.800',
}
case 'practicing':
return {
...baseStyle,
backgroundColor: isDark ? 'yellow.800' : 'yellow.100',
color: isDark ? 'yellow.200' : 'yellow.800',
}
default:
return {
...baseStyle,
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
}
}
}
function SkillCard({
skill,
isDark,
overallAvgMs,
}: {
skill: SkillPerformance
isDark: boolean
overallAvgMs: number | null
}) {
const speedIndicator = (() => {
if (!skill.avgResponseTimeMs || !overallAvgMs) return null
const ratio = skill.avgResponseTimeMs / overallAvgMs
if (ratio < 0.7) return { emoji: '🚀', label: 'Fast', color: 'green' }
if (ratio > 1.3) return { emoji: '🐢', label: 'Slow', color: 'orange' }
return { emoji: '➡️', label: 'Average', color: 'gray' }
})()
return (
<div
data-element="skill-card"
className={css({
padding: '12px',
borderRadius: '8px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
})}
>
<span className={css({ fontWeight: 'bold', color: isDark ? 'gray.100' : 'gray.900' })}>
{formatSkillName(skill.skillId)}
</span>
<span className={css(getMasteryBadgeStyle(skill.masteryLevel, isDark))}>
{skill.masteryLevel}
</span>
</div>
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
fontSize: '0.875rem',
})}
>
<div>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>Accuracy: </span>
<span
className={css({
color:
skill.accuracy >= 0.7
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'orange.400'
: 'orange.600',
fontWeight: 'medium',
})}
>
{Math.round(skill.accuracy * 100)}%
</span>
</div>
<div>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>Attempts: </span>
<span className={css({ color: isDark ? 'gray.200' : 'gray.700' })}>{skill.attempts}</span>
</div>
{skill.avgResponseTimeMs && (
<div className={css({ gridColumn: 'span 2' })}>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>Avg Time: </span>
<span className={css({ color: isDark ? 'gray.200' : 'gray.700' })}>
{formatTime(skill.avgResponseTimeMs)}
</span>
{speedIndicator && (
<span className={css({ marginLeft: '8px' })} title={speedIndicator.label}>
{speedIndicator.emoji}
</span>
)}
</div>
)}
</div>
</div>
)
}
export function SkillPerformanceReports({
playerId,
isDark = false,
}: SkillPerformanceReportsProps) {
const [analysis, setAnalysis] = useState<SkillPerformanceAnalysis | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function fetchPerformance() {
try {
const response = await fetch(`/api/curriculum/${playerId}/skills/performance`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setAnalysis(data.analysis)
} catch (err) {
setError('Failed to load performance data')
console.error('Error fetching skill performance:', err)
} finally {
setLoading(false)
}
}
fetchPerformance()
}, [playerId])
if (loading) {
return (
<div
data-component="skill-performance-reports"
className={css({
padding: '24px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ color: isDark ? 'gray.400' : 'gray.500', textAlign: 'center' })}>
Loading performance data...
</div>
</div>
)
}
if (error || !analysis) {
return (
<div
data-component="skill-performance-reports"
className={css({
padding: '24px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ color: isDark ? 'red.400' : 'red.600', textAlign: 'center' })}>
{error || 'No performance data available'}
</div>
</div>
)
}
const hasTimingData = analysis.skills.some((s) => s.responseTimeCount > 0)
const hasSlowSkills = analysis.slowSkills.length > 0
const hasLowAccuracySkills = analysis.lowAccuracySkills.length > 0
const hasReinforcementSkills = analysis.reinforcementSkills.length > 0
// No data yet
if (analysis.skills.length === 0) {
return (
<div
data-component="skill-performance-reports"
className={css({
padding: '24px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h3
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
marginBottom: '12px',
})}
>
📊 Skill Performance
</h3>
<p className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>
No practice data yet. Complete some practice sessions to see performance insights.
</p>
</div>
)
}
return (
<div
data-component="skill-performance-reports"
className={css({
padding: '24px',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h3
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
marginBottom: '16px',
})}
>
📊 Skill Performance Reports
</h3>
{/* Overall Stats */}
{hasTimingData && analysis.overallAvgResponseTimeMs && (
<div
className={css({
marginBottom: '20px',
padding: '12px',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
})}
>
<div className={css({ fontSize: '0.875rem', color: isDark ? 'gray.400' : 'gray.500' })}>
Average Response Time
</div>
<div
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{formatTime(analysis.overallAvgResponseTimeMs)}
</div>
</div>
)}
{/* Areas Needing Work */}
{(hasSlowSkills || hasLowAccuracySkills || hasReinforcementSkills) && (
<div className={css({ marginBottom: '20px' })}>
<h4
className={css({
fontSize: '1rem',
fontWeight: 'semibold',
color: isDark ? 'orange.400' : 'orange.600',
marginBottom: '12px',
})}
>
Areas Needing Work
</h4>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{analysis.slowSkills.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
{analysis.lowAccuracySkills
.filter((s) => !analysis.slowSkills.some((slow) => slow.skillId === s.skillId))
.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
{analysis.reinforcementSkills
.filter(
(s) =>
!analysis.slowSkills.some((slow) => slow.skillId === s.skillId) &&
!analysis.lowAccuracySkills.some((low) => low.skillId === s.skillId)
)
.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
</div>
</div>
)}
{/* Strengths */}
{analysis.fastSkills.length > 0 && (
<div className={css({ marginBottom: '20px' })}>
<h4
className={css({
fontSize: '1rem',
fontWeight: 'semibold',
color: isDark ? 'green.400' : 'green.600',
marginBottom: '12px',
})}
>
🌟 Strengths
</h4>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{analysis.fastSkills.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
</div>
</div>
)}
{/* All Skills Summary */}
<div>
<h4
className={css({
fontSize: '1rem',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '12px',
})}
>
All Skills ({analysis.skills.length})
</h4>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
maxHeight: '400px',
overflowY: 'auto',
})}
>
{analysis.skills.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,183 @@
'use client'
import { css } from '../../../styled-system/css'
export interface SpeedMeterProps {
/** Mean response time in milliseconds */
meanMs: number
/** Standard deviation of response times in milliseconds */
stdDevMs: number
/** Threshold for pause/slow indicator in milliseconds */
thresholdMs: number
/** Whether dark mode is enabled */
isDark: boolean
/** Optional current time to show as an indicator on the bar */
currentTimeMs?: number
/** Optional compact mode for inline display */
compact?: boolean
/** Label for the average marker (default: "Your usual speed") */
averageLabel?: string
/** Label for the fast end (default: "Fast") */
fastLabel?: string
/** Label for the slow/threshold end (default: "Pause") */
slowLabel?: string
}
/**
* Speed visualization bar - shows average speed vs variation
* Used in session pause modal and timing displays to visualize response time patterns
*/
export function SpeedMeter({
meanMs,
stdDevMs,
thresholdMs,
isDark,
currentTimeMs,
compact = false,
averageLabel = 'Your usual speed',
fastLabel = 'Fast',
slowLabel = 'Pause',
}: SpeedMeterProps) {
// Scale so the mean is around 50% and threshold is at 100%
// This ensures the visualization is always meaningful regardless of absolute values
const scaleMax = thresholdMs
const meanPercent = Math.min(95, Math.max(5, (meanMs / scaleMax) * 100))
// Variation should be visible but proportional - minimum 8% width for visibility
const rawVariationPercent = (stdDevMs / scaleMax) * 100
const variationPercent = Math.max(8, Math.min(40, rawVariationPercent))
// Current time position (if provided)
const currentPercent = currentTimeMs
? Math.min(110, Math.max(0, (currentTimeMs / scaleMax) * 100))
: null
const barHeight = compact ? '16px' : '24px'
const markerTop = compact ? '-2px' : '-4px'
const markerHeight = compact ? '20px' : '32px'
return (
<div
data-element="speed-meter"
className={css({
width: '100%',
padding: compact ? '0.5rem' : '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
})}
>
{/* Speed bar container */}
<div
className={css({
position: 'relative',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
overflow: 'visible',
})}
style={{ height: barHeight }}
>
{/* Variation range (the "wiggle room") */}
<div
className={css({
position: 'absolute',
height: '100%',
backgroundColor: isDark ? 'blue.800' : 'blue.100',
borderRadius: '12px',
transition: 'all 0.5s ease',
})}
style={{
left: `${Math.max(0, meanPercent - variationPercent)}%`,
width: `${variationPercent * 2}%`,
}}
/>
{/* Average marker */}
<div
data-element="average-marker"
className={css({
position: 'absolute',
width: '8px',
backgroundColor: isDark ? 'blue.400' : 'blue.500',
borderRadius: '4px',
transition: 'all 0.5s ease',
zIndex: 1,
})}
style={{
top: markerTop,
height: markerHeight,
left: `calc(${meanPercent}% - 4px)`,
}}
/>
{/* Current time marker (if provided) */}
{currentPercent !== null && (
<div
data-element="current-marker"
className={css({
position: 'absolute',
width: '4px',
backgroundColor:
currentPercent > 100
? isDark
? 'red.400'
: 'red.500'
: currentPercent > meanPercent + variationPercent
? isDark
? 'yellow.400'
: 'yellow.500'
: isDark
? 'green.400'
: 'green.500',
borderRadius: '2px',
transition: 'left 0.1s linear, background-color 0.3s ease',
zIndex: 2,
})}
style={{
top: markerTop,
height: markerHeight,
left: `calc(${Math.min(currentPercent, 105)}% - 2px)`,
}}
/>
)}
{/* Threshold marker */}
<div
data-element="threshold-marker"
className={css({
position: 'absolute',
top: '0',
width: '3px',
height: '100%',
backgroundColor: isDark ? 'yellow.500' : 'yellow.600',
borderRadius: '2px',
})}
style={{
left: 'calc(100% - 2px)',
}}
/>
</div>
{/* Labels */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginTop: compact ? '0.25rem' : '0.5rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
style={{ fontSize: compact ? '0.625rem' : '0.6875rem' }}
>
<span>{fastLabel}</span>
<span
className={css({
color: isDark ? 'blue.300' : 'blue.600',
fontWeight: 'bold',
})}
>
{averageLabel}
</span>
<span>{slowLabel}</span>
</div>
</div>
)
}

View File

@ -20,6 +20,8 @@ export interface AttemptInput {
partIndex: number
/** When the attempt started */
startTime: number
/** Accumulated time spent paused (ms) - subtract from elapsed time for actual response time */
accumulatedPauseMs: number
/** User's current answer input */
userAnswer: string
/** Number of times user used backspace or had digits rejected */
@ -94,6 +96,8 @@ export type InteractionPhase =
| {
phase: 'paused'
resumePhase: ActivePhase
/** When the pause started (used to calculate pause duration on resume) */
pauseStartedAt: number
}
/** Threshold for correction count before requiring manual submit */
@ -124,7 +128,7 @@ export function transformActivePhase(
if (phase.phase === 'paused') {
const newResumePhase = transform(phase.resumePhase)
if (newResumePhase === null) return phase
return { phase: 'paused', resumePhase: newResumePhase }
return { phase: 'paused', resumePhase: newResumePhase, pauseStartedAt: phase.pauseStartedAt }
}
const newPhase = transform(phase)
return newPhase === null ? phase : newPhase
@ -143,6 +147,7 @@ export function createAttemptInput(
slotIndex,
partIndex,
startTime: Date.now(),
accumulatedPauseMs: 0,
userAnswer: '',
correctionCount: 0,
manualSubmitRequired: false,
@ -841,14 +846,47 @@ export function useInteractionPhase(
setPhase((prev) => {
if (prev.phase === 'paused' || prev.phase === 'loading' || prev.phase === 'complete')
return prev
return { phase: 'paused', resumePhase: prev }
return { phase: 'paused', resumePhase: prev, pauseStartedAt: Date.now() }
})
}, [])
const resume = useCallback(() => {
setPhase((prev) => {
if (prev.phase !== 'paused') return prev
return prev.resumePhase
// Calculate how long we were paused
const pauseDuration = Date.now() - prev.pauseStartedAt
// Helper to add pause duration to an attempt
const addPauseDuration = (attempt: AttemptInput): AttemptInput => ({
...attempt,
accumulatedPauseMs: attempt.accumulatedPauseMs + pauseDuration,
})
// Update the attempt inside the resume phase to track accumulated pause time
const resumePhase = prev.resumePhase
switch (resumePhase.phase) {
case 'inputting':
return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) }
case 'awaitingDisambiguation':
return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) }
case 'helpMode':
return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) }
case 'submitting':
return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) }
case 'showingFeedback':
return { ...resumePhase, attempt: addPauseDuration(resumePhase.attempt) }
case 'transitioning':
// Update both outgoing (for accuracy of recorded time) and incoming
return {
...resumePhase,
incoming: addPauseDuration(resumePhase.incoming),
}
case 'loading':
case 'complete':
// No attempt to update
return resumePhase
}
})
}, [])

View File

@ -16,11 +16,15 @@ export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export type { SessionHudData } from './PracticeSubNav'
export { PracticeSubNav } from './PracticeSubNav'
export { PracticeTimingDisplay } from './PracticeTimingDisplay'
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
export { ProgressDashboard } from './ProgressDashboard'
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
export { SessionPausedModal } from './SessionPausedModal'
export { SessionSummary } from './SessionSummary'
export { SkillPerformanceReports } from './SkillPerformanceReports'
export type { SpeedMeterProps } from './SpeedMeter'
export { SpeedMeter } from './SpeedMeter'
export { StartPracticeModal } from './StartPracticeModal'
export type { StudentWithProgress } from './StudentSelector'
export { StudentSelector } from './StudentSelector'

View File

@ -100,6 +100,20 @@ export const playerSkillMastery = sqliteTable(
* Resets to 0 when reinforcement is cleared or when help level 2+ is used
*/
reinforcementStreak: integer('reinforcement_streak').notNull().default(0),
// ---- Response Time Tracking (for skill-level performance analysis) ----
/**
* Total response time in milliseconds across all attempts
* Used with responseTimeCount to calculate average: totalResponseTimeMs / responseTimeCount
*/
totalResponseTimeMs: integer('total_response_time_ms').notNull().default(0),
/**
* Number of attempts with recorded response times
* May differ from `attempts` if some early data didn't track time
*/
responseTimeCount: integer('response_time_count').notNull().default(0),
},
(table) => ({
/** Index for fast lookups by playerId */

View File

@ -236,7 +236,7 @@ export async function recordSkillAttempt(
}
/**
* Record a skill attempt with help level tracking
* Record a skill attempt with help level tracking and response time
* Applies credit multipliers based on help used and manages reinforcement
*
* Credit multipliers:
@ -248,12 +248,17 @@ export async function recordSkillAttempt(
* - If help level >= threshold, mark skill as needing reinforcement
* - If correct answer without heavy help, increment reinforcement streak
* - After N consecutive correct answers, clear reinforcement flag
*
* Response time tracking:
* - Accumulates total response time for calculating per-skill averages
* - Only recorded if responseTimeMs is provided (> 0)
*/
export async function recordSkillAttemptWithHelp(
playerId: string,
skillId: string,
isCorrect: boolean,
helpLevel: HelpLevel
helpLevel: HelpLevel,
responseTimeMs?: number
): Promise<PlayerSkillMastery> {
const existing = await getSkillMastery(playerId, skillId)
const now = new Date()
@ -302,6 +307,15 @@ export async function recordSkillAttemptWithHelp(
reinforcementStreak = 0
}
// Calculate response time updates (only if provided)
const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0
const newTotalResponseTimeMs = hasResponseTime
? existing.totalResponseTimeMs + responseTimeMs
: existing.totalResponseTimeMs
const newResponseTimeCount = hasResponseTime
? existing.responseTimeCount + 1
: existing.responseTimeCount
await db
.update(schema.playerSkillMastery)
.set({
@ -314,12 +328,17 @@ export async function recordSkillAttemptWithHelp(
needsReinforcement,
lastHelpLevel: helpLevel,
reinforcementStreak,
totalResponseTimeMs: newTotalResponseTimeMs,
responseTimeCount: newResponseTimeCount,
})
.where(eq(schema.playerSkillMastery.id, existing.id))
return (await getSkillMastery(playerId, skillId))!
}
// Calculate response time for new record (only if provided)
const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0
// Create new record with help tracking
const newRecord: NewPlayerSkillMastery = {
playerId,
@ -332,6 +351,8 @@ export async function recordSkillAttemptWithHelp(
needsReinforcement: isHeavyHelp,
lastHelpLevel: helpLevel,
reinforcementStreak: 0,
totalResponseTimeMs: hasResponseTime ? responseTimeMs : 0,
responseTimeCount: hasResponseTime ? 1 : 0,
}
await db.insert(schema.playerSkillMastery).values(newRecord)
@ -340,16 +361,24 @@ export async function recordSkillAttemptWithHelp(
/**
* Record multiple skill attempts with help tracking (for batch updates after a problem)
* Response time is shared across all skills since they come from the same problem
*/
export async function recordSkillAttemptsWithHelp(
playerId: string,
skillResults: Array<{ skillId: string; isCorrect: boolean }>,
helpLevel: HelpLevel
helpLevel: HelpLevel,
responseTimeMs?: number
): Promise<PlayerSkillMastery[]> {
const results: PlayerSkillMastery[] = []
for (const { skillId, isCorrect } of skillResults) {
const result = await recordSkillAttemptWithHelp(playerId, skillId, isCorrect, helpLevel)
const result = await recordSkillAttemptWithHelp(
playerId,
skillId,
isCorrect,
helpLevel,
responseTimeMs
)
results.push(result)
}
@ -611,3 +640,150 @@ export async function initializeStudent(playerId: string): Promise<PlayerCurricu
visualizationMode: false,
})
}
// ============================================================================
// SKILL PERFORMANCE ANALYSIS
// ============================================================================
/**
* Skill performance data with calculated averages
*/
export interface SkillPerformance {
skillId: string
masteryLevel: MasteryLevel
attempts: number
accuracy: number // 0-1
avgResponseTimeMs: number | null // null if no timing data
responseTimeCount: number
}
/**
* Analysis of a player's skill strengths and weaknesses
*/
export interface SkillPerformanceAnalysis {
/** All skills with performance data */
skills: SkillPerformance[]
/** Overall average response time (ms) across all skills with timing data */
overallAvgResponseTimeMs: number | null
/** Skills where student is significantly faster than average (excelling) */
fastSkills: SkillPerformance[]
/** Skills where student is significantly slower than average (struggling) */
slowSkills: SkillPerformance[]
/** Skills with low accuracy that may need intervention */
lowAccuracySkills: SkillPerformance[]
/** Skills needing reinforcement (from help system) */
reinforcementSkills: SkillPerformance[]
}
/**
* Thresholds for performance analysis
*/
const PERFORMANCE_THRESHOLDS = {
/** Speed deviation threshold (percentage faster/slower than average to flag) */
speedDeviationPercent: 0.3, // 30% faster/slower
/** Minimum accuracy to not flag as low */
minAccuracyThreshold: 0.7, // 70%
/** Minimum responses needed for timing analysis */
minResponsesForTiming: 3,
} as const
/**
* Analyze a player's skill performance to identify strengths and weaknesses
* Uses response time data to find skills where the student excels vs struggles
*/
export async function analyzeSkillPerformance(playerId: string): Promise<SkillPerformanceAnalysis> {
const allSkills = await getAllSkillMastery(playerId)
// Calculate performance data for each skill
const skills: SkillPerformance[] = allSkills.map((s) => ({
skillId: s.skillId,
masteryLevel: s.masteryLevel as MasteryLevel,
attempts: s.attempts,
accuracy: s.attempts > 0 ? s.correct / s.attempts : 0,
avgResponseTimeMs:
s.responseTimeCount > 0 ? Math.round(s.totalResponseTimeMs / s.responseTimeCount) : null,
responseTimeCount: s.responseTimeCount,
}))
// Calculate overall average response time (only from skills with sufficient data)
const skillsWithTiming = skills.filter(
(s) =>
s.avgResponseTimeMs !== null &&
s.responseTimeCount >= PERFORMANCE_THRESHOLDS.minResponsesForTiming
)
const overallAvgResponseTimeMs =
skillsWithTiming.length > 0
? Math.round(
skillsWithTiming.reduce((sum, s) => sum + (s.avgResponseTimeMs ?? 0), 0) /
skillsWithTiming.length
)
: null
// Identify fast skills (significantly faster than average)
const fastSkills =
overallAvgResponseTimeMs !== null
? skillsWithTiming.filter(
(s) =>
s.avgResponseTimeMs !== null &&
s.avgResponseTimeMs <
overallAvgResponseTimeMs * (1 - PERFORMANCE_THRESHOLDS.speedDeviationPercent)
)
: []
// Identify slow skills (significantly slower than average)
const slowSkills =
overallAvgResponseTimeMs !== null
? skillsWithTiming.filter(
(s) =>
s.avgResponseTimeMs !== null &&
s.avgResponseTimeMs >
overallAvgResponseTimeMs * (1 + PERFORMANCE_THRESHOLDS.speedDeviationPercent)
)
: []
// Identify low accuracy skills
const lowAccuracySkills = skills.filter(
(s) =>
s.attempts >= PERFORMANCE_THRESHOLDS.minResponsesForTiming &&
s.accuracy < PERFORMANCE_THRESHOLDS.minAccuracyThreshold
)
// Get skills needing reinforcement
const reinforcementRecords = await getSkillsNeedingReinforcement(playerId)
const reinforcementSkillIds = new Set(reinforcementRecords.map((r) => r.skillId))
const reinforcementSkills = skills.filter((s) => reinforcementSkillIds.has(s.skillId))
return {
skills,
overallAvgResponseTimeMs,
fastSkills,
slowSkills,
lowAccuracySkills,
reinforcementSkills,
}
}
/**
* Get skills ranked by response time (slowest first)
* Useful for identifying skills that need practice
*/
export async function getSkillsByResponseTime(
playerId: string,
order: 'slowest' | 'fastest' = 'slowest'
): Promise<SkillPerformance[]> {
const analysis = await analyzeSkillPerformance(playerId)
// Filter to only skills with timing data
const skillsWithTiming = analysis.skills.filter(
(s) =>
s.avgResponseTimeMs !== null &&
s.responseTimeCount >= PERFORMANCE_THRESHOLDS.minResponsesForTiming
)
// Sort by response time
return skillsWithTiming.sort((a, b) => {
const timeA = a.avgResponseTimeMs ?? 0
const timeB = b.avgResponseTimeMs ?? 0
return order === 'slowest' ? timeB - timeA : timeA - timeB
})
}

View File

@ -38,7 +38,12 @@ import {
type getPhaseSkillConstraints,
} from './definitions'
import { generateProblemFromConstraints } from './problem-generator'
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
import {
getAllSkillMastery,
getPlayerCurriculum,
getRecentSessions,
recordSkillAttemptsWithHelp,
} from './progress-manager'
// ============================================================================
// Plan Generation
@ -452,6 +457,21 @@ export async function recordSlotResult(
.where(eq(schema.sessionPlans.id, planId))
.returning()
// Update global skill mastery with response time data
// This builds the per-kid stats for identifying strengths/weaknesses
if (result.skillsExercised && result.skillsExercised.length > 0) {
const skillResults = result.skillsExercised.map((skillId) => ({
skillId,
isCorrect: result.isCorrect,
}))
await recordSkillAttemptsWithHelp(
plan.playerId,
skillResults,
result.helpLevelUsed,
result.responseTimeMs
)
}
return updated
}