diff --git a/apps/web/package.json b/apps/web/package.json
index 2d8c2057..3b178117 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -90,6 +90,7 @@
"react-resizable-panels": "^3.0.6",
"react-simple-keyboard": "^3.8.139",
"react-textfit": "^1.1.1",
+ "react-use-measure": "^2.1.7",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
index 00dfe7bf..13003c61 100644
--- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
+++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
@@ -1,5 +1,6 @@
'use client'
+import { useQuery } from '@tanstack/react-query'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
@@ -27,11 +28,13 @@ import {
import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
+import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
+import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
import {
+ type BktComputeOptions,
computeBktFromHistory,
getConfidenceLabel,
getStalenessWarning,
- type BktComputeOptions,
type SkillBktResult,
} from '@/lib/curriculum/bkt'
import {
@@ -40,8 +43,8 @@ import {
MASTERY_MULTIPLIERS,
} from '@/lib/curriculum/config'
import type { ProblemResultWithContext } from '@/lib/curriculum/server'
-import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
-import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
+import { api } from '@/lib/queryClient'
+import { curriculumKeys } from '@/lib/queryKeys'
import { computeMasteryState } from '@/utils/skillComplexity'
import { css } from '../../../../../styled-system/css'
@@ -928,7 +931,12 @@ function SkillsTab({
const [applyDecay, setApplyDecay] = useState(false)
const bktResult = useMemo(() => {
- const options: BktComputeOptions = { confidenceThreshold, applyDecay, decayHalfLifeDays: 30 }
+ const options: BktComputeOptions = {
+ confidenceThreshold,
+ applyDecay,
+ decayHalfLifeDays: 30,
+ useCrossStudentPriors: false,
+ }
return computeBktFromHistory(problemHistory, options)
}, [problemHistory, confidenceThreshold, applyDecay])
@@ -1409,7 +1417,7 @@ function HistoryTab({
- {new Date(session.completedAt || session.createdAt).toLocaleDateString()}
+ {new Date(session.completedAt || session.startedAt).toLocaleDateString()}
{
+ const response = await api(`curriculum/${studentId}`)
+ if (!response.ok) throw new Error('Failed to fetch curriculum')
+ return response.json()
+ },
+ initialData: { skills },
+ staleTime: 0, // Always refetch when invalidated
+ })
+
+ // Use skills from React Query cache (falls back to SSR prop structure)
+ const liveSkills: PlayerSkillMastery[] = skillsQueryData?.skills ?? skills
+
// React Query mutations
const abandonMutation = useAbandonSession()
const setMasteredSkillsMutation = useSetMasteredSkills()
@@ -1532,29 +1556,35 @@ export function DashboardClient({
const currentPhase = curriculum
? getPhaseInfo(curriculum.currentPhaseId)
: getPhaseInfo('L1.add.+1.direct')
- if (skills.length > 0) {
- const phaseSkills = skills.filter((s) => currentPhase.skillsToMaster.includes(s.skillId))
+ if (liveSkills.length > 0) {
+ const phaseSkills = liveSkills.filter((s) => currentPhase.skillsToMaster.includes(s.skillId))
currentPhase.masteredSkills = phaseSkills.filter(
(s) => s.isPracticing && hasFluency(s.attempts, s.correct, s.consecutiveCorrect)
).length
currentPhase.totalSkills = currentPhase.skillsToMaster.length
}
+ // Derive practicing skill IDs from live skills data (reactive to mutations)
+ const livePracticingSkillIds = useMemo(
+ () => liveSkills.filter((s) => s.isPracticing).map((s) => s.skillId),
+ [liveSkills]
+ )
+
// Build active session state
const activeSessionState: ActiveSessionState | null = activeSession
? (() => {
const sessionSkillIds = activeSession.masteredSkillIds || []
const sessionSet = new Set(sessionSkillIds)
- const currentSet = new Set(currentPracticingSkillIds)
+ const currentSet = new Set(livePracticingSkillIds)
return {
id: activeSession.id,
status: activeSession.status as 'draft' | 'approved' | 'in_progress',
completedCount: activeSession.results.length,
totalCount: activeSession.summary.totalProblemCount,
hasSkillMismatch:
- currentPracticingSkillIds.some((id) => !sessionSet.has(id)) ||
+ livePracticingSkillIds.some((id) => !sessionSet.has(id)) ||
sessionSkillIds.some((id) => !currentSet.has(id)),
- skillsAdded: currentPracticingSkillIds.filter((id) => !sessionSet.has(id)).length,
+ skillsAdded: livePracticingSkillIds.filter((id) => !sessionSet.has(id)).length,
skillsRemoved: sessionSkillIds.filter((id) => !currentSet.has(id)).length,
}
})()
@@ -1610,6 +1640,7 @@ export function DashboardClient({
const handleSaveManualSkills = useCallback(
async (masteredSkillIds: string[]): Promise => {
+ // Optimistic update in the mutation handles immediate UI feedback
await setMasteredSkillsMutation.mutateAsync({
playerId: studentId,
masteredSkillIds,
@@ -1667,7 +1698,7 @@ export function DashboardClient({
{activeTab === 'skills' && (
setShowManualSkillModal(true)}
@@ -1695,8 +1726,8 @@ export function DashboardClient({
onClose={() => setShowManualSkillModal(false)}
onSave={handleSaveManualSkills}
onRefreshSkill={handleRefreshSkill}
- currentMasteredSkills={skills.filter((s) => s.isPracticing).map((s) => s.skillId)}
- skillMasteryData={skills}
+ currentMasteredSkills={liveSkills.filter((s) => s.isPracticing).map((s) => s.skillId)}
+ skillMasteryData={liveSkills}
/>
diff --git a/apps/web/src/components/practice/ManualSkillSelector.tsx b/apps/web/src/components/practice/ManualSkillSelector.tsx
index 4a996ee1..1fca8e34 100644
--- a/apps/web/src/components/practice/ManualSkillSelector.tsx
+++ b/apps/web/src/components/practice/ManualSkillSelector.tsx
@@ -2,15 +2,14 @@
import * as Accordion from '@radix-ui/react-accordion'
import * as Dialog from '@radix-ui/react-dialog'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { animated, useSpring } from '@react-spring/web'
+import useMeasure from 'react-use-measure'
+import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
-import { FLUENCY_CONFIG, type FluencyState } from '@/db/schema/player-skill-mastery'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
-import {
- BASE_SKILL_COMPLEXITY,
- computeMasteryState,
- type MasteryState,
-} from '@/utils/skillComplexity'
+import { FLUENCY_CONFIG, type FluencyState } from '@/db/schema/player-skill-mastery'
+import { BASE_SKILL_COMPLEXITY, computeMasteryState } from '@/utils/skillComplexity'
import { css } from '../../../styled-system/css'
/**
@@ -153,22 +152,23 @@ function ComplexityLegend({ isDark }: { isDark: boolean }) {
className={css({
display: 'flex',
flexWrap: 'wrap',
- gap: '3',
+ gap: '2',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
- p: '2',
+ py: '1.5',
+ px: '2',
bg: isDark ? 'gray.750' : 'gray.50',
borderRadius: 'md',
- mb: '3',
+ alignItems: 'center',
})}
>
- Complexity:
+ Complexity:
+
{children}
+
+ )
+}
+
/**
* Book preset mappings (SAI Abacus Mind Math levels)
*/
@@ -434,6 +477,86 @@ export function ManualSkillSelector({
const [isRefreshing, setIsRefreshing] = useState(null)
const [expandedCategories, setExpandedCategories] = useState([])
+ // Scroll state for showing/hiding scroll indicators
+ const scrollContainerRef = useRef(null)
+ const [canScrollUp, setCanScrollUp] = useState(false)
+ const [canScrollDown, setCanScrollDown] = useState(false)
+
+ // Update scroll indicators based on scroll position
+ const updateScrollIndicators = useCallback(() => {
+ const el = scrollContainerRef.current
+ if (!el) return
+ const { scrollTop, scrollHeight, clientHeight } = el
+ setCanScrollUp(scrollTop > 5)
+ setCanScrollDown(scrollTop + clientHeight < scrollHeight - 5)
+ }, [])
+
+ // Check scroll state on mount, content changes, and category expansion
+ useEffect(() => {
+ updateScrollIndicators()
+ // Re-check after a brief delay to account for accordion animation
+ const timer = setTimeout(updateScrollIndicators, 300)
+ return () => clearTimeout(timer)
+ }, [expandedCategories, updateScrollIndicators])
+
+ // Track previous expanded categories to detect newly expanded ones
+ const prevExpandedRef = useRef([])
+
+ // Scroll to show expanded category content optimally
+ useEffect(() => {
+ const prevExpanded = prevExpandedRef.current
+ const newlyExpanded = expandedCategories.filter((cat) => !prevExpanded.includes(cat))
+ prevExpandedRef.current = expandedCategories
+
+ if (newlyExpanded.length === 0) return
+
+ // Wait for accordion animation to complete
+ const timer = setTimeout(() => {
+ const container = scrollContainerRef.current
+ if (!container) return
+
+ // Find the newly expanded category element
+ const categoryKey = newlyExpanded[0]
+ const categoryEl = container.querySelector(`[data-category="${categoryKey}"]`) as HTMLElement
+ if (!categoryEl) return
+
+ const containerRect = container.getBoundingClientRect()
+ const categoryRect = categoryEl.getBoundingClientRect()
+
+ // Check if the entire category fits in the visible area
+ const categoryHeight = categoryRect.height
+ const containerHeight = containerRect.height
+ const categoryTopRelative = categoryRect.top - containerRect.top + container.scrollTop
+
+ if (categoryHeight <= containerHeight) {
+ // Category fits - scroll to show it entirely, with header at top if needed
+ const categoryBottomRelative = categoryTopRelative + categoryHeight
+ const visibleBottom = container.scrollTop + containerHeight
+
+ if (categoryBottomRelative > visibleBottom) {
+ // Category extends below visible area - scroll to show it
+ // Prefer showing header at top if that shows more content
+ const scrollToShowAll = categoryBottomRelative - containerHeight
+ const scrollToShowHeader = categoryTopRelative
+
+ // Use whichever scroll position shows the category better
+ container.scrollTo({
+ top: Math.max(scrollToShowHeader, scrollToShowAll),
+ behavior: 'smooth',
+ })
+ }
+ } else {
+ // Category doesn't fit - scroll header to top to show as many checkboxes as possible
+ container.scrollTo({
+ top: categoryTopRelative,
+ behavior: 'smooth',
+ })
+ }
+ }, 150) // Wait for accordion animation
+
+ return () => clearTimeout(timer)
+ }, [expandedCategories])
+
// Build a map from skill ID to mastery data for quick lookup
const skillMasteryMap = new Map(skillMasteryData.map((s) => [s.skillId, s]))
@@ -474,9 +597,16 @@ export function ManualSkillSelector({
}
}
- // Sync selected skills when modal opens with new data
+ // Track previous open state to detect open transition
+ const wasOpenRef = useRef(open)
+
+ // Sync selected skills only when modal OPENS (closed→open transition)
+ // Don't reset when props change while already open (prevents flicker on save)
useEffect(() => {
- if (open) {
+ const justOpened = open && !wasOpenRef.current
+ wasOpenRef.current = open
+
+ if (justOpened) {
setSelectedSkills(new Set(currentMasteredSkills))
}
}, [open, currentMasteredSkills])
@@ -558,356 +688,449 @@ export function ManualSkillSelector({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
- zIndex: 50,
+ zIndex: Z_INDEX.MODAL_BACKDROP,
})}
/>
- {/* Header */}
-
+ {/* Fixed Header Section - kept compact */}
+
+ {/* Title row with close hint */}
- Set Skills for {studentName}
+ Skills for {studentName}
+
- Select the skills this student has already mastered. You can use a book level preset
- or select individual skills.
+ Select mastered skills or import from a book level preset.
-