feat(practice): add intervention system and improve skill chart hierarchy

- Add "Needs Attention" section to /practice page surfacing struggling students
- Implement intervention detection (struggling, declining, stale, absent, plateau)
- Group skill chart legend into Mastered/In Progress/Not Started hierarchy
- Use color families: green for mastered (strong+stale), patterns for attention states
- Add diagonal stripe pattern to Stale and Weak categories in chart and legend
- Update tooltip to show same grouped structure with tree branches
- Fix encouragement message to not celebrate when weak skills are growing
- Make Unassessed transparent/dashed to fade into background

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-21 15:59:25 -06:00
parent 1fc8949b06
commit bf5b99afe9
6 changed files with 945 additions and 99 deletions

View File

@@ -9,7 +9,7 @@ import { StudentSelector, type StudentWithProgress } from '@/components/practice
import { useTheme } from '@/contexts/ThemeContext'
import { usePlayersWithSkillData, useUpdatePlayer } from '@/hooks/useUserPlayers'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
import { filterStudents, groupStudents } from '@/utils/studentGrouping'
import { filterStudents, getStudentsNeedingAttention, groupStudents } from '@/utils/studentGrouping'
import { css } from '../../../styled-system/css'
import { AddStudentModal } from './AddStudentModal'
@@ -58,6 +58,59 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
const groupedStudents = useMemo(() => groupStudents(filteredStudents), [filteredStudents])
// Students needing intervention (only from non-archived, filtered set)
const studentsNeedingAttention = useMemo(
() => getStudentsNeedingAttention(filteredStudents),
[filteredStudents]
)
// Set of student IDs shown in attention section (for filtering)
const attentionStudentIds = useMemo(
() => new Set(studentsNeedingAttention.map((s) => s.id)),
[studentsNeedingAttention]
)
// Track attention counts per bucket/category for placeholder display
const attentionCountsByBucket = useMemo(() => {
const counts = new Map<string, Map<string | null, number>>()
for (const student of studentsNeedingAttention) {
// Find which bucket/category this student would be in
for (const bucket of groupedStudents) {
for (const category of bucket.categories) {
if (category.students.some((s) => s.id === student.id)) {
const bucketKey = bucket.bucket
if (!counts.has(bucketKey)) {
counts.set(bucketKey, new Map())
}
const categoryKey = category.category
const categoryMap = counts.get(bucketKey)!
categoryMap.set(categoryKey, (categoryMap.get(categoryKey) ?? 0) + 1)
}
}
}
}
return counts
}, [studentsNeedingAttention, groupedStudents])
// Filter grouped students to exclude those in attention section
const filteredGroupedStudents = useMemo(() => {
return groupedStudents
.map((bucket) => ({
...bucket,
categories: bucket.categories
.map((category) => ({
...category,
students: category.students.filter((s) => !attentionStudentIds.has(s.id)),
}))
.filter(
(category) =>
category.students.length > 0 ||
(attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0) > 0
),
}))
.filter((bucket) => bucket.categories.length > 0)
}, [groupedStudents, attentionStudentIds, attentionCountsByBucket])
// Handle student selection - navigate to student's resume page
const handleSelectStudent = useCallback(
(student: StudentWithProgress) => {
@@ -182,8 +235,67 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
</p>
</header>
{/* Needs Attention Section - uses same bucket styling as other sections */}
{studentsNeedingAttention.length > 0 && (
<div data-bucket="attention" data-component="needs-attention-bucket">
{/* Bucket header - sticky below filter bar */}
<h2
data-element="bucket-header"
className={css({
position: 'sticky',
top: '160px', // Nav (80px) + Filter bar (~80px)
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'orange.400' : 'orange.600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
bg: isDark ? 'gray.900' : 'gray.50',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span></span>
<span>Needs Attention</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '20px',
height: '20px',
padding: '0 6px',
borderRadius: '10px',
backgroundColor: isDark ? 'orange.700' : 'orange.500',
color: 'white',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
{studentsNeedingAttention.length}
</span>
</h2>
{/* Student cards - intervention badges will show on each card */}
<StudentSelector
students={studentsNeedingAttention as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
title=""
editMode={editMode}
selectedIds={selectedIds}
hideAddButton
/>
</div>
)}
{/* Grouped Student List */}
{groupedStudents.length === 0 ? (
{filteredGroupedStudents.length === 0 && studentsNeedingAttention.length === 0 ? (
<div
className={css({
textAlign: 'center',
@@ -237,7 +349,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
gap: '24px',
})}
>
{groupedStudents.map((bucket) => (
{filteredGroupedStudents.map((bucket) => (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
{/* Bucket header - sticky below filter bar */}
<h2
@@ -270,42 +382,82 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
gap: '16px',
})}
>
{bucket.categories.map((category) => (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
{bucket.categories.map((category) => {
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0
return (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{category.categoryName}
</h3>
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{category.categoryName}
</h3>
{/* Student cards */}
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
title=""
editMode={editMode}
selectedIds={selectedIds}
hideAddButton
/>
</div>
))}
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards */}
{category.students.length > 0 && (
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
title=""
editMode={editMode}
selectedIds={selectedIds}
hideAddButton
/>
)}
{/* Attention placeholder */}
{attentionCount > 0 && (
<div
data-element="attention-placeholder"
data-attention-count={attentionCount}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '60px',
flexShrink: 0,
})}
>
+{attentionCount} in Needs Attention
</div>
)}
</div>
</div>
)
})}
</div>
</div>
))}

View File

@@ -61,43 +61,63 @@ interface SkillProgressChartProps {
// Constants
// ============================================================================
/**
* Color design rationale:
* - Strong & Stale are both "mastered" skills, so they share the green family
* - Strong: Vibrant emerald green (healthy, fresh)
* - Stale: Sage/olive green with gold undertone (aged, needs refresh)
* - Developing & Weak are both "in progress", so they share the blue-violet family
* - Developing: Clear blue (learning, progressing)
* - Weak: Warm coral-red (struggling, needs attention)
* - Unassessed: Neutral gray (not yet engaged)
*/
const CLASSIFICATION_CONFIG: Record<
SkillClassification,
{ label: string; emoji: string; color: string; lightColor: string; darkColor: string }
{
label: string
emoji: string
color: string
lightColor: string
darkColor: string
/** Whether to show a subtle pattern in the chart (indicates warning/attention state) */
hasPattern?: boolean
}
> = {
strong: {
label: 'Strong',
emoji: '🟢',
color: '#22c55e',
color: '#22c55e', // emerald-500
lightColor: 'green.500',
darkColor: 'green.400',
},
stale: {
label: 'Stale',
emoji: '🟡',
color: '#eab308',
lightColor: 'yellow.500',
darkColor: 'yellow.400',
emoji: '🌿', // leaf emoji to suggest "aging green"
color: '#84cc16', // lime-500 - green trending toward yellow
lightColor: 'lime.500',
darkColor: 'lime.400',
hasPattern: true, // subtle stripes to indicate "needs attention"
},
developing: {
label: 'Developing',
emoji: '🔵',
color: '#3b82f6',
color: '#3b82f6', // blue-500
lightColor: 'blue.500',
darkColor: 'blue.400',
},
weak: {
label: 'Weak',
emoji: '🔴',
color: '#ef4444',
lightColor: 'red.500',
color: '#f87171', // red-400 - slightly softer red
lightColor: 'red.400',
darkColor: 'red.400',
hasPattern: true, // subtle stripes to indicate "needs attention"
},
unassessed: {
label: 'Unassessed',
emoji: '⚪',
color: '#9ca3af',
lightColor: 'gray.400',
color: 'rgba(156, 163, 175, 0.4)', // gray-400 at 40% opacity - fades into background
lightColor: 'gray.300',
darkColor: 'gray.500',
},
}
@@ -379,7 +399,7 @@ function analyzeSessionTiming(snapshots: SessionSnapshot[]): TimingAnalysis | nu
// Check for high variance (sporadic)
if (gaps.length >= 3) {
const variance = gaps.reduce((acc, g) => acc + Math.pow(g - averageGapDays, 2), 0) / gaps.length
const variance = gaps.reduce((acc, g) => acc + (g - averageGapDays) ** 2, 0) / gaps.length
const stdDev = Math.sqrt(variance)
// High coefficient of variation indicates sporadic practice
if (stdDev / averageGapDays > 0.8) {
@@ -504,10 +524,14 @@ function getMotivationalMessage(
const achievedTrendSlope = calculateTrendSlope(achievedCounts)
const achievedTrend = classifyTrend(achievedTrendSlope, totalSkills)
// Track weak skill reduction
// Track weak skill changes
const weakCounts = snapshots.map((s) => s.distribution.weak)
const weakTrendSlope = calculateTrendSlope(weakCounts)
const weakImproving = weakTrendSlope < -totalSkills * 0.03 // Declining weak is good
const weakWorsening = weakTrendSlope > totalSkills * 0.03 // Growing weak is bad
const currentWeak = currentDistribution.weak
const firstWeak = first.distribution.weak
const weakGrowth = currentWeak - firstWeak
// === Current State Analysis ===
const peakStrong = Math.max(...strongCounts)
@@ -542,23 +566,33 @@ function getMotivationalMessage(
return `⏰ Practice gaps are growing. ${currentStale} skill${currentStale > 1 ? 's' : ''} became stale during the break.`
}
// PRIORITY 3: Strong positive momentum (both trend and recent gains)
if (achievedTrend === 'improving' && achievedGain > 0) {
// PRIORITY 3: Growing weak skills - this is a problem, don't celebrate
if (weakWorsening && weakGrowth > 0 && currentWeak >= 2) {
// If weak skills are outpacing mastery gains, that's concerning
if (achievedGain > 0 && weakGrowth > achievedGain) {
return `⚠️ ${currentWeak} weak skill${currentWeak > 1 ? 's' : ''} need${currentWeak === 1 ? 's' : ''} attention. Weak skills are growing faster than mastery.`
}
return `⚠️ ${currentWeak} skill${currentWeak > 1 ? 's are' : ' is'} weak ${scope}. Focus on these before adding new skills.`
}
// PRIORITY 4: Strong positive momentum (both trend and recent gains)
// Only celebrate if weak skills aren't also growing
if (achievedTrend === 'improving' && achievedGain > 0 && !weakWorsening) {
if (weakImproving && weakReduction > 0) {
return `📈 Excellent momentum ${scope}! Gained ${achievedGain} strong skill${achievedGain > 1 ? 's' : ''} while reducing weak skills by ${weakReduction}.`
}
return `📈 Strong upward trend ${scope}! You've mastered ${achievedGain} more skill${achievedGain > 1 ? 's' : ''}.`
}
// PRIORITY 4: Recent gains even if trend is stable
if (achievedGain > 0) {
// PRIORITY 5: Recent gains even if trend is stable (but not if weak is growing)
if (achievedGain > 0 && !weakWorsening) {
if (currentStale > 0) {
return `📈 Gained ${achievedGain} skill${achievedGain > 1 ? 's' : ''} ${scope}, but ${currentStale} ${currentStale > 1 ? 'are' : 'is'} now stale. A quick review will refresh them!`
}
return `📈 Great progress ${scope}! ${achievedGain} more skill${achievedGain > 1 ? 's' : ''} mastered.`
}
// PRIORITY 5: Skills becoming stale (lost freshness but not mastery)
// PRIORITY 6: Skills becoming stale (lost freshness but not mastery)
if (lostToStaleness > 0 && currentStale > 0) {
if (timing && timing.isRecentGapLarger) {
return `${currentStale} skill${currentStale > 1 ? 's' : ''} became stale during the longer gap between sessions.`
@@ -566,17 +600,22 @@ function getMotivationalMessage(
return `${currentStale} skill${currentStale > 1 ? 's' : ''} became stale. Practice soon to keep them fresh!`
}
// PRIORITY 6: Weak skill improvement
// PRIORITY 7: Weak skill improvement
if (weakImproving && weakReduction > 0) {
return `💪 ${weakReduction} fewer weak skill${weakReduction > 1 ? 's' : ''} ${scope}. Keep building!`
}
// PRIORITY 7: Current stale skills (even without recent loss)
// PRIORITY 8: Current weak skills that need attention (fallback if not caught by PRIORITY 3)
if (currentWeak > 0) {
return `⚠️ ${currentWeak} skill${currentWeak > 1 ? 's are' : ' is'} weak. Practice these to build mastery!`
}
// PRIORITY 9: Current stale skills (even without recent loss)
if (currentStale > 0) {
return `${currentStale} skill${currentStale > 1 ? 's are' : ' is'} stale. A quick practice session will refresh them!`
}
// PRIORITY 8: Stable trend with good state
// PRIORITY 10: Stable trend with good state
if (strongTrend === 'stable' && currentStrong > 0) {
if (timing && timing.practiceFrequency === 'regular') {
return `🎯 Consistent practice ${scope} is paying off. Keep up the steady rhythm!`
@@ -597,6 +636,74 @@ function getMotivationalMessage(
// Components
// ============================================================================
/**
* Descriptors explain the classification criteria.
* Stale needs special clarification since it's a time-based warning on Strong, not a mastery level.
*/
const CLASSIFICATION_DESCRIPTORS: Partial<Record<SkillClassification, string>> = {
stale: '7+ days ago',
}
/**
* Generate CSS for diagonal stripe pattern overlay.
* Used for Stale and Weak to indicate "needs attention" state.
*/
function getStripePattern(isDark: boolean): string {
const stripeColor = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.35)'
return `repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
${stripeColor} 3px,
${stripeColor} 6px
)`
}
/**
* Get text colors that ensure readability against the colored background.
*/
function getTextColors(
classification: SkillClassification,
isDark: boolean
): { count: string; label: string; descriptor: string } {
// For colored backgrounds, we need good contrast
// Light mode: darker shades of the color
// Dark mode: lighter shades or white
switch (classification) {
case 'strong':
return {
count: isDark ? '#bbf7d0' : '#14532d', // green-200 / green-900
label: isDark ? '#86efac' : '#166534', // green-300 / green-800
descriptor: isDark ? '#4ade80' : '#15803d', // green-400 / green-700
}
case 'stale':
return {
count: isDark ? '#ecfccb' : '#365314', // lime-100 / lime-900
label: isDark ? '#d9f99d' : '#3f6212', // lime-200 / lime-800
descriptor: isDark ? '#bef264' : '#4d7c0f', // lime-300 / lime-700
}
case 'developing':
return {
count: isDark ? '#bfdbfe' : '#1e3a5f', // blue-200 / custom dark blue
label: isDark ? '#93c5fd' : '#1e40af', // blue-300 / blue-800
descriptor: isDark ? '#60a5fa' : '#1d4ed8', // blue-400 / blue-700
}
case 'weak':
return {
count: isDark ? '#fecaca' : '#7f1d1d', // red-200 / red-900
label: isDark ? '#fca5a5' : '#991b1b', // red-300 / red-800
descriptor: isDark ? '#f87171' : '#b91c1c', // red-400 / red-700
}
case 'unassessed':
default:
return {
count: isDark ? '#e5e7eb' : '#374151', // gray-200 / gray-700
label: isDark ? '#d1d5db' : '#4b5563', // gray-300 / gray-600
descriptor: isDark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
}
}
}
function LegendCard({
classification,
count,
@@ -611,6 +718,22 @@ function LegendCard({
isDark: boolean
}) {
const config = CLASSIFICATION_CONFIG[classification]
const descriptor = CLASSIFICATION_DESCRIPTORS[classification]
const textColors = getTextColors(classification, isDark)
// Unassessed is special: transparent/borderless to fade into background
const isUnassessed = classification === 'unassessed'
// Background styling
const getBackgroundStyle = () => {
if (isUnassessed) {
// Transparent with subtle border
return isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(209, 213, 219, 0.4)'
}
// Other categories get their full color
const bgOpacity = isDark ? 0.85 : 0.8
return `color-mix(in srgb, ${config.color} ${bgOpacity * 100}%, transparent)`
}
return (
<button
@@ -627,39 +750,55 @@ function LegendCard({
padding: '0.75rem 1rem',
minWidth: '90px',
borderRadius: '12px',
border: '2px solid',
border: isUnassessed ? '1px dashed' : '2px solid',
borderColor: isActive
? isDark
? config.darkColor
: config.lightColor
: isDark
? 'gray.600'
: 'gray.200',
backgroundColor: isActive
? isDark
? `${config.lightColor}/20`
: `${config.lightColor}/10`
: isDark
? 'gray.800'
: 'white',
? 'white'
: 'gray.800'
: isUnassessed
? isDark
? 'gray.600'
: 'gray.300'
: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
position: 'relative',
overflow: 'hidden',
// Ring effect on hover
_hover: {
borderColor: isDark ? config.darkColor : config.lightColor,
backgroundColor: isDark ? 'gray.700' : 'gray.50',
boxShadow: `0 0 0 2px ${isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)'}`,
},
})}
style={{
backgroundColor: getBackgroundStyle(),
}}
>
{/* Stripe pattern overlay for hasPattern categories */}
{config.hasPattern && (
<div
className={css({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: '10px', // slightly less than parent for clean edges
})}
style={{
background: getStripePattern(isDark),
}}
/>
)}
{/* Active indicator */}
{isActive && (
<span
className={css({
position: 'absolute',
top: '4px',
right: '4px',
fontSize: '0.625rem',
right: '6px',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
style={{ color: textColors.count }}
>
</span>
@@ -670,9 +809,11 @@ function LegendCard({
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? config.darkColor : config.lightColor,
lineHeight: 1,
position: 'relative', // Above pattern
textShadow: isDark ? '0 1px 2px rgba(0,0,0,0.3)' : '0 1px 1px rgba(255,255,255,0.5)',
})}
style={{ color: textColors.count }}
>
{count}
</span>
@@ -681,16 +822,33 @@ function LegendCard({
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.300' : 'gray.600',
fontWeight: 'medium',
marginTop: '0.25rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
position: 'relative', // Above pattern
})}
style={{ color: textColors.label }}
>
<span>{config.emoji}</span>
<span>{config.label}</span>
</span>
{/* Descriptor (explains classification criteria) */}
{descriptor && (
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'medium',
marginTop: '0.125rem',
position: 'relative', // Above pattern
})}
style={{ color: textColors.descriptor }}
>
{descriptor}
</span>
)}
</button>
)
}
@@ -787,12 +945,29 @@ export function SkillProgressChart({
const series = CLASSIFICATION_ORDER.map((classification) => {
const config = CLASSIFICATION_CONFIG[classification]
return {
// Decal pattern for "needs attention" categories (Stale, Weak)
// Applied via areaStyle for stacked area charts
// Must explicitly set 'none' for series without patterns when aria.decal is enabled
const decalPattern = config.hasPattern
? {
symbol: 'rect',
symbolSize: 1,
rotation: Math.PI / 4, // 45 degree diagonal
color: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.4)',
dashArrayX: [1, 0],
dashArrayY: [4, 3], // stripe pattern matching legend cards
}
: { symbol: 'none' } // Explicitly disable pattern for non-attention categories
// Base series configuration
const seriesConfig: Record<string, unknown> = {
name: config.label,
type: 'line',
stack: 'total',
areaStyle: {
opacity: 0.8,
opacity: classification === 'unassessed' ? 0.4 : 0.85,
decal: decalPattern,
},
emphasis: {
focus: 'series',
@@ -807,13 +982,27 @@ export function SkillProgressChart({
},
data: snapshots.map((s) => toPercent(s.distribution[classification], s.distribution.total)),
}
return seriesConfig
})
return {
backgroundColor: 'transparent',
// Enable decal patterns (required for patterns to render)
aria: {
enabled: true,
decal: {
show: true,
},
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: isDark ? 'rgba(30, 30, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? '#374151' : '#e5e7eb',
textStyle: {
color: isDark ? '#e5e7eb' : '#1f2937',
},
formatter: (
params: Array<{ seriesName: string; value: number; color: string; marker: string }>
) => {
@@ -821,13 +1010,106 @@ export function SkillProgressChart({
const snapshot = snapshots[idx]
if (!snapshot) return ''
let html = `<strong>${dates[idx]}</strong><br/>`
// Reverse order for tooltip (show from top of stack to bottom)
for (const p of [...params].reverse()) {
const count =
snapshot.distribution[p.seriesName.toLowerCase() as SkillClassification] ?? 0
html += `${p.marker} ${p.seriesName}: ${count} (${p.value}%)<br/>`
const dist = snapshot.distribution
const groupHeaderStyle = `color: ${isDark ? '#9ca3af' : '#6b7280'}; font-size: 10px; font-weight: 600; letter-spacing: 0.5px;`
const branchStyle = `color: ${isDark ? '#4b5563' : '#d1d5db'}; font-family: monospace;`
const labelStyle = `color: ${isDark ? '#e5e7eb' : '#374151'};`
const countStyle = `color: ${isDark ? '#9ca3af' : '#6b7280'}; font-size: 11px;`
const descriptorStyle = `color: ${isDark ? '#6b7280' : '#9ca3af'}; font-size: 10px;`
// Helper to create a row with branch characters
const row = (
branch: string,
marker: string,
label: string,
count: number,
percent: number,
descriptor?: string
) => {
const descHtml = descriptor
? ` <span style="${descriptorStyle}">(${descriptor})</span>`
: ''
return `<div style="display: flex; align-items: center; gap: 4px; margin: 2px 0;">
<span style="${branchStyle}">${branch}</span>
<span>${marker}</span>
<span style="${labelStyle}">${label}</span>
<span style="${countStyle}">${count} (${percent}%)</span>${descHtml}
</div>`
}
// Find params by series name
const getParam = (name: string) => params.find((p) => p.seriesName === name)
const strongParam = getParam('Strong')
const staleParam = getParam('Stale')
const devParam = getParam('Developing')
const weakParam = getParam('Weak')
const unassessedParam = getParam('Unassessed')
let html = `<div style="font-size: 12px; line-height: 1.4;">
<div style="font-weight: 600; margin-bottom: 8px; border-bottom: 1px solid ${isDark ? '#374151' : '#e5e7eb'}; padding-bottom: 4px;">${dates[idx]}</div>`
// MASTERED group (if either Strong or Stale has data)
if ((dist.strong > 0 || dist.stale > 0) && (strongParam || staleParam)) {
html += `<div style="margin-bottom: 6px;">
<div style="${groupHeaderStyle}">MASTERED</div>`
if (strongParam && dist.strong > 0) {
html += row(
'├─',
strongParam.marker,
'Strong',
dist.strong,
strongParam.value as number
)
}
if (staleParam && dist.stale > 0) {
const branch = dist.strong > 0 ? '└─' : '──'
html += row(
branch,
staleParam.marker,
'Stale',
dist.stale,
staleParam.value as number,
'7+ days ago'
)
}
html += '</div>'
}
// IN PROGRESS group (if either Developing or Weak has data)
if ((dist.developing > 0 || dist.weak > 0) && (devParam || weakParam)) {
html += `<div style="margin-bottom: 6px;">
<div style="${groupHeaderStyle}">IN PROGRESS</div>`
if (devParam && dist.developing > 0) {
html += row(
'├─',
devParam.marker,
'Developing',
dist.developing,
devParam.value as number
)
}
if (weakParam && dist.weak > 0) {
const branch = dist.developing > 0 ? '└─' : '──'
html += row(branch, weakParam.marker, 'Weak', dist.weak, weakParam.value as number)
}
html += '</div>'
}
// NOT STARTED group (if Unassessed has data)
if (dist.unassessed > 0 && unassessedParam) {
html += `<div>
<div style="${groupHeaderStyle}">NOT STARTED</div>`
html += row(
'──',
unassessedParam.marker,
'Unassessed',
dist.unassessed,
unassessedParam.value as number
)
html += '</div>'
}
html += '</div>'
return html
},
},
@@ -959,7 +1241,7 @@ export function SkillProgressChart({
</div>
)}
{/* Legend cards */}
{/* Legend cards - grouped by mastery level to show hierarchy */}
<div
data-element="legend-cards"
className={css({
@@ -967,19 +1249,152 @@ export function SkillProgressChart({
flexWrap: 'wrap',
gap: '0.5rem',
justifyContent: 'center',
alignItems: 'flex-start',
marginBottom: '0.75rem',
})}
>
{legendClassifications.map((classification) => (
<LegendCard
key={classification}
classification={classification}
count={currentDistribution[classification]}
isActive={activeFilters.has(classification)}
onToggle={() => handleToggle(classification)}
isDark={isDark}
/>
))}
{/* Mastered group (Strong + Stale) */}
{(legendClassifications.includes('strong') || legendClassifications.includes('stale')) && (
<div
data-element="legend-group-mastered"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
})}
>
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Mastered
</span>
<div className={css({ display: 'flex', gap: '0.5rem' })}>
{legendClassifications
.filter((c) => c === 'strong' || c === 'stale')
.map((classification) => (
<LegendCard
key={classification}
classification={classification}
count={currentDistribution[classification]}
isActive={activeFilters.has(classification)}
onToggle={() => handleToggle(classification)}
isDark={isDark}
/>
))}
</div>
</div>
)}
{/* Divider between Mastered and In Progress */}
{(legendClassifications.includes('strong') || legendClassifications.includes('stale')) &&
(legendClassifications.includes('developing') ||
legendClassifications.includes('weak')) && (
<div
data-element="legend-divider"
className={css({
width: '1px',
alignSelf: 'stretch',
marginTop: '1rem', // Skip the header height
backgroundColor: isDark ? 'gray.700' : 'gray.200',
})}
/>
)}
{/* In Progress group (Developing + Weak) */}
{(legendClassifications.includes('developing') ||
legendClassifications.includes('weak')) && (
<div
data-element="legend-group-in-progress"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
})}
>
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
In Progress
</span>
<div className={css({ display: 'flex', gap: '0.5rem' })}>
{legendClassifications
.filter((c) => c === 'developing' || c === 'weak')
.map((classification) => (
<LegendCard
key={classification}
classification={classification}
count={currentDistribution[classification]}
isActive={activeFilters.has(classification)}
onToggle={() => handleToggle(classification)}
isDark={isDark}
/>
))}
</div>
</div>
)}
{/* Divider before Not Started */}
{(legendClassifications.includes('developing') ||
legendClassifications.includes('weak') ||
legendClassifications.includes('strong') ||
legendClassifications.includes('stale')) &&
legendClassifications.includes('unassessed') && (
<div
data-element="legend-divider"
className={css({
width: '1px',
alignSelf: 'stretch',
marginTop: '1rem', // Skip the header height
backgroundColor: isDark ? 'gray.700' : 'gray.200',
})}
/>
)}
{/* Not Started group (Unassessed) */}
{legendClassifications.includes('unassessed') && (
<div
data-element="legend-group-not-started"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
})}
>
<span
className={css({
fontSize: '0.625rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Not Started
</span>
<LegendCard
classification="unassessed"
count={currentDistribution.unassessed}
isActive={activeFilters.has('unassessed')}
onToggle={() => handleToggle('unassessed')}
isDark={isDark}
/>
</div>
)}
</div>
{/* Clear filters button */}

View File

@@ -27,6 +27,16 @@ import {
wrap,
} from './styles'
/**
* Intervention data for students needing attention
*/
export interface StudentIntervention {
type: 'struggling' | 'declining' | 'stale' | 'absent' | 'plateau'
severity: 'high' | 'medium' | 'low'
message: string
icon: string
}
/**
* Student data with curriculum info for display
*/
@@ -38,6 +48,7 @@ export interface StudentWithProgress extends Player {
practicingSkills?: string[]
lastPracticedAt?: Date | null
skillCategory?: string | null
intervention?: StudentIntervention | null
}
interface StudentCardProps {
@@ -271,6 +282,52 @@ function StudentCard({ student, onSelect, onOpenNotes, editMode, isSelected }: S
/>
</div>
)}
{/* Intervention badge (if needing attention) */}
{student.intervention && (
<div
data-element="intervention-badge"
data-intervention-type={student.intervention.type}
data-intervention-severity={student.intervention.severity}
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '0.6875rem',
fontWeight: 'medium',
backgroundColor:
student.intervention.severity === 'high'
? isDark
? 'red.900/60'
: 'red.100'
: student.intervention.severity === 'medium'
? isDark
? 'orange.900/60'
: 'orange.100'
: isDark
? 'blue.900/60'
: 'blue.100',
color:
student.intervention.severity === 'high'
? isDark
? 'red.300'
: 'red.700'
: student.intervention.severity === 'medium'
? isDark
? 'orange.300'
: 'orange.700'
: isDark
? 'blue.300'
: 'blue.700',
marginTop: '4px',
})}
>
<span>{student.intervention.icon}</span>
<span>{student.intervention.message}</span>
</div>
)}
</button>
</div>
)

View File

@@ -47,7 +47,7 @@ export { getSkillClassification, SkillProgressChart } from './SkillProgressChart
export type { SpeedMeterProps } from './SpeedMeter'
export { SpeedMeter } from './SpeedMeter'
export { StartPracticeModal } from './StartPracticeModal'
export type { StudentWithProgress } from './StudentSelector'
export type { StudentIntervention, StudentWithProgress } from './StudentSelector'
export { StudentSelector } from './StudentSelector'
export { VerticalProblem } from './VerticalProblem'
export { VirtualizedSessionList } from './VirtualizedSessionList'

View File

@@ -14,14 +14,20 @@ import { db, schema } from '@/db'
import type { Player } from '@/db/schema/players'
import { getPlayer } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { computeSkillCategory, type StudentWithSkillData } from '@/utils/studentGrouping'
import {
computeIntervention,
computeSkillCategory,
type SkillDistribution,
type StudentWithSkillData,
} from '@/utils/studentGrouping'
import { computeBktFromHistory, getStalenessWarning } from './bkt'
import {
getAllSkillMastery,
getPaginatedSessions,
getPlayerCurriculum,
getRecentSessions,
} from './progress-manager'
import { getActiveSessionPlan } from './session-planner'
import { getActiveSessionPlan, getRecentSessionResults } from './session-planner'
export type { PlayerCurriculum } from '@/db/schema/player-curriculum'
export type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
@@ -87,6 +93,70 @@ export async function getPlayersForViewer(): Promise<Player[]> {
return players
}
/**
* Compute skill distribution for a player from their problem history.
* Uses BKT to determine mastery levels and staleness.
*/
async function computePlayerSkillDistribution(
playerId: string,
practicingSkillIds: string[]
): Promise<SkillDistribution> {
const distribution: SkillDistribution = {
strong: 0,
stale: 0,
developing: 0,
weak: 0,
unassessed: 0,
total: practicingSkillIds.length,
}
if (practicingSkillIds.length === 0) return distribution
// Fetch recent problem history (last 100 problems is enough for BKT)
const problemHistory = await getRecentSessionResults(playerId, 100)
if (problemHistory.length === 0) {
// No history = all unassessed
distribution.unassessed = practicingSkillIds.length
return distribution
}
// Compute BKT
const now = new Date()
const bktResult = computeBktFromHistory(problemHistory, {})
const bktMap = new Map(bktResult.skills.map((s) => [s.skillId, s]))
for (const skillId of practicingSkillIds) {
const bkt = bktMap.get(skillId)
if (!bkt || bkt.opportunities === 0) {
distribution.unassessed++
continue
}
const classification = bkt.masteryClassification ?? 'developing'
if (classification === 'strong') {
// Check staleness
const lastPracticed = bkt.lastPracticedAt
if (lastPracticed) {
const daysSince = (now.getTime() - lastPracticed.getTime()) / (1000 * 60 * 60 * 24)
if (getStalenessWarning(daysSince)) {
distribution.stale++
} else {
distribution.strong++
}
} else {
distribution.strong++
}
} else {
distribution[classification]++
}
}
return distribution
}
/**
* Get all players for the current viewer with enhanced skill data.
*
@@ -94,6 +164,7 @@ export async function getPlayersForViewer(): Promise<Player[]> {
* - practicingSkills: List of skill IDs being practiced
* - lastPracticedAt: Most recent practice timestamp (max of all skill lastPracticedAt)
* - skillCategory: Computed highest-level skill category
* - intervention: Intervention data if student needs attention
*/
export async function getPlayersWithSkillData(): Promise<StudentWithSkillData[]> {
const viewerId = await getViewerId()
@@ -140,11 +211,27 @@ export async function getPlayersWithSkillData(): Promise<StudentWithSkillData[]>
// Compute skill category
const skillCategory = computeSkillCategory(practicingSkills)
// Compute intervention data (only for non-archived students with skills)
let intervention = null
if (!player.isArchived && practicingSkills.length > 0) {
const distribution = await computePlayerSkillDistribution(player.id, practicingSkills)
const daysSinceLastPractice = lastPracticedAt
? (Date.now() - lastPracticedAt.getTime()) / (1000 * 60 * 60 * 24)
: Infinity
intervention = computeIntervention(
distribution,
daysSinceLastPractice,
practicingSkills.length > 0
)
}
return {
...player,
practicingSkills,
lastPracticedAt,
skillCategory,
intervention,
}
})
)

View File

@@ -18,6 +18,48 @@ import type { Player } from '@/db/schema/players'
*/
export type RecencyBucket = 'today' | 'thisWeek' | 'older' | 'new'
// ============================================================================
// Intervention Types
// ============================================================================
/**
* Types of intervention signals that indicate a student needs attention.
* Ordered by severity (struggling is most severe).
*/
export type InterventionType = 'struggling' | 'declining' | 'stale' | 'absent' | 'plateau'
/**
* Severity level for intervention signals.
*/
export type InterventionSeverity = 'high' | 'medium' | 'low'
/**
* Skill distribution at a point in time.
*/
export interface SkillDistribution {
strong: number
stale: number
developing: number
weak: number
unassessed: number
total: number
}
/**
* Intervention data for a student.
* Null if the student doesn't need intervention.
*/
export interface StudentIntervention {
/** Type of intervention needed */
type: InterventionType
/** Severity level */
severity: InterventionSeverity
/** Human-readable message (e.g., "4 skills are stale") */
message: string
/** Icon/emoji for the intervention type */
icon: string
}
/**
* Extended student type with skill data for grouping
*/
@@ -28,6 +70,99 @@ export interface StudentWithSkillData extends Player {
lastPracticedAt: Date | null
/** Computed skill category (highest level) */
skillCategory: SkillCategoryKey | null
/** Intervention data if student needs attention (null = no intervention needed) */
intervention: StudentIntervention | null
}
// ============================================================================
// Intervention Configuration
// ============================================================================
const INTERVENTION_CONFIG: Record<
InterventionType,
{ severity: InterventionSeverity; icon: string }
> = {
struggling: { severity: 'high', icon: '🆘' },
declining: { severity: 'medium', icon: '📉' },
stale: { severity: 'medium', icon: '⏰' },
absent: { severity: 'medium', icon: '👻' },
plateau: { severity: 'low', icon: '📊' },
}
/**
* Compute intervention status for a student based on their skill distribution.
*
* Priority order (first match wins):
* 1. Struggling: ≥50% weak skills
* 2. Stale: ≥3 stale skills OR >50% of strong+stale are stale
* 3. Absent: No practice in >14 days (with active skills)
*
* Note: "Declining" and "Plateau" require historical trend data,
* which is more expensive to compute. They're handled separately.
*/
export function computeIntervention(
distribution: SkillDistribution,
daysSinceLastPractice: number,
hasPracticingSkills: boolean
): StudentIntervention | null {
const { strong, stale, weak, total } = distribution
// Skip students with no skills to assess
if (total === 0) return null
// PRIORITY 1: Struggling (≥50% weak skills)
const weakPercent = (weak / total) * 100
if (weakPercent >= 50) {
return {
type: 'struggling',
...INTERVENTION_CONFIG.struggling,
message: `${Math.round(weakPercent)}% weak skills`,
}
}
// PRIORITY 2: Stale (≥3 stale skills OR >50% of mastered skills are stale)
const masteredTotal = strong + stale
if (stale >= 3 || (masteredTotal > 0 && stale / masteredTotal > 0.5)) {
return {
type: 'stale',
...INTERVENTION_CONFIG.stale,
message: `${stale} stale skill${stale !== 1 ? 's' : ''}`,
}
}
// PRIORITY 3: Absent (>14 days without practice, with active skills)
if (hasPracticingSkills && daysSinceLastPractice > 14) {
const weeks = Math.floor(daysSinceLastPractice / 7)
return {
type: 'absent',
...INTERVENTION_CONFIG.absent,
message: weeks >= 2 ? `${weeks} weeks absent` : `${daysSinceLastPractice} days absent`,
}
}
return null
}
/**
* Get students who need intervention, sorted by severity.
*/
export function getStudentsNeedingAttention(
students: StudentWithSkillData[]
): StudentWithSkillData[] {
const needsAttention = students.filter((s) => s.intervention !== null && !s.isArchived)
// Sort by severity (high → medium → low)
const severityOrder: Record<InterventionSeverity, number> = {
high: 0,
medium: 1,
low: 2,
}
return needsAttention.sort((a, b) => {
const aSeverity = a.intervention?.severity ?? 'low'
const bSeverity = b.intervention?.severity ?? 'low'
return severityOrder[aSeverity] - severityOrder[bSeverity]
})
}
/**