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:
parent
7a2390bd1b
commit
bf4334b281
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue