feat(practice): add student organization with filtering and archiving

Implements comprehensive student organization system for /practice page:

Database:
- Add isArchived column to players table (migration 0039)
- Drop unused skill stat columns from playerSkillMastery (migration 0038)

New features:
- Search bar for filtering by student name or skill
- Skill filter pills with AND logic
- Hierarchical grouping: recency buckets → skill categories
- Archive/unarchive from notes modal
- Edit mode with bulk checkbox selection
- Bulk archive for selected students
- Show/hide archived students toggle

New files:
- src/constants/skillCategories.ts - shared skill category definitions
- src/utils/studentGrouping.ts - grouping/filtering logic
- src/utils/skillSearch.ts - skill search utilities
- src/components/practice/StudentFilterBar.tsx - filter bar component

Updated:
- PracticeClient.tsx - filter state management, grouped display
- StudentSelector.tsx - edit mode, archived badges
- NotesModal.tsx - archive button in footer
- ManualSkillSelector.tsx - uses shared skill categories
- server.ts - getPlayersWithSkillData() for enhanced queries

🤖 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-20 10:37:34 -06:00
parent 11d48465d7
commit 538718a814
26 changed files with 2675 additions and 253 deletions

View File

@@ -250,7 +250,8 @@
"Bash(\"apps/web/src/components/practice/ProgressDashboard.stories.tsx\" )",
"Bash(\"apps/web/src/lib/curriculum/placement-test.ts\" )",
"Bash(\"apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts\")",
"Bash(mcp__sqlite__read_query:*)"
"Bash(mcp__sqlite__read_query:*)",
"Bash(mcp__sqlite__describe_table:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,18 @@
-- Custom SQL migration file, put your code below! --
-- Drop deprecated skill stat columns from player_skill_mastery table
-- These stats are now computed on-the-fly from session results (single source of truth)
-- Requires SQLite 3.35.0+ (2021-03-12) for ALTER TABLE DROP COLUMN support
ALTER TABLE `player_skill_mastery` DROP COLUMN `attempts`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `correct`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `consecutive_correct`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `total_response_time_ms`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `response_time_count`;

View File

@@ -0,0 +1,4 @@
-- Custom SQL migration file, put your code below! --
-- Add isArchived column to players table for filtering inactive students
ALTER TABLE `players` ADD `is_archived` integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -267,6 +267,20 @@
"when": 1766068695014,
"tag": "0037_drop_practice_sessions",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1766246063026,
"tag": "0038_drop_skill_stat_columns",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1766275200000,
"tag": "0039_add_player_archived",
"breakpoints": true
}
]
}

View File

