Fix fraction answer key QR placement syntax

This commit is contained in:
Thomas Hallock 2025-12-11 04:32:59 -06:00
parent 9159608dcd
commit aca08b1aec
18 changed files with 395 additions and 62 deletions

View File

@ -140,7 +140,7 @@ async function fetchExample(options: {
showTenFrames: boolean
showTenFramesForAll: boolean
showBorrowNotation: boolean
operator: 'addition' | 'subtraction' | 'mixed'
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
addend1?: number
addend2?: number
minuend?: number

View File

@ -25,7 +25,7 @@ interface OrientationPanelProps {
// Config for problem space validation
digitRange?: { min: number; max: number }
pAnyStart?: number
operator?: 'addition' | 'subtraction' | 'mixed'
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
mode?: 'custom' | 'mastery'
// Layout options
problemNumbers?: 'always' | 'never'

View File

@ -10,7 +10,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
export interface WorksheetConfigContextValue {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
operator: 'addition' | 'subtraction' | 'mixed'
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
isReadOnly?: boolean
}

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,8 +15,9 @@ const sizeMap = {
xl: 'xl',
} as const
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string {
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string {
if (operator === 'mixed') return '±'
if (operator === 'fractions') 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,8 +11,13 @@ 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 (fractionsChecked) {
onChange(checked ? 'addition' : 'subtraction')
return
}
if (!checked && !subtractionChecked) {
// Can't uncheck if it's the only one checked
return
@ -27,6 +32,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
}
const handleSubtractionChange = (checked: boolean) => {
if (fractionsChecked) {
onChange(checked ? 'subtraction' : 'addition')
return
}
if (!checked && !additionChecked) {
// Can't uncheck if it's the only one checked
return
@ -187,6 +196,69 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
</span>
</div>
</label>
{/* Fractions Selector */}
<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={(e) => onChange(e.target.checked ? 'fractions' : 'addition')}
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} />
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.200' : 'gray.700',
'@media (max-width: 200px)': {
fontSize: 'xs',
},
})}
>
Mixed denominator fractions
</span>
</div>
</label>
</div>
<p
@ -196,11 +268,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
lineHeight: '1.5',
})}
>
{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'}
</p>
</div>
)

View File

