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 */}
+
+ onChange('fractions')}
+ className={css({
+ width: '5',
+ height: '5',
+ cursor: 'pointer',
+ accentColor: 'brand.600',
+ flexShrink: 0,
+ })}
+ />
+
+
+
+ Mixed Denominator Fractions
+
+
+
- {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)