fix: prevent regrouping problems in no-regrouping skills and enable progressive difficulty toggle

Problem Generation Fixes:
- Fixed validation.ts to preserve pAnyStart=0 and pAllStart=0 values (were being overridden to defaults)
- Fixed problemGenerator.ts category switching logic to respect difficulty constraints
- When pAny=0 (no regrouping), prevent switching to regrouping categories when stuck
- Fixed last-resort fallback to use appropriate generator instead of random numbers
- Added operator property to last-resort problems

UI Improvements:
- Made progressive difficulty toggle available in all modes (smart, manual, mastery)
- Default interpolate to false for mastery mode, true for others
- Removed forced interpolate=false in MasteryModePanel to respect user preference
- Created mini/compact skill panes for mixed mode (reduced padding, font sizes, spacing)

The root cause was two bugs:
1. Category switching after 50 failed attempts would randomly pick ANY category including regrouping
2. Last-resort code generated random numbers without checking regrouping constraints

🤖 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-10 13:25:41 -06:00
parent f23cf8d2c0
commit 59712e1021
6 changed files with 543 additions and 84 deletions

View File

@@ -10,7 +10,7 @@ import { OperatorSection } from './config-panel/OperatorSection'
import { ProgressiveDifficultyToggle } from './config-panel/ProgressiveDifficultyToggle'
import { SmartModeControls } from './config-panel/SmartModeControls'
import { ManualModeControls } from './config-panel/ManualModeControls'
import { ProgressionModePanel } from './config-panel/ProgressionModePanel'
import { MasteryModePanel } from './config-panel/MasteryModePanel'
interface ConfigPanelProps {
formState: WorksheetFormState
@@ -105,7 +105,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
isDark={isDark}
/>
{/* Progressive Difficulty Toggle - Available for both modes */}
{/* Progressive Difficulty Toggle - Available for all modes */}
<ProgressiveDifficultyToggle
interpolate={formState.interpolate}
onChange={(interpolate) => onChange({ interpolate })}
@@ -124,7 +124,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
{/* Mastery Mode Controls */}
{formState.mode === 'mastery' && (
<ProgressionModePanel formState={formState} onChange={onChange} isDark={isDark} />
<MasteryModePanel formState={formState} onChange={onChange} isDark={isDark} />
)}
</div>
)

View File

@@ -27,6 +27,10 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
console.log(`[WorksheetPreview] fetchWorksheetPreview called (ID: ${fetchId})`, {
seed: formState.seed,
problemsPerPage: formState.problemsPerPage,
pAnyStart: formState.pAnyStart,
pAllStart: formState.pAllStart,
mode: formState.mode,
operator: formState.operator,
})
// Set current date for preview

View File

