feat(skills-modal): add spring animations and UX improvements

- Add smooth spring-animated accordion expand/collapse using react-spring
- Add dynamic scroll indicators that show when content is scrolled
- Auto-scroll to show expanded category content optimally
- Replace ambiguous arrows with "Show/Hide" + rotating chevron
- Make modal full-screen on mobile, centered on desktop
- Add sticky category headers within scroll container
- Fix z-index layering using shared constants
- Add optimistic updates for skill mutations (instant UI feedback)
- Fix React Query cache sync for live skill updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-17 07:23:14 -06:00
parent fad386f216
commit b94f5338e5
5 changed files with 598 additions and 299 deletions

View File

@ -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",

View File

@ -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({
<span
className={css({ fontWeight: 'bold', color: isDark ? 'gray.100' : 'gray.900' })}
>
{new Date(session.completedAt || session.createdAt).toLocaleDateString()}
{new Date(session.completedAt || session.startedAt).toLocaleDateString()}
</span>
<span
className={css({
@ -1488,6 +1496,22 @@ export function DashboardClient({
// React Query: Use server props as initial data, get live updates from cache
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
// React Query: Skills data with SSR props as initial data
// This ensures mutations that invalidate the cache will trigger re-renders
const { data: skillsQueryData } = useQuery({
queryKey: curriculumKeys.detail(studentId),
queryFn: async () => {
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<void> => {
// Optimistic update in the mutation handles immediate UI feedback
await setMasteredSkillsMutation.mutateAsync({
playerId: studentId,
masteredSkillIds,
@ -1667,7 +1698,7 @@ export function DashboardClient({
{activeTab === 'skills' && (
<SkillsTab
skills={skills}
skills={liveSkills}
problemHistory={problemHistory}
isDark={isDark}
onManageSkills={() => 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}
/>
</main>

View File

@ -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',
})}
>
<span className={css({ fontWeight: 'medium' })}>Complexity:</span>
<span className={css({ fontWeight: 'medium', mr: '1' })}>Complexity:</span>
<span className={css({ display: 'flex', alignItems: 'center', gap: '1' })}>
<span
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
px: '1',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'green.900' : 'green.100',
@ -184,7 +184,7 @@ function ComplexityLegend({ isDark }: { isDark: boolean }) {
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
px: '1',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'orange.900' : 'orange.100',
@ -200,7 +200,7 @@ function ComplexityLegend({ isDark }: { isDark: boolean }) {
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
px: '1',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'red.900' : 'red.100',
@ -306,6 +306,49 @@ function daysSince(date: Date | null | undefined): number | undefined {
return Math.floor((now.getTime() - new Date(date).getTime()) / (1000 * 60 * 60 * 24))
}
/**
* AnimatedAccordionContent - Spring-animated height accordion content
*
* Provides smooth expand/collapse animation using react-spring.
* Content is always rendered (for measurement) but height is animated.
*/
function AnimatedAccordionContent({
isOpen,
children,
className,
}: {
isOpen: boolean
children: React.ReactNode
className?: string
}) {
const [measureRef, bounds] = useMeasure()
const spring = useSpring({
height: isOpen ? bounds.height : 0,
opacity: isOpen ? 1 : 0,
config: {
tension: 280,
friction: 28,
clamp: true, // Prevents overshoot on height
},
})
return (
<animated.div
data-element="animated-accordion-content"
data-state={isOpen ? 'open' : 'closed'}
style={{
height: spring.height,
opacity: spring.opacity,
overflow: 'hidden',
}}
className={className}
>
<div ref={measureRef}>{children}</div>
</animated.div>
)
}
/**
* Book preset mappings (SAI Abacus Mind Math levels)
*/
@ -434,6 +477,86 @@ export function ManualSkillSelector({
const [isRefreshing, setIsRefreshing] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
// Scroll state for showing/hiding scroll indicators
const scrollContainerRef = useRef<HTMLDivElement>(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<string[]>([])
// 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,
})}
/>
<Dialog.Content
data-component="manual-skill-selector"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
// Mobile: full screen over nav
// Desktop: centered modal below nav
top: { base: 0, md: 'calc(10vh + 120px)' },
left: { base: 0, md: '50%' },
transform: { base: 'none', md: 'translateX(-50%)' },
bg: isDark ? 'gray.800' : 'white',
borderRadius: 'xl',
boxShadow: 'xl',
p: '6',
maxWidth: '550px',
width: '90vw',
maxHeight: '85vh',
overflowY: 'auto',
zIndex: 51,
borderRadius: { base: 0, md: 'xl' },
boxShadow: { base: 'none', md: 'xl' },
px: { base: '4', md: '6' },
py: '4',
maxWidth: { base: 'none', md: '550px' },
width: { base: '100vw', md: '90vw' },
height: { base: '100vh', md: '70vh' },
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: Z_INDEX.MODAL,
})}
>
{/* Header */}
<div className={css({ mb: '5' })}>
{/* Fixed Header Section - kept compact */}
<div data-section="modal-header" className={css({ flexShrink: 0, mb: '3' })}>
{/* Title row with close hint */}
<Dialog.Title
className={css({
fontSize: 'xl',
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
mb: '2',
})}
>
Set Skills for {studentName}
Skills for {studentName}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
mt: '1',
mb: '3',
})}
>
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.
</Dialog.Description>
</div>
{/* Book Preset Selector */}
<div className={css({ mb: '4' })}>
<label
htmlFor="preset-select"
{/* Compact controls row */}
<div
data-element="controls-row"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
display: 'flex',
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
mb: '2',
})}
>
Import from Book Level
</label>
<select
id="preset-select"
data-element="book-preset-select"
onChange={(e) => handlePresetChange(e.target.value)}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
bg: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
cursor: 'pointer',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
>
<option value="">-- Select a preset --</option>
{Object.entries(BOOK_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name} - {preset.description}
</option>
))}
</select>
{/* Book Preset Selector - inline */}
<select
id="preset-select"
data-element="book-preset-select"
onChange={(e) => handlePresetChange(e.target.value)}
className={css({
flex: '1',
minWidth: '180px',
px: '2',
py: '1.5',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: 'md',
bg: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
cursor: 'pointer',
_focus: {
outline: 'none',
borderColor: 'blue.500',
},
})}
>
<option value="">Import preset...</option>
{Object.entries(BOOK_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name}
</option>
))}
</select>
{/* Selected count */}
<span
data-element="selected-count"
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
whiteSpace: 'nowrap',
})}
>
{selectedCount}/{totalSkills}
</span>
{/* Clear All */}
<button
type="button"
data-action="clear-all"
onClick={() => setSelectedSkills(new Set())}
className={css({
fontSize: 'xs',
color: isDark ? 'red.400' : 'red.600',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
_hover: { textDecoration: 'underline' },
})}
>
Clear
</button>
</div>
{/* Complexity Legend - more compact */}
<ComplexityLegend isDark={isDark} />
</div>
{/* Selected count */}
{/* Scrollable Skills Section with dynamic scroll indicators */}
<div
data-element="skills-scroll-wrapper"
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
mb: '3',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span>
{selectedCount} of {totalSkills} skills marked as mastered
</span>
<button
type="button"
onClick={() => setSelectedSkills(new Set())}
className={css({
fontSize: 'xs',
color: isDark ? 'red.400' : 'red.600',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: { textDecoration: 'underline' },
})}
>
Clear All
</button>
</div>
{/* Complexity Legend */}
<ComplexityLegend isDark={isDark} />
{/* Skills Accordion */}
<Accordion.Root
type="multiple"
value={expandedCategories}
onValueChange={setExpandedCategories}
className={css({
flex: 1,
minHeight: 0,
position: 'relative',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
borderRadius: 'lg',
overflow: 'hidden',
})}
>
{(
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 */}
<div
data-element="scroll-indicator-top"
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '24px',
background: isDark
? 'linear-gradient(to bottom, rgba(0,0,0,0.4), transparent)'
: 'linear-gradient(to bottom, rgba(0,0,0,0.12), transparent)',
pointerEvents: 'none',
zIndex: 2,
opacity: canScrollUp ? 1 : 0,
transition: 'opacity 0.15s ease',
})}
/>
{/* Bottom scroll shadow - appears when more content below */}
<div
data-element="scroll-indicator-bottom"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '24px',
background: isDark
? 'linear-gradient(to top, rgba(0,0,0,0.4), transparent)'
: 'linear-gradient(to top, rgba(0,0,0,0.12), transparent)',
pointerEvents: 'none',
zIndex: 2,
opacity: canScrollDown ? 1 : 0,
transition: 'opacity 0.15s ease',
})}
/>
<div
ref={scrollContainerRef}
onScroll={updateScrollIndicators}
data-element="skills-scroll-container"
className={css({
height: '100%',
overflowY: 'auto',
})}
>
{/* Skills Accordion */}
<Accordion.Root
type="multiple"
value={expandedCategories}
onValueChange={setExpandedCategories}
>
{(
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 (
<Accordion.Item
key={categoryKey}
value={categoryKey}
className={css({
borderBottom: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
_last: { borderBottom: 'none' },
})}
>
<Accordion.Header>
<Accordion.Trigger
return (
<Accordion.Item
key={categoryKey}
value={categoryKey}
data-category={categoryKey}
className={css({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
bg: isDark ? 'gray.700' : 'gray.50',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
_hover: { bg: isDark ? 'gray.600' : 'gray.100' },
borderBottom: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
_last: { borderBottom: 'none' },
})}
>
<div
<Accordion.Header
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
position: 'sticky',
top: 0,
zIndex: 1,
})}
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected
}}
onChange={(e) => {
e.stopPropagation()
toggleCategory(categoryKey)
}}
onClick={(e) => e.stopPropagation()}
<Accordion.Trigger
className={css({
width: '18px',
height: '18px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
bg: isDark ? 'gray.700' : 'gray.50',
border: 'none',
cursor: 'pointer',
})}
/>
<span
className={css({
fontWeight: 'semibold',
color: isDark ? 'gray.100' : 'gray.800',
textAlign: 'left',
_hover: { bg: isDark ? 'gray.600' : 'gray.100' },
})}
>
{category.name}
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{selectedInCategory}/{categorySkillIds.length}
</span>
<span
className={css({
transition: 'transform 0.2s',
})}
>
{expandedCategories.includes(categoryKey) ? '▲' : '▼'}
</span>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
className={css({
overflow: 'hidden',
bg: isDark ? 'gray.800' : 'white',
})}
>
<div className={css({ p: '3' })}>
{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 (
<label
key={skillId}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
padding: '8px 12px',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.700' : 'gray.50' },
})}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSkill(skillId)}
data-action="toggle-category"
data-category={categoryKey}
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected
}}
onChange={(e) => {
e.stopPropagation()
toggleCategory(categoryKey)
}}
onClick={(e) => e.stopPropagation()}
className={css({
width: '16px',
height: '16px',
width: '18px',
height: '18px',
cursor: 'pointer',
})}
/>
<ComplexityBadge skillId={skillId} isDark={isDark} />
<span
className={css({
fontSize: 'sm',
color: isSelected
? isDark
? 'green.400'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.700',
fontWeight: isSelected ? 'medium' : 'normal',
flex: 1,
fontWeight: 'semibold',
color: isDark ? 'gray.100' : 'gray.800',
})}
>
{skillName}
{category.name}
</span>
{/* Show fluency state badge for practicing skills */}
{isSelected && fluencyState && (
<FluencyStateBadge fluencyState={fluencyState} isDark={isDark} />
)}
{/* Show "Mastered" if selected but no mastery data (newly added) */}
{isSelected && !skillMasteryMap.has(skillId) && (
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{selectedInCategory}/{categorySkillIds.length}
</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '1',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>
{expandedCategories.includes(categoryKey) ? 'Hide' : 'Show'}
</span>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
bg: isDark ? 'gray.700' : 'gray.100',
px: '2',
py: '0.5',
borderRadius: 'full',
display: 'inline-block',
transition: 'transform 0.2s ease',
transform: expandedCategories.includes(categoryKey)
? 'rotate(90deg)'
: 'rotate(0deg)',
})}
>
New
</span>
)}
{/* Refresh button for rusty skills */}
{showRefreshButton && (
<button
type="button"
onClick={(e) => handleRefreshSkill(skillId, e)}
disabled={isRefreshing === skillId}
title="Mark as recently practiced (sets to Fluent)"
data-action="refresh-skill"
</span>
</div>
</Accordion.Trigger>
</Accordion.Header>
<AnimatedAccordionContent
isOpen={expandedCategories.includes(categoryKey)}
className={css({
bg: isDark ? 'gray.800' : 'white',
})}
>
<div className={css({ p: '3' })}>
{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 (
<label
key={skillId}
data-skill={skillId}
className={css({
fontSize: '10px',
fontWeight: 'medium',
px: '2',
py: '1',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
display: 'flex',
alignItems: 'center',
gap: '3',
padding: '8px 12px',
borderRadius: 'md',
bg: isDark ? 'blue.900' : 'blue.50',
color: isDark ? 'blue.300' : 'blue.700',
cursor: 'pointer',
_hover: { bg: isDark ? 'blue.800' : 'blue.100' },
_disabled: { opacity: 0.5, cursor: 'wait' },
_hover: { bg: isDark ? 'gray.700' : 'gray.50' },
})}
>
{isRefreshing === skillId ? '...' : '↻ Refresh'}
</button>
)}
</label>
)
})}
</div>
</Accordion.Content>
</Accordion.Item>
)
})}
</Accordion.Root>
<input
type="checkbox"
data-action="toggle-skill"
data-skill={skillId}
checked={isSelected}
onChange={() => toggleSkill(skillId)}
className={css({
width: '16px',
height: '16px',
cursor: 'pointer',
})}
/>
<ComplexityBadge skillId={skillId} isDark={isDark} />
<span
className={css({
fontSize: 'sm',
color: isSelected
? isDark
? 'green.400'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.700',
fontWeight: isSelected ? 'medium' : 'normal',
flex: 1,
})}
>
{skillName}
</span>
{/* Show fluency state badge for practicing skills */}
{isSelected && fluencyState && (
<FluencyStateBadge fluencyState={fluencyState} isDark={isDark} />
)}
{/* Show "Mastered" if selected but no mastery data (newly added) */}
{isSelected && !skillMasteryMap.has(skillId) && (
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
bg: isDark ? 'gray.700' : 'gray.100',
px: '2',
py: '0.5',
borderRadius: 'full',
})}
>
New
</span>
)}
{/* Refresh button for rusty skills */}
{showRefreshButton && (
<button
type="button"
onClick={(e) => handleRefreshSkill(skillId, e)}
disabled={isRefreshing === skillId}
title="Mark as recently practiced (sets to Fluent)"
data-action="refresh-skill"
className={css({
fontSize: '10px',
fontWeight: 'medium',
px: '2',
py: '1',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: 'md',
bg: isDark ? 'blue.900' : 'blue.50',
color: isDark ? 'blue.300' : 'blue.700',
cursor: 'pointer',
_hover: { bg: isDark ? 'blue.800' : 'blue.100' },
_disabled: { opacity: 0.5, cursor: 'wait' },
})}
>
{isRefreshing === skillId ? '...' : '↻ Refresh'}
</button>
)}
</label>
)
})}
</div>
</AnimatedAccordionContent>
</Accordion.Item>
)
})}
</Accordion.Root>
</div>
</div>
{/* Actions */}
{/* Fixed Footer Section */}
<div
data-section="modal-footer"
className={css({
flexShrink: 0,
display: 'flex',
gap: '3',
justifyContent: 'flex-end',
mt: '6',
pt: '4',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
mt: '2',
})}
>
<button

