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. -
- {/* Book Preset Selector */} -
- - + {/* Book Preset Selector - inline */} + + + {/* Selected count */} + + {selectedCount}/{totalSkills} + + + {/* Clear All */} + +
+ + {/* Complexity Legend - more compact */} +
- {/* Selected count */} + {/* Scrollable Skills Section with dynamic scroll indicators */}
- - {selectedCount} of {totalSkills} skills marked as mastered - - -
- - {/* Complexity Legend */} - - - {/* Skills Accordion */} - - {( - Object.entries(SKILL_CATEGORIES) as [ - CategoryKey, - (typeof SKILL_CATEGORIES)[CategoryKey], - ][] - ).map(([categoryKey, category]) => { - const categorySkillIds = Object.keys(category.skills).map( - (skill) => `${categoryKey}.${skill}` - ) - const selectedInCategory = categorySkillIds.filter((id) => - selectedSkills.has(id) - ).length - const allSelected = selectedInCategory === categorySkillIds.length - const someSelected = selectedInCategory > 0 && !allSelected + {/* Top scroll shadow - appears when content is scrolled down */} +
+ {/* Bottom scroll shadow - appears when more content below */} +
+
+ {/* Skills Accordion */} + + {( + Object.entries(SKILL_CATEGORIES) as [ + CategoryKey, + (typeof SKILL_CATEGORIES)[CategoryKey], + ][] + ).map(([categoryKey, category]) => { + const categorySkillIds = Object.keys(category.skills).map( + (skill) => `${categoryKey}.${skill}` + ) + const selectedInCategory = categorySkillIds.filter((id) => + selectedSkills.has(id) + ).length + const allSelected = selectedInCategory === categorySkillIds.length + const someSelected = selectedInCategory > 0 && !allSelected - return ( - - - -
- { - if (el) el.indeterminate = someSelected - }} - onChange={(e) => { - e.stopPropagation() - toggleCategory(categoryKey) - }} - onClick={(e) => e.stopPropagation()} + - - {category.name} - -
-
- - {selectedInCategory}/{categorySkillIds.length} - - - {expandedCategories.includes(categoryKey) ? '▲' : '▼'} - -
-
-
- -
- {Object.entries(category.skills).map(([skillKey, skillName]) => { - const skillId = `${categoryKey}.${skillKey}` - const isSelected = selectedSkills.has(skillId) - const fluencyState = getFluencyStateForSkill(skillId) - const isRustyOrOlder = - fluencyState === 'rusty' || (isSelected && !skillMasteryMap.has(skillId)) - const showRefreshButton = isSelected && onRefreshSkill && isRustyOrOlder - - return ( -
+
+ + {selectedInCategory}/{categorySkillIds.length} + + + + {expandedCategories.includes(categoryKey) ? 'Hide' : 'Show'} + - New + › - )} - {/* Refresh button for rusty skills */} - {showRefreshButton && ( -
+ + + +
+ {Object.entries(category.skills).map(([skillKey, skillName]) => { + const skillId = `${categoryKey}.${skillKey}` + const isSelected = selectedSkills.has(skillId) + const fluencyState = getFluencyStateForSkill(skillId) + const isRustyOrOlder = + fluencyState === 'rusty' || + (isSelected && !skillMasteryMap.has(skillId)) + const showRefreshButton = isSelected && onRefreshSkill && isRustyOrOlder + + return ( + - ) - })} -
-
-
- ) - })} -
+ toggleSkill(skillId)} + className={css({ + width: '16px', + height: '16px', + cursor: 'pointer', + })} + /> + + + {skillName} + + {/* Show fluency state badge for practicing skills */} + {isSelected && fluencyState && ( + + )} + {/* Show "Mastered" if selected but no mastery data (newly added) */} + {isSelected && !skillMasteryMap.has(skillId) && ( + + New + + )} + {/* Refresh button for rusty skills */} + {showRefreshButton && ( + + )} + + ) + })} +
+ + + ) + })} + +
+
- {/* Actions */} + {/* Fixed Footer Section */}