@@ -28,36 +28,64 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
const [isAllSkillsModalOpen, setIsAllSkillsModalOpen] = useState(false)
const [isCustomizeMixModalOpen, setIsCustomizeMixModalOpen] = useState(false)
// Get current operator (default to addition, filter out 'mixed')
const rawOperator = formState.operator ?? 'addition'
const operator: 'addition' | 'subtraction' = rawOperator === 'mixed' ? 'addition' : rawOperator
// Get current operator (default to addition)
const operator = formState.operator ?? 'addition'
// Get skills for current operator
const availableSkills = getSkillsByOperator(operator)
// For mixed mode, we need to track skills for BOTH operators
const isMixedMode = operator === 'mixed'
// Get current skill ID from form state, or use first available skill
const currentSkillId = formState.currentSkillId ?? availableSkills[0]?.id
// Get skills for current operator(s)
const additionSkills = getSkillsByOperator('addition')
const subtractionSkills = getSkillsByOperator('subtraction')
// Get current skill definition
const currentSkill = currentSkillId ? getSkillById(currentSkillId as SkillId) : availableSkills[0]
// Get current skill IDs for each operator
const currentAdditionSkillId =
formState.currentAdditionSkillId ?? additionSkills[0]?.id ?? 'sd-no-regroup'
const currentSubtractionSkillId =
formState.currentSubtractionSkillId ?? subtractionSkills[0]?.id ?? 'sd-sub-no-borrow'
// Get current skill definitions
const currentAdditionSkill = getSkillById(currentAdditionSkillId as SkillId)
const currentSubtractionSkill = getSkillById(currentSubtractionSkillId as SkillId)
// For single-operator modes, use the appropriate skill
const currentSkill = isMixedMode
? currentAdditionSkill // Just use one for single-mode UI components
: operator === 'subtraction'
? currentSubtractionSkill
: currentAdditionSkill
const availableSkills = isMixedMode
? [...additionSkills, ...subtractionSkills] // Combined for modal
: operator === 'subtraction'
? subtractionSkills
: additionSkills
// Load mastery states from API
useEffect(() => {
async function loadMasteryStates() {
try {
setIsLoadingMastery(true)
const response = await fetch(`/api/worksheets/mastery?operator=${operator}`)
if (!response.ok) {
throw new Error('Failed to load mastery states')
}
const data = await response.json()
// Convert to Map<SkillId, boolean>
const statesMap = new Map<SkillId, boolean>()
for (const record of data.masteryStates) {
statesMap.set(record.skillId as SkillId, record.isMastered)
// For mixed mode, load both addition and subtraction mastery states
const operatorsToLoad = isMixedMode ? ['addition', 'subtraction'] : [operator]
const allStates = new Map<SkillId, boolean>()
for (const op of operatorsToLoad) {
const response = await fetch(`/api/worksheets/mastery?operator=${op}`)
if (!response.ok) {
throw new Error(`Failed to load mastery states for ${op}`)
}
const data = await response.json()
// Merge into combined map
for (const record of data.masteryStates) {
allStates.set(record.skillId as SkillId, record.isMastered)
}
}
setMasteryStates(statesMap)
setMasteryStates(allStates)
} catch (error) {
console.error('Failed to load mastery states:', error)
} finally {
@@ -66,35 +94,52 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
}
loadMasteryStates()
}, [operator])
}, [operator, isMixedMode])
// Apply current skill configuration to form state
useEffect(() => {
if (!currentSkill) return
if (isMixedMode) {
// Mixed mode: Use current addition and subtraction skills
if (!currentAdditionSkill || !currentSubtractionSkill) return
console.log('[MasteryModePanel] Applying skill config:', {
skillId: currentSkill.id,
skillName: currentSkill.name,
digitRange: currentSkill.digitRange,
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
pAllStart: currentSkill.regroupingConfig.pAllStart,
displayRules: currentSkill.recommendedScaffolding,
operator: currentSkill.operator,
})
console.log('[MasteryModePanel] Applying mixed mode config:', {
additionSkill: currentAdditionSkill.id,
subtractionSkill: currentSubtractionSkill.id,
})
// Apply skill's configuration to form state
// This updates the preview to show problems appropriate for this skill
onChange({
// Keep mode as 'mastery' - displayRules will still apply conditional scaffolding
digitRange: currentSkill.digitRange,
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
pAllStart: currentSkill.regroupingConfig.pAllStart,
displayRules: currentSkill.recommendedScaffolding,
operator: currentSkill.operator,
interpolate: false, // CRITICAL: Disable progressive difficulty in mastery mode
} as Partial<WorksheetFormState>)
// Store both skill IDs - worksheet generation will query these
onChange({
currentAdditionSkillId: currentAdditionSkill.id,
currentSubtractionSkillId: currentSubtractionSkill.id,
operator: 'mixed',
// Do NOT force interpolate - let user control it via the toggle
} as Partial<WorksheetFormState>)
} else {
// Single operator mode: Use the current skill
if (!currentSkill) return
console.log('[MasteryModePanel] Applying skill config:', {
skillId: currentSkill.id,
skillName: currentSkill.name,
digitRange: currentSkill.digitRange,
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
pAllStart: currentSkill.regroupingConfig.pAllStart,
displayRules: currentSkill.recommendedScaffolding,
operator: currentSkill.operator,
})
// Apply skill's configuration to form state
onChange({
digitRange: currentSkill.digitRange,
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
pAllStart: currentSkill.regroupingConfig.pAllStart,
displayRules: currentSkill.recommendedScaffolding,
operator: currentSkill.operator,
// Do NOT force interpolate - let user control it via the toggle
} as Partial<WorksheetFormState>)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSkill?.id]) // Only run when skill ID changes, not when onChange changes
}, [isMixedMode, currentAdditionSkill?.id, currentSubtractionSkill?.id, currentSkill?.id])
// Handler: Navigate to previous skill
const handlePreviousSkill = () => {
@@ -102,7 +147,13 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
if (currentIndex > 0) {
const prevSkill = availableSkills[currentIndex - 1]
onChange({ currentSkillId: prevSkill.id } as Partial<WorksheetFormState>)
// Update the appropriate skill ID based on operator
if (operator === 'addition') {
onChange({ currentAdditionSkillId: prevSkill.id } as Partial<WorksheetFormState>)
} else if (operator === 'subtraction') {
onChange({ currentSubtractionSkillId: prevSkill.id } as Partial<WorksheetFormState>)
}
}
}
@@ -112,7 +163,13 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
if (currentIndex < availableSkills.length - 1) {
const nextSkill = availableSkills[currentIndex + 1]
onChange({ currentSkillId: nextSkill.id } as Partial<WorksheetFormState>)
// Update the appropriate skill ID based on operator
if (operator === 'addition') {
onChange({ currentAdditionSkillId: nextSkill.id } as Partial<WorksheetFormState>)
} else if (operator === 'subtraction') {
onChange({ currentSubtractionSkillId: nextSkill.id } as Partial<WorksheetFormState>)
}
}
}
@@ -151,6 +208,260 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
}
}
// Mixed mode: Show both skills
if (isMixedMode) {
if (!currentAdditionSkill || !currentSubtractionSkill) {
return (
<div
data-component="mastery-mode-panel"
className={css({
padding: '1.5rem',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
<p className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>Loading skills...</p>
</div>
)
}
const additionMastered = masteryStates.get(currentAdditionSkill.id) ?? false
const subtractionMastered = masteryStates.get(currentSubtractionSkill.id) ?? false
return (
<div
data-component="mastery-mode-panel"
data-mode="mixed"
className={css({
padding: '1.5rem',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{/* Header */}
<div className={css({ marginBottom: '1rem' })}>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Mixed Operations Mode
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
fontStyle: 'italic',
})}
>
Practicing operator recognition with problems from both current addition and subtraction
skill levels
</p>
</div>
{/* Current Skills Display - Mini Compact Cards */}
<div className={css({ display: 'flex', gap: '0.75rem', marginTop: '1rem' })}>
{/* Addition Skill - Mini */}
<div
data-skill-card="addition-mini"
className={css({
flex: 1,
padding: '0.625rem',
borderRadius: '4px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.600' : 'white',
})}
>
<div
className={css({
fontSize: '0.625rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.25rem',
textTransform: 'uppercase',
letterSpacing: '0.025em',
})}
>
Addition
</div>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.375rem' })}>
<h4
className={css({
fontSize: '0.8125rem',
fontWeight: '600',
color: isDark ? 'white' : 'gray.900',
lineHeight: '1.2',
})}
>
{currentAdditionSkill.name}
</h4>
{additionMastered && (
<span
className={css({
fontSize: '0.8125rem',
lineHeight: '1',
})}
title="Mastered"
>
</span>
)}
</div>
<p
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginTop: '0.25rem',
lineHeight: '1.3',
})}
>
{currentAdditionSkill.description}
</p>
</div>
{/* Subtraction Skill - Mini */}
<div
data-skill-card="subtraction-mini"
className={css({
flex: 1,
padding: '0.625rem',
borderRadius: '4px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.600' : 'white',
})}
>
<div
className={css({
fontSize: '0.625rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.25rem',
textTransform: 'uppercase',
letterSpacing: '0.025em',
})}
>
Subtraction
</div>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.375rem' })}>
<h4
className={css({
fontSize: '0.8125rem',
fontWeight: '600',
color: isDark ? 'white' : 'gray.900',
lineHeight: '1.2',
})}
>
{currentSubtractionSkill.name}
</h4>
{subtractionMastered && (
<span
className={css({
fontSize: '0.8125rem',
lineHeight: '1',
})}
title="Mastered"
>
</span>
)}
</div>
<p
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginTop: '0.25rem',
lineHeight: '1.3',
})}
>
{currentSubtractionSkill.description}
</p>
</div>
</div>
{/* View All Skills Button */}
<div className={css({ marginTop: '1.5rem' })}>
<button
type="button"
data-action="view-all-skills"
onClick={() => setIsAllSkillsModalOpen(true)}
className={css({
width: '100%',
padding: '0.75rem 1rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.500' : 'gray.300',
backgroundColor: isDark ? 'gray.600' : 'white',
color: isDark ? 'gray.200' : 'gray.700',
fontSize: '0.875rem',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
backgroundColor: isDark ? 'gray.500' : 'gray.50',
},
})}
>
View All Skills
</button>
</div>
{/* All Skills Modal */}
<AllSkillsModal
isOpen={isAllSkillsModalOpen}
onClose={() => setIsAllSkillsModalOpen(false)}
skills={availableSkills}
currentSkillId={currentAdditionSkill.id}
masteryStates={masteryStates}
onSelectSkill={(skillId) => {
// Determine which operator this skill belongs to
const skill = getSkillById(skillId)
if (!skill) return
if (skill.operator === 'addition') {
onChange({ currentAdditionSkillId: skillId } as Partial<WorksheetFormState>)
} else {
onChange({ currentSubtractionSkillId: skillId } as Partial<WorksheetFormState>)
}
}}
onToggleMastery={async (skillId, isMastered) => {
const newStates = new Map(masteryStates)
newStates.set(skillId, isMastered)
setMasteryStates(newStates)
try {
const response = await fetch('/api/worksheets/mastery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skillId, isMastered }),
})
if (!response.ok) throw new Error('Failed to update mastery state')
} catch (error) {
console.error('Failed to update mastery state:', error)
const revertedStates = new Map(masteryStates)
revertedStates.set(skillId, !isMastered)
setMasteryStates(revertedStates)
}
}}
isDark={isDark}
/>
</div>
)
}
// Single operator mode
if (!currentSkill) {
return (
<div

View File

@@ -6,9 +6,11 @@ import {
generateProblems,
generateSubtractionProblems,
generateMixedProblems,
generateMasteryMixedProblems,
} from './problemGenerator'
import { generateTypstSource } from './typstGenerator'
import type { WorksheetFormState } from './types'
import { getSkillById } from './skills'
export interface PreviewResult {
success: boolean
@@ -37,33 +39,78 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
// Generate all problems for full preview based on operator
const operator = validatedConfig.operator ?? 'addition'
const problems =
operator === 'addition'
? generateProblems(
validatedConfig.total,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed,
validatedConfig.digitRange
)
: operator === 'subtraction'
? generateSubtractionProblems(
const mode = config.mode ?? 'smart'
let problems
// Special handling for mastery + mixed mode
if (mode === 'mastery' && operator === 'mixed') {
// Query both skill configs
const addSkillId = config.currentAdditionSkillId
const subSkillId = config.currentSubtractionSkillId
if (!addSkillId || !subSkillId) {
return {
success: false,
error: 'Mixed mastery mode requires both addition and subtraction skill IDs',
}
}
const addSkill = getSkillById(addSkillId as any)
const subSkill = getSkillById(subSkillId as any)
if (!addSkill || !subSkill) {
return {
success: false,
error: 'Invalid skill IDs',
}
}
// Use skill-specific configs
problems = generateMasteryMixedProblems(
validatedConfig.total,
{
digitRange: addSkill.digitRange,
pAnyStart: addSkill.regroupingConfig.pAnyStart,
pAllStart: addSkill.regroupingConfig.pAllStart,
},
{
digitRange: subSkill.digitRange,
pAnyStart: subSkill.regroupingConfig.pAnyStart,
pAllStart: subSkill.regroupingConfig.pAllStart,
},
validatedConfig.seed
)
} else {
// Standard problem generation
problems =
operator === 'addition'
? generateProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
: generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
validatedConfig.seed,
validatedConfig.digitRange
)
: operator === 'subtraction'
? generateSubtractionProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
: generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
}
// Generate Typst sources (one per page)
const typstSources = generateTypstSource(validatedConfig, problems)

View File

@@ -284,19 +284,30 @@ export function generateProblems(
}
ok = uniquePush(problems, a, b, seen)
// If stuck, try a different category
// If stuck, try a different category - but respect the difficulty constraints
// Don't switch to a harder category if pAny is 0 (no regrouping allowed)
if (!ok && tries % 50 === 0) {
picked = pick(['both', 'onesOnly', 'non'], rand)
if (pAny > 0) {
// Can use any category
picked = pick(['both', 'onesOnly', 'non'], rand)
}
// If pAny is 0, keep trying 'non' - don't switch to regrouping categories
}
}
// Last resort: add any valid problem in digit range
// Last resort: use the appropriate generator one more time, even if not unique
// This respects the difficulty constraints (non/onesOnly/both)
if (!ok) {
const digitsA = randint(minDigits, maxDigits, rand)
const digitsB = randint(minDigits, maxDigits, rand)
const a = generateNumber(digitsA, rand)
const b = generateNumber(digitsB, rand)
uniquePush(problems, a, b, seen)
let a: number, b: number
if (picked === 'both') {
;[a, b] = generateBoth(rand, minDigits, maxDigits)
} else if (picked === 'onesOnly') {
;[a, b] = generateOnesOnly(rand, minDigits, maxDigits)
} else {
;[a, b] = generateNonRegroup(rand, minDigits, maxDigits)
}
// Allow duplicate as last resort - add with operator property
problems.push({ a, b, operator: '+' })
}
}
@@ -410,8 +421,10 @@ export function generateOnesOnlyBorrow(
return [minuend, subtrahend]
}
}
// Fallback
return minDigits === 1 ? [5, 7] : [52, 17]
// Fallback: Return safe problems that require ones-place borrowing
// For single-digit: Use teens minus singles (13-7, 15-8)
// For two-digit: Use problems like 52-17
return minDigits === 1 ? [13, 7] : [52, 17]
}
/**
@@ -596,11 +609,23 @@ export function generateSubtractionProblems(
let minuend = generateNumber(digitsM, rand)
let subtrahend = generateNumber(digitsS, rand)
// Ensure minuend >= subtrahend
if (minuend < subtrahend) {
// Ensure minuend > subtrahend (strictly greater to avoid 0-0 and ensure borrowing is possible)
if (minuend <= subtrahend) {
;[minuend, subtrahend] = [subtrahend, minuend]
}
// Final safety check: ensure minuend is actually greater
if (minuend <= subtrahend) {
// Both were equal or something went wrong - use safe fallback
if (minDigits === 1) {
minuend = 13
subtrahend = 7
} else {
minuend = 52
subtrahend = 17
}
}
uniquePush(minuend, subtrahend)
}
}
@@ -608,6 +633,68 @@ export function generateSubtractionProblems(
return problems
}
/**
* Generate mixed addition and subtraction problems for mastery mode
* Uses separate configs for addition and subtraction based on current skill levels
*
* @param count Number of problems to generate
* @param additionConfig Configuration for addition problems (from current addition skill)
* @param subtractionConfig Configuration for subtraction problems (from current subtraction skill)
* @param seed Random seed
* @returns Array of mixed problems, shuffled randomly
*/
export function generateMasteryMixedProblems(
count: number,
additionConfig: {
digitRange: { min: number; max: number }
pAnyStart: number
pAllStart: number
},
subtractionConfig: {
digitRange: { min: number; max: number }
pAnyStart: number
pAllStart: number
},
seed: number
): WorksheetProblem[] {
// Generate half from each operator
const halfCount = Math.floor(count / 2)
const addCount = halfCount
const subCount = count - halfCount // Handle odd counts
// Generate addition problems using addition skill config
const addProblems = generateProblems(
addCount,
additionConfig.pAnyStart,
additionConfig.pAllStart,
false, // No interpolation in mastery mode
seed,
additionConfig.digitRange
)
// Generate subtraction problems using subtraction skill config
const subProblems = generateSubtractionProblems(
subCount,
subtractionConfig.digitRange,
subtractionConfig.pAnyStart,
subtractionConfig.pAllStart,
false, // No interpolation in mastery mode
seed + 1000000 // Different seed space
)
// Combine and shuffle
const allProblems = [...addProblems, ...subProblems]
const rand = createPRNG(seed + 2000000)
// Fisher-Yates shuffle
for (let i = allProblems.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1))
;[allProblems[i], allProblems[j]] = [allProblems[j], allProblems[i]]
}
return allProblems
}
/**
* Generate mixed addition and subtraction problems
* Randomly alternates between addition and subtraction (50/50)

View File

@@ -37,8 +37,12 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
const rows = Math.ceil(total / cols)
// Validate probabilities (0-1 range)
const pAnyStart = formState.pAnyStart ?? 0.75
const pAllStart = formState.pAllStart ?? 0.25
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
// because 0 is a valid value (e.g., "no regrouping" skills set pAnyStart=0)
const pAnyStart =
formState.pAnyStart !== undefined && formState.pAnyStart !== null ? formState.pAnyStart : 0.75
const pAllStart =
formState.pAllStart !== undefined && formState.pAllStart !== null ? formState.pAllStart : 0.25
if (pAnyStart < 0 || pAnyStart > 1) {
errors.push('pAnyStart must be between 0 and 1')
}
@@ -111,7 +115,13 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
date: formState.date?.trim() || getDefaultDate(),
pAnyStart,
pAllStart,
interpolate: formState.interpolate ?? true,
// Default interpolate based on mode: true for smart/manual, false for mastery
interpolate:
formState.interpolate !== undefined
? formState.interpolate
: mode === 'mastery'
? false
: true,
// V4: Digit range for problem generation
digitRange,