feat: create unified skill configuration interface with intuitive modes
- Replace separate required/target/forbidden sections with single interface - Implement 4 clear skill modes: Off, Allowed, Target, Forbidden - Add click-to-cycle interaction with visual icons and color coding - Provide conversion utilities between old and new configuration formats - Include clear legend explaining each mode's purpose - Maintain backward compatibility with existing problem generation logic 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,54 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { hstack, vstack } from '../../styled-system/patterns'
|
||||
import { SkillSet } from '../../types/tutorial'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { hstack, vstack } from '../../../styled-system/patterns'
|
||||
import { SkillConfiguration, SkillMode } from '../../utils/skillConfiguration'
|
||||
|
||||
// Export for convenience
|
||||
export type { SkillConfiguration, SkillMode } from '../../utils/skillConfiguration'
|
||||
|
||||
interface SkillSelectorProps {
|
||||
skills: SkillSet
|
||||
onChange: (skills: SkillSet) => void
|
||||
mode?: 'required' | 'target' | 'forbidden'
|
||||
skills: SkillConfiguration
|
||||
onChange: (skills: SkillConfiguration) => void
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SkillMode = 'required' | 'target' | 'forbidden'
|
||||
|
||||
export function SkillSelector({
|
||||
skills,
|
||||
onChange,
|
||||
mode = 'required',
|
||||
title = 'Skills',
|
||||
title = 'Skill Configuration',
|
||||
className
|
||||
}: SkillSelectorProps) {
|
||||
|
||||
const updateSkill = useCallback((category: keyof SkillSet, skill: string, enabled: boolean) => {
|
||||
const updateSkill = useCallback((category: keyof SkillConfiguration, skill: string, mode: SkillMode) => {
|
||||
const newSkills = { ...skills }
|
||||
|
||||
if (category === 'basic') {
|
||||
newSkills.basic = { ...newSkills.basic, [skill]: enabled }
|
||||
newSkills.basic = { ...newSkills.basic, [skill]: mode }
|
||||
} else if (category === 'fiveComplements') {
|
||||
newSkills.fiveComplements = { ...newSkills.fiveComplements, [skill]: enabled }
|
||||
newSkills.fiveComplements = { ...newSkills.fiveComplements, [skill]: mode }
|
||||
} else if (category === 'tenComplements') {
|
||||
newSkills.tenComplements = { ...newSkills.tenComplements, [skill]: enabled }
|
||||
newSkills.tenComplements = { ...newSkills.tenComplements, [skill]: mode }
|
||||
}
|
||||
|
||||
onChange(newSkills)
|
||||
}, [skills, onChange])
|
||||
|
||||
const getModeStyles = (skillEnabled: boolean): string => {
|
||||
if (!skillEnabled) {
|
||||
return css({
|
||||
bg: 'gray.100',
|
||||
color: 'gray.400',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'required':
|
||||
const getModeStyles = (skillMode: SkillMode): string => {
|
||||
switch (skillMode) {
|
||||
case 'off':
|
||||
return css({
|
||||
bg: 'gray.100',
|
||||
color: 'gray.400',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})
|
||||
case 'allowed':
|
||||
return css({
|
||||
bg: 'green.100',
|
||||
color: 'green.800',
|
||||
@@ -79,19 +76,35 @@ export function SkillSelector({
|
||||
}
|
||||
}
|
||||
|
||||
const getModeIcon = (skillMode: SkillMode): string => {
|
||||
switch (skillMode) {
|
||||
case 'off': return '⚫'
|
||||
case 'allowed': return '✅'
|
||||
case 'target': return '🎯'
|
||||
case 'forbidden': return '❌'
|
||||
default: return '⚫'
|
||||
}
|
||||
}
|
||||
|
||||
const getNextMode = (currentMode: SkillMode): SkillMode => {
|
||||
const modes: SkillMode[] = ['off', 'allowed', 'target', 'forbidden']
|
||||
const currentIndex = modes.indexOf(currentMode)
|
||||
return modes[(currentIndex + 1) % modes.length]
|
||||
}
|
||||
|
||||
const SkillButton = ({
|
||||
category,
|
||||
skill,
|
||||
label,
|
||||
enabled
|
||||
mode
|
||||
}: {
|
||||
category: keyof SkillSet
|
||||
category: keyof SkillConfiguration
|
||||
skill: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
mode: SkillMode
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => updateSkill(category, skill, !enabled)}
|
||||
onClick={() => updateSkill(category, skill, getNextMode(mode))}
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 2,
|
||||
@@ -100,13 +113,15 @@ export function SkillSelector({
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: { opacity: 0.8 }
|
||||
}, getModeStyles(enabled))}
|
||||
_hover: { opacity: 0.8 },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
}, getModeStyles(mode))}
|
||||
title={`Click to cycle: ${mode} → ${getNextMode(mode)}`}
|
||||
>
|
||||
{enabled && mode === 'required' && '✅ '}
|
||||
{enabled && mode === 'target' && '🎯 '}
|
||||
{enabled && mode === 'forbidden' && '❌ '}
|
||||
{label}
|
||||
<span>{getModeIcon(mode)}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -143,19 +158,19 @@ export function SkillSelector({
|
||||
category="basic"
|
||||
skill="directAddition"
|
||||
label="Direct Addition (1-4)"
|
||||
enabled={skills.basic.directAddition}
|
||||
mode={skills.basic.directAddition}
|
||||
/>
|
||||
<SkillButton
|
||||
category="basic"
|
||||
skill="heavenBead"
|
||||
label="Heaven Bead (5)"
|
||||
enabled={skills.basic.heavenBead}
|
||||
mode={skills.basic.heavenBead}
|
||||
/>
|
||||
<SkillButton
|
||||
category="basic"
|
||||
skill="simpleCombinations"
|
||||
label="Simple Combinations (6-9)"
|
||||
enabled={skills.basic.simpleCombinations}
|
||||
mode={skills.basic.simpleCombinations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,25 +190,25 @@ export function SkillSelector({
|
||||
category="fiveComplements"
|
||||
skill="4=5-1"
|
||||
label="4 = 5 - 1"
|
||||
enabled={skills.fiveComplements["4=5-1"]}
|
||||
mode={skills.fiveComplements["4=5-1"]}
|
||||
/>
|
||||
<SkillButton
|
||||
category="fiveComplements"
|
||||
skill="3=5-2"
|
||||
label="3 = 5 - 2"
|
||||
enabled={skills.fiveComplements["3=5-2"]}
|
||||
mode={skills.fiveComplements["3=5-2"]}
|
||||
/>
|
||||
<SkillButton
|
||||
category="fiveComplements"
|
||||
skill="2=5-3"
|
||||
label="2 = 5 - 3"
|
||||
enabled={skills.fiveComplements["2=5-3"]}
|
||||
mode={skills.fiveComplements["2=5-3"]}
|
||||
/>
|
||||
<SkillButton
|
||||
category="fiveComplements"
|
||||
skill="1=5-4"
|
||||
label="1 = 5 - 4"
|
||||
enabled={skills.fiveComplements["1=5-4"]}
|
||||
mode={skills.fiveComplements["1=5-4"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,13 +224,13 @@ export function SkillSelector({
|
||||
Ten Complements
|
||||
</h5>
|
||||
<div className={hstack({ gap: 2, flexWrap: 'wrap' })}>
|
||||
{Object.entries(skills.tenComplements).map(([complement, enabled]) => (
|
||||
{Object.entries(skills.tenComplements).map(([complement, mode]) => (
|
||||
<SkillButton
|
||||
key={complement}
|
||||
category="tenComplements"
|
||||
skill={complement}
|
||||
label={complement}
|
||||
enabled={enabled}
|
||||
mode={mode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -231,9 +246,21 @@ export function SkillSelector({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{mode === 'required' && '✅ Skills the user must know to solve problems'}
|
||||
{mode === 'target' && '🎯 Skills to specifically practice (generates problems requiring these)'}
|
||||
{mode === 'forbidden' && '❌ Skills the user hasn\'t learned yet (problems won\'t require these)'}
|
||||
<div className={css({ fontWeight: 'medium', mb: 2 })}>Click skills to cycle through modes:</div>
|
||||
<div className={hstack({ gap: 4, flexWrap: 'wrap' })}>
|
||||
<div className={hstack({ gap: 1, alignItems: 'center' })}>
|
||||
<span>⚫</span><span>Off - Not used</span>
|
||||
</div>
|
||||
<div className={hstack({ gap: 1, alignItems: 'center' })}>
|
||||
<span>✅</span><span>Allowed - Can be used</span>
|
||||
</div>
|
||||
<div className={hstack({ gap: 1, alignItems: 'center' })}>
|
||||
<span>🎯</span><span>Target - Focus practice</span>
|
||||
</div>
|
||||
<div className={hstack({ gap: 1, alignItems: 'center' })}>
|
||||
<span>❌</span><span>Forbidden - Not learned yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
210
apps/web/src/utils/skillConfiguration.ts
Normal file
210
apps/web/src/utils/skillConfiguration.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { SkillSet } from '../types/tutorial'
|
||||
|
||||
export type SkillMode = 'off' | 'allowed' | 'target' | 'forbidden'
|
||||
|
||||
export interface SkillConfiguration {
|
||||
basic: {
|
||||
directAddition: SkillMode
|
||||
heavenBead: SkillMode
|
||||
simpleCombinations: SkillMode
|
||||
}
|
||||
fiveComplements: {
|
||||
"4=5-1": SkillMode
|
||||
"3=5-2": SkillMode
|
||||
"2=5-3": SkillMode
|
||||
"1=5-4": SkillMode
|
||||
}
|
||||
tenComplements: {
|
||||
"9=10-1": SkillMode
|
||||
"8=10-2": SkillMode
|
||||
"7=10-3": SkillMode
|
||||
"6=10-4": SkillMode
|
||||
"5=10-5": SkillMode
|
||||
"4=10-6": SkillMode
|
||||
"3=10-7": SkillMode
|
||||
"2=10-8": SkillMode
|
||||
"1=10-9": SkillMode
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for new skill configuration
|
||||
export function createDefaultSkillConfiguration(): SkillConfiguration {
|
||||
return {
|
||||
basic: {
|
||||
directAddition: 'allowed',
|
||||
heavenBead: 'off',
|
||||
simpleCombinations: 'off'
|
||||
},
|
||||
fiveComplements: {
|
||||
"4=5-1": 'off',
|
||||
"3=5-2": 'off',
|
||||
"2=5-3": 'off',
|
||||
"1=5-4": 'off'
|
||||
},
|
||||
tenComplements: {
|
||||
"9=10-1": 'off',
|
||||
"8=10-2": 'off',
|
||||
"7=10-3": 'off',
|
||||
"6=10-4": 'off',
|
||||
"5=10-5": 'off',
|
||||
"4=10-6": 'off',
|
||||
"3=10-7": 'off',
|
||||
"2=10-8": 'off',
|
||||
"1=10-9": 'off'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBasicAllowedConfiguration(): SkillConfiguration {
|
||||
return {
|
||||
basic: {
|
||||
directAddition: 'allowed',
|
||||
heavenBead: 'allowed',
|
||||
simpleCombinations: 'off'
|
||||
},
|
||||
fiveComplements: {
|
||||
"4=5-1": 'off',
|
||||
"3=5-2": 'off',
|
||||
"2=5-3": 'off',
|
||||
"1=5-4": 'off'
|
||||
},
|
||||
tenComplements: {
|
||||
"9=10-1": 'off',
|
||||
"8=10-2": 'off',
|
||||
"7=10-3": 'off',
|
||||
"6=10-4": 'off',
|
||||
"5=10-5": 'off',
|
||||
"4=10-6": 'off',
|
||||
"3=10-7": 'off',
|
||||
"2=10-8": 'off',
|
||||
"1=10-9": 'off'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert between old and new formats
|
||||
export function skillConfigurationToSkillSets(config: SkillConfiguration): {
|
||||
required: SkillSet
|
||||
target: Partial<SkillSet>
|
||||
forbidden: Partial<SkillSet>
|
||||
} {
|
||||
const required: SkillSet = {
|
||||
basic: {
|
||||
directAddition: false,
|
||||
heavenBead: false,
|
||||
simpleCombinations: false
|
||||
},
|
||||
fiveComplements: {
|
||||
"4=5-1": false,
|
||||
"3=5-2": false,
|
||||
"2=5-3": false,
|
||||
"1=5-4": false
|
||||
},
|
||||
tenComplements: {
|
||||
"9=10-1": false,
|
||||
"8=10-2": false,
|
||||
"7=10-3": false,
|
||||
"6=10-4": false,
|
||||
"5=10-5": false,
|
||||
"4=10-6": false,
|
||||
"3=10-7": false,
|
||||
"2=10-8": false,
|
||||
"1=10-9": false
|
||||
}
|
||||
}
|
||||
|
||||
const target: Partial<SkillSet> = { basic: {}, fiveComplements: {}, tenComplements: {} }
|
||||
const forbidden: Partial<SkillSet> = { basic: {}, fiveComplements: {}, tenComplements: {} }
|
||||
|
||||
// Basic skills
|
||||
Object.entries(config.basic).forEach(([skill, mode]) => {
|
||||
if (mode === 'allowed' || mode === 'target') {
|
||||
required.basic[skill as keyof typeof required.basic] = true
|
||||
}
|
||||
if (mode === 'target') {
|
||||
target.basic![skill as keyof typeof target.basic] = true
|
||||
}
|
||||
if (mode === 'forbidden') {
|
||||
forbidden.basic![skill as keyof typeof forbidden.basic] = true
|
||||
}
|
||||
})
|
||||
|
||||
// Five complements
|
||||
Object.entries(config.fiveComplements).forEach(([skill, mode]) => {
|
||||
if (mode === 'allowed' || mode === 'target') {
|
||||
required.fiveComplements[skill as keyof typeof required.fiveComplements] = true
|
||||
}
|
||||
if (mode === 'target') {
|
||||
target.fiveComplements![skill as keyof typeof target.fiveComplements] = true
|
||||
}
|
||||
if (mode === 'forbidden') {
|
||||
forbidden.fiveComplements![skill as keyof typeof forbidden.fiveComplements] = true
|
||||
}
|
||||
})
|
||||
|
||||
// Ten complements
|
||||
Object.entries(config.tenComplements).forEach(([skill, mode]) => {
|
||||
if (mode === 'allowed' || mode === 'target') {
|
||||
required.tenComplements[skill as keyof typeof required.tenComplements] = true
|
||||
}
|
||||
if (mode === 'target') {
|
||||
target.tenComplements![skill as keyof typeof target.tenComplements] = true
|
||||
}
|
||||
if (mode === 'forbidden') {
|
||||
forbidden.tenComplements![skill as keyof typeof forbidden.tenComplements] = true
|
||||
}
|
||||
})
|
||||
|
||||
return { required, target, forbidden }
|
||||
}
|
||||
|
||||
// Convert from old format to new format
|
||||
export function skillSetsToConfiguration(
|
||||
required: SkillSet,
|
||||
target?: Partial<SkillSet>,
|
||||
forbidden?: Partial<SkillSet>
|
||||
): SkillConfiguration {
|
||||
const config = createDefaultSkillConfiguration()
|
||||
|
||||
// Process each skill category
|
||||
Object.entries(required.basic).forEach(([skill, isRequired]) => {
|
||||
const skillKey = skill as keyof typeof config.basic
|
||||
if (forbidden?.basic?.[skillKey]) {
|
||||
config.basic[skillKey] = 'forbidden'
|
||||
} else if (target?.basic?.[skillKey]) {
|
||||
config.basic[skillKey] = 'target'
|
||||
} else if (isRequired) {
|
||||
config.basic[skillKey] = 'allowed'
|
||||
} else {
|
||||
config.basic[skillKey] = 'off'
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(required.fiveComplements).forEach(([skill, isRequired]) => {
|
||||
const skillKey = skill as keyof typeof config.fiveComplements
|
||||
if (forbidden?.fiveComplements?.[skillKey]) {
|
||||
config.fiveComplements[skillKey] = 'forbidden'
|
||||
} else if (target?.fiveComplements?.[skillKey]) {
|
||||
config.fiveComplements[skillKey] = 'target'
|
||||
} else if (isRequired) {
|
||||
config.fiveComplements[skillKey] = 'allowed'
|
||||
} else {
|
||||
config.fiveComplements[skillKey] = 'off'
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(required.tenComplements).forEach(([skill, isRequired]) => {
|
||||
const skillKey = skill as keyof typeof config.tenComplements
|
||||
if (forbidden?.tenComplements?.[skillKey]) {
|
||||
config.tenComplements[skillKey] = 'forbidden'
|
||||
} else if (target?.tenComplements?.[skillKey]) {
|
||||
config.tenComplements[skillKey] = 'target'
|
||||
} else if (isRequired) {
|
||||
config.tenComplements[skillKey] = 'allowed'
|
||||
} else {
|
||||
config.tenComplements[skillKey] = 'off'
|
||||
}
|
||||
})
|
||||
|
||||
return config
|
||||
}
|
||||
Reference in New Issue
Block a user