From 52f33ee276a4259b6bd0dfc7bdf0777d3f7a36c5 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 04:25:14 -0600 Subject: [PATCH] Add mixed-denominator fraction worksheets --- .../config-panel/OperatorSection.tsx | 77 +++++++- .../config-sidebar/ProblemPreview.tsx | 2 +- .../config-sidebar/TabNavigation.tsx | 10 +- .../app/create/worksheets/config-schemas.ts | 2 +- .../create/worksheets/difficultyProfiles.ts | 6 +- .../app/create/worksheets/generatePreview.ts | 5 +- .../app/create/worksheets/problemGenerator.ts | 47 +++++ apps/web/src/app/create/worksheets/types.ts | 15 +- .../app/create/worksheets/typstGenerator.ts | 176 +++++++++++++++++- .../worksheets/utils/validateProblemSpace.ts | 7 +- .../src/app/create/worksheets/validation.ts | 4 +- 11 files changed, 327 insertions(+), 24 deletions(-) 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..b305e9e4 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 } @@ -187,6 +187,67 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator + + {/* Fractions Toggle */} +

- {additionChecked && subtractionChecked - ? 'Problems will randomly use addition or subtraction' - : subtractionChecked - ? 'All problems will be subtraction' - : 'All problems will be addition'} + {operator === 'fractions' + ? 'All problems will add fractions with different denominators' + : 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/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..dd77d874 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,10 @@ 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 +23,7 @@ export interface Tab { pages?: number displayRules?: DisplayRules resolvedDisplayRules?: DisplayRules - operator?: 'addition' | 'subtraction' | 'mixed' + operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions' }) => string | null } @@ -31,6 +34,7 @@ export const TABS: Tab[] = [ icon: (operator) => { if (operator === 'mixed') return '±' if (operator === 'subtraction') return '−' + if (operator === 'fractions') return '⅟' return '+' }, }, @@ -115,7 +119,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..4abd6f0b 100644 --- a/apps/web/src/app/create/worksheets/generatePreview.ts +++ b/apps/web/src/app/create/worksheets/generatePreview.ts @@ -5,6 +5,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types' import { generateMasteryMixedProblems, generateMixedProblems, + generateFractionProblems, generateProblems, generateSubtractionProblems, } from './problemGenerator' @@ -86,7 +87,9 @@ export async function generateWorksheetPreview( let problems // Special handling for mastery + mixed mode - if (mode === 'mastery' && operator === 'mixed') { + if (operator === 'fractions') { + problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed ?? Date.now()) + } else if (mode === 'mastery' && operator === 'mixed') { // Query both skill configs const addSkillId = config.currentAdditionSkillId const subSkillId = config.currentSubtractionSkillId diff --git a/apps/web/src/app/create/worksheets/problemGenerator.ts b/apps/web/src/app/create/worksheets/problemGenerator.ts index fd32c9e7..916628dc 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, @@ -1269,3 +1270,49 @@ export function generateMixedProblems( return problems } + +export function generateFractionProblems( + total: number, + seed: number, + options: { + minNumerator?: number + maxNumerator?: number + minDenominator?: number + maxDenominator?: number + } = {} +): FractionProblem[] { + const { + minNumerator = 1, + maxNumerator = 9, + minDenominator = 2, + maxDenominator = 12, + } = options + + const rand = createPRNG(seed) + const problems: FractionProblem[] = [] + + const randomFraction = () => { + const denominator = randint(minDenominator, maxDenominator, rand) + const numerator = randint(minNumerator, Math.max(minNumerator, denominator - 1), rand) + return { numerator, denominator } + } + + for (let i = 0; i < total; i++) { + let a = randomFraction() + let b = randomFraction() + + while (a.denominator === b.denominator) { + b = randomFraction() + } + + problems.push({ + aNumerator: a.numerator, + aDenominator: a.denominator, + bNumerator: b.numerator, + bDenominator: b.denominator, + operator: 'fractions', + }) + } + + return problems +} diff --git a/apps/web/src/app/create/worksheets/types.ts b/apps/web/src/app/create/worksheets/types.ts index bf06177c..da8990ab 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' } +/** + * Fraction addition problem (mixed denominators) + */ +export interface FractionProblem { + aNumerator: number + aDenominator: number + bNumerator: number + bDenominator: number + operator: 'fractions' +} + /** * 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..2dda0e0d 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' @@ -24,6 +24,13 @@ function generateWorksheetDescription(config: WorksheetConfig): { title: string scaffolding: string } { + if (config.operator === 'fractions') { + return { + title: 'Fraction addition (mixed denominators)', + scaffolding: 'No scaffolding elements required', + } + } + // Line 1: Digit range + operator + regrouping percentage const parts: string[] = [] @@ -116,6 +123,26 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee return pages } +function gcd(a: number, b: number): number { + return b === 0 ? Math.abs(a) : gcd(b, a % b) +} + +function lcm(a: number, b: number): number { + return Math.abs((a * b) / gcd(a, b)) +} + +function addAndSimplifyFractions(problem: FractionProblem) { + const commonDenominator = lcm(problem.aDenominator, problem.bDenominator) + const numerator = + problem.aNumerator * (commonDenominator / problem.aDenominator) + + problem.bNumerator * (commonDenominator / problem.bDenominator) + const divisor = gcd(numerator, commonDenominator) + return { + numerator: numerator / divisor, + denominator: commonDenominator / divisor, + } +} + /** * Calculate maximum number of digits in any problem on this page * Returns max digits across all operands (handles both addition and subtraction) @@ -128,12 +155,21 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number { const digitsB = problem.b.toString().length const maxProblemDigits = Math.max(digitsA, digitsB) maxDigits = Math.max(maxDigits, maxProblemDigits) - } else { + } else if (problem.operator === 'sub') { // Subtraction const digitsMinuend = problem.minuend.toString().length const digitsSubtrahend = problem.subtrahend.toString().length const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend) maxDigits = Math.max(maxDigits, maxProblemDigits) + } else { + const fractionProblem = problem as FractionProblem + const maxProblemDigits = Math.max( + fractionProblem.aNumerator.toString().length, + fractionProblem.aDenominator.toString().length, + fractionProblem.bNumerator.toString().length, + fractionProblem.bDenominator.toString().length + ) + maxDigits = Math.max(maxDigits, maxProblemDigits) } } return maxDigits @@ -596,6 +632,12 @@ export async function generateTypstSource( shareUrl?: string, domain?: string ): Promise { + const hasFractionProblems = problems.some((p) => (p as WorksheetProblem).operator === 'fractions') + + if (hasFractionProblems) { + return await generateFractionTypstSource(config, problems as FractionProblem[], shareUrl, domain) + } + // Use the problemsPerPage directly from config (primary state) const problemsPerPage = config.problemsPerPage const rowsPerPage = problemsPerPage / config.cols @@ -651,3 +693,133 @@ export async function generateTypstSource( return worksheetPages } + +function generateFractionPageTypst( + config: WorksheetConfig, + pageProblems: FractionProblem[], + problemOffset: number, + qrCodeSvg?: string, + shareCode?: string, + domain?: string +): string { + const margin = 0.4 + const problemData = pageProblems + .map((p, idx) => { + const answer = addAndSimplifyFractions(p) + return ` (number: ${problemOffset + idx + 1}, aNum: ${p.aNumerator}, aDen: ${p.aDenominator}, bNum: ${p.bNumerator}, bDen: ${p.bDenominator}, ansNum: ${answer.numerator}, ansDen: ${answer.denominator}),` + }) + .join('\n') + + return `#let fraction-problems = ( +${problemData} +) + +#page( + paper: (${config.page.wIn}in, ${config.page.hIn}in), + margin: (${margin}in, ${margin}in), + header: align(center)[ + #text(size: 14pt, weight: 'bold')[${config.name}] + #v(6pt) + #text(size: 10pt)[Fraction Addition (Mixed Denominators)] + ], + footer: align(center)[ + #text(size: 8pt)[${domain || 'abaci.one'}] + ], +)[ + #grid(columns: ${config.cols}, gutter: 0.3in)[ + ..for p in fraction-problems { + box(stroke: 0.5pt, inset: 10pt)[ + #stack(dir: ttb, spacing: 6pt)[ + #align(center)[ + #text(size: 13pt)[#frac(p.aNum, p.aDen) + #frac(p.bNum, p.bDen) = \h(30pt)] + ] + #align(right)[#text(size: 8pt)[#p.number]] + ] + ] + } + ] + ${ + qrCodeSvg + ? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#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'}]])]` + : '' + } +] +` +} + +function generateFractionAnswerKey( + config: WorksheetConfig, + problems: FractionProblem[], + qrCodeSvg?: string, + shareCode?: string +): string[] { + const margin = 0.4 + const answerLines = problems + .map((p, idx) => { + const answer = addAndSimplifyFractions(p) + return ` #text(size: 11pt)[${idx + 1}. #frac(${p.aNumerator}, ${p.aDenominator}) + #frac(${p.bNumerator}, ${p.bDenominator}) = #frac(${answer.numerator}, ${answer.denominator})]` + }) + .join('\n') + + const qrBlock = + qrCodeSvg && shareCode + ? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#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}])])]` + : '' + + return [ + `#page( + paper: (${config.page.wIn}in, ${config.page.hIn}in), + margin: (${margin}in, ${margin}in), + header: align(center)[#text(size: 14pt, weight: 'bold')[Answer Key - Fractions]], +)[ + #stack(dir: ttb, spacing: 6pt)[ +${answerLines} + ] + ${qrBlock} +] +`, + ] +} + +async function generateFractionTypstSource( + config: WorksheetConfig, + problems: FractionProblem[], + shareUrl?: string, + domain?: string +): Promise { + const problemsPerPage = config.problemsPerPage + const pages = chunkProblems(problems, problemsPerPage) + + let qrCodeSvg: string | undefined + let shareCode: string | undefined + if (config.includeQRCode && shareUrl) { + qrCodeSvg = await generateQRCodeSVG(shareUrl, 200) + shareCode = extractShareCode(shareUrl) + } + + let brandDomain = domain + if (!brandDomain && shareUrl) { + try { + brandDomain = new URL(shareUrl).hostname + } catch { + // Ignore invalid URL and fall back to default + } + } + + const fractionPages = pages.map((page, pageIndex) => + generateFractionPageTypst( + config, + page, + pageIndex * problemsPerPage, + qrCodeSvg, + shareCode, + brandDomain + ) + ) + + if (config.includeAnswerKey) { + return [...fractionPages, ...generateFractionAnswerKey(config, problems, qrCodeSvg, shareCode)] + } + + return fractionPages +} diff --git a/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts b/apps/web/src/app/create/worksheets/utils/validateProblemSpace.ts index 42e6e102..09c1de7a 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,12 +123,15 @@ export function validateProblemSpace( const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition') const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction') estimatedSpace = addSpace + subSpace + } else if (operator === 'fractions') { + // Fractions have extremely large combinatorial space; treat as effectively infinite + estimatedSpace = Number.POSITIVE_INFINITY } else { estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator) } // Calculate duplicate risk - const ratio = requestedProblems / estimatedSpace + const ratio = estimatedSpace === Number.POSITIVE_INFINITY ? 0 : requestedProblems / estimatedSpace let duplicateRisk: 'none' | 'low' | 'medium' | 'high' | 'extreme' if (ratio < 0.3) { diff --git a/apps/web/src/app/create/worksheets/validation.ts b/apps/web/src/app/create/worksheets/validation.ts index 91520dd3..18a02165 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", "fractions", or "mixed"') } // Validate seed (must be positive integer)