Add mixed-denominator fraction worksheets

This commit is contained in:
Thomas Hallock 2025-12-11 04:25:14 -06:00
parent 9159608dcd
commit 52f33ee276
11 changed files with 327 additions and 24 deletions

View File

@ -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
</span>
</div>
</label>
{/* Fractions Toggle */}
<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:
operator === 'fractions'
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
borderColor: operator === 'fractions' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
_hover: {
borderColor: 'brand.400',
},
})}
>
<input
type="radio"
checked={operator === 'fractions'}
onChange={() => onChange('fractions')}
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="addition" isDark={isDark} />
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
Mixed Denominator Fractions
</span>
</div>
</label>
</div>
<p
@ -196,7 +257,9 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
lineHeight: '1.5',
})}
>
{additionChecked && subtractionChecked
{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'

View File

@ -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
}

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
/**
* 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

View File

@ -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<string[]> {
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<string[]> {
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
}

View File

@ -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) {

View File

@ -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)