Add mixed denominator fractions worksheet option

This commit is contained in:
Thomas Hallock 2025-12-11 04:22:29 -06:00
parent 9159608dcd
commit 2f841f2435
8 changed files with 268 additions and 33 deletions

View File

@ -1,7 +1,7 @@
import { css } from '@styled/css' import { css } from '@styled/css'
export interface OperatorIconProps { export interface OperatorIconProps {
operator: 'addition' | 'subtraction' | 'mixed' operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
size?: 'sm' | 'md' | 'lg' | 'xl' size?: 'sm' | 'md' | 'lg' | 'xl'
isDark?: boolean isDark?: boolean
color?: 'gray' | 'green' color?: 'gray' | 'green'
@ -15,7 +15,8 @@ const sizeMap = {
xl: 'xl', xl: 'xl',
} as const } as const
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string { function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string {
if (operator === 'fractions') return '⅟'
if (operator === 'mixed') return '±' if (operator === 'mixed') return '±'
if (operator === 'subtraction') return '' if (operator === 'subtraction') return ''
return '+' return '+'

View File

@ -2,8 +2,8 @@ import { css } from '@styled/css'
import { OperatorIcon } from './OperatorIcon' import { OperatorIcon } from './OperatorIcon'
export interface OperatorSectionProps { export interface OperatorSectionProps {
operator: 'addition' | 'subtraction' | 'mixed' | undefined operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined
onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void
isDark?: boolean isDark?: boolean
} }
@ -11,6 +11,7 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
// Derive checkbox states from operator value // Derive checkbox states from operator value
const additionChecked = operator === 'addition' || operator === 'mixed' || !operator const additionChecked = operator === 'addition' || operator === 'mixed' || !operator
const subtractionChecked = operator === 'subtraction' || operator === 'mixed' const subtractionChecked = operator === 'subtraction' || operator === 'mixed'
const fractionsChecked = operator === 'fractions'
const handleAdditionChange = (checked: boolean) => { const handleAdditionChange = (checked: boolean) => {
if (!checked && !subtractionChecked) { if (!checked && !subtractionChecked) {
@ -40,6 +41,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
} }
} }
const handleFractionsSelect = () => {
onChange('fractions')
}
return ( return (
<div data-section="operator-selection"> <div data-section="operator-selection">
<label <label
@ -187,6 +192,85 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
</span> </span>
</div> </div>
</label> </label>
{/* Mixed-Denominator Fractions */}
<label
data-action="toggle-fractions"
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
cursor: 'pointer',
px: '3',
py: '2.5',
rounded: 'lg',
border: '2px solid',
transition: 'all 0.2s',
bg: fractionsChecked
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
borderColor: fractionsChecked ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
_hover: {
borderColor: 'brand.400',
},
})}
>
<input
type="radio"
checked={fractionsChecked}
onChange={handleFractionsSelect}
className={css({
width: '5',
height: '5',
cursor: 'pointer',
accentColor: 'brand.600',
flexShrink: 0,
})}
/>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
flex: 1,
minWidth: 0,
})}
>
<OperatorIcon operator="fractions" isDark={isDark} />
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
'@media (max-width: 200px)': {
fontSize: 'xs',
},
})}
>
Mixed Denominator Fractions
</span>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build fluency adding unlike denominators.
</span>
</div>
</div>
</label>
</div> </div>
<p <p
@ -196,11 +280,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
lineHeight: '1.5', lineHeight: '1.5',
})} })}
> >
{additionChecked && subtractionChecked {fractionsChecked
? 'Problems will randomly use addition or subtraction' ? 'All problems will be mixed-denominator fraction addition'
: subtractionChecked : additionChecked && subtractionChecked
? 'All problems will be subtraction' ? 'Problems will randomly use addition or subtraction'
: 'All problems will be addition'} : subtractionChecked
? 'All problems will be subtraction'
: 'All problems will be addition'}
</p> </p>
</div> </div>
) )

View File

