Compare commits

...

1 Commits

Author SHA1 Message Date
Thomas Hallock 2f841f2435 Add mixed denominator fractions worksheet option 2025-12-11 04:22:29 -06:00
8 changed files with 268 additions and 33 deletions

View File

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

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
}
@ -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 (
<div data-section="operator-selection">
<label
@ -187,6 +192,85 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
</span>
</div>
</label>
{/* Mixed-Denominator Fractions */}
<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: fractionsChecked
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
borderColor: fractionsChecked ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
_hover: {
borderColor: 'brand.400',
},
})}
>
<input
type="radio"
checked={fractionsChecked}
onChange={handleFractionsSelect}
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="fractions" isDark={isDark} />
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
'@media (max-width: 200px)': {
fontSize: 'xs',
},
})}
>
Mixed Denominator Fractions
</span>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build fluency adding unlike denominators.
</span>
</div>
</div>
</label>
</div>
<p
@ -196,7 +280,9 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
lineHeight: '1.5',
})}
>
{additionChecked && subtractionChecked
{fractionsChecked
? 'All problems will be mixed-denominator fraction addition'
: additionChecked && subtractionChecked
? 'Problems will randomly use addition or subtraction'
: subtractionChecked
? 'All problems will be subtraction'

View File

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

View File

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

View File

@ -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<typeof generateFractionProblems>,
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 `<g transform="translate(${x}, ${y})">
<text text-anchor="middle" dominant-baseline="middle" font-size="22" font-family="Inter, Arial" fill="#111827">${
(pageIndex * (config.problemsPerPage ?? 20)) + index + 1
}.) ${label}</text>
</g>`
})
.join('\n')
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="white"/>
<text x="${margin}" y="${margin - 16}" font-size="18" font-family="Inter, Arial" fill="#374151">
${config.name || 'Fraction Practice'} Page ${pageIndex + 1}
</text>
${problemTexts}
</svg>`
}
export interface PreviewResult {
success: boolean
pages?: string[]
@ -64,7 +108,10 @@ export async function generateWorksheetPreview(
// Validate problem space for duplicate risk
const operator = validatedConfig.operator ?? 'addition'
const spaceValidation = validateProblemSpace(
const spaceValidation =
operator === 'fractions'
? { warnings: [] }
: validateProblemSpace(
validatedConfig.problemsPerPage,
validatedConfig.pages,
validatedConfig.digitRange,
@ -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,6 +194,8 @@ export async function generateWorksheetPreview(
validatedConfig.interpolate,
validatedConfig.seed
)
: operator === 'fractions'
? generateFractionProblems(validatedConfig.total, rand)
: generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
@ -158,6 +208,38 @@ export async function generateWorksheetPreview(
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

View File

@ -2,6 +2,7 @@
import type {
AdditionProblem,
FractionProblem,
ProblemCategory,
SubtractionProblem,
WorksheetProblem,
@ -40,6 +41,42 @@ function shuffleArray<T>(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)
*/

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'
}
/**
* 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

View File

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