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,
|
"when": 1765331044112,
|
||||||
"tag": "0028_medical_wolfsbane",
|
"tag": "0028_medical_wolfsbane",
|
||||||
"breakpoints": true
|
"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) {
|
export async function PATCH(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const viewerId = await getViewerId()
|
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
|
// Security: Strip userId from request body - it must come from session only
|
||||||
const { userId: _, ...updates } = body
|
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,
|
type CurrentPhaseInfo,
|
||||||
PracticeSubNav,
|
PracticeSubNav,
|
||||||
ProgressDashboard,
|
ProgressDashboard,
|
||||||
|
SkillPerformanceReports,
|
||||||
type SkillProgress,
|
type SkillProgress,
|
||||||
StartPracticeModal,
|
StartPracticeModal,
|
||||||
type StudentWithProgress,
|
type StudentWithProgress,
|
||||||
|
|
@ -301,6 +302,11 @@ export function DashboardClient({
|
||||||
onSetSkillsManually={handleSetSkillsManually}
|
onSetSkillsManually={handleSetSkillsManually}
|
||||||
onRecordOfflinePractice={handleRecordOfflinePractice}
|
onRecordOfflinePractice={handleRecordOfflinePractice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Skill Performance Reports - shows response time analysis */}
|
||||||
|
<div className={css({ marginTop: '24px' })}>
|
||||||
|
<SkillPerformanceReports playerId={studentId} isDark={isDark} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Skill Selector Modal */}
|
{/* Manual Skill Selector Modal */}
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ import { useInteractionPhase } from './hooks/useInteractionPhase'
|
||||||
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
|
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
|
||||||
import { NumericKeypad } from './NumericKeypad'
|
import { NumericKeypad } from './NumericKeypad'
|
||||||
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
||||||
|
import { PracticeTimingDisplay } from './PracticeTimingDisplay'
|
||||||
import { ProblemDebugPanel } from './ProblemDebugPanel'
|
import { ProblemDebugPanel } from './ProblemDebugPanel'
|
||||||
import { VerticalProblem } from './VerticalProblem'
|
import { VerticalProblem } from './VerticalProblem'
|
||||||
|
|
||||||
|
|
@ -586,7 +587,8 @@ export function ActiveSession({
|
||||||
// Transition to submitting phase
|
// Transition to submitting phase
|
||||||
startSubmit()
|
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
|
const isCorrect = answerNum === attemptData.problem.answer
|
||||||
|
|
||||||
// Record the result
|
// Record the result
|
||||||
|
|
@ -715,8 +717,8 @@ export function ActiveSession({
|
||||||
// Calculate the threshold and stats from historical results
|
// Calculate the threshold and stats from historical results
|
||||||
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
|
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
|
||||||
|
|
||||||
// Calculate remaining time until auto-pause
|
// Calculate remaining time until auto-pause (using actual working time, not total elapsed)
|
||||||
const elapsedMs = Date.now() - attempt.startTime
|
const elapsedMs = Date.now() - attempt.startTime - attempt.accumulatedPauseMs
|
||||||
const remainingMs = threshold - elapsedMs
|
const remainingMs = threshold - elapsedMs
|
||||||
|
|
||||||
// Create pause info for auto-timeout
|
// Create pause info for auto-timeout
|
||||||
|
|
@ -742,7 +744,15 @@ export function ActiveSession({
|
||||||
}, remainingMs)
|
}, remainingMs)
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId)
|
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 handlePause = useCallback(() => {
|
||||||
const pauseInfo: PauseInfo = {
|
const pauseInfo: PauseInfo = {
|
||||||
|
|
@ -1029,6 +1039,20 @@ export function ActiveSession({
|
||||||
</div>
|
</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 */}
|
{/* Problem display */}
|
||||||
<div
|
<div
|
||||||
data-section="problem-area"
|
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 { useTheme } from '@/contexts/ThemeContext'
|
||||||
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
|
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
|
||||||
import { css } from '../../../styled-system/css'
|
import { css } from '../../../styled-system/css'
|
||||||
|
import { SpeedMeter } from './SpeedMeter'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statistics about response times used for auto-pause threshold
|
* 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))}`
|
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 {
|
export interface SessionPausedModalProps {
|
||||||
/** Whether the modal is visible */
|
/** Whether the modal is visible */
|
||||||
isOpen: boolean
|
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
|
partIndex: number
|
||||||
/** When the attempt started */
|
/** When the attempt started */
|
||||||
startTime: number
|
startTime: number
|
||||||
|
/** Accumulated time spent paused (ms) - subtract from elapsed time for actual response time */
|
||||||
|
accumulatedPauseMs: number
|
||||||
/** User's current answer input */
|
/** User's current answer input */
|
||||||
userAnswer: string
|
userAnswer: string
|
||||||
/** Number of times user used backspace or had digits rejected */
|
/** Number of times user used backspace or had digits rejected */
|
||||||
|
|
@ -94,6 +96,8 @@ export type InteractionPhase =
|
||||||
| {
|
| {
|
||||||
phase: 'paused'
|
phase: 'paused'
|
||||||
resumePhase: ActivePhase
|
resumePhase: ActivePhase
|
||||||
|
/** When the pause started (used to calculate pause duration on resume) */
|
||||||
|
pauseStartedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Threshold for correction count before requiring manual submit */
|
/** Threshold for correction count before requiring manual submit */
|
||||||
|
|
@ -124,7 +128,7 @@ export function transformActivePhase(
|
||||||
if (phase.phase === 'paused') {
|
if (phase.phase === 'paused') {
|
||||||
const newResumePhase = transform(phase.resumePhase)
|
const newResumePhase = transform(phase.resumePhase)
|
||||||
if (newResumePhase === null) return phase
|
if (newResumePhase === null) return phase
|
||||||
return { phase: 'paused', resumePhase: newResumePhase }
|
return { phase: 'paused', resumePhase: newResumePhase, pauseStartedAt: phase.pauseStartedAt }
|
||||||
}
|
}
|
||||||
const newPhase = transform(phase)
|
const newPhase = transform(phase)
|
||||||
return newPhase === null ? phase : newPhase
|
return newPhase === null ? phase : newPhase
|
||||||
|
|
@ -143,6 +147,7 @@ export function createAttemptInput(
|
||||||
slotIndex,
|
slotIndex,
|
||||||
partIndex,
|
partIndex,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
|
accumulatedPauseMs: 0,
|
||||||
userAnswer: '',
|
userAnswer: '',
|
||||||
correctionCount: 0,
|
correctionCount: 0,
|
||||||
manualSubmitRequired: false,
|
manualSubmitRequired: false,
|
||||||
|
|
@ -841,14 +846,47 @@ export function useInteractionPhase(
|
||||||
setPhase((prev) => {
|
setPhase((prev) => {
|
||||||
if (prev.phase === 'paused' || prev.phase === 'loading' || prev.phase === 'complete')
|
if (prev.phase === 'paused' || prev.phase === 'loading' || prev.phase === 'complete')
|
||||||
return prev
|
return prev
|
||||||
return { phase: 'paused', resumePhase: prev }
|
return { phase: 'paused', resumePhase: prev, pauseStartedAt: Date.now() }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
setPhase((prev) => {
|
setPhase((prev) => {
|
||||||
if (prev.phase !== 'paused') return 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 { PracticeErrorBoundary } from './PracticeErrorBoundary'
|
||||||
export type { SessionHudData } from './PracticeSubNav'
|
export type { SessionHudData } from './PracticeSubNav'
|
||||||
export { PracticeSubNav } from './PracticeSubNav'
|
export { PracticeSubNav } from './PracticeSubNav'
|
||||||
|
export { PracticeTimingDisplay } from './PracticeTimingDisplay'
|
||||||
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
|
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
|
||||||
export { ProgressDashboard } from './ProgressDashboard'
|
export { ProgressDashboard } from './ProgressDashboard'
|
||||||
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
|
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
|
||||||
export { SessionPausedModal } from './SessionPausedModal'
|
export { SessionPausedModal } from './SessionPausedModal'
|
||||||
export { SessionSummary } from './SessionSummary'
|
export { SessionSummary } from './SessionSummary'
|
||||||
|
export { SkillPerformanceReports } from './SkillPerformanceReports'
|
||||||
|
export type { SpeedMeterProps } from './SpeedMeter'
|
||||||
|
export { SpeedMeter } from './SpeedMeter'
|
||||||
export { StartPracticeModal } from './StartPracticeModal'
|
export { StartPracticeModal } from './StartPracticeModal'
|
||||||
export type { StudentWithProgress } from './StudentSelector'
|
export type { StudentWithProgress } from './StudentSelector'
|
||||||
export { StudentSelector } 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
|
* Resets to 0 when reinforcement is cleared or when help level 2+ is used
|
||||||
*/
|
*/
|
||||||
reinforcementStreak: integer('reinforcement_streak').notNull().default(0),
|
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) => ({
|
(table) => ({
|
||||||
/** Index for fast lookups by playerId */
|
/** 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
|
* Applies credit multipliers based on help used and manages reinforcement
|
||||||
*
|
*
|
||||||
* Credit multipliers:
|
* Credit multipliers:
|
||||||
|
|
@ -248,12 +248,17 @@ export async function recordSkillAttempt(
|
||||||
* - If help level >= threshold, mark skill as needing reinforcement
|
* - If help level >= threshold, mark skill as needing reinforcement
|
||||||
* - If correct answer without heavy help, increment reinforcement streak
|
* - If correct answer without heavy help, increment reinforcement streak
|
||||||
* - After N consecutive correct answers, clear reinforcement flag
|
* - 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(
|
export async function recordSkillAttemptWithHelp(
|
||||||
playerId: string,
|
playerId: string,
|
||||||
skillId: string,
|
skillId: string,
|
||||||
isCorrect: boolean,
|
isCorrect: boolean,
|
||||||
helpLevel: HelpLevel
|
helpLevel: HelpLevel,
|
||||||
|
responseTimeMs?: number
|
||||||
): Promise<PlayerSkillMastery> {
|
): Promise<PlayerSkillMastery> {
|
||||||
const existing = await getSkillMastery(playerId, skillId)
|
const existing = await getSkillMastery(playerId, skillId)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
@ -302,6 +307,15 @@ export async function recordSkillAttemptWithHelp(
|
||||||
reinforcementStreak = 0
|
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
|
await db
|
||||||
.update(schema.playerSkillMastery)
|
.update(schema.playerSkillMastery)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -314,12 +328,17 @@ export async function recordSkillAttemptWithHelp(
|
||||||
needsReinforcement,
|
needsReinforcement,
|
||||||
lastHelpLevel: helpLevel,
|
lastHelpLevel: helpLevel,
|
||||||
reinforcementStreak,
|
reinforcementStreak,
|
||||||
|
totalResponseTimeMs: newTotalResponseTimeMs,
|
||||||
|
responseTimeCount: newResponseTimeCount,
|
||||||
})
|
})
|
||||||
.where(eq(schema.playerSkillMastery.id, existing.id))
|
.where(eq(schema.playerSkillMastery.id, existing.id))
|
||||||
|
|
||||||
return (await getSkillMastery(playerId, skillId))!
|
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
|
// Create new record with help tracking
|
||||||
const newRecord: NewPlayerSkillMastery = {
|
const newRecord: NewPlayerSkillMastery = {
|
||||||
playerId,
|
playerId,
|
||||||
|
|
@ -332,6 +351,8 @@ export async function recordSkillAttemptWithHelp(
|
||||||
needsReinforcement: isHeavyHelp,
|
needsReinforcement: isHeavyHelp,
|
||||||
lastHelpLevel: helpLevel,
|
lastHelpLevel: helpLevel,
|
||||||
reinforcementStreak: 0,
|
reinforcementStreak: 0,
|
||||||
|
totalResponseTimeMs: hasResponseTime ? responseTimeMs : 0,
|
||||||
|
responseTimeCount: hasResponseTime ? 1 : 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(schema.playerSkillMastery).values(newRecord)
|
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)
|
* 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(
|
export async function recordSkillAttemptsWithHelp(
|
||||||
playerId: string,
|
playerId: string,
|
||||||
skillResults: Array<{ skillId: string; isCorrect: boolean }>,
|
skillResults: Array<{ skillId: string; isCorrect: boolean }>,
|
||||||
helpLevel: HelpLevel
|
helpLevel: HelpLevel,
|
||||||
|
responseTimeMs?: number
|
||||||
): Promise<PlayerSkillMastery[]> {
|
): Promise<PlayerSkillMastery[]> {
|
||||||
const results: PlayerSkillMastery[] = []
|
const results: PlayerSkillMastery[] = []
|
||||||
|
|
||||||
for (const { skillId, isCorrect } of skillResults) {
|
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)
|
results.push(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,3 +640,150 @@ export async function initializeStudent(playerId: string): Promise<PlayerCurricu
|
||||||
visualizationMode: false,
|
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,
|
type getPhaseSkillConstraints,
|
||||||
} from './definitions'
|
} from './definitions'
|
||||||
import { generateProblemFromConstraints } from './problem-generator'
|
import { generateProblemFromConstraints } from './problem-generator'
|
||||||
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
import {
|
||||||
|
getAllSkillMastery,
|
||||||
|
getPlayerCurriculum,
|
||||||
|
getRecentSessions,
|
||||||
|
recordSkillAttemptsWithHelp,
|
||||||
|
} from './progress-manager'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plan Generation
|
// Plan Generation
|
||||||
|
|
@ -452,6 +457,21 @@ export async function recordSlotResult(
|
||||||
.where(eq(schema.sessionPlans.id, planId))
|
.where(eq(schema.sessionPlans.id, planId))
|
||||||
.returning()
|
.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
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue