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:
parent
0c40dd5c42
commit
18ce1f41af
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue