diff --git a/apps/web/src/app/create/worksheets/components/DisplayOptionsPreview.tsx b/apps/web/src/app/create/worksheets/components/DisplayOptionsPreview.tsx index c93211cf..6f474a4e 100644 --- a/apps/web/src/app/create/worksheets/components/DisplayOptionsPreview.tsx +++ b/apps/web/src/app/create/worksheets/components/DisplayOptionsPreview.tsx @@ -140,7 +140,7 @@ async function fetchExample(options: { showTenFrames: boolean showTenFramesForAll: boolean showBorrowNotation: boolean - operator: 'addition' | 'subtraction' | 'mixed' + operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' addend1?: number addend2?: number minuend?: number diff --git a/apps/web/src/app/create/worksheets/components/OrientationPanel.tsx b/apps/web/src/app/create/worksheets/components/OrientationPanel.tsx index ab4340bc..1850d27c 100644 --- a/apps/web/src/app/create/worksheets/components/OrientationPanel.tsx +++ b/apps/web/src/app/create/worksheets/components/OrientationPanel.tsx @@ -25,7 +25,7 @@ interface OrientationPanelProps { // Config for problem space validation digitRange?: { min: number; max: number } pAnyStart?: number - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' mode?: 'custom' | 'mastery' // Layout options problemNumbers?: 'always' | 'never' diff --git a/apps/web/src/app/create/worksheets/components/WorksheetConfigContext.tsx b/apps/web/src/app/create/worksheets/components/WorksheetConfigContext.tsx index e05d3416..3d99cea0 100644 --- a/apps/web/src/app/create/worksheets/components/WorksheetConfigContext.tsx +++ b/apps/web/src/app/create/worksheets/components/WorksheetConfigContext.tsx @@ -10,7 +10,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types' export interface WorksheetConfigContextValue { formState: WorksheetFormState onChange: (updates: Partial) => void - operator: 'addition' | 'subtraction' | 'mixed' + operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' isReadOnly?: boolean } 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..0f705afe 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,8 +15,9 @@ const sizeMap = { xl: 'xl', } as const -function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string { +function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string { if (operator === 'mixed') return '±' + if (operator === 'fractions') 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..30a3ec80 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,8 +11,13 @@ 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 (fractionsChecked) { + onChange(checked ? 'addition' : 'subtraction') + return + } if (!checked && !subtractionChecked) { // Can't uncheck if it's the only one checked return @@ -27,6 +32,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator } const handleSubtractionChange = (checked: boolean) => { + if (fractionsChecked) { + onChange(checked ? 'subtraction' : 'addition') + return + } if (!checked && !additionChecked) { // Can't uncheck if it's the only one checked return @@ -187,6 +196,69 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator + + {/* Fractions Selector */} +

- {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-panel/RuleThermometer.tsx b/apps/web/src/app/create/worksheets/components/config-panel/RuleThermometer.tsx index a6b29cd8..e0f62282 100644 --- a/apps/web/src/app/create/worksheets/components/config-panel/RuleThermometer.tsx +++ b/apps/web/src/app/create/worksheets/components/config-panel/RuleThermometer.tsx @@ -102,7 +102,7 @@ export function RuleThermometer({ const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value // Determine which operator to show - let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null + let operatorToShow: 'addition' | 'subtraction' | 'mixed' | 'fractions' | null = null if (isAutoResolvedMixed) { if (additionDefersHere && subtractionDefersHere) { operatorToShow = 'mixed' // Both defer here diff --git a/apps/web/src/app/create/worksheets/components/config-panel/utils.tsx b/apps/web/src/app/create/worksheets/components/config-panel/utils.tsx index 538e49fd..b868479c 100644 --- a/apps/web/src/app/create/worksheets/components/config-panel/utils.tsx +++ b/apps/web/src/app/create/worksheets/components/config-panel/utils.tsx @@ -9,7 +9,7 @@ import { css } from '@styled/css' */ export function getScaffoldingSummary( displayRules: any, - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' ): React.ReactNode { console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator) diff --git a/apps/web/src/app/create/worksheets/components/config-sidebar/ProblemPreview.tsx b/apps/web/src/app/create/worksheets/components/config-sidebar/ProblemPreview.tsx index 791c7f1d..a10c81bf 100644 --- a/apps/web/src/app/create/worksheets/components/config-sidebar/ProblemPreview.tsx +++ b/apps/web/src/app/create/worksheets/components/config-sidebar/ProblemPreview.tsx @@ -8,7 +8,7 @@ import type { DisplayRules } from '../../displayRules' interface ProblemPreviewProps { displayRules: DisplayRules resolvedDisplayRules?: DisplayRules - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' digitRange?: { min: number; max: number } className?: string } 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..6139919a 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 @@ -20,7 +20,7 @@ export interface Tab { pages?: number displayRules?: DisplayRules resolvedDisplayRules?: DisplayRules - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' }) => string | null } @@ -30,6 +30,7 @@ export const TABS: Tab[] = [ label: 'Operator', icon: (operator) => { if (operator === 'mixed') return '±' + if (operator === 'fractions') 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..4c48e16a 100644 --- a/apps/web/src/app/create/worksheets/config-schemas.ts +++ b/apps/web/src/app/create/worksheets/config-schemas.ts @@ -343,7 +343,7 @@ const additionConfigV4BaseSchema = z.object({ }), // V4: Operator selection (addition, subtraction, or mixed) - operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'), + 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/difficultyProfiles.ts b/apps/web/src/app/create/worksheets/difficultyProfiles.ts index 2237d044..4c6ee96e 100644 --- a/apps/web/src/app/create/worksheets/difficultyProfiles.ts +++ b/apps/web/src/app/create/worksheets/difficultyProfiles.ts @@ -243,7 +243,7 @@ function describeScaffoldingChange( fromRules: DisplayRules, toRules: DisplayRules, direction: 'added' | 'reduced', - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' ): string { const changes: string[] = [] @@ -786,7 +786,7 @@ export function makeHarder( displayRules: DisplayRules }, mode: DifficultyMode = 'both', - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' ): { pAnyStart: number pAllStart: number @@ -989,7 +989,7 @@ export function makeEasier( displayRules: DisplayRules }, mode: DifficultyMode = 'both', - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' ): { pAnyStart: number pAllStart: number diff --git a/apps/web/src/app/create/worksheets/generatePreview.ts b/apps/web/src/app/create/worksheets/generatePreview.ts index d9302362..10f466c0 100644 --- a/apps/web/src/app/create/worksheets/generatePreview.ts +++ b/apps/web/src/app/create/worksheets/generatePreview.ts @@ -3,6 +3,7 @@ import { execSync } from 'child_process' import type { WorksheetFormState } from '@/app/create/worksheets/types' import { + generateFractionProblems, generateMasteryMixedProblems, generateMixedProblems, generateProblems, @@ -127,33 +128,36 @@ export async function generateWorksheetPreview( ) } else { // Standard problem generation - problems = - operator === 'addition' - ? generateProblems( - validatedConfig.total, - validatedConfig.pAnyStart, - validatedConfig.pAllStart, - validatedConfig.interpolate, - 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 - ) + if (operator === 'addition') { + problems = generateProblems( + validatedConfig.total, + validatedConfig.pAnyStart, + validatedConfig.pAllStart, + validatedConfig.interpolate, + validatedConfig.seed, + validatedConfig.digitRange + ) + } else if (operator === 'subtraction') { + problems = generateSubtractionProblems( + validatedConfig.total, + validatedConfig.digitRange, + validatedConfig.pAnyStart, + validatedConfig.pAllStart, + validatedConfig.interpolate, + validatedConfig.seed + ) + } else if (operator === 'fractions') { + problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed) + } else { + problems = generateMixedProblems( + validatedConfig.total, + validatedConfig.digitRange, + validatedConfig.pAnyStart, + validatedConfig.pAllStart, + validatedConfig.interpolate, + validatedConfig.seed + ) + } } console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`) @@ -328,6 +332,8 @@ export async function generateSinglePage( validatedConfig.interpolate, validatedConfig.seed ) + } else if (operator === 'fractions') { + problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed) } else if (operator === 'subtraction') { problems = generateSubtractionProblems( validatedConfig.total, diff --git a/apps/web/src/app/create/worksheets/problemGenerator.ts b/apps/web/src/app/create/worksheets/problemGenerator.ts index fd32c9e7..17b158fd 100644 --- a/apps/web/src/app/create/worksheets/problemGenerator.ts +++ b/apps/web/src/app/create/worksheets/problemGenerator.ts @@ -1269,3 +1269,45 @@ export function generateMixedProblems( return problems } + +function gcd(a: number, b: number): number { + return b === 0 ? Math.abs(a) : gcd(b, a % b) +} + +function simplifyFraction(numerator: number, denominator: number): { numerator: number; denominator: number } { + const divisor = gcd(numerator, denominator) + return { numerator: numerator / divisor, denominator: denominator / divisor } +} + +/** + * Generate addition problems with mixed denominators (fractions) + */ +export function generateFractionProblems(total: number, seed: number): FractionProblem[] { + const rand = createPRNG(seed) + const problems: FractionProblem[] = [] + + for (let i = 0; i < total; i++) { + let leftDen = randint(2, 12, rand) + let rightDen = randint(2, 12, rand) + + // Ensure mixed denominators + if (rightDen === leftDen) { + rightDen = ((rightDen % 12) + 1) + 1 // rotate to a different denominator between 2-13 + if (rightDen > 12) rightDen = 2 + } + + const leftNum = randint(1, leftDen - 1, rand) + const rightNum = randint(1, rightDen - 1, rand) + + const left = simplifyFraction(leftNum, leftDen) + const right = simplifyFraction(rightNum, rightDen) + + problems.push({ + left, + right, + operator: 'fraction', + }) + } + + return problems +} diff --git a/apps/web/src/app/create/worksheets/types.ts b/apps/web/src/app/create/worksheets/types.ts index bf06177c..ba298e72 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,19 @@ export interface SubtractionProblem { operator: 'sub' } +/** + * A single fraction addition problem with mixed denominators + */ +export interface FractionProblem { + left: { numerator: number; denominator: number } + right: { numerator: number; denominator: 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/typstGenerator.ts b/apps/web/src/app/create/worksheets/typstGenerator.ts index 2de3688c..c3b29992 100644 --- a/apps/web/src/app/create/worksheets/typstGenerator.ts +++ b/apps/web/src/app/create/worksheets/typstGenerator.ts @@ -1,6 +1,6 @@ // Typst document generator for addition worksheets -import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types' +import type { FractionProblem, WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types' import { resolveDisplayForProblem } from './displayRules' import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis' import { generateQRCodeSVG } from './qrCodeGenerator' @@ -116,6 +116,21 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee return pages } +function gcdInt(a: number, b: number): number { + return b === 0 ? Math.abs(a) : gcdInt(b, a % b) +} + +function addFractions(problem: FractionProblem): { numerator: number; denominator: number } { + const lcmDenominator = + (problem.left.denominator * problem.right.denominator) / + gcdInt(problem.left.denominator, problem.right.denominator) + const numerator = + problem.left.numerator * (lcmDenominator / problem.left.denominator) + + problem.right.numerator * (lcmDenominator / problem.right.denominator) + const divisor = gcdInt(numerator, lcmDenominator) + return { numerator: numerator / divisor, denominator: lcmDenominator / divisor } +} + /** * Calculate maximum number of digits in any problem on this page * Returns max digits across all operands (handles both addition and subtraction) @@ -448,9 +463,11 @@ ${(() => { function calculateAnswer(problem: WorksheetProblem): number { if (problem.operator === 'add') { return problem.a + problem.b - } else { + } else if (problem.operator === 'sub') { return problem.minuend - problem.subtrahend } + const fractionAnswer = addFractions(problem as FractionProblem) + return fractionAnswer.numerator / fractionAnswer.denominator } /** @@ -462,14 +479,164 @@ function formatProblemWithAnswer( index: number, showNumber: boolean ): string { - const answer = calculateAnswer(problem) + const prefix = showNumber ? `*${index + 1}.* ` : '' if (problem.operator === 'add') { - const prefix = showNumber ? `*${index + 1}.* ` : '' - return `${prefix}${problem.a} + ${problem.b} = *${answer}*` - } else { - const prefix = showNumber ? `*${index + 1}.* ` : '' - return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${answer}*` + return `${prefix}${problem.a} + ${problem.b} = *${calculateAnswer(problem)}*` } + if (problem.operator === 'sub') { + return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${calculateAnswer(problem)}*` + } + const answer = addFractions(problem as FractionProblem) + const left = (problem as FractionProblem).left + const right = (problem as FractionProblem).right + return `${prefix}${left.numerator}/${left.denominator} + ${right.numerator}/${right.denominator} = *${answer.numerator}/${answer.denominator}*` +} + +function generateFractionPageTypst( + config: WorksheetConfig, + pageProblems: WorksheetProblem[], + problemOffset: number, + rowsPerPage: number, + qrCodeSvg?: string, + shareCode?: string, + domain?: string +): string { + const actualRows = Math.ceil(pageProblems.length / config.cols) + const margin = 0.4 + const contentWidth = config.page.wIn - margin * 2 + const contentHeight = config.page.hIn - margin * 2 + const headerHeight = 0.35 + const availableHeight = contentHeight - headerHeight + const problemBoxHeight = availableHeight / actualRows + const problemBoxWidth = contentWidth / config.cols + + const problemsTypst = pageProblems + .map((p) => { + const fraction = p as FractionProblem + return ` (left_num: ${fraction.left.numerator}, left_den: ${fraction.left.denominator}, right_num: ${fraction.right.numerator}, right_den: ${fraction.right.denominator}),` + }) + .join('\n') + + const description = generateWorksheetDescription(config) + const brandDomain = domain || 'abaci.one' + const breadcrumb = 'Create › Worksheets' + + return String.raw` +// fraction-worksheet-page.typ (auto-generated) + +#set page( + width: ${config.page.wIn}in, + height: ${config.page.hIn}in, + margin: ${margin}in, + fill: white +) +#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math") + +#let problems = ( +${problemsTypst} +) + +#block(breakable: false)[ + #box( + width: 100%, + stroke: (bottom: 1pt + gray), + inset: (bottom: 2pt), + )[ + #grid( + columns: (1fr, auto), + column-gutter: 0.1in, + align: (left + top, right + top), + [ + #text(size: 0.85em, weight: "bold")[${description.title}] \\ + #text(size: 0.6em, fill: gray.darken(20%))[${description.scaffolding}] + ], + [ + #stack(dir: ttb, spacing: 1pt, align(right)[ + #text(size: 0.6em)[*Date:* ${config.date}] + ], align(right)[ + #text(size: 0.5em, fill: gray.darken(10%), weight: "medium")[${brandDomain}] + ], align(right)[ + #text(size: 0.4em, fill: gray)[${breadcrumb}] + ]) + ] + ) + ] + + #v(-0.25in) + + #let fraction-box(problem, index) = { + box( + inset: (top: 0pt, bottom: -${(problemBoxHeight / 3).toFixed(3)}in, left: 0pt, right: 0pt), + width: ${problemBoxWidth}in, + height: ${problemBoxHeight}in, + stroke: (thickness: 1pt, dash: "dashed", paint: gray.darken(20%)) + )[ + #if index != none { + place(top + left, dx: 0.02in, dy: 0.02in)[ + #text(size: ${(problemBoxHeight * 72 * 0.35).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).] + ] + } + #align(center + horizon)[ + #text(size: ${config.fontSize}pt)[#frac(problem.left_num, problem.left_den) + #frac(problem.right_num, problem.right_den) = ] + ] + ] + } + + #grid( + columns: ${config.cols}, + column-gutter: 0pt, + row-gutter: 0pt, + ..for r in range(0, ${actualRows}) { + for c in range(0, ${config.cols}) { + let idx = r * ${config.cols} + c + if idx < problems.len() { + (fraction-box(problems.at(idx), ${problemOffset} + idx),) + } else { + (box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),) + } + } + } + ) +] +` +} + +function generateFractionAnswerKeyTypst( + config: WorksheetConfig, + problems: WorksheetProblem[], + showProblemNumbers: boolean, + qrCodeSvg?: string, + shareCode?: string +): string[] { + const problemsPerPage = config.problemsPerPage + const worksheetPageCount = Math.ceil(problems.length / problemsPerPage) + const pages: string[] = [] + + for (let i = 0; i < worksheetPageCount; i++) { + const start = i * problemsPerPage + const pageProblems = problems.slice(start, start + problemsPerPage) + const answers = pageProblems + .map((problem, idx) => formatProblemWithAnswer(problem, start + idx, showProblemNumbers)) + .join(' \\\n') + + pages.push(String.raw` +#set page(width: ${config.page.wIn}in, height: ${config.page.hIn}in, margin: 0.6in) +#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math") + +#block(breakable: false)[ + #text(size: 12pt, weight: "bold")[Answer Key - Page ${i + 1}] \\ + ${answers} + ${ + qrCodeSvg + ? `\\ + #place(bottom + left, dx: 0.1in, dy: -0.1in)[#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])]` + : '' + } +] +`) + } + + return pages } /** @@ -622,6 +789,35 @@ export async function generateTypstSource( // Chunk problems into discrete pages const pages = chunkProblems(problems, problemsPerPage) + const hasFractionProblems = problems.some((p) => p.operator === 'fraction') + if (hasFractionProblems) { + const worksheetPages = pages.map((pageProblems, pageIndex) => + generateFractionPageTypst( + config, + pageProblems, + pageIndex * problemsPerPage, + rowsPerPage, + qrCodeSvg, + shareCode, + brandDomain + ) + ) + + if (config.includeAnswerKey) { + const showProblemNumbers = true + const answerPages = generateFractionAnswerKeyTypst( + config, + problems, + showProblemNumbers, + qrCodeSvg, + shareCode + ) + return [...worksheetPages, ...answerPages] + } + + return worksheetPages + } + // Generate separate Typst source for each worksheet page const worksheetPages = pages.map((pageProblems, pageIndex) => generatePageTypst( diff --git a/apps/web/src/app/create/worksheets/utils/settingsSummary.ts b/apps/web/src/app/create/worksheets/utils/settingsSummary.ts index a981cf83..7e0f7d6b 100644 --- a/apps/web/src/app/create/worksheets/utils/settingsSummary.ts +++ b/apps/web/src/app/create/worksheets/utils/settingsSummary.ts @@ -10,6 +10,7 @@ export const SETTING_ICONS = { multiplication: '×', division: '÷', mixed: '±', + fractions: '¾', }, difficulty: { smart: '🎯', diff --git a/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts b/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts index 42e6e102..75902085 100644 --- a/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts +++ b/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts @@ -112,7 +112,7 @@ export function validateProblemSpace( pages: number, digitRange: { min: number; max: number }, pAnyStart: number, - operator: 'addition' | 'subtraction' | 'mixed' + operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' ): ProblemSpaceValidation { const requestedProblems = problemsPerPage * pages const warnings: string[] = [] @@ -123,6 +123,9 @@ export function validateProblemSpace( const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition') const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction') estimatedSpace = addSpace + subSpace + } else if (operator === 'fractions') { + // Fractions have a very large combinatorial space when denominators are unconstrained + estimatedSpace = 1000000 } else { estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator) } diff --git a/apps/web/src/app/create/worksheets/validation.ts b/apps/web/src/app/create/worksheets/validation.ts index 91520dd3..fac67708 100644 --- a/apps/web/src/app/create/worksheets/validation.ts +++ b/apps/web/src/app/create/worksheets/validation.ts @@ -167,8 +167,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati // V4: Validate operator (addition, subtraction, or mixed) 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)