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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user