refactor: extract OverallDifficultySlider and integrate DifficultyPresetDropdown
Part 2 of SmartModeControls.tsx refactoring Completed: 1. OverallDifficultySlider (236 lines) - Slider with preset markers - Complex interpolation between difficulty points - Finds nearest valid configuration - Full dark mode support 2. Integrated DifficultyPresetDropdown - Replaced inline dropdown JSX with component call - Cleaned up 270+ lines from SmartModeControls - Maintains all functionality with simpler interface Progress: - Extracted 3 components (808 lines total) - Reduced SmartModeControls complexity - Improved maintainability and testability Next steps: - Integrate MakeEasierHarderButtons component - Integrate OverallDifficultySlider component - Consider extracting DifficultySpaceMap (~390 lines) - Final reduction: 1505 → ~400-600 lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { css } from '../../../../../../../styled-system/css'
|
||||
import {
|
||||
DIFFICULTY_PROFILES,
|
||||
DIFFICULTY_PROGRESSION,
|
||||
calculateOverallDifficulty,
|
||||
calculateRegroupingIntensity,
|
||||
calculateScaffoldingLevel,
|
||||
REGROUPING_PROGRESSION,
|
||||
SCAFFOLDING_PROGRESSION,
|
||||
findNearestValidState,
|
||||
getProfileFromConfig,
|
||||
type DifficultyLevel,
|
||||
} from '../../difficultyProfiles'
|
||||
import type { DisplayRules } from '../../displayRules'
|
||||
|
||||
export interface OverallDifficultySliderProps {
|
||||
currentDifficulty: number
|
||||
onChange: (updates: {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
displayRules: DisplayRules
|
||||
difficultyProfile?: DifficultyLevel
|
||||
}) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function OverallDifficultySlider({
|
||||
currentDifficulty,
|
||||
onChange,
|
||||
isDark = false,
|
||||
}: OverallDifficultySliderProps) {
|
||||
const handleValueChange = (value: number[]) => {
|
||||
const targetDifficulty = value[0] / 10
|
||||
|
||||
// Calculate preset positions in 2D space
|
||||
const presetPoints = DIFFICULTY_PROGRESSION.map((presetName) => {
|
||||
const preset = DIFFICULTY_PROFILES[presetName]
|
||||
const regrouping = calculateRegroupingIntensity(
|
||||
preset.regrouping.pAnyStart,
|
||||
preset.regrouping.pAllStart
|
||||
)
|
||||
const scaffolding = calculateScaffoldingLevel(preset.displayRules, regrouping)
|
||||
const difficulty = calculateOverallDifficulty(
|
||||
preset.regrouping.pAnyStart,
|
||||
preset.regrouping.pAllStart,
|
||||
preset.displayRules
|
||||
)
|
||||
return {
|
||||
regrouping,
|
||||
scaffolding,
|
||||
difficulty,
|
||||
name: presetName,
|
||||
}
|
||||
})
|
||||
|
||||
// Find which path segment we're on and interpolate
|
||||
let idealRegrouping = 0
|
||||
let idealScaffolding = 10
|
||||
|
||||
for (let i = 0; i < presetPoints.length - 1; i++) {
|
||||
const start = presetPoints[i]
|
||||
const end = presetPoints[i + 1]
|
||||
|
||||
if (targetDifficulty >= start.difficulty && targetDifficulty <= end.difficulty) {
|
||||
// Interpolate between start and end
|
||||
const t = (targetDifficulty - start.difficulty) / (end.difficulty - start.difficulty)
|
||||
idealRegrouping = start.regrouping + t * (end.regrouping - start.regrouping)
|
||||
idealScaffolding = start.scaffolding + t * (end.scaffolding - start.scaffolding)
|
||||
console.log('[Slider] Interpolating between', start.name, 'and', end.name, {
|
||||
t,
|
||||
idealRegrouping,
|
||||
idealScaffolding,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edge cases (before first or after last preset)
|
||||
if (targetDifficulty < presetPoints[0].difficulty) {
|
||||
idealRegrouping = presetPoints[0].regrouping
|
||||
idealScaffolding = presetPoints[0].scaffolding
|
||||
} else if (targetDifficulty > presetPoints[presetPoints.length - 1].difficulty) {
|
||||
idealRegrouping = presetPoints[presetPoints.length - 1].regrouping
|
||||
idealScaffolding = presetPoints[presetPoints.length - 1].scaffolding
|
||||
}
|
||||
|
||||
// Find valid configuration closest to ideal point on path
|
||||
let closestConfig: {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
displayRules: any
|
||||
distance: number
|
||||
} | null = null
|
||||
|
||||
for (let regIdx = 0; regIdx < REGROUPING_PROGRESSION.length; regIdx++) {
|
||||
for (let scaffIdx = 0; scaffIdx < SCAFFOLDING_PROGRESSION.length; scaffIdx++) {
|
||||
const validState = findNearestValidState(regIdx, scaffIdx)
|
||||
if (validState.regroupingIdx !== regIdx || validState.scaffoldingIdx !== scaffIdx) {
|
||||
continue
|
||||
}
|
||||
|
||||
const regrouping = REGROUPING_PROGRESSION[regIdx]
|
||||
const displayRules = SCAFFOLDING_PROGRESSION[scaffIdx]
|
||||
|
||||
const actualRegrouping = calculateRegroupingIntensity(
|
||||
regrouping.pAnyStart,
|
||||
regrouping.pAllStart
|
||||
)
|
||||
const actualScaffolding = calculateScaffoldingLevel(displayRules, actualRegrouping)
|
||||
|
||||
// Euclidean distance to ideal point on pedagogical path
|
||||
const distance = Math.sqrt(
|
||||
(actualRegrouping - idealRegrouping) ** 2 + (actualScaffolding - idealScaffolding) ** 2
|
||||
)
|
||||
|
||||
if (closestConfig === null || distance < closestConfig.distance) {
|
||||
closestConfig = {
|
||||
pAnyStart: regrouping.pAnyStart,
|
||||
pAllStart: regrouping.pAllStart,
|
||||
displayRules,
|
||||
distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closestConfig) {
|
||||
console.log('[Slider] Closest config:', {
|
||||
...closestConfig,
|
||||
regrouping: calculateRegroupingIntensity(closestConfig.pAnyStart, closestConfig.pAllStart),
|
||||
scaffolding: calculateScaffoldingLevel(
|
||||
closestConfig.displayRules,
|
||||
calculateRegroupingIntensity(closestConfig.pAnyStart, closestConfig.pAllStart)
|
||||
),
|
||||
})
|
||||
const matchedProfile = getProfileFromConfig(
|
||||
closestConfig.pAllStart,
|
||||
closestConfig.pAnyStart,
|
||||
closestConfig.displayRules
|
||||
)
|
||||
onChange({
|
||||
pAnyStart: closestConfig.pAnyStart,
|
||||
pAllStart: closestConfig.pAllStart,
|
||||
displayRules: closestConfig.displayRules,
|
||||
difficultyProfile: matchedProfile !== 'custom' ? matchedProfile : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({ mb: '2' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
mb: '1.5',
|
||||
})}
|
||||
>
|
||||
Overall Difficulty: {currentDifficulty.toFixed(1)} / 10
|
||||
</div>
|
||||
|
||||
{/* Difficulty Slider */}
|
||||
<div className={css({ position: 'relative', px: '2' })}>
|
||||
<Slider.Root
|
||||
value={[currentDifficulty * 10]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleValueChange}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
h: '8',
|
||||
})}
|
||||
>
|
||||
<Slider.Track
|
||||
className={css({
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
h: '2',
|
||||
rounded: 'full',
|
||||
})}
|
||||
>
|
||||
<Slider.Range
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bg: 'brand.500',
|
||||
h: 'full',
|
||||
rounded: 'full',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Preset markers on track */}
|
||||
{DIFFICULTY_PROGRESSION.map((profileName) => {
|
||||
const p = DIFFICULTY_PROFILES[profileName]
|
||||
const presetDifficulty = calculateOverallDifficulty(
|
||||
p.regrouping.pAnyStart,
|
||||
p.regrouping.pAllStart,
|
||||
p.displayRules
|
||||
)
|
||||
const position = (presetDifficulty / 10) * 100
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profileName}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: `${position}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
w: '1.5',
|
||||
h: '1.5',
|
||||
bg: 'gray.400',
|
||||
rounded: 'full',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
})}
|
||||
title={p.label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Slider.Track>
|
||||
|
||||
<Slider.Thumb
|
||||
className={css({
|
||||
display: 'block',
|
||||
w: '5',
|
||||
h: '5',
|
||||
bg: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.500',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
_hover: {
|
||||
bg: 'brand.50',
|
||||
},
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import type { DisplayRules } from '../../displayRules'
|
||||
import { getScaffoldingSummary } from './utils'
|
||||
import { RegroupingFrequencyPanel } from './RegroupingFrequencyPanel'
|
||||
import { DigitRangeSection } from './DigitRangeSection'
|
||||
import { DifficultyPresetDropdown } from './DifficultyPresetDropdown'
|
||||
import { MakeEasierHarderButtons } from './MakeEasierHarderButtons'
|
||||
import { OverallDifficultySlider } from './OverallDifficultySlider'
|
||||
|
||||
export interface SmartModeControlsProps {
|
||||
formState: WorksheetFormState
|
||||
@@ -273,277 +276,17 @@ export function SmartModeControls({ formState, onChange, isDark = false }: Smart
|
||||
return (
|
||||
<>
|
||||
{/* Preset Selector Dropdown */}
|
||||
<div className={css({ mb: '3' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Difficulty Preset
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-preset-dropdown"
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '24',
|
||||
px: '3',
|
||||
py: '2.5',
|
||||
border: '2px solid',
|
||||
borderColor: isCustom
|
||||
? (isDark ? 'orange.500' : 'orange.400')
|
||||
: (isDark ? 'gray.600' : 'gray.300'),
|
||||
bg: isCustom
|
||||
? (isDark ? 'orange.900' : 'orange.50')
|
||||
: (isDark ? 'gray.800' : 'white'),
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
textAlign: 'left',
|
||||
gap: '2',
|
||||
_hover: {
|
||||
borderColor: isCustom ? 'orange.500' : 'brand.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: hoverPreview
|
||||
? (isDark ? 'orange.300' : 'orange.700')
|
||||
: (isDark ? 'gray.200' : 'gray.700'),
|
||||
})}
|
||||
>
|
||||
{hoverPreview ? (
|
||||
<>
|
||||
{hoverPreview.matchedProfile !== 'custom' ? (
|
||||
<>
|
||||
{DIFFICULTY_PROFILES[hoverPreview.matchedProfile].label}{' '}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'orange.500',
|
||||
})}
|
||||
>
|
||||
(hover preview)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
✨ Custom{' '}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'orange.500',
|
||||
})}
|
||||
>
|
||||
(hover preview)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : isCustom ? (
|
||||
nearestEasier && nearestHarder ? (
|
||||
<>
|
||||
{DIFFICULTY_PROFILES[nearestEasier].label}
|
||||
{' ↔ '}
|
||||
{DIFFICULTY_PROFILES[nearestHarder].label}
|
||||
</>
|
||||
) : (
|
||||
'✨ Custom'
|
||||
)
|
||||
) : currentProfile ? (
|
||||
DIFFICULTY_PROFILES[currentProfile].label
|
||||
) : (
|
||||
'Early Learner'
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: hoverPreview
|
||||
? (isDark ? 'orange.400' : 'orange.600')
|
||||
: isCustom
|
||||
? (isDark ? 'orange.400' : 'orange.600')
|
||||
: (isDark ? 'gray.400' : 'gray.500'),
|
||||
lineHeight: '1.3',
|
||||
h: '14',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{hoverPreview ? (
|
||||
(() => {
|
||||
const regroupingPercent = Math.round(hoverPreview.pAnyStart * 100)
|
||||
const scaffoldingSummary = getScaffoldingSummary(
|
||||
hoverPreview.displayRules,
|
||||
formState.operator
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<div>{regroupingPercent}% regrouping</div>
|
||||
{scaffoldingSummary}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : isCustom ? (
|
||||
customDescription
|
||||
) : currentProfile ? (
|
||||
(() => {
|
||||
const preset = DIFFICULTY_PROFILES[currentProfile]
|
||||
const regroupingPercent = Math.round(
|
||||
preset.regrouping.pAnyStart * 100
|
||||
)
|
||||
const scaffoldingSummary = getScaffoldingSummary(
|
||||
preset.displayRules,
|
||||
formState.operator
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<div>{regroupingPercent}% regrouping</div>
|
||||
{scaffoldingSummary}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<div>25% regrouping</div>
|
||||
<div>
|
||||
Always: carry boxes, answer boxes, place value colors, ten-frames
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className={css({
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
rounded: 'lg',
|
||||
shadow: 'modal',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
p: '2',
|
||||
minW: '64',
|
||||
maxH: '96',
|
||||
overflowY: 'auto',
|
||||
zIndex: 50,
|
||||
})}
|
||||
sideOffset={5}
|
||||
>
|
||||
{DIFFICULTY_PROGRESSION.map((presetName) => {
|
||||
const preset = DIFFICULTY_PROFILES[presetName]
|
||||
const isSelected = currentProfile === presetName && !isCustom
|
||||
|
||||
// Generate preset description
|
||||
const regroupingPercent = Math.round(
|
||||
calculateRegroupingIntensity(
|
||||
preset.regrouping.pAnyStart,
|
||||
preset.regrouping.pAllStart
|
||||
) * 10
|
||||
)
|
||||
const scaffoldingSummary = getScaffoldingSummary(
|
||||
preset.displayRules,
|
||||
formState.operator
|
||||
)
|
||||
const presetDescription = (
|
||||
<>
|
||||
<div>{regroupingPercent}% regrouping</div>
|
||||
{scaffoldingSummary}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={presetName}
|
||||
data-action={`select-preset-${presetName}`}
|
||||
onSelect={() => {
|
||||
// Apply preset configuration
|
||||
onChange({
|
||||
difficultyProfile: presetName,
|
||||
pAnyStart: preset.regrouping.pAnyStart,
|
||||
pAllStart: preset.regrouping.pAllStart,
|
||||
displayRules: preset.displayRules,
|
||||
})
|
||||
}}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1',
|
||||
px: '3',
|
||||
py: '2.5',
|
||||
rounded: 'md',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
bg: isSelected ? 'brand.50' : 'transparent',
|
||||
_hover: {
|
||||
bg: 'brand.50',
|
||||
},
|
||||
_focus: {
|
||||
bg: 'brand.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isSelected
|
||||
? 'brand.700'
|
||||
: (isDark ? 'gray.200' : 'gray.700'),
|
||||
})}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isSelected
|
||||
? 'brand.600'
|
||||
: (isDark ? 'gray.400' : 'gray.500'),
|
||||
lineHeight: '1.3',
|
||||
})}
|
||||
>
|
||||
{presetDescription}
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
<DifficultyPresetDropdown
|
||||
currentProfile={currentProfile}
|
||||
isCustom={isCustom}
|
||||
nearestEasier={nearestEasier}
|
||||
nearestHarder={nearestHarder}
|
||||
customDescription={customDescription}
|
||||
hoverPreview={hoverPreview}
|
||||
operator={formState.operator}
|
||||
onChange={onChange}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Make Easier/Harder buttons with preview */}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user