diff --git a/apps/web/src/app/create/worksheets/addition/page.tsx b/apps/web/src/app/create/worksheets/addition/page.tsx new file mode 100644 index 00000000..152d984e --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/page.tsx @@ -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('idle') + const [error, setError] = useState(null) + + // Immediate form state (for controls - updates instantly) + const [formState, setFormState] = useState({ + 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(formState) + + // Debounce preview updates (500ms delay) + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedFormState(formState) + }, 500) + + return () => clearTimeout(timer) + }, [formState]) + + const handleFormChange = (updates: Partial) => { + 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 ( + +
+ {/* Main Content */} +
+
+
+

+ {t('pageTitle')} +

+

+ {t('pageSubtitle')} +

+
+
+ + {/* Configuration Interface */} +
+ {/* Configuration Panel */} +
+ +
+ + {/* Preview & Generate Panel */} +
+ {/* Preview */} +
+ +
+ + {/* Generate Button */} +
+ +
+
+
+ + {/* Error Display */} + {generationStatus === 'error' && error && ( +
+
+
+
+

+ {t('error.title')} +

+
+
+                  {error}
+                
+ +
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/app/create/worksheets/addition/problemGenerator.ts b/apps/web/src/app/create/worksheets/addition/problemGenerator.ts new file mode 100644 index 00000000..060481ff --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/problemGenerator.ts @@ -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(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): 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() + + 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 +} diff --git a/apps/web/src/app/create/worksheets/addition/types.ts b/apps/web/src/app/create/worksheets/addition/types.ts new file mode 100644 index 00000000..85ac5ef2 --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/types.ts @@ -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' diff --git a/apps/web/src/app/create/worksheets/addition/validation.ts b/apps/web/src/app/create/worksheets/addition/validation.ts new file mode 100644 index 00000000..c27d83be --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/validation.ts @@ -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 } +}