feat(skills): add Skills Dashboard with honest skill assessment framing

Add new Skills Dashboard at /practice/[studentId]/skills that provides:
- Per-skill performance tracking with problem history drill-down
- Fluency state categorization (practicing, fluent, effortless, rusty)
- Skills grouped by category (basic, five/ten complements, etc.)

Use conservative presentation to honestly represent skill data:
- Show "Correct: N" and "In errors: M" instead of misleading "Accuracy: X%"
- Label sections "Appear Frequently in Errors" instead of "Need Intervention"
- Add disclaimer: errors may have been caused by other skills in the problem

This addresses the epistemological issue that incorrect answers only tell us
ONE OR MORE skills failed, not which specific skill(s) caused the error.

Files:
- New: SkillsClient.tsx, skills/page.tsx (Skills Dashboard)
- Updated: DashboardClient.tsx (link to skills page)
- Updated: SkillPerformanceReports.tsx (honest framing)
- Updated: session-planner.ts (getRecentSessionResults for problem history)
- Updated: server.ts (re-export new function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-14 10:46:25 -06:00
parent 7a2390bd1b
commit bf4334b281
6 changed files with 1322 additions and 15 deletions

View File

@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
@ -323,6 +324,59 @@ export function DashboardClient({
onRecordOfflinePractice={handleRecordOfflinePractice}
/>
{/* Skills Dashboard Link */}
<div className={css({ marginTop: '24px' })}>
<Link
href={`/practice/${studentId}/skills`}
data-action="view-skills"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderRadius: '12px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
border: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
textDecoration: 'none',
transition: 'all 0.15s ease',
_hover: {
borderColor: isDark ? 'blue.600' : 'blue.400',
backgroundColor: isDark ? 'gray.750' : 'blue.50',
},
})}
>
<div>
<div
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
marginBottom: '0.25rem',
})}
>
View Skills Dashboard
</div>
<div
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
See detailed skill performance, intervention points, and problem history
</div>
</div>
<span
className={css({
fontSize: '1.5rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
</span>
</Link>
</div>
{/* Skill Performance Reports - shows response time analysis */}
<div className={css({ marginTop: '24px' })}>
<SkillPerformanceReports playerId={studentId} isDark={isDark} />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
import { notFound } from 'next/navigation'
import { getAllSkillMastery, getPlayer, getPlayerCurriculum } from '@/lib/curriculum/server'
import { getRecentSessionResults } from '@/lib/curriculum/server'
import { SkillsClient } from './SkillsClient'
// Disable caching for this page - progress data should be fresh
export const dynamic = 'force-dynamic'
interface SkillsPageProps {
params: Promise<{ studentId: string }>
}
/**
* Skills Dashboard Page - Server Component
*
* Shows comprehensive skill performance data:
* - Intervention needed (struggling skills)
* - Current focus (curriculum position)
* - Ready to advance (fluent skills)
* - All skills by category with drill-down to problem history
*
* URL: /practice/[studentId]/skills
*/
export default async function SkillsPage({ params }: SkillsPageProps) {
const { studentId } = await params
// Fetch all required data in parallel
const [player, curriculum, skills, problemHistory] = await Promise.all([
getPlayer(studentId),
getPlayerCurriculum(studentId),
getAllSkillMastery(studentId),
getRecentSessionResults(studentId, 50), // Get last 50 sessions worth of problems
])
// 404 if player doesn't exist
if (!player) {
notFound()
}
return (
<SkillsClient
studentId={studentId}
player={player}
curriculum={curriculum}
skills={skills}
problemHistory={problemHistory}
/>
)
}

View File

@ -152,27 +152,35 @@ function SkillCard({
fontSize: '0.875rem',
})}
>
{/* Honest framing: show counts instead of misleading accuracy % */}
<div>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>Accuracy: </span>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>Correct: </span>
<span
className={css({
color:
skill.accuracy >= 0.7
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'orange.400'
: 'orange.600',
color: isDark ? 'green.400' : 'green.600',
fontWeight: 'medium',
})}
>
{Math.round(skill.accuracy * 100)}%
{Math.round(skill.attempts * skill.accuracy)}
</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>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>In errors: </span>
<span
className={css({
color:
skill.attempts - Math.round(skill.attempts * skill.accuracy) > 0
? isDark
? 'orange.400'
: 'orange.600'
: isDark
? 'gray.400'
: 'gray.500',
fontWeight: 'medium',
})}
>
{skill.attempts - Math.round(skill.attempts * skill.accuracy)}
</span>
</div>
{skill.avgResponseTimeMs && (
<div className={css({ gridColumn: 'span 2' })}>
@ -337,7 +345,7 @@ export function SkillPerformanceReports({
</div>
)}
{/* Areas Needing Work */}
{/* Skills appearing frequently in errors or slow responses */}
{(hasSlowSkills || hasLowAccuracySkills || hasReinforcementSkills) && (
<div className={css({ marginBottom: '20px' })}>
<h4
@ -348,8 +356,19 @@ export function SkillPerformanceReports({
marginBottom: '12px',
})}
>
Areas Needing Work
🔍 Appear in Frequent Errors
</h4>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '12px',
fontStyle: 'italic',
})}
>
Note: These skills appeared in problems with errors. The error may have been caused by
other skills in the same problem.
</p>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{analysis.slowSkills.map((skill) => (
<SkillCard

View File

@ -83,4 +83,9 @@ export async function getPlayersForViewer(): Promise<Player[]> {
// Re-export the individual functions for granular prefetching
export { getPlayer } from '@/lib/arcade/player-manager'
export { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
export { getActiveSessionPlan, getMostRecentCompletedSession } from './session-planner'
export {
getActiveSessionPlan,
getMostRecentCompletedSession,
getRecentSessionResults,
} from './session-planner'
export type { ProblemResultWithContext } from './session-planner'

View File

@ -452,6 +452,66 @@ export async function getMostRecentCompletedSession(playerId: string): Promise<S
return result ?? null
}
/**
* Result from a problem with session context for traceability
*/
export interface ProblemResultWithContext extends SlotResult {
/** Session ID this result came from */
sessionId: string
/** When the session was completed */
sessionCompletedAt: Date
/** Part type (abacus/visualization/linear) */
partType: SessionPartType
}
/**
* Get recent problem results across multiple sessions for skills analysis
*
* Returns a flat list of problem results with session context,
* ordered by most recent first.
*
* @param playerId - The player to fetch results for
* @param sessionCount - Number of recent sessions to include (default 50)
*/
export async function getRecentSessionResults(
playerId: string,
sessionCount = 50
): Promise<ProblemResultWithContext[]> {
const sessions = await db.query.sessionPlans.findMany({
where: and(
eq(schema.sessionPlans.playerId, playerId),
eq(schema.sessionPlans.status, 'completed')
),
orderBy: (plans, { desc }) => [desc(plans.completedAt)],
limit: sessionCount,
})
// Flatten results with session context
const results: ProblemResultWithContext[] = []
for (const session of sessions) {
if (!session.completedAt) continue
for (const result of session.results) {
// Find the part type for this result
const part = session.parts.find((p) => p.partNumber === result.partNumber)
const partType = part?.type ?? 'linear'
results.push({
...result,
sessionId: session.id,
sessionCompletedAt: session.completedAt,
partType,
})
}
}
// Sort by timestamp descending (most recent first)
results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
return results
}
/**
* Approve a plan (teacher says "Let's Go!")
*/