feat: integrate unified skill configuration interface into practice step editor

Updates PracticeStepEditor to use the new SkillSelector component with unified skill modes (off/allowed/target/forbidden) instead of separate required/target skill sections. This provides a more intuitive and compact interface for configuring practice steps.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-21 08:09:01 -05:00
parent fc79540f78
commit 9305f11a01

View File

@@ -1,10 +1,13 @@
'use client'
import { useState, useCallback } from 'react'
import { css } from '../../styled-system/css'
import { vstack, hstack } from '../../styled-system/patterns'
import { PracticeStep, SkillSet, createBasicSkillSet, createEmptySkillSet } from '../../types/tutorial'
import { SkillSelector } from './SkillSelector'
import { useState, useCallback, useEffect } from 'react'
import { css } from '../../../styled-system/css'
import { vstack, hstack } from '../../../styled-system/patterns'
import { PracticeStep, createBasicSkillSet } from '../../types/tutorial'
import { SkillSelector, SkillConfiguration } from './SkillSelector'
import { validatePracticeStepConfiguration, generateSingleProblem } from '../../utils/problemGenerator'
import { createBasicAllowedConfiguration, skillConfigurationToSkillSets } from '../../utils/skillConfiguration'
import type { GeneratedProblem } from '../../utils/problemGenerator'
interface PracticeStepEditorProps {
step: PracticeStep
@@ -20,21 +23,25 @@ export function PracticeStepEditor({
className
}: PracticeStepEditorProps) {
const [showAdvanced, setShowAdvanced] = useState(false)
const [sampleProblems, setSampleProblems] = useState<GeneratedProblem[]>([])
const [validationResult, setValidationResult] = useState<ReturnType<typeof validatePracticeStepConfiguration> | null>(null)
const [skillConfig, setSkillConfig] = useState<SkillConfiguration>(() => {
// Initialize with a basic configuration for new steps or convert from existing
return createBasicAllowedConfiguration()
})
const updateStep = useCallback((updates: Partial<PracticeStep>) => {
onChange({ ...step, ...updates })
}, [step, onChange])
const updateRequiredSkills = useCallback((skills: SkillSet) => {
updateStep({ requiredSkills: skills })
}, [updateStep])
const updateTargetSkills = useCallback((skills: Partial<SkillSet>) => {
updateStep({ targetSkills: skills })
}, [updateStep])
const updateForbiddenSkills = useCallback((skills: Partial<SkillSet>) => {
updateStep({ forbiddenSkills: skills })
const updateSkillConfiguration = useCallback((config: SkillConfiguration) => {
setSkillConfig(config)
const { required, target, forbidden } = skillConfigurationToSkillSets(config)
updateStep({
requiredSkills: required,
targetSkills: target,
forbiddenSkills: forbidden
})
}, [updateStep])
// Convert partial skill sets to full skill sets for the selector
@@ -63,33 +70,71 @@ export function PracticeStepEditor({
}
}
// Validate configuration when step changes
useEffect(() => {
const result = validatePracticeStepConfiguration(step)
setValidationResult(result)
}, [step])
// Generate sample problems
const generateSampleProblems = useCallback(() => {
const samples: GeneratedProblem[] = []
const maxSamples = Math.min(3, step.problemCount) // Show up to 3 samples
const { required, target, forbidden } = skillConfigurationToSkillSets(skillConfig)
for (let i = 0; i < maxSamples; i++) {
const problem = generateSingleProblem(
{
numberRange: step.numberRange || { min: 1, max: 9 },
maxSum: step.sumConstraints?.maxSum,
minSum: step.sumConstraints?.minSum,
maxTerms: step.maxTerms,
problemCount: step.problemCount
},
required,
target,
forbidden,
50 // More attempts for samples
)
if (problem) {
samples.push(problem)
}
}
setSampleProblems(samples)
}, [step, skillConfig])
const presetConfigurations = [
{
name: 'Basic Addition (1-4)',
skills: createBasicSkillSet()
name: 'Basic Addition Only',
config: {
...createBasicAllowedConfiguration(),
basic: { directAddition: 'allowed', heavenBead: 'off', simpleCombinations: 'off' }
} as SkillConfiguration
},
{
name: 'With Heaven Bead',
skills: {
...createBasicSkillSet(),
basic: { ...createBasicSkillSet().basic, heavenBead: true, simpleCombinations: true }
}
name: 'Practice Heaven Bead',
config: {
...createBasicAllowedConfiguration(),
basic: { directAddition: 'allowed', heavenBead: 'target', simpleCombinations: 'allowed' }
} as SkillConfiguration
},
{
name: 'First Five Complement (4=5-1)',
skills: {
...createBasicSkillSet(),
basic: { directAddition: true, heavenBead: true, simpleCombinations: true },
fiveComplements: { ...createEmptySkillSet().fiveComplements, "4=5-1": true }
}
name: 'Learn Five Complements',
config: {
...createBasicAllowedConfiguration(),
basic: { directAddition: 'allowed', heavenBead: 'allowed', simpleCombinations: 'allowed' },
fiveComplements: { "4=5-1": 'target', "3=5-2": 'target', "2=5-3": 'off', "1=5-4": 'off' }
} as SkillConfiguration
},
{
name: 'All Five Complements',
skills: {
...createBasicSkillSet(),
basic: { directAddition: true, heavenBead: true, simpleCombinations: true },
fiveComplements: { "4=5-1": true, "3=5-2": true, "2=5-3": true, "1=5-4": true }
}
name: 'All Basic Skills',
config: {
...createBasicAllowedConfiguration(),
basic: { directAddition: 'allowed', heavenBead: 'allowed', simpleCombinations: 'allowed' },
fiveComplements: { "4=5-1": 'allowed', "3=5-2": 'allowed', "2=5-3": 'allowed', "1=5-4": 'allowed' }
} as SkillConfiguration
}
]
@@ -260,7 +305,7 @@ export function PracticeStepEditor({
{presetConfigurations.map((preset) => (
<button
key={preset.name}
onClick={() => updateRequiredSkills(preset.skills)}
onClick={() => updateSkillConfiguration(preset.config)}
className={css({
px: 3,
py: 2,
@@ -280,12 +325,11 @@ export function PracticeStepEditor({
</div>
</div>
{/* Required Skills */}
{/* Unified Skill Configuration */}
<SkillSelector
skills={step.requiredSkills}
onChange={updateRequiredSkills}
mode="required"
title="Required Skills (User Must Know)"
skills={skillConfig}
onChange={updateSkillConfiguration}
title="Skill Configuration"
/>
{/* Advanced Options Toggle */}
@@ -304,172 +348,203 @@ export function PracticeStepEditor({
_hover: { bg: 'gray.200' }
})}
>
{showAdvanced ? '▼' : '▶'} Advanced Options
{showAdvanced ? '▼' : '▶'} Advanced Constraints
</button>
{/* Advanced Options */}
{showAdvanced && (
<div className={vstack({ gap: 4, alignItems: 'stretch' })}>
{/* Target Skills */}
<SkillSelector
skills={targetSkillsForSelector}
onChange={(skills) => updateTargetSkills(skills)}
mode="target"
title="Target Skills (Specific Practice Focus)"
/>
<div className={vstack({ gap: 3, alignItems: 'stretch' })}>
<h5 className={css({
fontSize: 'md',
fontWeight: 'medium',
color: 'gray.700'
})}>
Number & Sum Constraints
</h5>
{/* Constraints */}
<div className={vstack({ gap: 3, alignItems: 'stretch' })}>
<h5 className={css({
fontSize: 'md',
fontWeight: 'medium',
color: 'gray.700'
})}>
Problem Constraints
</h5>
<div className={hstack({ gap: 4 })}>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Number Range Min
</label>
<input
type="number"
min={1}
max={99}
value={step.numberRange?.min || 1}
onChange={(e) => updateStep({
numberRange: {
...step.numberRange,
min: parseInt(e.target.value) || 1,
max: step.numberRange?.max || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Number Range Max
</label>
<input
type="number"
min={1}
max={99}
value={step.numberRange?.max || 9}
onChange={(e) => updateStep({
numberRange: {
min: step.numberRange?.min || 1,
max: parseInt(e.target.value) || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
<div className={hstack({ gap: 4 })}>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Number Range Min
</label>
<input
type="number"
min={1}
max={99}
value={step.numberRange?.min || 1}
onChange={(e) => updateStep({
numberRange: {
...step.numberRange,
min: parseInt(e.target.value) || 1,
max: step.numberRange?.max || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
<div className={hstack({ gap: 4 })}>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Maximum Sum
</label>
<input
type="number"
min={1}
max={999}
value={step.sumConstraints?.maxSum || 9}
onChange={(e) => updateStep({
sumConstraints: {
...step.sumConstraints,
maxSum: parseInt(e.target.value) || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Number Range Max
</label>
<input
type="number"
min={1}
max={99}
value={step.numberRange?.max || 9}
onChange={(e) => updateStep({
numberRange: {
min: step.numberRange?.min || 1,
max: parseInt(e.target.value) || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
</div>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Minimum Sum (Optional)
</label>
<input
type="number"
min={1}
max={999}
value={step.sumConstraints?.minSum || ''}
onChange={(e) => updateStep({
sumConstraints: {
...step.sumConstraints,
minSum: e.target.value ? parseInt(e.target.value) : undefined
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
placeholder="Leave empty for no minimum"
/>
</div>
<div className={hstack({ gap: 4 })}>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Maximum Sum
</label>
<input
type="number"
min={1}
max={999}
value={step.sumConstraints?.maxSum || 9}
onChange={(e) => updateStep({
sumConstraints: {
...step.sumConstraints,
maxSum: parseInt(e.target.value) || 9
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
/>
</div>
<div className={css({ flex: 1 })}>
<label className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
mb: 1
})}>
Minimum Sum (Optional)
</label>
<input
type="number"
min={1}
max={999}
value={step.sumConstraints?.minSum || ''}
onChange={(e) => updateStep({
sumConstraints: {
...step.sumConstraints,
minSum: e.target.value ? parseInt(e.target.value) : undefined
}
})}
className={css({
w: 'full',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'md',
fontSize: 'sm'
})}
placeholder="Leave empty for no minimum"
/>
</div>
</div>
</div>
)}
{/* Problem Preview */}
{/* Validation Results */}
{validationResult && (
<div className={css({
p: 3,
bg: validationResult.isValid ? 'green.50' : 'yellow.50',
border: '1px solid',
borderColor: validationResult.isValid ? 'green.200' : 'yellow.200',
rounded: 'md'
})}>
<h5 className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: validationResult.isValid ? 'green.800' : 'yellow.800',
mb: 2
})}>
{validationResult.isValid ? '✅ Configuration Valid' : '⚠️ Configuration Warnings'}
</h5>
{validationResult.warnings.length > 0 && (
<div className={css({ mb: 2 })}>
<strong className={css({ fontSize: 'xs', color: 'yellow.800' })}>Warnings:</strong>
<ul className={css({ fontSize: 'xs', color: 'yellow.700', pl: 4, mt: 1 })}>
{validationResult.warnings.map((warning, index) => (
<li key={index}> {warning}</li>
))}
</ul>
</div>
)}
{validationResult.suggestions.length > 0 && (
<div>
<strong className={css({ fontSize: 'xs', color: 'blue.800' })}>Suggestions:</strong>
<ul className={css({ fontSize: 'xs', color: 'blue.700', pl: 4, mt: 1 })}>
{validationResult.suggestions.map((suggestion, index) => (
<li key={index}> {suggestion}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Sample Problems Preview */}
<div className={css({
p: 3,
bg: 'blue.50',
@@ -477,15 +552,117 @@ export function PracticeStepEditor({
borderColor: 'blue.200',
rounded: 'md'
})}>
<h5 className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: 'blue.800',
mb: 2
})}>
Configuration Summary
</h5>
<div className={hstack({ justifyContent: 'space-between', alignItems: 'center', mb: 2 })}>
<h5 className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: 'blue.800'
})}>
Sample Problems Preview
</h5>
<button
onClick={generateSampleProblems}
className={css({
px: 2,
py: 1,
fontSize: 'xs',
bg: 'blue.500',
color: 'white',
border: 'none',
rounded: 'sm',
cursor: 'pointer',
_hover: { bg: 'blue.600' }
})}
>
Generate
</button>
</div>
{sampleProblems.length > 0 ? (
<div className={hstack({ gap: 3, flexWrap: 'wrap', alignItems: 'flex-start' })}>
{sampleProblems.map((problem, index) => (
<div key={problem.id} className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1
})}>
{/* Compact vertical problem display */}
<div className={css({
textAlign: 'right',
fontFamily: 'mono',
fontSize: 'sm',
fontWeight: 'bold',
bg: 'gray.50',
px: 2,
py: 1,
rounded: 'sm',
border: '1px solid',
borderColor: 'gray.200',
minW: '40px'
})}>
{problem.terms.map((term, termIndex) => (
<div key={termIndex} className={css({ lineHeight: 'tight' })}>
{term}
</div>
))}
<div className={css({
borderTop: '1px solid',
borderColor: 'gray.400',
mt: 0.5,
pt: 0.5
})}>
{problem.answer}
</div>
</div>
{/* Difficulty badge below */}
<span className={css({
px: 1,
py: 0.5,
rounded: 'xs',
fontSize: 'xs',
bg: problem.difficulty === 'easy' ? 'green.100' :
problem.difficulty === 'medium' ? 'yellow.100' : 'red.100',
color: problem.difficulty === 'easy' ? 'green.800' :
problem.difficulty === 'medium' ? 'yellow.800' : 'red.800'
})}>
{problem.difficulty}
</span>
</div>
))}
</div>
) : (
<div className={css({
fontSize: 'xs',
color: 'blue.700',
textAlign: 'center',
py: 2
})}>
Click "Generate" to see sample problems
</div>
)}
{/* Skills summary below problems */}
{sampleProblems.length > 0 && (
<div className={css({
mt: 2,
pt: 2,
borderTop: '1px solid',
borderColor: 'blue.200',
fontSize: 'xs',
color: 'blue.600'
})}>
<strong>Skills used:</strong> {[...new Set(sampleProblems.flatMap(p => p.requiredSkills))].join(', ')}
</div>
)}
{/* Configuration Summary */}
<div className={css({
mt: 3,
pt: 2,
borderTop: '1px solid',
borderColor: 'blue.200',
fontSize: 'xs',
color: 'blue.700',
lineHeight: 'relaxed'