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 */}
+
+
+
+
+
+
+ Mixed Denominator Fractions
+
+
+ Build fluency adding unlike denominators.
+
+
+
+
- {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)