@ -9,7 +9,7 @@ import { ProblemPreview } from './ProblemPreview'
export interface Tab { export interface Tab {
id: string id: string
label: string label: string
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview' icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string) | 'preview'
subtitle?: (props: { subtitle?: (props: {
mode?: 'custom' | 'manual' | 'mastery' mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string difficultyProfile?: string
@ -29,6 +29,7 @@ export const TABS: Tab[] = [
id: 'operator', id: 'operator',
label: 'Operator', label: 'Operator',
icon: (operator) => { icon: (operator) => {
if (operator === 'fractions') return '⅟'
if (operator === 'mixed') return '±' if (operator === 'mixed') return '±'
if (operator === 'subtraction') return '' if (operator === 'subtraction') return ''
return '+' return '+'
@ -115,7 +116,7 @@ export const TABS: Tab[] = [
interface TabNavigationProps { interface TabNavigationProps {
activeTab: string activeTab: string
onChange: (tabId: string) => void onChange: (tabId: string) => void
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
mode?: 'custom' | 'manual' | 'mastery' mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string difficultyProfile?: string
interpolate?: boolean interpolate?: boolean

View File

@ -342,8 +342,8 @@ const additionConfigV4BaseSchema = z.object({
message: 'min must be less than or equal to max', message: 'min must be less than or equal to max',
}), }),
// V4: Operator selection (addition, subtraction, or mixed) // V4: Operator selection (addition, subtraction, mixed, or fractions)
operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'), operator: z.enum(['addition', 'subtraction', 'mixed', 'fractions']).default('addition'),
// Regrouping probabilities (shared between modes) // Regrouping probabilities (shared between modes)
pAnyStart: z.number().min(0).max(1), pAnyStart: z.number().min(0).max(1),

View File

@ -3,7 +3,9 @@
import { execSync } from 'child_process' import { execSync } from 'child_process'
import type { WorksheetFormState } from '@/app/create/worksheets/types' import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { import {
createPRNG,
generateMasteryMixedProblems, generateMasteryMixedProblems,
generateFractionProblems,
generateMixedProblems, generateMixedProblems,
generateProblems, generateProblems,
generateSubtractionProblems, generateSubtractionProblems,
@ -13,6 +15,48 @@ import { generateTypstSource } from './typstGenerator'
import { validateProblemSpace } from './utils/validateProblemSpace' import { validateProblemSpace } from './utils/validateProblemSpace'
import { validateWorksheetConfig } from './validation' import { validateWorksheetConfig } from './validation'
function renderFractionPageSvg(
pageProblems: ReturnType<typeof generateFractionProblems>,
config: WorksheetFormState,
pageIndex: number
): string {
const width = config.orientation === 'portrait' ? 850 : 1100
const height = config.orientation === 'portrait' ? 1100 : 850
const margin = 48
const cols = config.cols ?? 4
const rows = Math.max(1, Math.ceil(pageProblems.length / cols))
const cellWidth = (width - margin * 2) / cols
const cellHeight = (height - margin * 2) / rows
const problemTexts = pageProblems
.map((problem, index) => {
const col = index % cols
const row = Math.floor(index / cols)
const x = margin + col * cellWidth + cellWidth / 2
const y = margin + row * cellHeight + cellHeight / 2
const label = problem
? `${problem.numerator1}/${problem.denominator1} + ${problem.numerator2}/${problem.denominator2} =`
: ''
return `<g transform="translate(${x}, ${y})">
<text text-anchor="middle" dominant-baseline="middle" font-size="22" font-family="Inter, Arial" fill="#111827">${
(pageIndex * (config.problemsPerPage ?? 20)) + index + 1
}.) ${label}</text>
</g>`
})
.join('\n')
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="white"/>
<text x="${margin}" y="${margin - 16}" font-size="18" font-family="Inter, Arial" fill="#374151">
${config.name || 'Fraction Practice'} Page ${pageIndex + 1}
</text>
${problemTexts}
</svg>`
}
export interface PreviewResult { export interface PreviewResult {
success: boolean success: boolean
pages?: string[] pages?: string[]
@ -64,13 +108,16 @@ export async function generateWorksheetPreview(
// Validate problem space for duplicate risk // Validate problem space for duplicate risk
const operator = validatedConfig.operator ?? 'addition' const operator = validatedConfig.operator ?? 'addition'
const spaceValidation = validateProblemSpace( const spaceValidation =
validatedConfig.problemsPerPage, operator === 'fractions'
validatedConfig.pages, ? { warnings: [] }
validatedConfig.digitRange, : validateProblemSpace(
validatedConfig.pAnyStart, validatedConfig.problemsPerPage,
operator validatedConfig.pages,
) validatedConfig.digitRange,
validatedConfig.pAnyStart,
operator
)
if (spaceValidation.warnings.length > 0) { if (spaceValidation.warnings.length > 0) {
console.log('[PREVIEW] Problem space warnings:', spaceValidation.warnings) console.log('[PREVIEW] Problem space warnings:', spaceValidation.warnings)
@ -78,6 +125,7 @@ export async function generateWorksheetPreview(
// Generate all problems for full preview based on operator // Generate all problems for full preview based on operator
const mode = config.mode ?? 'custom' const mode = config.mode ?? 'custom'
const rand = createPRNG(validatedConfig.seed ?? Date.now() % 2147483647)
console.log( console.log(
`[PREVIEW] Step 2: Generating ${validatedConfig.total} problems (mode: ${mode}, operator: ${operator})...` `[PREVIEW] Step 2: Generating ${validatedConfig.total} problems (mode: ${mode}, operator: ${operator})...`
@ -146,18 +194,52 @@ export async function generateWorksheetPreview(
validatedConfig.interpolate, validatedConfig.interpolate,
validatedConfig.seed validatedConfig.seed
) )
: generateMixedProblems( : operator === 'fractions'
validatedConfig.total, ? generateFractionProblems(validatedConfig.total, rand)
validatedConfig.digitRange, : generateMixedProblems(
validatedConfig.pAnyStart, validatedConfig.total,
validatedConfig.pAllStart, validatedConfig.digitRange,
validatedConfig.interpolate, validatedConfig.pAnyStart,
validatedConfig.seed validatedConfig.pAllStart,
) validatedConfig.interpolate,
validatedConfig.seed
)
} }
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`) console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
if (operator === 'fractions') {
const problemsPerPage = validatedConfig.problemsPerPage ?? 20
const totalPages = Math.ceil(problems.length / problemsPerPage)
const start = startPage !== undefined ? Math.max(0, startPage) : 0
const end = endPage !== undefined ? Math.min(endPage, totalPages - 1) : totalPages - 1
if (start > end || start >= totalPages) {
return {
success: false,
error: `Invalid page range: start=${start}, end=${end}, totalPages=${totalPages}`,
}
}
const pages: string[] = []
for (let i = start; i <= end; i++) {
const startIndex = i * problemsPerPage
const pageProblems = problems.slice(startIndex, startIndex + problemsPerPage) as ReturnType<
typeof generateFractionProblems
>
pages.push(renderFractionPageSvg(pageProblems, validatedConfig, i))
}
return {
success: true,
pages,
totalPages,
startPage: start,
endPage: end,
warnings: spaceValidation.warnings.length > 0 ? spaceValidation.warnings : undefined,
}
}
// Generate Typst sources (one per page) // Generate Typst sources (one per page)
// Use placeholder URL for QR code in preview (actual URL will be generated when PDF is created) // Use placeholder URL for QR code in preview (actual URL will be generated when PDF is created)
const previewShareUrl = validatedConfig.includeQRCode const previewShareUrl = validatedConfig.includeQRCode
@ -280,6 +362,7 @@ export async function generateSinglePage(
// This is unavoidable because problems are distributed across pages // This is unavoidable because problems are distributed across pages
const operator = validatedConfig.operator ?? 'addition' const operator = validatedConfig.operator ?? 'addition'
const mode = config.mode ?? 'custom' const mode = config.mode ?? 'custom'
const rand = createPRNG(validatedConfig.seed ?? Date.now() % 2147483647)
let problems let problems
@ -328,6 +411,8 @@ export async function generateSinglePage(
validatedConfig.interpolate, validatedConfig.interpolate,
validatedConfig.seed validatedConfig.seed
) )
} else if (operator === 'fractions') {
problems = generateFractionProblems(validatedConfig.total, rand)
} else if (operator === 'subtraction') { } else if (operator === 'subtraction') {
problems = generateSubtractionProblems( problems = generateSubtractionProblems(
validatedConfig.total, validatedConfig.total,
@ -349,6 +434,20 @@ export async function generateSinglePage(
) )
} }
if (operator === 'fractions') {
const problemsPerPage = validatedConfig.problemsPerPage ?? 20
const startIndex = pageNumber * problemsPerPage
const pageProblems = problems.slice(startIndex, startIndex + problemsPerPage) as ReturnType<
typeof generateFractionProblems
>
return {
success: true,
page: renderFractionPageSvg(pageProblems, validatedConfig, pageNumber),
totalPages,
}
}
// Generate Typst source for ALL pages (lightweight operation) // Generate Typst source for ALL pages (lightweight operation)
// Use placeholder URL for QR code in preview // Use placeholder URL for QR code in preview
const previewShareUrl = validatedConfig.includeQRCode const previewShareUrl = validatedConfig.includeQRCode

View File

@ -2,6 +2,7 @@
import type { import type {
AdditionProblem, AdditionProblem,
FractionProblem,
ProblemCategory, ProblemCategory,
SubtractionProblem, SubtractionProblem,
WorksheetProblem, WorksheetProblem,
@ -40,6 +41,42 @@ function shuffleArray<T>(arr: T[], rand: () => number): T[] {
return shuffled return shuffled
} }
/**
* Generate mixed-denominator fraction addition problems
*/
export function generateFractionProblems(
total: number,
rand: () => number
): FractionProblem[] {
const problems: FractionProblem[] = []
const pickDenominator = () => randint(2, 12, rand)
const pickNumerator = (denominator: number) => randint(1, denominator - 1, rand)
while (problems.length < total) {
const denominator1 = pickDenominator()
let denominator2 = pickDenominator()
// Ensure denominators are different to enforce mixed denominators
if (denominator2 === denominator1) {
denominator2 = ((denominator2 + 1 - 2) % 11) + 2 // rotate within 2-12
}
const numerator1 = pickNumerator(denominator1)
const numerator2 = pickNumerator(denominator2)
problems.push({
numerator1,
denominator1,
numerator2,
denominator2,
operator: 'fraction',
})
}
return problems
}
/** /**
* Generate random integer between min and max (inclusive) * Generate random integer between min and max (inclusive)
*/ */

View File

@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
/** /**
* Worksheet operator type * Worksheet operator type
*/ */
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions'
/** /**
* A single addition problem * A single addition problem
@ -136,10 +136,21 @@ export interface SubtractionProblem {
operator: 'sub' operator: 'sub'
} }
/**
* A single fraction addition problem with different denominators
*/
export interface FractionProblem {
numerator1: number
denominator1: number
numerator2: number
denominator2: number
operator: 'fraction'
}
/** /**
* Unified problem type (addition or subtraction) * Unified problem type (addition or subtraction)
*/ */
export type WorksheetProblem = AdditionProblem | SubtractionProblem export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem
/** /**
* Validation result * Validation result

View File

@ -165,10 +165,10 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
errors.push('Digit range min cannot be greater than max') errors.push('Digit range min cannot be greater than max')
} }
// V4: Validate operator (addition, subtraction, or mixed) // V4: Validate operator (addition, subtraction, mixed, or fractions)
const operator = formState.operator ?? 'addition' const operator = formState.operator ?? 'addition'
if (!['addition', 'subtraction', 'mixed'].includes(operator)) { if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) {
errors.push('Operator must be "addition", "subtraction", or "mixed"') errors.push('Operator must be "addition", "subtraction", "mixed", or "fractions"')
} }
// Validate seed (must be positive integer) // Validate seed (must be positive integer)