feat(worksheets): add double-digit addition worksheet creator

- Problem generator with tunable difficulty (regrouping percentages)
- Progressive difficulty option (easy → hard)
- Mulberry32 PRNG for reproducible problem sets
- Validation and type definitions
- Main page component with layout configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-05 13:25:57 -06:00
parent d8b4951d63
commit 1a75213df0
4 changed files with 698 additions and 0 deletions

View File

@ -0,0 +1,337 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { container, grid, hstack, stack } from '../../../../../styled-system/patterns'
import { ConfigPanel } from './components/ConfigPanel'
import { WorksheetPreview } from './components/WorksheetPreview'
import type { WorksheetFormState } from './types'
import { validateWorksheetConfig } from './validation'
type GenerationStatus = 'idle' | 'generating' | 'error'
/**
* Get current date formatted as "Month Day, Year"
*/
function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
export default function AdditionWorksheetPage() {
const t = useTranslations('create.worksheets.addition')
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
// Immediate form state (for controls - updates instantly)
const [formState, setFormState] = useState<WorksheetFormState>({
total: 20,
cols: 5,
rows: 4,
name: '',
date: '', // Will be set at generation time
pAnyStart: 0.75,
pAllStart: 0.25,
interpolate: true,
showCarryBoxes: true,
showCellBorder: true,
fontSize: 16,
seed: Date.now() % 2147483647,
orientation: 'landscape',
})
// Debounced form state (for preview - updates after delay)
const [debouncedFormState, setDebouncedFormState] = useState<WorksheetFormState>(formState)
// Debounce preview updates (500ms delay)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedFormState(formState)
}, 500)
return () => clearTimeout(timer)
}, [formState])
const handleFormChange = (updates: Partial<WorksheetFormState>) => {
setFormState((prev) => {
const newState = { ...prev, ...updates }
// Generate new seed when problem settings change
const affectsProblems =
updates.total !== undefined ||
updates.cols !== undefined ||
updates.rows !== undefined ||
updates.orientation !== undefined ||
updates.pAnyStart !== undefined ||
updates.pAllStart !== undefined ||
updates.interpolate !== undefined
if (affectsProblems) {
newState.seed = Date.now() % 2147483647
}
return newState
})
}
const handleGenerate = async () => {
setGenerationStatus('generating')
setError(null)
try {
// Set current date at generation time
const configWithDate = {
...formState,
date: getDefaultDate(),
}
// Validate configuration
const validation = validateWorksheetConfig(configWithDate)
if (!validation.isValid || !validation.config) {
throw new Error(validation.errors?.join(', ') || 'Invalid configuration')
}
const response = await fetch('/api/create/worksheets/addition', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(configWithDate),
})
if (!response.ok) {
const errorResult = await response.json()
const errorMsg = errorResult.details
? `${errorResult.error}\n\n${errorResult.details}`
: errorResult.error || 'Generation failed'
throw new Error(errorMsg)
}
// Success - response is binary PDF data, trigger download
const blob = await response.blob()
const filename = `addition-worksheet-${formState.name || 'student'}-${Date.now()}.pdf`
// Create download link and trigger download
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
setGenerationStatus('idle')
} catch (err) {
console.error('Generation error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setGenerationStatus('error')
}
}
const handleNewGeneration = () => {
setGenerationStatus('idle')
setError(null)
}
return (
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
<div
data-component="addition-worksheet-page"
className={css({ minHeight: '100vh', bg: 'gray.50' })}
>
{/* Main Content */}
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
<div className={stack({ gap: '6', mb: '8' })}>
<div className={stack({ gap: '2', textAlign: 'center' })}>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
color: 'gray.900',
})}
>
{t('pageTitle')}
</h1>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
{t('pageSubtitle')}
</p>
</div>
</div>
{/* Configuration Interface */}
<div
className={grid({
columns: { base: 1, lg: 2 },
gap: '8',
alignItems: 'start',
})}
>
{/* Configuration Panel */}
<div
data-section="config-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '8',
})}
>
<ConfigPanel formState={formState} onChange={handleFormChange} />
</div>
{/* Preview & Generate Panel */}
<div className={stack({ gap: '8' })}>
{/* Preview */}
<div
data-section="preview-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '6',
})}
>
<WorksheetPreview formState={debouncedFormState} />
</div>
{/* Generate Button */}
<div
data-section="generate-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '6',
})}
>
<button
data-action="generate-worksheet"
onClick={handleGenerate}
disabled={generationStatus === 'generating'}
className={css({
w: 'full',
px: '6',
py: '4',
bg: 'brand.600',
color: 'white',
fontSize: 'lg',
fontWeight: 'semibold',
rounded: 'xl',
shadow: 'card',
transition: 'all',
cursor: generationStatus === 'generating' ? 'not-allowed' : 'pointer',
opacity: generationStatus === 'generating' ? '0.7' : '1',
_hover:
generationStatus === 'generating'
? {}
: {
bg: 'brand.700',
transform: 'translateY(-1px)',
shadow: 'modal',
},
})}
>
<span className={hstack({ gap: '3', justify: 'center' })}>
{generationStatus === 'generating' ? (
<>
<div
className={css({
w: '5',
h: '5',
border: '2px solid',
borderColor: 'white',
borderTopColor: 'transparent',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
{t('generate.generating')}
</>
) : (
<>
<div className={css({ fontSize: 'xl' })}>📝</div>
{t('generate.button')}
</>
)}
</span>
</button>
</div>
</div>
</div>
{/* Error Display */}
{generationStatus === 'error' && error && (
<div
data-status="error"
className={css({
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
rounded: '2xl',
p: '8',
mt: '8',
})}
>
<div className={stack({ gap: '4' })}>
<div className={hstack({ gap: '3', alignItems: 'center' })}>
<div className={css({ fontSize: '2xl' })}></div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'semibold',
color: 'red.800',
})}
>
{t('error.title')}
</h3>
</div>
<pre
className={css({
color: 'red.700',
lineHeight: 'relaxed',
whiteSpace: 'pre-wrap',
fontFamily: 'mono',
fontSize: 'sm',
overflowX: 'auto',
})}
>
{error}
</pre>
<button
data-action="try-again"
onClick={handleNewGeneration}
className={css({
alignSelf: 'start',
px: '4',
py: '2',
bg: 'red.600',
color: 'white',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'red.700' },
})}
>
{t('error.tryAgain')}
</button>
</div>
</div>
)}
</div>
</div>
</PageWithNav>
)
}

