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:
Thomas Hallock
2025-11-11 05:55:15 -06:00
parent 4d1c2c1e79
commit 783f269a2f
2 changed files with 269 additions and 271 deletions

View File

@@ -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>
)
}

View File

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