View File

@ -381,6 +381,11 @@ export function usePlayerCurriculum(playerId: string | null) {
/**
* Hook: Set mastered skills (manual skill management)
* Used by dashboard to manually enable/disable skills
*
* Uses optimistic updates for instant UI feedback:
* - Cache is updated immediately when mutation starts
* - Rolled back if the API call fails
* - Refetched on settle to ensure sync with server
*/
export function useSetMasteredSkills() {
const queryClient = useQueryClient()
@ -406,8 +411,44 @@ export function useSetMasteredSkills() {
return response.json()
},
onSuccess: (_, { playerId }) => {
// Invalidate curriculum to refetch skills
// Optimistic update: update cache immediately before API call
onMutate: async ({ playerId, masteredSkillIds }) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: curriculumKeys.detail(playerId) })
// Snapshot the previous value for rollback
const previousData = queryClient.getQueryData(curriculumKeys.detail(playerId))
// Optimistically update the cache
queryClient.setQueryData(
curriculumKeys.detail(playerId),
(old: CurriculumData | undefined) => {
if (!old?.skills) return old
const masteredSet = new Set(masteredSkillIds)
return {
...old,
skills: old.skills.map((skill) => ({
...skill,
isPracticing: masteredSet.has(skill.skillId),
})),
}
}
)
// Return context with the previous value for rollback
return { previousData }
},
// Rollback on error
onError: (_err, { playerId }, context) => {
if (context?.previousData) {
queryClient.setQueryData(curriculumKeys.detail(playerId), context.previousData)
}
},
// Always refetch after mutation to ensure sync with server
onSettled: (_, __, { playerId }) => {
queryClient.invalidateQueries({ queryKey: curriculumKeys.detail(playerId) })
},
})

View File

@ -245,6 +245,9 @@ importers:
react-textfit:
specifier: ^1.1.1
version: 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-use-measure:
specifier: ^2.1.7
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rehype-autolink-headings:
specifier: ^7.1.0
version: 7.1.0