View File

@ -0,0 +1,184 @@
// Problem generation logic for double-digit addition worksheets
import type { AdditionProblem, ProblemCategory } from './types'
/**
* Mulberry32 PRNG for reproducible random number generation
*/
export function createPRNG(seed: number) {
let state = seed
return function rand(): number {
let t = (state += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* Pick a random element from an array
*/
function pick<T>(arr: T[], rand: () => number): T {
return arr[Math.floor(rand() * arr.length)]
}
/**
* Generate random integer between min and max (inclusive)
*/
function randint(min: number, max: number, rand: () => number): number {
return Math.floor(rand() * (max - min + 1)) + min
}
/**
* Generate a random two-digit number (10-99)
*/
function twoDigit(rand: () => number): number {
const tens = randint(1, 9, rand)
const ones = randint(0, 9, rand)
return tens * 10 + ones
}
/**
* Generate a problem with NO regrouping
* (ones sum < 10 AND tens sum < 10)
*/
export function generateNonRegroup(rand: () => number): [number, number] {
for (let i = 0; i < 5000; i++) {
const a = twoDigit(rand)
const b = twoDigit(rand)
const aT = Math.floor((a % 100) / 10)
const aO = a % 10
const bT = Math.floor((b % 100) / 10)
const bO = b % 10
if (aO + bO < 10 && aT + bT < 10) {
return [a, b]
}
}
// Fallback
return [12, 34]
}
/**
* Generate a problem with regrouping in ONES only
* (ones sum >= 10 AND tens sum + carry < 10)
*/
export function generateOnesOnly(rand: () => number): [number, number] {
for (let i = 0; i < 5000; i++) {
const a = twoDigit(rand)
const b = twoDigit(rand)
const aT = Math.floor((a % 100) / 10)
const aO = a % 10
const bT = Math.floor((b % 100) / 10)
const bO = b % 10
if (aO + bO >= 10 && aT + bT + 1 < 10) {
return [a, b]
}
}
// Fallback
return [58, 31]
}
/**
* Generate a problem with regrouping in BOTH ones and tens
* (ones sum >= 10 AND tens sum + carry >= 10)
*/
export function generateBoth(rand: () => number): [number, number] {
for (let i = 0; i < 5000; i++) {
const a = twoDigit(rand)
const b = twoDigit(rand)
const aT = Math.floor((a % 100) / 10)
const aO = a % 10
const bT = Math.floor((b % 100) / 10)
const bO = b % 10
if (aO + bO >= 10 && aT + bT + 1 >= 10) {
return [a, b]
}
}
// Fallback
return [68, 47]
}
/**
* Try to add a unique problem to the list
* Returns true if added, false if duplicate
*/
function uniquePush(list: AdditionProblem[], a: number, b: number, seen: Set<string>): boolean {
const key = [Math.min(a, b), Math.max(a, b)].join('+')
if (seen.has(key) || a === b) {
return false
}
seen.add(key)
list.push({ a, b })
return true
}
/**
* Generate a complete set of problems based on difficulty parameters
*/
export function generateProblems(
total: number,
pAnyStart: number,
pAllStart: number,
interpolate: boolean,
seed: number
): AdditionProblem[] {
const rand = createPRNG(seed)
const problems: AdditionProblem[] = []
const seen = new Set<string>()
for (let i = 0; i < total; i++) {
// Calculate position from start (0) to end (1)
const frac = total <= 1 ? 0 : i / (total - 1)
// Progressive difficulty: start easy, end hard
const difficultyMultiplier = interpolate ? frac : 1.0
// Effective probabilities at this position
const pAll = Math.max(0, Math.min(1, pAllStart * difficultyMultiplier))
const pAny = Math.max(0, Math.min(1, pAnyStart * difficultyMultiplier))
const pOnesOnly = Math.max(0, pAny - pAll)
const pNon = Math.max(0, 1 - pAny)
// Sample category based on probabilities
const r = rand()
let picked: ProblemCategory
if (r < pAll) {
picked = 'both'
} else if (r < pAll + pOnesOnly) {
picked = 'onesOnly'
} else {
picked = 'non'
}
// Generate problem with retries for uniqueness
let tries = 0
let ok = false
while (tries++ < 3000 && !ok) {
let a: number, b: number
if (picked === 'both') {
;[a, b] = generateBoth(rand)
} else if (picked === 'onesOnly') {
;[a, b] = generateOnesOnly(rand)
} else {
;[a, b] = generateNonRegroup(rand)
}
ok = uniquePush(problems, a, b, seen)
// If stuck, try a different category
if (!ok && tries % 50 === 0) {
picked = pick(['both', 'onesOnly', 'non'], rand)
}
}
// Last resort: add any valid two-digit problem
if (!ok) {
const a = twoDigit(rand)
const b = twoDigit(rand)
uniquePush(problems, a, b, seen)
}
}
return problems
}

View File

@ -0,0 +1,80 @@
// Type definitions for double-digit addition worksheet creator
/**
* Complete, validated configuration for worksheet generation
* All fields have concrete values (no undefined/null)
*/
export interface WorksheetConfig {
// Problem set
total: number
cols: number
rows: number
// Personalization
name: string
date: string
// Difficulty controls
pAnyStart: number // Share of problems requiring any regrouping at start (0-1)
pAllStart: number // Share requiring both ones and tens regrouping at start (0-1)
interpolate: boolean // Whether to linearly decay difficulty across sheet
// Layout
page: {
wIn: number
hIn: number
}
margins: {
left: number
right: number
top: number
bottom: number
}
// Display options
showCarryBoxes: boolean
showCellBorder: boolean
fontSize: number
seed: number
}
/**
* Partial form state - user may be editing, fields optional
*/
export interface WorksheetFormState {
total?: number
cols?: number
rows?: number
name?: string
date?: string
pAnyStart?: number
pAllStart?: number
interpolate?: boolean
showCarryBoxes?: boolean
showCellBorder?: boolean
fontSize?: number
seed?: number
orientation?: 'portrait' | 'landscape'
}
/**
* A single addition problem
*/
export interface AdditionProblem {
a: number
b: number
}
/**
* Validation result
*/
export interface ValidationResult {
isValid: boolean
config?: WorksheetConfig
errors?: string[]
}
/**
* Problem category for difficulty control
*/
export type ProblemCategory = 'non' | 'onesOnly' | 'both'

View File

@ -0,0 +1,97 @@
// Validation logic for worksheet configuration
import type { WorksheetFormState, WorksheetConfig, ValidationResult } from './types'
/**
* Get current date formatted as "Month Day, Year"
*/
function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
/**
* Validate and create complete config from partial form state
*/
export function validateWorksheetConfig(formState: WorksheetFormState): ValidationResult {
const errors: string[] = []
// Validate total (must be positive, reasonable limit)
const total = formState.total ?? 20
if (total < 1 || total > 100) {
errors.push('Total problems must be between 1 and 100')
}
// Validate cols and auto-calculate rows
const cols = formState.cols ?? 4
if (cols < 1 || cols > 10) {
errors.push('Columns must be between 1 and 10')
}
// Auto-calculate rows to fit all problems
const rows = Math.ceil(total / cols)
// Validate probabilities (0-1 range)
const pAnyStart = formState.pAnyStart ?? 0.75
const pAllStart = formState.pAllStart ?? 0.25
if (pAnyStart < 0 || pAnyStart > 1) {
errors.push('pAnyStart must be between 0 and 1')
}
if (pAllStart < 0 || pAllStart > 1) {
errors.push('pAllStart must be between 0 and 1')
}
if (pAllStart > pAnyStart) {
errors.push('pAllStart cannot be greater than pAnyStart')
}
// Validate fontSize
const fontSize = formState.fontSize ?? 16
if (fontSize < 8 || fontSize > 32) {
errors.push('Font size must be between 8 and 32')
}
// Validate seed (must be positive integer)
const seed = formState.seed ?? Date.now() % 2147483647
if (!Number.isInteger(seed) || seed < 0) {
errors.push('Seed must be a non-negative integer')
}
if (errors.length > 0) {
return { isValid: false, errors }
}
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
// Build complete config with defaults
const config: WorksheetConfig = {
total,
cols,
rows,
name: formState.name?.trim() || 'Student',
date: formState.date?.trim() || getDefaultDate(),
pAnyStart,
pAllStart,
interpolate: formState.interpolate ?? true,
page: {
wIn: orientation === 'portrait' ? 8.5 : 11,
hIn: orientation === 'portrait' ? 11 : 8.5,
},
margins: {
left: 0.6,
right: 0.6,
top: 1.1,
bottom: 0.7,
},
showCarryBoxes: formState.showCarryBoxes ?? true,
showCellBorder: formState.showCellBorder ?? true,
fontSize,
seed,
}
return { isValid: true, config }
}