@ -102,7 +102,7 @@ export function RuleThermometer({
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
// Determine which operator to show
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | 'fractions' | null = null
if (isAutoResolvedMixed) {
if (additionDefersHere && subtractionDefersHere) {
operatorToShow = 'mixed' // Both defer here

View File

@ -9,7 +9,7 @@ import { css } from '@styled/css'
*/
export function getScaffoldingSummary(
displayRules: any,
operator?: 'addition' | 'subtraction' | 'mixed'
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): React.ReactNode {
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)

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,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
@ -20,7 +20,7 @@ export interface Tab {
pages?: number
displayRules?: DisplayRules
resolvedDisplayRules?: DisplayRules
operator?: 'addition' | 'subtraction' | 'mixed'
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
}) => string | null
}
@ -30,6 +30,7 @@ export const TABS: Tab[] = [
label: 'Operator',
icon: (operator) => {
if (operator === 'mixed') return '±'
if (operator === 'fractions') 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

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

@ -3,6 +3,7 @@
import { execSync } from 'child_process'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import {
generateFractionProblems,
generateMasteryMixedProblems,
generateMixedProblems,
generateProblems,
@ -127,33 +128,36 @@ export async function generateWorksheetPreview(
)
} else {
// Standard problem generation
problems =
operator === 'addition'
? generateProblems(
validatedConfig.total,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed,
validatedConfig.digitRange
)
: operator === 'subtraction'
? generateSubtractionProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
: generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
if (operator === 'addition') {
problems = generateProblems(
validatedConfig.total,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed,
validatedConfig.digitRange
)
} else if (operator === 'subtraction') {
problems = generateSubtractionProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
} else if (operator === 'fractions') {
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
} else {
problems = generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
}
}
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
@ -328,6 +332,8 @@ export async function generateSinglePage(
validatedConfig.interpolate,
validatedConfig.seed
)
} else if (operator === 'fractions') {
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
} else if (operator === 'subtraction') {
problems = generateSubtractionProblems(
validatedConfig.total,

View File

@ -1269,3 +1269,45 @@ export function generateMixedProblems(
return problems
}
function gcd(a: number, b: number): number {
return b === 0 ? Math.abs(a) : gcd(b, a % b)
}
function simplifyFraction(numerator: number, denominator: number): { numerator: number; denominator: number } {
const divisor = gcd(numerator, denominator)
return { numerator: numerator / divisor, denominator: denominator / divisor }
}
/**
* Generate addition problems with mixed denominators (fractions)
*/
export function generateFractionProblems(total: number, seed: number): FractionProblem[] {
const rand = createPRNG(seed)
const problems: FractionProblem[] = []
for (let i = 0; i < total; i++) {
let leftDen = randint(2, 12, rand)
let rightDen = randint(2, 12, rand)
// Ensure mixed denominators
if (rightDen === leftDen) {
rightDen = ((rightDen % 12) + 1) + 1 // rotate to a different denominator between 2-13
if (rightDen > 12) rightDen = 2
}
const leftNum = randint(1, leftDen - 1, rand)
const rightNum = randint(1, rightDen - 1, rand)
const left = simplifyFraction(leftNum, leftDen)
const right = simplifyFraction(rightNum, rightDen)
problems.push({
left,
right,
operator: 'fraction',
})
}
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,19 @@ export interface SubtractionProblem {
operator: 'sub'
}
/**
* A single fraction addition problem with mixed denominators
*/
export interface FractionProblem {
left: { numerator: number; denominator: number }
right: { numerator: number; denominator: number }
operator: 'fraction'
}
/**
* 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'
@ -116,6 +116,21 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
return pages
}
function gcdInt(a: number, b: number): number {
return b === 0 ? Math.abs(a) : gcdInt(b, a % b)
}
function addFractions(problem: FractionProblem): { numerator: number; denominator: number } {
const lcmDenominator =
(problem.left.denominator * problem.right.denominator) /
gcdInt(problem.left.denominator, problem.right.denominator)
const numerator =
problem.left.numerator * (lcmDenominator / problem.left.denominator) +
problem.right.numerator * (lcmDenominator / problem.right.denominator)
const divisor = gcdInt(numerator, lcmDenominator)
return { numerator: numerator / divisor, denominator: lcmDenominator / divisor }
}
/**
* Calculate maximum number of digits in any problem on this page
* Returns max digits across all operands (handles both addition and subtraction)
@ -448,9 +463,11 @@ ${(() => {
function calculateAnswer(problem: WorksheetProblem): number {
if (problem.operator === 'add') {
return problem.a + problem.b
} else {
} else if (problem.operator === 'sub') {
return problem.minuend - problem.subtrahend
}
const fractionAnswer = addFractions(problem as FractionProblem)
return fractionAnswer.numerator / fractionAnswer.denominator
}
/**
@ -462,14 +479,164 @@ function formatProblemWithAnswer(
index: number,
showNumber: boolean
): string {
const answer = calculateAnswer(problem)
const prefix = showNumber ? `*${index + 1}.* ` : ''
if (problem.operator === 'add') {
const prefix = showNumber ? `*${index + 1}.* ` : ''
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
} else {
const prefix = showNumber ? `*${index + 1}.* ` : ''
return `${prefix}${problem.minuend} ${problem.subtrahend} = *${answer}*`
return `${prefix}${problem.a} + ${problem.b} = *${calculateAnswer(problem)}*`
}
if (problem.operator === 'sub') {
return `${prefix}${problem.minuend} ${problem.subtrahend} = *${calculateAnswer(problem)}*`
}
const answer = addFractions(problem as FractionProblem)
const left = (problem as FractionProblem).left
const right = (problem as FractionProblem).right
return `${prefix}${left.numerator}/${left.denominator} + ${right.numerator}/${right.denominator} = *${answer.numerator}/${answer.denominator}*`
}
function generateFractionPageTypst(
config: WorksheetConfig,
pageProblems: WorksheetProblem[],
problemOffset: number,
rowsPerPage: number,
qrCodeSvg?: string,
shareCode?: string,
domain?: string
): string {
const actualRows = Math.ceil(pageProblems.length / config.cols)
const margin = 0.4
const contentWidth = config.page.wIn - margin * 2
const contentHeight = config.page.hIn - margin * 2
const headerHeight = 0.35
const availableHeight = contentHeight - headerHeight
const problemBoxHeight = availableHeight / actualRows
const problemBoxWidth = contentWidth / config.cols
const problemsTypst = pageProblems
.map((p) => {
const fraction = p as FractionProblem
return ` (left_num: ${fraction.left.numerator}, left_den: ${fraction.left.denominator}, right_num: ${fraction.right.numerator}, right_den: ${fraction.right.denominator}),`
})
.join('\n')
const description = generateWorksheetDescription(config)
const brandDomain = domain || 'abaci.one'
const breadcrumb = 'Create Worksheets'
return String.raw`
// fraction-worksheet-page.typ (auto-generated)
#set page(
width: ${config.page.wIn}in,
height: ${config.page.hIn}in,
margin: ${margin}in,
fill: white
)
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
#let problems = (
${problemsTypst}
)
#block(breakable: false)[
#box(
width: 100%,
stroke: (bottom: 1pt + gray),
inset: (bottom: 2pt),
)[
#grid(
columns: (1fr, auto),
column-gutter: 0.1in,
align: (left + top, right + top),
[
#text(size: 0.85em, weight: "bold")[${description.title}] \\
#text(size: 0.6em, fill: gray.darken(20%))[${description.scaffolding}]
],
[
#stack(dir: ttb, spacing: 1pt, align(right)[
#text(size: 0.6em)[*Date:* ${config.date}]
], align(right)[
#text(size: 0.5em, fill: gray.darken(10%), weight: "medium")[${brandDomain}]
], align(right)[
#text(size: 0.4em, fill: gray)[${breadcrumb}]
])
]
)
]
#v(-0.25in)
#let fraction-box(problem, index) = {
box(
inset: (top: 0pt, bottom: -${(problemBoxHeight / 3).toFixed(3)}in, left: 0pt, right: 0pt),
width: ${problemBoxWidth}in,
height: ${problemBoxHeight}in,
stroke: (thickness: 1pt, dash: "dashed", paint: gray.darken(20%))
)[
#if index != none {
place(top + left, dx: 0.02in, dy: 0.02in)[
#text(size: ${(problemBoxHeight * 72 * 0.35).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
]
}
#align(center + horizon)[
#text(size: ${config.fontSize}pt)[#frac(problem.left_num, problem.left_den) + #frac(problem.right_num, problem.right_den) = ]
]
]
}
#grid(
columns: ${config.cols},
column-gutter: 0pt,
row-gutter: 0pt,
..for r in range(0, ${actualRows}) {
for c in range(0, ${config.cols}) {
let idx = r * ${config.cols} + c
if idx < problems.len() {
(fraction-box(problems.at(idx), ${problemOffset} + idx),)
} else {
(box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),)
}
}
}
)
]
`
}
function generateFractionAnswerKeyTypst(
config: WorksheetConfig,
problems: WorksheetProblem[],
showProblemNumbers: boolean,
qrCodeSvg?: string,
shareCode?: string
): string[] {
const problemsPerPage = config.problemsPerPage
const worksheetPageCount = Math.ceil(problems.length / problemsPerPage)
const pages: string[] = []
for (let i = 0; i < worksheetPageCount; i++) {
const start = i * problemsPerPage
const pageProblems = problems.slice(start, start + problemsPerPage)
const answers = pageProblems
.map((problem, idx) => formatProblemWithAnswer(problem, start + idx, showProblemNumbers))
.join(' \\\n')
pages.push(String.raw`
#set page(width: ${config.page.wIn}in, height: ${config.page.hIn}in, margin: 0.6in)
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
#block(breakable: false)[
#text(size: 12pt, weight: "bold")[Answer Key - Page ${i + 1}] \\
${answers}
${
qrCodeSvg
? `\\
#place(bottom + left, dx: 0.1in, dy: -0.1in)[#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'}]])]`
: ''
}
]
`)
}
return pages
}
/**
@ -622,6 +789,35 @@ export async function generateTypstSource(
// Chunk problems into discrete pages
const pages = chunkProblems(problems, problemsPerPage)
const hasFractionProblems = problems.some((p) => p.operator === 'fraction')
if (hasFractionProblems) {
const worksheetPages = pages.map((pageProblems, pageIndex) =>
generateFractionPageTypst(
config,
pageProblems,
pageIndex * problemsPerPage,
rowsPerPage,
qrCodeSvg,
shareCode,
brandDomain
)
)
if (config.includeAnswerKey) {
const showProblemNumbers = true
const answerPages = generateFractionAnswerKeyTypst(
config,
problems,
showProblemNumbers,
qrCodeSvg,
shareCode
)
return [...worksheetPages, ...answerPages]
}
return worksheetPages
}
// Generate separate Typst source for each worksheet page
const worksheetPages = pages.map((pageProblems, pageIndex) =>
generatePageTypst(

View File

@ -10,6 +10,7 @@ export const SETTING_ICONS = {
multiplication: '×',
division: '÷',
mixed: '±',
fractions: '¾',
},
difficulty: {
smart: '🎯',

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,6 +123,9 @@ export function validateProblemSpace(
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
estimatedSpace = addSpace + subSpace
} else if (operator === 'fractions') {
// Fractions have a very large combinatorial space when denominators are unconstrained
estimatedSpace = 1000000
} else {
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
}

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", "mixed", or "fractions"')
}
// Validate seed (must be positive integer)