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:
@@ -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": []
|
||||
|
||||
18
apps/web/drizzle/0038_drop_skill_stat_columns.sql
Normal file
18
apps/web/drizzle/0038_drop_skill_stat_columns.sql
Normal 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`;
|
||||
4
apps/web/drizzle/0039_add_player_archived.sql
Normal file
4
apps/web/drizzle/0039_add_player_archived.sql
Normal 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;
|
||||
1038
apps/web/drizzle/meta/0038_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
479
apps/web/src/components/practice/StudentFilterBar.tsx
Normal file
479
apps/web/src/components/practice/StudentFilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
140
apps/web/src/constants/skillCategories.ts
Normal file
140
apps/web/src/constants/skillCategories.ts
Normal 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
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -142,9 +142,6 @@ export async function initializeSkillMastery(
|
||||
playerId,
|
||||
skillId,
|
||||
isPracticing,
|
||||
attempts: 0,
|
||||
correct: 0,
|
||||
consecutiveCorrect: 0,
|
||||
needsReinforcement: false,
|
||||
lastHelpLevel: 0,
|
||||
reinforcementStreak: 0,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
157
apps/web/src/utils/skillSearch.ts
Normal file
157
apps/web/src/utils/skillSearch.ts
Normal 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
|
||||
}
|
||||
226
apps/web/src/utils/studentGrouping.ts
Normal file
226
apps/web/src/utils/studentGrouping.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user