From 2f841f243592fcc3e0abb8b50be237baf889ad4f Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 04:22:29 -0600 Subject: [PATCH] Add mixed denominator fractions worksheet option --- .../components/config-panel/OperatorIcon.tsx | 5 +- .../config-panel/OperatorSection.tsx | 100 +++++++++++++- .../config-sidebar/TabNavigation.tsx | 5 +- .../app/create/worksheets/config-schemas.ts | 4 +- .../app/create/worksheets/generatePreview.ts | 129 ++++++++++++++++-- .../app/create/worksheets/problemGenerator.ts | 37 +++++ apps/web/src/app/create/worksheets/types.ts | 15 +- .../src/app/create/worksheets/validation.ts | 6 +- 8 files changed, 268 insertions(+), 33 deletions(-) diff --git a/apps/web/src/app/create/worksheets/components/config-panel/OperatorIcon.tsx b/apps/web/src/app/create/worksheets/components/config-panel/OperatorIcon.tsx index 132175dc..26e2cd13 100644 --- a/apps/web/src/app/create/worksheets/components/config-panel/OperatorIcon.tsx +++ b/apps/web/src/app/create/worksheets/components/config-panel/OperatorIcon.tsx @@ -1,7 +1,7 @@ import { css } from '@styled/css' export interface OperatorIconProps { - operator: 'addition' | 'subtraction' | 'mixed' + operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' size?: 'sm' | 'md' | 'lg' | 'xl' isDark?: boolean color?: 'gray' | 'green' @@ -15,7 +15,8 @@ const sizeMap = { xl: 'xl', } 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 === 'subtraction') return '−' return '+' diff --git a/apps/web/src/app/create/worksheets/components/config-panel/OperatorSection.tsx b/apps/web/src/app/create/worksheets/components/config-panel/OperatorSection.tsx index 9484e11d..1d05282b 100644 --- a/apps/web/src/app/create/worksheets/components/config-panel/OperatorSection.tsx +++ b/apps/web/src/app/create/worksheets/components/config-panel/OperatorSection.tsx @@ -2,8 +2,8 @@ import { css } from '@styled/css' import { OperatorIcon } from './OperatorIcon' export interface OperatorSectionProps { - operator: 'addition' | 'subtraction' | 'mixed' | undefined - onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void + operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined + onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void isDark?: boolean } @@ -11,6 +11,7 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator // Derive checkbox states from operator value const additionChecked = operator === 'addition' || operator === 'mixed' || !operator const subtractionChecked = operator === 'subtraction' || operator === 'mixed' + const fractionsChecked = operator === 'fractions' const handleAdditionChange = (checked: boolean) => { if (!checked && !subtractionChecked) { @@ -40,6 +41,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator } } + const handleFractionsSelect = () => { + onChange('fractions') + } + return (
+ + {/* Mixed-Denominator Fractions */} +

- {additionChecked && subtractionChecked - ? 'Problems will randomly use addition or subtraction' - : subtractionChecked - ? 'All problems will be subtraction' - : 'All problems will be addition'} + {fractionsChecked + ? 'All problems will be mixed-denominator fraction addition' + : additionChecked && subtractionChecked + ? 'Problems will randomly use addition or subtraction' + : subtractionChecked + ? 'All problems will be subtraction' + : 'All problems will be addition'}

) diff --git a/apps/web/src/app/create/worksheets/components/config-sidebar/TabNavigation.tsx b/apps/web/src/app/create/worksheets/components/config-sidebar/TabNavigation.tsx index ee480be2..fb7a6a5c 100644 --- a/apps/web/src/app/create/worksheets/components/config-sidebar/TabNavigation.tsx +++ b/apps/web/src/app/create/worksheets/components/config-sidebar/TabNavigation.tsx @@ -9,7 +9,7 @@ import { ProblemPreview } from './ProblemPreview' export interface Tab { id: string label: string - icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview' + icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string) | 'preview' subtitle?: (props: { mode?: 'custom' | 'manual' | 'mastery' difficultyProfile?: string @@ -29,6 +29,7 @@ export const TABS: Tab[] = [ id: 'operator', label: 'Operator', icon: (operator) => { + if (operator === 'fractions') return '⅟' if (operator === 'mixed') return '±' if (operator === 'subtraction') return '−' return '+' @@ -115,7 +116,7 @@ export const TABS: Tab[] = [ interface TabNavigationProps { activeTab: string onChange: (tabId: string) => void - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' mode?: 'custom' | 'manual' | 'mastery' difficultyProfile?: string interpolate?: boolean diff --git a/apps/web/src/app/create/worksheets/config-schemas.ts b/apps/web/src/app/create/worksheets/config-schemas.ts index a08f598c..e2aebe07 100644 --- a/apps/web/src/app/create/worksheets/config-schemas.ts +++ b/apps/web/src/app/create/worksheets/config-schemas.ts @@ -342,8 +342,8 @@ const additionConfigV4BaseSchema = z.object({ message: 'min must be less than or equal to max', }), - // V4: Operator selection (addition, subtraction, or mixed) - operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'), + // V4: Operator selection (addition, subtraction, mixed, or fractions) + operator: z.enum(['addition', 'subtraction', 'mixed', 'fractions']).default('addition'), // Regrouping probabilities (shared between modes) pAnyStart: z.number().min(0).max(1), diff --git a/apps/web/src/app/create/worksheets/generatePreview.ts b/apps/web/src/app/create/worksheets/generatePreview.ts index d9302362..cf433d01 100644 --- a/apps/web/src/app/create/worksheets/generatePreview.ts +++ b/apps/web/src/app/create/worksheets/generatePreview.ts @@ -3,7 +3,9 @@ import { execSync } from 'child_process' import type { WorksheetFormState } from '@/app/create/worksheets/types' import { + createPRNG, generateMasteryMixedProblems, + generateFractionProblems, generateMixedProblems, generateProblems, generateSubtractionProblems, @@ -13,6 +15,48 @@ import { generateTypstSource } from './typstGenerator' import { validateProblemSpace } from './utils/validateProblemSpace' import { validateWorksheetConfig } from './validation' +function renderFractionPageSvg( + pageProblems: ReturnType, + 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 ` + ${ + (pageIndex * (config.problemsPerPage ?? 20)) + index + 1 + }.) ${label} + ` + }) + .join('\n') + + return ` + + + ${config.name || 'Fraction Practice'} • Page ${pageIndex + 1} + + ${problemTexts} + ` +} + export interface PreviewResult { success: boolean pages?: string[] @@ -64,13 +108,16 @@ export async function generateWorksheetPreview( // Validate problem space for duplicate risk const operator = validatedConfig.operator ?? 'addition' - const spaceValidation = validateProblemSpace( - validatedConfig.problemsPerPage, - validatedConfig.pages, - validatedConfig.digitRange, - validatedConfig.pAnyStart, - operator - ) + const spaceValidation = + operator === 'fractions' + ? { warnings: [] } + : validateProblemSpace( + validatedConfig.problemsPerPage, + validatedConfig.pages, + validatedConfig.digitRange, + validatedConfig.pAnyStart, + operator + ) if (spaceValidation.warnings.length > 0) { 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 const mode = config.mode ?? 'custom' + const rand = createPRNG(validatedConfig.seed ?? Date.now() % 2147483647) console.log( `[PREVIEW] Step 2: Generating ${validatedConfig.total} problems (mode: ${mode}, operator: ${operator})...` @@ -146,18 +194,52 @@ export async function generateWorksheetPreview( validatedConfig.interpolate, validatedConfig.seed ) - : generateMixedProblems( - validatedConfig.total, - validatedConfig.digitRange, - validatedConfig.pAnyStart, - validatedConfig.pAllStart, - validatedConfig.interpolate, - validatedConfig.seed - ) + : operator === 'fractions' + ? generateFractionProblems(validatedConfig.total, rand) + : generateMixedProblems( + validatedConfig.total, + validatedConfig.digitRange, + validatedConfig.pAnyStart, + validatedConfig.pAllStart, + validatedConfig.interpolate, + validatedConfig.seed + ) } 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) // Use placeholder URL for QR code in preview (actual URL will be generated when PDF is created) const previewShareUrl = validatedConfig.includeQRCode @@ -280,6 +362,7 @@ export async function generateSinglePage( // This is unavoidable because problems are distributed across pages const operator = validatedConfig.operator ?? 'addition' const mode = config.mode ?? 'custom' + const rand = createPRNG(validatedConfig.seed ?? Date.now() % 2147483647) let problems @@ -328,6 +411,8 @@ export async function generateSinglePage( validatedConfig.interpolate, validatedConfig.seed ) + } else if (operator === 'fractions') { + problems = generateFractionProblems(validatedConfig.total, rand) } else if (operator === 'subtraction') { problems = generateSubtractionProblems( 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) // Use placeholder URL for QR code in preview const previewShareUrl = validatedConfig.includeQRCode diff --git a/apps/web/src/app/create/worksheets/problemGenerator.ts b/apps/web/src/app/create/worksheets/problemGenerator.ts index fd32c9e7..8a2498ca 100644 --- a/apps/web/src/app/create/worksheets/problemGenerator.ts +++ b/apps/web/src/app/create/worksheets/problemGenerator.ts @@ -2,6 +2,7 @@ import type { AdditionProblem, + FractionProblem, ProblemCategory, SubtractionProblem, WorksheetProblem, @@ -40,6 +41,42 @@ function shuffleArray(arr: T[], rand: () => number): T[] { 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) */ diff --git a/apps/web/src/app/create/worksheets/types.ts b/apps/web/src/app/create/worksheets/types.ts index bf06177c..f481548d 100644 --- a/apps/web/src/app/create/worksheets/types.ts +++ b/apps/web/src/app/create/worksheets/types.ts @@ -116,7 +116,7 @@ export type WorksheetFormState = Partial /** * Worksheet operator type */ -export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' +export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions' /** * A single addition problem @@ -136,10 +136,21 @@ export interface SubtractionProblem { 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) */ -export type WorksheetProblem = AdditionProblem | SubtractionProblem +export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem /** * Validation result diff --git a/apps/web/src/app/create/worksheets/validation.ts b/apps/web/src/app/create/worksheets/validation.ts index 91520dd3..a2caadf0 100644 --- a/apps/web/src/app/create/worksheets/validation.ts +++ b/apps/web/src/app/create/worksheets/validation.ts @@ -165,10 +165,10 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati 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' - if (!['addition', 'subtraction', 'mixed'].includes(operator)) { - errors.push('Operator must be "addition", "subtraction", or "mixed"') + if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) { + errors.push('Operator must be "addition", "subtraction", "mixed", or "fractions"') } // Validate seed (must be positive integer)