@@ -400,9 +400,7 @@ Use this student to test how the UI handles intervention alerts for foundational
],
// Tuning: Need at least 2 weak skills
successCriteria: { minWeak: 2 },
tuningAdjustments: [
{ skillId: 'all', accuracyMultiplier: 0.6, problemsAdd: 10 },
],
tuningAdjustments: [{ skillId: 'all', accuracyMultiplier: 0.6, problemsAdd: 10 }],
},
{
name: '🟡 Single-Skill Blocker',
@@ -917,7 +915,7 @@ Use this to verify:
// All L1 addition - strong
{ skillId: 'basic.directAddition', targetAccuracy: 0.95, problems: 40 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.93, problems: 35 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.90, problems: 30 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.9, problems: 30 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.88, problems: 28 },
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.87, problems: 25 },
{ skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.86, problems: 25 },
@@ -1074,7 +1072,8 @@ function generateSlotResults(
}
// Generate a plausible wrong answer if incorrect
const wrongAnswer = realistic.answer + (Math.random() > 0.5 ? 1 : -1) * (Math.floor(Math.random() * 3) + 1)
const wrongAnswer =
realistic.answer + (Math.random() > 0.5 ? 1 : -1) * (Math.floor(Math.random() * 3) + 1)
return {
partNumber: 1 as const,
@@ -1141,12 +1140,15 @@ function applyTuningAdjustments(
}
return skillHistory.map((config) => {
let newConfig = { ...config }
const newConfig = { ...config }
for (const adj of adjustments) {
if (adj.skillId === 'all' || adj.skillId === config.skillId) {
if (adj.accuracyMultiplier !== undefined) {
newConfig.targetAccuracy = Math.min(0.95, Math.max(0.05, newConfig.targetAccuracy * adj.accuracyMultiplier))
newConfig.targetAccuracy = Math.min(
0.95,
Math.max(0.05, newConfig.targetAccuracy * adj.accuracyMultiplier)
)
}
if (adj.problemsAdd !== undefined) {
newConfig.problems = newConfig.problems + adj.problemsAdd
@@ -1189,7 +1191,9 @@ function formatTuningHistory(history: TuningRound[]): string {
for (const round of history) {
lines.push('')
lines.push(`Round ${round.round}:`)
lines.push(` Classifications: 🔴 ${round.classifications.weak} weak, 📚 ${round.classifications.developing} developing, ✅ ${round.classifications.strong} strong`)
lines.push(
` Classifications: 🔴 ${round.classifications.weak} weak, 📚 ${round.classifications.developing} developing, ✅ ${round.classifications.strong} strong`
)
if (round.success) {
lines.push(` Result: ✅ Success`)
@@ -1295,7 +1299,11 @@ async function createTestStudent(
profile: TestStudentProfile,
userId: string,
skillHistoryOverride?: SkillConfig[]
): Promise<{ playerId: string; classifications: Record<string, number>; bktResult: { skills: SkillBktResult[] } }> {
): Promise<{
playerId: string
classifications: Record<string, number>
bktResult: { skills: SkillBktResult[] }
}> {
let effectiveSkillHistory = skillHistoryOverride ?? profile.skillHistory
// If ensureAllPracticingHaveHistory is set, add missing practicing skills with default strong history
@@ -1355,9 +1363,6 @@ async function createTestStudent(
playerId,
skillId,
isPracticing: true,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
lastPracticedAt,
})
}
@@ -1498,17 +1503,28 @@ async function createTestStudentWithTuning(
profile: TestStudentProfile,
userId: string,
maxRounds: number = 3
): Promise<{ playerId: string; classifications: Record<string, number>; tuningHistory: TuningRound[] }> {
): Promise<{
playerId: string
classifications: Record<string, number>
tuningHistory: TuningRound[]
}> {
const tuningHistory: TuningRound[] = []
let currentSkillHistory = profile.skillHistory
let result: { playerId: string; classifications: Record<string, number>; bktResult: { skills: SkillBktResult[] } }
let result: {
playerId: string
classifications: Record<string, number>
bktResult: { skills: SkillBktResult[] }
}
for (let round = 1; round <= maxRounds; round++) {
// Generate the student
result = await createTestStudent(profile, userId, currentSkillHistory)
// Check success criteria
const { success, reasons } = checkSuccessCriteria(result.classifications, profile.successCriteria)
const { success, reasons } = checkSuccessCriteria(
result.classifications,
profile.successCriteria
)
// Record this round
const roundEntry: TuningRound = {
@@ -1660,7 +1676,9 @@ async function main() {
}
if (tuningHistory.length > 1) {
const finalRound = tuningHistory[tuningHistory.length - 1]
console.log(` Tuning: ${tuningHistory.length} rounds, final: ${finalRound.success ? '✅ success' : '⚠️ best effort'}`)
console.log(
` Tuning: ${tuningHistory.length} rounds, final: ${finalRound.success ? '✅ success' : '⚠️ best effort'}`
)
}
console.log(` Player ID: ${playerId}`)
console.log('')

View File

@@ -37,7 +37,10 @@ export async function GET(_request: Request, { params }: RouteParams) {
])
// Compute skill stats from session results (single source of truth)
const skillStats = new Map<string, { attempts: number; correct: number; responseTimes: number[] }>()
const skillStats = new Map<
string,
{ attempts: number; correct: number; responseTimes: number[] }
>()
for (const result of sessionResults) {
for (const skillId of result.skillsExercised) {
if (!skillStats.has(skillId)) {

View File

@@ -49,6 +49,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
...(body.emoji !== undefined && { emoji: body.emoji }),
...(body.color !== undefined && { color: body.color }),
...(body.isActive !== undefined && { isActive: body.isActive }),
...(body.isArchived !== undefined && { isArchived: body.isArchived }),
...(body.notes !== undefined && { notes: body.notes }),
// userId is explicitly NOT included - it comes from session
})

View File

@@ -1,51 +1,124 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
import { filterStudents, groupStudents } from '@/utils/studentGrouping'
import { css } from '../../../styled-system/css'
interface PracticeClientProps {
initialPlayers: Player[]
initialPlayers: StudentWithSkillData[]
}
/**
* Practice page client component
*
* Receives prefetched player data as props from the server component.
* This avoids SSR hydration issues with React Query.
* Receives prefetched player data with skill information from the server component.
* Manages filter state (search, skills, archived, edit mode) and passes
* grouped/filtered students to StudentSelector.
*/
export function PracticeClient({ initialPlayers }: PracticeClientProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Use initial data from server
const players = initialPlayers
// Filter state
const [searchQuery, setSearchQuery] = useState('')
const [skillFilters, setSkillFilters] = useState<string[]>([])
const [showArchived, setShowArchived] = useState(false)
const [editMode, setEditMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Convert players to StudentWithProgress format
const students: StudentWithProgress[] = players.map((player) => ({
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
createdAt: player.createdAt,
notes: player.notes,
}))
// Use initial data from server (local state for optimistic updates)
const [players, setPlayers] = useState(initialPlayers)
// Count archived students
const archivedCount = useMemo(() => players.filter((p) => p.isArchived).length, [players])
// Filter and group students
const filteredStudents = useMemo(
() => filterStudents(players, searchQuery, skillFilters, showArchived),
[players, searchQuery, skillFilters, showArchived]
)
const groupedStudents = useMemo(() => groupStudents(filteredStudents), [filteredStudents])
// Convert to StudentWithProgress format for StudentSelector
// (maintaining backwards compatibility)
const students: StudentWithProgress[] = useMemo(
() =>
filteredStudents.map((player) => ({
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
createdAt: player.createdAt,
notes: player.notes,
isArchived: player.isArchived,
// Pass through skill data for grouping display
practicingSkills: player.practicingSkills,
lastPracticedAt: player.lastPracticedAt,
skillCategory: player.skillCategory,
})),
[filteredStudents]
)
// Handle student selection - navigate to student's resume page
// The /resume route shows "Welcome back" for in-progress sessions
const handleSelectStudent = useCallback(
(student: StudentWithProgress) => {
router.push(`/practice/${student.id}/resume`, { scroll: false })
if (editMode) {
// In edit mode, toggle selection instead of navigating
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(student.id)) {
next.delete(student.id)
} else {
next.add(student.id)
}
return next
})
} else {
router.push(`/practice/${student.id}/resume`, { scroll: false })
}
},
[router]
[router, editMode]
)
// Handle bulk archive
const handleBulkArchive = useCallback(async () => {
if (selectedIds.size === 0) return
// Optimistically update local state
setPlayers((prev) => prev.map((p) => (selectedIds.has(p.id) ? { ...p, isArchived: true } : p)))
// Send requests to archive each selected student
const promises = Array.from(selectedIds).map((id) =>
fetch(`/api/players/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isArchived: true }),
})
)
await Promise.all(promises)
// Clear selection and exit edit mode
setSelectedIds(new Set())
setEditMode(false)
}, [selectedIds])
// Handle edit mode change
const handleEditModeChange = useCallback((editing: boolean) => {
setEditMode(editing)
if (!editing) {
setSelectedIds(new Set())
}
}, [])
return (
<PageWithNav>
<main
@@ -53,16 +126,72 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
paddingTop: '80px', // Nav height
})}
>
{/* Filter Bar */}
<StudentFilterBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
skillFilters={skillFilters}
onSkillFiltersChange={setSkillFilters}
showArchived={showArchived}
onShowArchivedChange={setShowArchived}
editMode={editMode}
onEditModeChange={handleEditModeChange}
archivedCount={archivedCount}
/>
{/* Edit mode bulk actions */}
{editMode && selectedIds.size > 0 && (
<div
data-element="bulk-actions"
className={css({
display: 'flex',
justifyContent: 'center',
gap: '12px',
padding: '12px 16px',
bg: isDark ? 'amber.900/50' : 'amber.50',
borderBottom: '1px solid',
borderColor: isDark ? 'amber.800' : 'amber.200',
})}
>
<span
className={css({
fontSize: '14px',
color: isDark ? 'amber.200' : 'amber.700',
})}
>
{selectedIds.size} selected
</span>
<button
type="button"
onClick={handleBulkArchive}
data-action="bulk-archive"
className={css({
padding: '6px 12px',
bg: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.200' : 'red.700',
border: '1px solid',
borderColor: isDark ? 'red.700' : 'red.300',
borderRadius: '6px',
fontSize: '13px',
cursor: 'pointer',
_hover: {
bg: isDark ? 'red.800' : 'red.200',
},
})}
>
Archive Selected
</button>
</div>
)}
<div
className={css({
maxWidth: '800px',
maxWidth: '1000px',
margin: '0 auto',
padding: '2rem',
})}
>
{/* Header */}
@@ -92,8 +221,90 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
</p>
</header>
{/* Student Selector */}
<StudentSelector students={students} onSelectStudent={handleSelectStudent} />
{/* Grouped Student List */}
{groupedStudents.length === 0 ? (
<div
className={css({
textAlign: 'center',
padding: '3rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{searchQuery || skillFilters.length > 0
? 'No students match your filters'
: showArchived
? 'No archived students'
: 'No students yet. Add students from the Manage Students page.'}
</div>
) : (
<div
data-component="grouped-students"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
})}
>
{groupedStudents.map((bucket) => (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
{/* Bucket header */}
<h2
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{bucket.bucketName}
</h2>
{/* Categories within bucket */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{bucket.categories.map((category) => (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Category header */}
<h3
className={css({
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingLeft: '4px',
})}
>
{category.categoryName}
</h3>
{/* Student cards */}
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
title=""
editMode={editMode}
selectedIds={selectedIds}
/>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</main>
</PageWithNav>

View File

@@ -265,12 +265,32 @@ function processSkills(
bktResults: Map<string, SkillBktResult>
): ProcessedSkill[] {
const now = new Date()
const problemsBySkill = new Map<string, ProblemResultWithContext[]>()
// Group problems by skill and compute stats on-the-fly
const skillStats = new Map<
string,
{
problems: ProblemResultWithContext[]
attempts: number
correct: number
responseTimes: number[]
}
>()
for (const problem of problemHistory) {
for (const skillId of problem.skillsExercised) {
const existing = problemsBySkill.get(skillId) ?? []
existing.push(problem)
problemsBySkill.set(skillId, existing)
if (!skillStats.has(skillId)) {
skillStats.set(skillId, { problems: [], attempts: 0, correct: 0, responseTimes: [] })
}
const stats = skillStats.get(skillId)!
stats.problems.push(problem)
stats.attempts++
if (problem.isCorrect) {
stats.correct++
}
if (problem.responseTimeMs > 0) {
stats.responseTimes.push(problem.responseTimeMs)
}
}
}
@@ -282,10 +302,17 @@ function processSkills(
)
: null
const accuracy = skill.attempts > 0 ? skill.correct / skill.attempts : 0
// Get computed stats from problem history
const stats = skillStats.get(skill.skillId)
const attempts = stats?.attempts ?? 0
const correct = stats?.correct ?? 0
const accuracy = attempts > 0 ? correct / attempts : 0
const avgResponseTimeMs =
skill.responseTimeCount > 0 ? skill.totalResponseTimeMs / skill.responseTimeCount : null
const problems = problemsBySkill.get(skill.skillId) ?? []
stats && stats.responseTimes.length > 0
? stats.responseTimes.reduce((a, b) => a + b, 0) / stats.responseTimes.length
: null
const problems = stats?.problems ?? []
const bkt = bktResults.get(skill.skillId)
const stalenessWarning = getStalenessWarning(daysSinceLastPractice)
const usingBktMultiplier = bkt !== undefined && isBktConfident(bkt.confidence)
@@ -307,9 +334,9 @@ function processSkills(
category: category.name,
categoryOrder: category.order,
accuracy,
attempts: skill.attempts,
correct: skill.correct,
consecutiveCorrect: skill.consecutiveCorrect,
attempts,
correct,
consecutiveCorrect: 0, // No longer tracked - would need session history analysis
isPracticing: skill.isPracticing,
needsReinforcement: skill.needsReinforcement,
lastPracticedAt: skill.lastPracticedAt,
@@ -1016,7 +1043,9 @@ function SkillsTab({
}) {
const [selectedSkill, setSelectedSkill] = useState<ProcessedSkill | null>(null)
const refreshSkillRecency = useRefreshSkillRecency()
const isRefreshing = refreshSkillRecency.isPending ? refreshSkillRecency.variables?.skillId ?? null : null
const isRefreshing = refreshSkillRecency.isPending
? (refreshSkillRecency.variables?.skillId ?? null)
: null
const [confidenceThreshold, setConfidenceThreshold] = useState(0.5)
const [applyDecay, setApplyDecay] = useState(false)

View File

@@ -1,4 +1,4 @@
import { getPlayersForViewer } from '@/lib/curriculum/server'
import { getPlayersWithSkillData } from '@/lib/curriculum/server'
import { PracticeClient } from './PracticeClient'
/**
@@ -10,8 +10,8 @@ import { PracticeClient } from './PracticeClient'
* URL: /practice
*/
export default async function PracticePage() {
// Fetch players directly on server - no HTTP round-trip
const players = await getPlayersForViewer()
// Fetch players with skill data directly on server - no HTTP round-trip
const players = await getPlayersWithSkillData()
return <PracticeClient initialPlayers={players} />
}

View File

@@ -5,6 +5,7 @@ import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useRef, useState } from 'react'
import { animated, useSpring } from '@react-spring/web'
import useMeasure from 'react-use-measure'
import { SKILL_CATEGORIES, type SkillCategoryKey } from '@/constants/skillCategories'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
@@ -12,77 +13,17 @@ import type { MasteryClassification, SkillBktResult } from '@/lib/curriculum/bkt
import { BASE_SKILL_COMPLEXITY } from '@/utils/skillComplexity'
import { css } from '../../../styled-system/css'
/**
* Skill categories and their human-readable names
*/
const SKILL_CATEGORIES = {
basic: {
name: 'Basic Skills',
skills: {
directAddition: 'Direct Addition (1-4)',
heavenBead: 'Heaven Bead (5)',
simpleCombinations: 'Simple Combinations (6-9)',
directSubtraction: 'Direct Subtraction (1-4)',
heavenBeadSubtraction: 'Heaven Bead Subtraction (5)',
simpleCombinationsSub: 'Simple Combinations Subtraction (6-9)',
},
},
fiveComplements: {
name: 'Five Complements (Addition)',
skills: {
'4=5-1': '+4 = +5 - 1',
'3=5-2': '+3 = +5 - 2',
'2=5-3': '+2 = +5 - 3',
'1=5-4': '+1 = +5 - 4',
},
},
fiveComplementsSub: {
name: 'Five Complements (Subtraction)',
skills: {
'-4=-5+1': '-4 = -5 + 1',
'-3=-5+2': '-3 = -5 + 2',
'-2=-5+3': '-2 = -5 + 3',
'-1=-5+4': '-1 = -5 + 4',
},
},
tenComplements: {
name: 'Ten Complements (Addition)',
skills: {
'9=10-1': '+9 = +10 - 1',
'8=10-2': '+8 = +10 - 2',
'7=10-3': '+7 = +10 - 3',
'6=10-4': '+6 = +10 - 4',
'5=10-5': '+5 = +10 - 5',
'4=10-6': '+4 = +10 - 6',
'3=10-7': '+3 = +10 - 7',
'2=10-8': '+2 = +10 - 8',
'1=10-9': '+1 = +10 - 9',
},
},
tenComplementsSub: {
name: 'Ten Complements (Subtraction)',
skills: {
'-9=+1-10': '-9 = +1 - 10',
'-8=+2-10': '-8 = +2 - 10',
'-7=+3-10': '-7 = +3 - 10',
'-6=+4-10': '-6 = +4 - 10',
'-5=+5-10': '-5 = +5 - 10',
'-4=+6-10': '-4 = +6 - 10',
'-3=+7-10': '-3 = +7 - 10',
'-2=+8-10': '-2 = +8 - 10',
'-1=+9-10': '-1 = +9 - 10',
},
},
advanced: {
name: 'Advanced Multi-Column Operations',
skills: {
cascadingCarry: 'Cascading Carry (e.g., 999 + 1 = 1000)',
cascadingBorrow: 'Cascading Borrow (e.g., 1000 - 1 = 999)',
},
},
} as const
// Use the same order as the original component for UI display
const DISPLAY_ORDER: SkillCategoryKey[] = [
'basic',
'fiveComplements',
'fiveComplementsSub',
'tenComplements',
'tenComplementsSub',
'advanced',
]
type CategoryKey = keyof typeof SKILL_CATEGORIES
type CategoryKey = SkillCategoryKey
/**
* ComplexityBadge - Shows the base complexity cost for a skill
@@ -833,12 +774,10 @@ export function ManualSkillSelector({
value={expandedCategories}
onValueChange={setExpandedCategories}
>
{(
Object.entries(SKILL_CATEGORIES) as [
CategoryKey,
(typeof SKILL_CATEGORIES)[CategoryKey],
][]
).map(([categoryKey, category]) => {
{DISPLAY_ORDER.map((categoryKey) => {
const category = SKILL_CATEGORIES[categoryKey]
return { categoryKey, category }
}).map(({ categoryKey, category }) => {
const categorySkillIds = Object.keys(category.skills).map(
(skill) => `${categoryKey}.${skill}`
)

View File

@@ -14,6 +14,7 @@ interface NotesModalProps {
emoji: string
color: string
notes: string | null
isArchived?: boolean
}
/** Bounding rect of the source tile for zoom animation */
sourceBounds: DOMRect | null
@@ -21,6 +22,8 @@ interface NotesModalProps {
onClose: () => void
/** Called when notes are saved */
onSave: (notes: string) => Promise<void>
/** Called when archive status is toggled */
onToggleArchive?: () => Promise<void>
/** Dark mode */
isDark: boolean
}
@@ -40,6 +43,7 @@ export function NotesModal({
sourceBounds,
onClose,
onSave,
onToggleArchive,
isDark,
}: NotesModalProps) {
const [isEditing, setIsEditing] = useState(false)
@@ -426,16 +430,67 @@ export function NotesModal({
)}
</div>
{/* View mode action button */}
{/* View mode action buttons */}
<div
className={css({
padding: '1rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
justifyContent: 'flex-end',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
{/* Archive button on the left */}
{onToggleArchive && (
<button
type="button"
data-action="toggle-archive"
onClick={onToggleArchive}
className={css({
padding: '0.625rem 1rem',
borderRadius: '8px',
backgroundColor: student.isArchived
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'gray.700'
: 'gray.100',
color: student.isArchived
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.600',
fontSize: '0.875rem',
fontWeight: 'medium',
border: '1px solid',
borderColor: student.isArchived
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'gray.600'
: 'gray.300',
cursor: 'pointer',
_hover: {
backgroundColor: student.isArchived
? isDark
? 'green.800'
: 'green.200'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
{student.isArchived ? '📦 Unarchive' : '📦 Archive'}
</button>
)}
{/* Edit notes button on the right */}
<button
type="button"
data-action="edit-notes"
@@ -449,6 +504,7 @@ export function NotesModal({
fontWeight: 'medium',
border: 'none',
cursor: 'pointer',
marginLeft: 'auto',
_hover: {
backgroundColor: isDark ? 'blue.800' : 'blue.600',
},

View File

@@ -440,7 +440,8 @@ export function ProblemToReview({
color: isDark ? 'yellow.400' : 'yellow.600',
})}
>
Response time exceeded auto-pause threshold of {formatMs(autoPauseInfo.threshold)}
Response time exceeded auto-pause threshold of{' '}
{formatMs(autoPauseInfo.threshold)}
</span>
)}
@@ -471,7 +472,6 @@ export function ProblemToReview({
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,479 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { Z_INDEX } from '@/constants/zIndex'
import {
formatSkillChipName,
getSkillDisplayName,
searchSkills,
type SkillSearchResult,
} from '@/utils/skillSearch'
import { css } from '../../../styled-system/css'
interface StudentFilterBarProps {
/** Current search query */
searchQuery: string
/** Callback when search query changes */
onSearchChange: (query: string) => void
/** Currently selected skill filter IDs */
skillFilters: string[]
/** Callback when skill filters change */
onSkillFiltersChange: (skillIds: string[]) => void
/** Whether to show archived students */
showArchived: boolean
/** Callback when archive toggle changes */
onShowArchivedChange: (show: boolean) => void
/** Whether edit mode is active */
editMode: boolean
/** Callback when edit mode changes */
onEditModeChange: (editing: boolean) => void
/** Number of archived students (for badge) */
archivedCount: number
}
/**
* Filter bar for the student list.
*
* Features:
* - Search input with debounce
* - Skill autocomplete dropdown
* - Skill filter pills with remove button
* - Archive toggle button
* - Edit mode toggle button
*/
export function StudentFilterBar({
searchQuery,
onSearchChange,
skillFilters,
onSkillFiltersChange,
showArchived,
onShowArchivedChange,
editMode,
onEditModeChange,
archivedCount,
}: StudentFilterBarProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [localQuery, setLocalQuery] = useState(searchQuery)
const [showDropdown, setShowDropdown] = useState(false)
const [skillResults, setSkillResults] = useState<SkillSearchResult[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
onSearchChange(localQuery)
}, 300)
return () => clearTimeout(timer)
}, [localQuery, onSearchChange])
// Search for skills as user types
useEffect(() => {
if (localQuery.trim()) {
const results = searchSkills(localQuery)
// Filter out already selected skills
const filtered = results.filter((r) => !skillFilters.includes(r.skillId))
setSkillResults(filtered)
setShowDropdown(filtered.length > 0)
} else {
setSkillResults([])
setShowDropdown(false)
}
}, [localQuery, skillFilters])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleAddSkillFilter = useCallback(
(skillId: string) => {
onSkillFiltersChange([...skillFilters, skillId])
setLocalQuery('')
setShowDropdown(false)
},
[skillFilters, onSkillFiltersChange]
)
const handleRemoveSkillFilter = useCallback(
(skillId: string) => {
onSkillFiltersChange(skillFilters.filter((id) => id !== skillId))
},
[skillFilters, onSkillFiltersChange]
)
const handleClearAll = useCallback(() => {
onSkillFiltersChange([])
setLocalQuery('')
}, [onSkillFiltersChange])
return (
<div
data-component="student-filter-bar"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
bg: isDark ? 'gray.800' : 'white',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Top row: Search input and buttons */}
<div
className={css({
display: 'flex',
gap: '12px',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
{/* Search input with dropdown */}
<div
className={css({
position: 'relative',
flex: '1',
minWidth: '200px',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
bg: isDark ? 'gray.700' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '8px',
_focusWithin: {
borderColor: 'blue.500',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.2)',
},
})}
>
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>🔍</span>
<input
ref={inputRef}
type="text"
placeholder="Search students or skills..."
value={localQuery}
onChange={(e) => setLocalQuery(e.target.value)}
onFocus={() => {
if (skillResults.length > 0) {
setShowDropdown(true)
}
}}
data-element="search-input"
className={css({
flex: 1,
bg: 'transparent',
border: 'none',
outline: 'none',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: '14px',
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
},
})}
/>
{localQuery && (
<button
type="button"
onClick={() => setLocalQuery('')}
data-action="clear-search"
className={css({
color: isDark ? 'gray.400' : 'gray.500',
cursor: 'pointer',
padding: '2px',
_hover: { color: isDark ? 'gray.300' : 'gray.700' },
})}
>
</button>
)}
</div>
{/* Skill autocomplete dropdown */}
{showDropdown && (
<div
ref={dropdownRef}
data-element="skill-dropdown"
className={css({
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: '4px',
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
borderRadius: '8px',
boxShadow: 'lg',
zIndex: Z_INDEX.DROPDOWN,
maxHeight: '300px',
overflowY: 'auto',
})}
>
<div
className={css({
padding: '8px 12px',
fontSize: '11px',
fontWeight: 'medium',
color: isDark ? 'gray.400' : 'gray.500',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.100',
})}
>
Add skill filter (AND logic)
</div>
{skillResults.slice(0, 10).map((skill) => (
<button
key={skill.skillId}
type="button"
onClick={() => handleAddSkillFilter(skill.skillId)}
data-action="add-skill-filter"
data-skill-id={skill.skillId}
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '2px',
padding: '10px 12px',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
_hover: { bg: isDark ? 'gray.700' : 'gray.50' },
})}
>
<span
className={css({
fontSize: '14px',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{skill.displayName}
</span>
<span
className={css({
fontSize: '12px',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
{skill.categoryName}
</span>
</button>
))}
</div>
)}
</div>
{/* Archive toggle button */}
<button
type="button"
onClick={() => onShowArchivedChange(!showArchived)}
data-action="toggle-archived"
data-status={showArchived ? 'showing' : 'hiding'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
bg: showArchived
? isDark
? 'blue.900'
: 'blue.100'
: isDark
? 'gray.700'
: 'gray.100',
border: '1px solid',
borderColor: showArchived
? isDark
? 'blue.700'
: 'blue.300'
: isDark
? 'gray.600'
: 'gray.300',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
color: showArchived
? isDark
? 'blue.300'
: 'blue.700'
: isDark
? 'gray.300'
: 'gray.700',
transition: 'all 0.15s ease',
_hover: {
borderColor: showArchived
? isDark
? 'blue.600'
: 'blue.400'
: isDark
? 'gray.500'
: 'gray.400',
},
})}
>
<span>{showArchived ? '👁' : '👁‍🗨'}</span>
<span>Archived</span>
{archivedCount > 0 && (
<span
className={css({
fontSize: '12px',
fontWeight: 'medium',
padding: '2px 6px',
bg: showArchived
? isDark
? 'blue.800'
: 'blue.200'
: isDark
? 'gray.600'
: 'gray.200',
borderRadius: '10px',
})}
>
{archivedCount}
</span>
)}
</button>
{/* Edit mode toggle button */}
<button
type="button"
onClick={() => onEditModeChange(!editMode)}
data-action="toggle-edit-mode"
data-status={editMode ? 'editing' : 'viewing'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
bg: editMode ? (isDark ? 'amber.900' : 'amber.100') : isDark ? 'gray.700' : 'gray.100',
border: '1px solid',
borderColor: editMode
? isDark
? 'amber.700'
: 'amber.300'
: isDark
? 'gray.600'
: 'gray.300',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
color: editMode
? isDark
? 'amber.300'
: 'amber.700'
: isDark
? 'gray.300'
: 'gray.700',
transition: 'all 0.15s ease',
_hover: {
borderColor: editMode
? isDark
? 'amber.600'
: 'amber.400'
: isDark
? 'gray.500'
: 'gray.400',
},
})}
>
<span>{editMode ? '✓' : '✏️'}</span>
<span>{editMode ? 'Done' : 'Edit'}</span>
</button>
</div>
{/* Skill filter pills */}
{skillFilters.length > 0 && (
<div
data-element="skill-filter-pills"
className={css({
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<span
className={css({
fontSize: '12px',
fontWeight: 'medium',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Skill filters:
</span>
{skillFilters.map((skillId) => (
<div
key={skillId}
data-element="skill-pill"
data-skill-id={skillId}
title={getSkillDisplayName(skillId)}
className={css({
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
bg: isDark ? 'blue.900' : 'blue.100',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '16px',
fontSize: '12px',
color: isDark ? 'blue.300' : 'blue.700',
})}
>
<span>{formatSkillChipName(skillId)}</span>
<button
type="button"
onClick={() => handleRemoveSkillFilter(skillId)}
data-action="remove-skill-filter"
className={css({
cursor: 'pointer',
padding: '0 2px',
color: isDark ? 'blue.400' : 'blue.600',
_hover: { color: isDark ? 'blue.200' : 'blue.800' },
})}
>
×
</button>
</div>
))}
<button
type="button"
onClick={handleClearAll}
data-action="clear-all-filters"
className={css({
fontSize: '12px',
color: isDark ? 'gray.400' : 'gray.500',
cursor: 'pointer',
padding: '4px 8px',
_hover: { color: isDark ? 'gray.200' : 'gray.700' },
})}
>
Clear all
</button>
</div>
)}
</div>
)
}

View File

@@ -32,23 +32,30 @@ export interface StudentWithProgress extends Player {
currentLevel?: number
currentPhaseId?: string
masteryPercent?: number
isArchived?: boolean
practicingSkills?: string[]
lastPracticedAt?: Date | null
skillCategory?: string | null
}
interface StudentCardProps {
student: StudentWithProgress
onSelect: (student: StudentWithProgress) => void
onOpenNotes: (student: StudentWithProgress, bounds: DOMRect) => void
editMode?: boolean
isSelected?: boolean
}
/**
* Individual student card showing avatar, name, and progress
* Clicking navigates to the student's practice page
* Clicking navigates to the student's practice page (or toggles selection in edit mode)
*/
function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
function StudentCard({ student, onSelect, onOpenNotes, editMode, isSelected }: StudentCardProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const levelLabel = student.currentLevel ? `Lv.${student.currentLevel}` : 'New'
const cardRef = useRef<HTMLDivElement>(null)
const isArchived = student.isArchived ?? false
const handleNotesClick = useCallback(
(e: React.MouseEvent) => {
@@ -65,6 +72,8 @@ function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
<div
ref={cardRef}
data-component="student-card"
data-archived={isArchived}
data-selected={isSelected}
className={css({
...centerStack,
...gapSm,
@@ -72,12 +81,69 @@ function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
...roundedLg,
...transitionNormal,
border: '2px solid',
borderColor: themed('border', isDark),
backgroundColor: themed('surface', isDark),
borderColor: isSelected
? 'blue.500'
: isArchived
? isDark
? 'gray.700'
: 'gray.300'
: themed('border', isDark),
backgroundColor: isArchived
? isDark
? 'gray.800/50'
: 'gray.100'
: themed('surface', isDark),
minWidth: '100px',
position: 'relative',
opacity: isArchived ? 0.6 : 1,
})}
>
{/* Edit mode checkbox */}
{editMode && (
<div
data-element="checkbox"
className={css({
position: 'absolute',
top: '6px',
left: '6px',
width: '22px',
height: '22px',
borderRadius: '4px',
border: '2px solid',
borderColor: isSelected ? 'blue.500' : isDark ? 'gray.500' : 'gray.400',
backgroundColor: isSelected ? 'blue.500' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
})}
>
{isSelected && '✓'}
</div>
)}
{/* Archived badge */}
{isArchived && (
<div
data-element="archived-badge"
className={css({
position: 'absolute',
top: '6px',
left: editMode ? '32px' : '6px',
padding: '2px 6px',
bg: isDark ? 'gray.700' : 'gray.300',
color: isDark ? 'gray.400' : 'gray.600',
fontSize: '10px',
fontWeight: 'medium',
borderRadius: '4px',
})}
>
Archived
</div>
)}
{/* Notes button */}
<button
type="button"
@@ -141,7 +207,7 @@ function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
border: 'none',
cursor: 'pointer',
padding: '0.5rem',
paddingTop: '1.5rem', // Extra space for the notes button
paddingTop: '1.5rem', // Extra space for the notes/checkbox
width: '100%',
_hover: {
'& > div:first-child': {
@@ -155,6 +221,7 @@ function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
className={css({
...avatarStyles('md'),
transition: 'transform 0.15s ease',
filter: isArchived ? 'grayscale(0.5)' : 'none',
})}
style={{ backgroundColor: student.color }}
>
@@ -166,7 +233,7 @@ function StudentCard({ student, onSelect, onOpenNotes }: StudentCardProps) {
className={css({
...fontBold,
...textBase,
color: themed('text', isDark),
color: isArchived ? (isDark ? 'gray.500' : 'gray.500') : themed('text', isDark),
})}
>
{student.name}
@@ -250,6 +317,8 @@ interface StudentSelectorProps {
students: StudentWithProgress[]
onSelectStudent: (student: StudentWithProgress) => void
title?: string
editMode?: boolean
selectedIds?: Set<string>
}
/**
@@ -258,11 +327,15 @@ interface StudentSelectorProps {
* Displays all available students (players) with their current
* curriculum level and progress. Clicking a student navigates
* to their practice page at /practice/[studentId].
*
* In edit mode, clicking toggles selection for bulk operations.
*/
export function StudentSelector({
students,
onSelectStudent,
title = 'Who is practicing today?',
editMode = false,
selectedIds = new Set(),
}: StudentSelectorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -316,6 +389,32 @@ export function StudentSelector({
[selectedStudentForNotes]
)
const handleToggleArchive = useCallback(async () => {
if (!selectedStudentForNotes) return
const newArchivedState = !selectedStudentForNotes.isArchived
const response = await fetch(`/api/players/${selectedStudentForNotes.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isArchived: newArchivedState }),
})
if (!response.ok) {
throw new Error('Failed to toggle archive status')
}
// Optimistically update local state
setLocalStudents((prev) =>
prev.map((s) =>
s.id === selectedStudentForNotes.id ? { ...s, isArchived: newArchivedState } : s
)
)
// Update the selected student for modal
setSelectedStudentForNotes((prev) => (prev ? { ...prev, isArchived: newArchivedState } : null))
}, [selectedStudentForNotes])
return (
<>
<div
@@ -351,6 +450,8 @@ export function StudentSelector({
student={student}
onSelect={onSelectStudent}
onOpenNotes={handleOpenNotes}
editMode={editMode}
isSelected={selectedIds.has(student.id)}
/>
))}
@@ -368,10 +469,12 @@ export function StudentSelector({
emoji: selectedStudentForNotes.emoji,
color: selectedStudentForNotes.color,
notes: selectedStudentForNotes.notes ?? null,
isArchived: selectedStudentForNotes.isArchived,
}}
sourceBounds={sourceBounds}
onClose={handleCloseNotes}
onSave={handleSaveNotes}
onToggleArchive={handleToggleArchive}
isDark={isDark}
/>
)}

View File

@@ -0,0 +1,140 @@
/**
* Skill categories and their human-readable names
*
* This is the single source of truth for skill groupings used in:
* - ManualSkillSelector (manage skills modal)
* - StudentSelector (student grouping by skill category)
* - Skill search and filtering
*
* Order determines priority for student grouping (first = highest level)
*/
export const SKILL_CATEGORIES = {
advanced: {
name: 'Advanced Multi-Column Operations',
skills: {
cascadingCarry: 'Cascading Carry (e.g., 999 + 1 = 1000)',
cascadingBorrow: 'Cascading Borrow (e.g., 1000 - 1 = 999)',
},
},
tenComplementsSub: {
name: 'Ten Complements (Subtraction)',
skills: {
'-9=+1-10': '-9 = +1 - 10',
'-8=+2-10': '-8 = +2 - 10',
'-7=+3-10': '-7 = +3 - 10',
'-6=+4-10': '-6 = +4 - 10',
'-5=+5-10': '-5 = +5 - 10',
'-4=+6-10': '-4 = +6 - 10',
'-3=+7-10': '-3 = +7 - 10',
'-2=+8-10': '-2 = +8 - 10',
'-1=+9-10': '-1 = +9 - 10',
},
},
tenComplements: {
name: 'Ten Complements (Addition)',
skills: {
'9=10-1': '+9 = +10 - 1',
'8=10-2': '+8 = +10 - 2',
'7=10-3': '+7 = +10 - 3',
'6=10-4': '+6 = +10 - 4',
'5=10-5': '+5 = +10 - 5',
'4=10-6': '+4 = +10 - 6',
'3=10-7': '+3 = +10 - 7',
'2=10-8': '+2 = +10 - 8',
'1=10-9': '+1 = +10 - 9',
},
},
fiveComplementsSub: {
name: 'Five Complements (Subtraction)',
skills: {
'-4=-5+1': '-4 = -5 + 1',
'-3=-5+2': '-3 = -5 + 2',
'-2=-5+3': '-2 = -5 + 3',
'-1=-5+4': '-1 = -5 + 4',
},
},
fiveComplements: {
name: 'Five Complements (Addition)',
skills: {
'4=5-1': '+4 = +5 - 1',
'3=5-2': '+3 = +5 - 2',
'2=5-3': '+2 = +5 - 3',
'1=5-4': '+1 = +5 - 4',
},
},
basic: {
name: 'Basic Skills',
skills: {
directAddition: 'Direct Addition (1-4)',
heavenBead: 'Heaven Bead (5)',
simpleCombinations: 'Simple Combinations (6-9)',
directSubtraction: 'Direct Subtraction (1-4)',
heavenBeadSubtraction: 'Heaven Bead Subtraction (5)',
simpleCombinationsSub: 'Simple Combinations Subtraction (6-9)',
},
},
} as const
export type SkillCategoryKey = keyof typeof SKILL_CATEGORIES
/**
* Priority order for skill categories (highest level first)
* This determines which category a student is grouped into
*/
export const CATEGORY_PRIORITY: SkillCategoryKey[] = [
'advanced',
'tenComplementsSub',
'tenComplements',
'fiveComplementsSub',
'fiveComplements',
'basic',
]
/**
* Convert a short skill key (e.g., "4=5-1") to a full skill ID (e.g., "fiveComplements.4=5-1")
*/
export function getFullSkillId(category: SkillCategoryKey, shortKey: string): string {
return `${category}.${shortKey}`
}
/**
* Build a map of full skill IDs to their categories for fast lookup
*/
export function buildSkillToCategoryMap(): Map<string, SkillCategoryKey> {
const map = new Map<string, SkillCategoryKey>()
for (const [categoryKey, category] of Object.entries(SKILL_CATEGORIES)) {
for (const shortKey of Object.keys(category.skills)) {
map.set(
getFullSkillId(categoryKey as SkillCategoryKey, shortKey),
categoryKey as SkillCategoryKey
)
}
}
return map
}
// Pre-built map for performance
const skillToCategoryMap = buildSkillToCategoryMap()
/**
* Get the category for a full skill ID
*/
export function getSkillCategory(skillId: string): SkillCategoryKey | null {
return skillToCategoryMap.get(skillId) ?? null
}
/**
* Get all skill IDs for a category
*/
export function getCategorySkillIds(category: SkillCategoryKey): string[] {
return Object.keys(SKILL_CATEGORIES[category].skills).map((shortKey) =>
getFullSkillId(category, shortKey)
)
}
/**
* Get the display name for a category
*/
export function getCategoryDisplayName(category: SkillCategoryKey): string {
return SKILL_CATEGORIES[category].name
}

View File

@@ -32,18 +32,9 @@ export const playerSkillMastery = sqliteTable(
*/
skillId: text('skill_id').notNull(),
/** Total number of problems where this skill was required */
attempts: integer('attempts').notNull().default(0),
/** Number of problems solved correctly */
correct: integer('correct').notNull().default(0),
/**
* Current consecutive correct streak
* Resets to 0 on any incorrect answer
* Used for mastery determination
*/
consecutiveCorrect: integer('consecutive_correct').notNull().default(0),
// NOTE: attempts, correct, consecutiveCorrect columns REMOVED
// These are now computed on-the-fly from session results (single source of truth)
// See: getRecentSessionResults() in session-planner.ts
/**
* Whether this skill is in the student's active practice rotation.
@@ -88,19 +79,9 @@ export const playerSkillMastery = sqliteTable(
*/
reinforcementStreak: integer('reinforcement_streak').notNull().default(0),
// ---- Response Time Tracking (for skill-level performance analysis) ----
/**
* Total response time in milliseconds across all attempts
* Used with responseTimeCount to calculate average: totalResponseTimeMs / responseTimeCount
*/
totalResponseTimeMs: integer('total_response_time_ms').notNull().default(0),
/**
* Number of attempts with recorded response times
* May differ from `attempts` if some early data didn't track time
*/
responseTimeCount: integer('response_time_count').notNull().default(0),
// NOTE: totalResponseTimeMs, responseTimeCount columns REMOVED
// These are now computed on-the-fly from session results (single source of truth)
// See: getRecentSessionResults() in session-planner.ts
},
(table) => ({
/** Index for fast lookups by playerId */

View File

@@ -93,6 +93,12 @@ export const players = sqliteTable(
* Free-form text for observations, reminders, etc.
*/
notes: text('notes'),
/**
* Whether this student is archived (hidden from default view)
* Archived students are not deleted but don't appear in normal lists
*/
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
},
(table) => ({
/** Index for fast lookups by userId */

View File

@@ -165,9 +165,6 @@ export async function setPracticingSkills(
const newRecord: NewPlayerSkillMastery = {
playerId,
skillId,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
isPracticing: true,
lastPracticedAt: now,
}
@@ -225,28 +222,25 @@ export async function refreshSkillRecency(
/**
* Record a skill attempt (correct or incorrect)
* Updates the skill mastery record and recalculates mastery level
* Updates the lastPracticedAt timestamp on the skill mastery record.
*
* NOTE: Attempt/correct statistics are now computed on-the-fly from session results.
* This function only updates metadata fields (lastPracticedAt) and ensures the
* skill record exists.
*/
export async function recordSkillAttempt(
playerId: string,
skillId: string,
isCorrect: boolean
_isCorrect: boolean
): Promise<PlayerSkillMastery> {
const existing = await getSkillMastery(playerId, skillId)
const now = new Date()
if (existing) {
// Update existing record
const newAttempts = existing.attempts + 1
const newCorrect = existing.correct + (isCorrect ? 1 : 0)
const newConsecutive = isCorrect ? existing.consecutiveCorrect + 1 : 0
// Update lastPracticedAt timestamp
await db
.update(schema.playerSkillMastery)
.set({
attempts: newAttempts,
correct: newCorrect,
consecutiveCorrect: newConsecutive,
lastPracticedAt: now,
updatedAt: now,
})
@@ -259,9 +253,6 @@ export async function recordSkillAttempt(
const newRecord: NewPlayerSkillMastery = {
playerId,
skillId,
attempts: 1,
correct: isCorrect ? 1 : 0,
consecutiveCorrect: isCorrect ? 1 : 0,
isPracticing: true, // skill is being practiced
lastPracticedAt: now,
}
@@ -271,53 +262,30 @@ export async function recordSkillAttempt(
}
/**
* Record a skill attempt with help level tracking and response time
* Applies credit multipliers based on help used and manages reinforcement
*
* Credit multipliers:
* - L0 (no help) or L1 (hint): Full credit (1.0)
* - L2 (decomposition): Half credit (0.5)
* - L3 (bead arrows): Quarter credit (0.25)
* Record a skill attempt with help level tracking
*
* Reinforcement logic:
* - If help level >= threshold, mark skill as needing reinforcement
* - If correct answer without heavy help, increment reinforcement streak
* - After N consecutive correct answers, clear reinforcement flag
*
* Response time tracking:
* - Accumulates total response time for calculating per-skill averages
* - Only recorded if responseTimeMs is provided (> 0)
* NOTE: Attempt/correct statistics and response times are now computed on-the-fly
* from session results. This function only updates metadata and reinforcement tracking.
*/
export async function recordSkillAttemptWithHelp(
playerId: string,
skillId: string,
isCorrect: boolean,
helpLevel: HelpLevel,
responseTimeMs?: number
_responseTimeMs?: number
): Promise<PlayerSkillMastery> {
const existing = await getSkillMastery(playerId, skillId)
const now = new Date()
// Calculate effective credit based on help level
const creditMultiplier = REINFORCEMENT_CONFIG.creditMultipliers[helpLevel]
// Determine if this help level triggers reinforcement tracking
const isHeavyHelp = helpLevel >= REINFORCEMENT_CONFIG.helpLevelThreshold
if (existing) {
// Update existing record with help-adjusted progress
const newAttempts = existing.attempts + 1
// Apply credit multiplier - only count fraction of correct answer
// For simplicity, we round: 1.0 = full credit, 0.5+ = credit, <0.5 = no credit
const effectiveCorrect = isCorrect && creditMultiplier >= 0.5 ? 1 : 0
const newCorrect = existing.correct + effectiveCorrect
// Consecutive streak logic with help consideration
// Heavy help (L2, L3) breaks the streak even if correct
const newConsecutive =
isCorrect && !isHeavyHelp ? existing.consecutiveCorrect + 1 : isCorrect ? 1 : 0
// Reinforcement tracking
let needsReinforcement = existing.needsReinforcement
let reinforcementStreak = existing.reinforcementStreak
@@ -340,51 +308,29 @@ export async function recordSkillAttemptWithHelp(
reinforcementStreak = 0
}
// Calculate response time updates (only if provided)
const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0
const newTotalResponseTimeMs = hasResponseTime
? existing.totalResponseTimeMs + responseTimeMs
: existing.totalResponseTimeMs
const newResponseTimeCount = hasResponseTime
? existing.responseTimeCount + 1
: existing.responseTimeCount
await db
.update(schema.playerSkillMastery)
.set({
attempts: newAttempts,
correct: newCorrect,
consecutiveCorrect: newConsecutive,
lastPracticedAt: now,
updatedAt: now,
needsReinforcement,
lastHelpLevel: helpLevel,
reinforcementStreak,
totalResponseTimeMs: newTotalResponseTimeMs,
responseTimeCount: newResponseTimeCount,
})
.where(eq(schema.playerSkillMastery.id, existing.id))
return (await getSkillMastery(playerId, skillId))!
}
// Calculate response time for new record (only if provided)
const hasResponseTime = responseTimeMs !== undefined && responseTimeMs > 0
// Create new record with help tracking - skill is being practiced
const newRecord: NewPlayerSkillMastery = {
playerId,
skillId,
attempts: 1,
correct: isCorrect && creditMultiplier >= 0.5 ? 1 : 0,
consecutiveCorrect: isCorrect && !isHeavyHelp ? 1 : 0,
isPracticing: true, // skill is being practiced
lastPracticedAt: now,
needsReinforcement: isHeavyHelp,
lastHelpLevel: helpLevel,
reinforcementStreak: 0,
totalResponseTimeMs: hasResponseTime ? responseTimeMs : 0,
responseTimeCount: hasResponseTime ? 1 : 0,
}
await db.insert(schema.playerSkillMastery).values(newRecord)
@@ -1035,9 +981,6 @@ export async function enableSkillForPractice(
const newRecord: NewPlayerSkillMastery = {
playerId,
skillId,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
isPracticing: true,
lastPracticedAt: now,
}

View File

@@ -14,6 +14,7 @@ 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 { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
import { getActiveSessionPlan } from './session-planner'
@@ -23,6 +24,7 @@ export type { Player } from '@/db/schema/players'
export type { PracticeSession } from '@/db/schema/practice-sessions'
// Re-export types that consumers might need
export type { SessionPlan } from '@/db/schema/session-plans'
export type { StudentWithSkillData } from '@/utils/studentGrouping'
/**
* Prefetch all data needed for the practice page
@@ -80,6 +82,71 @@ export async function getPlayersForViewer(): Promise<Player[]> {
return players
}
/**
* Get all players for the current viewer with enhanced skill data.
*
* Includes:
* - practicingSkills: List of skill IDs being practiced
* - lastPracticedAt: Most recent practice timestamp (max of all skill lastPracticedAt)
* - skillCategory: Computed highest-level skill category
*/
export async function getPlayersWithSkillData(): Promise<StudentWithSkillData[]> {
const viewerId = await getViewerId()
// Get or create user record
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
// Get all players for this user
const players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
// Fetch skill mastery for all players in parallel
const playersWithSkills = await Promise.all(
players.map(async (player) => {
const skills = await db.query.playerSkillMastery.findMany({
where: eq(schema.playerSkillMastery.playerId, player.id),
})
// Get practicing skills and compute lastPracticedAt
const practicingSkills: string[] = []
let lastPracticedAt: Date | null = null
for (const skill of skills) {
if (skill.isPracticing) {
practicingSkills.push(skill.skillId)
}
// Track the most recent practice date across all skills
if (skill.lastPracticedAt) {
if (!lastPracticedAt || skill.lastPracticedAt > lastPracticedAt) {
lastPracticedAt = skill.lastPracticedAt
}
}
}
// Compute skill category
const skillCategory = computeSkillCategory(practicingSkills)
return {
...player,
practicingSkills,
lastPracticedAt,
skillCategory,
}
})
)
return playersWithSkills
}
// Re-export the individual functions for granular prefetching
export { getPlayer } from '@/lib/arcade/player-manager'
export {

View File

@@ -142,9 +142,6 @@ export async function initializeSkillMastery(
playerId,
skillId,
isPracticing,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
needsReinforcement: false,
lastHelpLevel: 0,
reinforcementStreak: 0,

View File

@@ -187,25 +187,16 @@ describe('buildStudentSkillHistoryFromRecords', () => {
{
skillId: 'fiveComplements.4=5-1',
isPracticing: true,
attempts: 10,
correct: 9,
consecutiveCorrect: 5,
lastPracticedAt: new Date('2024-02-25'),
},
{
skillId: 'tenComplements.9=10-1',
isPracticing: true,
attempts: 5,
correct: 4,
consecutiveCorrect: 2,
lastPracticedAt: null,
},
{
skillId: 'basic.directAddition',
isPracticing: false,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
lastPracticedAt: null,
},
]

View File

@@ -219,13 +219,13 @@ export function calculateMaxSkillCost(calculator: SkillCostCalculator, skillIds:
/**
* Database record shape for skill practice status
*
* NOTE: attempts/correct/consecutiveCorrect were removed.
* Stats are now computed on-the-fly from session results.
*/
export interface DbSkillRecord {
skillId: string
isPracticing: boolean
attempts: number
correct: number
consecutiveCorrect: number
lastPracticedAt?: Date | null
}

View File

@@ -0,0 +1,157 @@
/**
* Skill Search Utilities
*
* Functions for searching and filtering skills by name.
*/
import {
SKILL_CATEGORIES,
getFullSkillId,
type SkillCategoryKey,
} from '@/constants/skillCategories'
/**
* Skill search result
*/
export interface SkillSearchResult {
/** Full skill ID (e.g., "fiveComplements.4=5-1") */
skillId: string
/** Display name (e.g., "+4 = +5 - 1") */
displayName: string
/** Category key */
category: SkillCategoryKey
/** Category display name */
categoryName: string
}
/**
* Build a flat list of all skills with their display information.
*/
function buildSkillList(): SkillSearchResult[] {
const results: SkillSearchResult[] = []
for (const [categoryKey, category] of Object.entries(SKILL_CATEGORIES)) {
for (const [shortKey, displayName] of Object.entries(category.skills)) {
results.push({
skillId: getFullSkillId(categoryKey as SkillCategoryKey, shortKey),
displayName,
category: categoryKey as SkillCategoryKey,
categoryName: category.name,
})
}
}
return results
}
// Pre-built skill list for performance
const allSkills = buildSkillList()
/**
* Search for skills matching a query string.
*
* Matches against:
* - Skill display name (e.g., "+4 = +5 - 1")
* - Category name (e.g., "Five Complements")
*
* @param query - Search query (case-insensitive)
* @returns Matching skills, sorted by relevance
*/
export function searchSkills(query: string): SkillSearchResult[] {
if (!query.trim()) {
return []
}
const lowerQuery = query.toLowerCase()
// Filter skills matching the query
const matches = allSkills.filter((skill) => {
const displayLower = skill.displayName.toLowerCase()
const categoryLower = skill.categoryName.toLowerCase()
return displayLower.includes(lowerQuery) || categoryLower.includes(lowerQuery)
})
// Sort by relevance:
// 1. Exact display name match (starts with)
// 2. Category name match
// 3. Partial match
matches.sort((a, b) => {
const aDisplayLower = a.displayName.toLowerCase()
const bDisplayLower = b.displayName.toLowerCase()
const aStartsWith = aDisplayLower.startsWith(lowerQuery)
const bStartsWith = bDisplayLower.startsWith(lowerQuery)
if (aStartsWith && !bStartsWith) return -1
if (bStartsWith && !aStartsWith) return 1
// Then sort by category name
return a.categoryName.localeCompare(b.categoryName)
})
return matches
}
/**
* Get all skills in a category.
*/
export function getSkillsInCategory(category: SkillCategoryKey): SkillSearchResult[] {
return allSkills.filter((skill) => skill.category === category)
}
/**
* Get all skills.
*/
export function getAllSkills(): SkillSearchResult[] {
return [...allSkills]
}
/**
* Get display name for a skill ID.
*
* @param skillId - Full skill ID (e.g., "fiveComplements.4=5-1")
* @returns Display name or the skillId if not found
*/
export function getSkillDisplayName(skillId: string): string {
const skill = allSkills.find((s) => s.skillId === skillId)
return skill?.displayName ?? skillId
}
/**
* Get category display name for a skill ID.
*/
export function getSkillCategoryDisplayName(skillId: string): string {
const skill = allSkills.find((s) => s.skillId === skillId)
return skill?.categoryName ?? 'Unknown'
}
/**
* Format a skill for display in filter pills.
*
* Returns a short name suitable for a chip/pill.
*/
export function formatSkillChipName(skillId: string): string {
const skill = allSkills.find((s) => s.skillId === skillId)
if (!skill) {
return skillId
}
// Use a shortened category name for the chip
const shortNames: Record<SkillCategoryKey, string> = {
basic: 'Basic',
fiveComplements: "5's Add",
fiveComplementsSub: "5's Sub",
tenComplements: "10's Add",
tenComplementsSub: "10's Sub",
advanced: 'Advanced',
}
const categoryShort = shortNames[skill.category]
// Extract the operation from the display name (e.g., "+4" from "+4 = +5 - 1")
const opMatch = skill.displayName.match(/^([+-]?\d+)/)
const op = opMatch ? opMatch[1] : ''
return op ? `${categoryShort}: ${op}` : categoryShort
}

View File

@@ -0,0 +1,226 @@
/**
* Student Grouping Utilities
*
* Functions for organizing students by recency and skill category
* for display in the practice page.
*/
import {
CATEGORY_PRIORITY,
getCategoryDisplayName,
getSkillCategory,
type SkillCategoryKey,
} from '@/constants/skillCategories'
import type { Player } from '@/db/schema/players'
/**
* Recency bucket for student grouping
*/
export type RecencyBucket = 'today' | 'thisWeek' | 'older' | 'new'
/**
* Extended student type with skill data for grouping
*/
export interface StudentWithSkillData extends Player {
/** List of skillIds being practiced */
practicingSkills: string[]
/** Most recent practice session timestamp */
lastPracticedAt: Date | null
/** Computed skill category (highest level) */
skillCategory: SkillCategoryKey | null
}
/**
* Grouped students structure
*/
export interface GroupedStudents {
/** Recency bucket */
bucket: RecencyBucket
/** Display name for the bucket */
bucketName: string
/** Categories within this bucket */
categories: {
/** Skill category key (null for new students) */
category: SkillCategoryKey | null
/** Display name for the category */
categoryName: string
/** Students in this category */
students: StudentWithSkillData[]
}[]
}
/**
* Compute the skill category for a student based on their practicing skills.
* Returns the highest-level category the student has skills in.
*/
export function computeSkillCategory(practicingSkills: string[]): SkillCategoryKey | null {
if (practicingSkills.length === 0) {
return null
}
// Check each category in priority order (highest first)
for (const category of CATEGORY_PRIORITY) {
for (const skillId of practicingSkills) {
if (getSkillCategory(skillId) === category) {
return category
}
}
}
// Fallback to basic if skills exist but don't match known categories
return 'basic'
}
/**
* Get the recency bucket for a student based on last practice date.
*/
export function getRecencyBucket(lastPracticedAt: Date | null): RecencyBucket {
if (!lastPracticedAt) {
return 'new'
}
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const oneWeekAgo = new Date(startOfToday.getTime() - 7 * 24 * 60 * 60 * 1000)
if (lastPracticedAt >= startOfToday) {
return 'today'
}
if (lastPracticedAt >= oneWeekAgo) {
return 'thisWeek'
}
return 'older'
}
/**
* Get display name for a recency bucket.
*/
export function getRecencyBucketName(bucket: RecencyBucket): string {
switch (bucket) {
case 'today':
return 'Today'
case 'thisWeek':
return 'This Week'
case 'older':
return 'Older'
case 'new':
return 'New Students'
}
}
/**
* Get display name for a skill category (including null for new students).
*/
export function getGroupCategoryName(category: SkillCategoryKey | null): string {
if (category === null) {
return 'Not Started'
}
return getCategoryDisplayName(category)
}
/**
* Group students by recency bucket, then by skill category.
*
* Students appear exactly once (no duplication).
* Within each bucket, they're grouped by their highest-level skill category.
*/
export function groupStudents(students: StudentWithSkillData[]): GroupedStudents[] {
// First, group by recency bucket
const byBucket = new Map<RecencyBucket, StudentWithSkillData[]>()
for (const student of students) {
const bucket = getRecencyBucket(student.lastPracticedAt)
if (!byBucket.has(bucket)) {
byBucket.set(bucket, [])
}
byBucket.get(bucket)!.push(student)
}
// Then, within each bucket, group by skill category
const bucketOrder: RecencyBucket[] = ['today', 'thisWeek', 'older', 'new']
const result: GroupedStudents[] = []
for (const bucket of bucketOrder) {
const studentsInBucket = byBucket.get(bucket)
if (!studentsInBucket || studentsInBucket.length === 0) {
continue
}
// Group by category
const byCategory = new Map<SkillCategoryKey | null, StudentWithSkillData[]>()
for (const student of studentsInBucket) {
const category = student.skillCategory
if (!byCategory.has(category)) {
byCategory.set(category, [])
}
byCategory.get(category)!.push(student)
}
// Order categories by priority (advanced first, then null/new at end)
const categoryOrder: (SkillCategoryKey | null)[] = [...CATEGORY_PRIORITY, null]
const categories: GroupedStudents['categories'] = []
for (const category of categoryOrder) {
const studentsInCategory = byCategory.get(category)
if (studentsInCategory && studentsInCategory.length > 0) {
categories.push({
category,
categoryName: getGroupCategoryName(category),
students: studentsInCategory,
})
}
}
if (categories.length > 0) {
result.push({
bucket,
bucketName: getRecencyBucketName(bucket),
categories,
})
}
}
return result
}
/**
* Filter students based on search query and skill filters.
*
* @param students - All students to filter
* @param searchQuery - Text search (matches student name)
* @param skillFilters - Skill IDs to filter by (AND logic - must have all)
* @param showArchived - Whether to include archived students
*/
export function filterStudents(
students: StudentWithSkillData[],
searchQuery: string,
skillFilters: string[],
showArchived: boolean
): StudentWithSkillData[] {
return students.filter((student) => {
// Filter by archived status
if (!showArchived && student.isArchived) {
return false
}
// Filter by search query (case-insensitive name match)
if (searchQuery) {
const query = searchQuery.toLowerCase()
if (!student.name.toLowerCase().includes(query)) {
return false
}
}
// Filter by skill filters (AND logic - must have ALL selected skills)
if (skillFilters.length > 0) {
const practicingSet = new Set(student.practicingSkills)
for (const skillId of skillFilters) {
if (!practicingSet.has(skillId)) {
return false
}
}
}
return true
})
}