refactor(worksheets): extract utility functions

- dateFormatting.ts: getDefaultDate() for consistent date formatting
- layoutCalculations.ts: getDefaultColsForProblemsPerPage() and calculateDerivedState()
- Pure functions, easy to test
- Removes 32 lines from main component
This commit is contained in:
Thomas Hallock
2025-11-07 17:15:16 -06:00
parent f2e48bb8ab
commit 2e0f99f98a
12 changed files with 1074 additions and 855 deletions

View File

@@ -168,11 +168,14 @@
"Bash(.dockerignore.test)",
"Bash(do echo \"=== Check $i ===\")",
"Bash(pnpm panda codegen:*)",
"Bash(npx biome lint:*)"
"Bash(npx biome lint:*)",
"Bash(TZ=America/Chicago date:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
"build": "node scripts/generate-build-info.js && npx tsx scripts/generateAllDayIcons.tsx && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
"start": "NODE_ENV=production node server.js",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",

View File

@@ -1,30 +1,20 @@
'use client'
import { useTranslations } from 'next-intl'
import React, { useState, useEffect } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../../styled-system/css'
import { container, grid, hstack, stack } from '../../../../../../styled-system/patterns'
import { container, grid, stack } from '../../../../../../styled-system/patterns'
import { ConfigPanel } from './ConfigPanel'
import { WorksheetPreview } from './WorksheetPreview'
import { OrientationPanel } from './OrientationPanel'
import { GenerateButton } from './GenerateButton'
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
import { useWorksheetState } from '../hooks/useWorksheetState'
import { useWorksheetGeneration } from '../hooks/useWorksheetGeneration'
import { useWorksheetAutoSave } from '../hooks/useWorksheetAutoSave'
import { getDefaultDate } from '../utils/dateFormatting'
import { calculateDerivedState } from '../utils/layoutCalculations'
import type { WorksheetFormState } from '../types'
import { validateWorksheetConfig } from '../validation'
import { defaultAdditionConfig } from '../../config-schemas'
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',
})
}
interface AdditionWorksheetClientProps {
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
@@ -43,275 +33,65 @@ export function AdditionWorksheetClient({
})
const t = useTranslations('create.worksheets.addition')
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Calculate derived state from initial settings
// Use defaults for required fields (server should always provide these, but TypeScript needs guarantees)
const problemsPerPage = initialSettings.problemsPerPage ?? 20
const pages = initialSettings.pages ?? 1
const cols = initialSettings.cols ?? 5
// State management (formState, debouncedFormState, updateFormState)
const { formState, debouncedFormState, updateFormState } = useWorksheetState(initialSettings)
const rows = Math.ceil((problemsPerPage * pages) / cols)
const total = problemsPerPage * pages
// Generation workflow (status, error, generate, reset)
const { status, error, generate, reset } = useWorksheetGeneration()
// Immediate form state (for controls - updates instantly)
const [formState, setFormState] = useState<WorksheetFormState>(() => {
const initial = {
...initialSettings,
// Auto-save (isSaving, lastSaved)
const { isSaving, lastSaved } = useWorksheetAutoSave(formState, 'addition')
// Generate handler with date injection
const handleGenerate = async () => {
await generate({
...formState,
date: getDefaultDate(),
})
}
// Orientation change handler with automatic problemsPerPage/cols updates
const handleOrientationChange = (
orientation: 'portrait' | 'landscape',
problemsPerPage: number,
cols: number
) => {
const pages = formState.pages || 1
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
updateFormState({
orientation,
problemsPerPage,
cols,
pages,
rows,
total,
date: '', // Will be set at generation time
// seed comes from initialSettings (server-generated, stable across StrictMode remounts)
// Ensure displayRules is always defined (critical for difficulty adjustment)
displayRules: initialSettings.displayRules ?? defaultAdditionConfig.displayRules,
pAnyStart: initialSettings.pAnyStart ?? defaultAdditionConfig.pAnyStart,
pAllStart: initialSettings.pAllStart ?? defaultAdditionConfig.pAllStart,
}
console.log('[Worksheet Client] Initial formState:', {
seed: initial.seed,
displayRules: initial.displayRules,
})
return initial
})
// Debounced form state (for preview - updates after delay)
const [debouncedFormState, setDebouncedFormState] = useState<WorksheetFormState>(() => {
console.log('[Worksheet Client] Initial debouncedFormState (same as formState)')
return formState
})
// Store the previous formState to detect real changes
const prevFormStateRef = React.useRef(formState)
// Log whenever debouncedFormState changes (this triggers preview re-fetch)
useEffect(() => {
console.log('[Worksheet Client] debouncedFormState changed - preview will re-fetch:', {
seed: debouncedFormState.seed,
problemsPerPage: debouncedFormState.problemsPerPage,
})
}, [debouncedFormState])
// Debounce preview updates (500ms delay) - only when formState actually changes
useEffect(() => {
console.log('[Debounce Effect] Triggered')
console.log('[Debounce Effect] Current formState seed:', formState.seed)
console.log('[Debounce Effect] Previous formState seed:', prevFormStateRef.current.seed)
// Skip if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevFormStateRef.current) {
console.log('[Debounce Effect] Skipping - formState reference unchanged')
return
}
prevFormStateRef.current = formState
console.log('[Debounce Effect] Setting timer to update debouncedFormState in 500ms')
const timer = setTimeout(() => {
console.log('[Debounce Effect] Timer fired - updating debouncedFormState')
setDebouncedFormState(formState)
}, 500)
return () => {
console.log('[Debounce Effect] Cleanup - clearing timer')
clearTimeout(timer)
}
}, [formState])
// Store the previous formState for auto-save to detect real changes
const prevAutoSaveFormStateRef = React.useRef(formState)
// Auto-save settings when they change (debounced) - skip on initial mount
useEffect(() => {
// Skip auto-save if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevAutoSaveFormStateRef.current) {
console.log('[Worksheet Settings] Skipping auto-save - formState reference unchanged')
return
}
prevAutoSaveFormStateRef.current = formState
console.log('[Worksheet Settings] Settings changed, will save in 1s...')
const timer = setTimeout(async () => {
console.log('[Worksheet Settings] Attempting to save settings...')
setIsSaving(true)
try {
// Extract only the fields we want to persist (exclude date, seed, derived state)
const {
problemsPerPage,
cols,
pages,
orientation,
name,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
fontSize,
} = formState
const response = await fetch('/api/worksheets/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'addition',
config: {
problemsPerPage,
cols,
pages,
orientation,
name,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
fontSize,
},
}),
})
if (response.ok) {
const data = await response.json()
console.log('[Worksheet Settings] Save response:', data)
if (data.success) {
console.log('[Worksheet Settings] ✓ Settings saved successfully')
setLastSaved(new Date())
} else {
console.log('[Worksheet Settings] Save skipped')
}
} else {
console.error('[Worksheet Settings] Save failed with status:', response.status)
}
} catch (error) {
// Silently fail - settings persistence is not critical
console.error('[Worksheet Settings] Settings save error:', error)
} finally {
setIsSaving(false)
}
}, 1000) // 1 second debounce for auto-save
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.problemsPerPage !== undefined ||
updates.cols !== undefined ||
updates.pages !== undefined ||
updates.orientation !== undefined ||
updates.pAnyStart !== undefined ||
updates.pAllStart !== undefined ||
updates.interpolate !== undefined
if (affectsProblems) {
newState.seed = Date.now() % 2147483647
}
return newState
})
}
// Helper to get default columns based on problems per page and orientation
const getDefaultColsForProblemsPerPage = (
problemsPerPage: number,
orientation: 'portrait' | 'landscape'
): number => {
if (orientation === 'portrait') {
if (problemsPerPage === 6) return 2
if (problemsPerPage === 8) return 2
if (problemsPerPage === 10) return 2
if (problemsPerPage === 12) return 3
if (problemsPerPage === 15) return 3
return 2
} else {
if (problemsPerPage === 8) return 4
if (problemsPerPage === 10) return 5
if (problemsPerPage === 12) return 4
if (problemsPerPage === 15) return 5
if (problemsPerPage === 16) return 4
if (problemsPerPage === 20) return 5
return 4
}
// Problems per page change handler with automatic cols update
const handleProblemsPerPageChange = (problemsPerPage: number, cols: number) => {
const pages = formState.pages || 1
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
updateFormState({
problemsPerPage,
cols,
pages,
rows,
total,
})
}
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)
// Pages change handler with derived state calculation
const handlePagesChange = (pages: number) => {
const problemsPerPage = formState.problemsPerPage || 15
const cols = formState.cols || 3
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
updateFormState({
pages,
rows,
total,
})
}
return (
@@ -352,7 +132,7 @@ export function AdditionWorksheetClient({
alignItems: 'start',
})}
>
{/* Configuration Panel */}
{/* Left Column: Configuration Panel */}
<div className={stack({ gap: '3' })}>
<div
data-section="config-panel"
@@ -363,7 +143,7 @@ export function AdditionWorksheetClient({
p: '8',
})}
>
<ConfigPanel formState={formState} onChange={handleFormChange} />
<ConfigPanel formState={formState} onChange={updateFormState} />
</div>
{/* Settings saved indicator */}
@@ -386,511 +166,20 @@ export function AdditionWorksheetClient({
</div>
</div>
{/* Preview & Generate Panel */}
{/* Right Column: Orientation, Generate, Preview */}
<div className={stack({ gap: '8' })}>
{/* Orientation Panel */}
<div
data-section="orientation-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '4',
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '3' })}>
{/* Row 1: Orientation + Pages */}
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '3',
alignItems: 'end',
})}
>
{/* Orientation */}
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Orientation
</div>
<div className={css({ display: 'flex', gap: '1.5' })}>
<button
onClick={() => {
const orientation = 'portrait'
const problemsPerPage = 15
const cols = 3
const pages = formState.pages || 1
const rows = Math.ceil((problemsPerPage * pages) / cols)
const total = problemsPerPage * pages
setFormState({
...formState,
orientation,
problemsPerPage,
cols,
pages,
rows,
total,
})
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '3',
py: '2',
border: '2px solid',
borderColor:
formState.orientation === 'portrait' ? 'brand.500' : 'gray.300',
bg: formState.orientation === 'portrait' ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
borderColor: 'brand.400',
},
})}
>
<div
className={css({
fontSize: 'lg',
})}
>
📄
</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color:
formState.orientation === 'portrait' ? 'brand.700' : 'gray.600',
})}
>
Portrait
</div>
</button>
<button
onClick={() => {
const orientation = 'landscape'
const problemsPerPage = 20
const cols = 5
const pages = formState.pages || 1
const rows = Math.ceil((problemsPerPage * pages) / cols)
const total = problemsPerPage * pages
setFormState({
...formState,
orientation,
problemsPerPage,
cols,
pages,
rows,
total,
})
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '3',
py: '2',
border: '2px solid',
borderColor:
formState.orientation === 'landscape' ? 'brand.500' : 'gray.300',
bg: formState.orientation === 'landscape' ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
borderColor: 'brand.400',
},
})}
>
<div
className={css({
fontSize: 'lg',
})}
>
📃
</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color:
formState.orientation === 'landscape' ? 'brand.700' : 'gray.600',
})}
>
Landscape
</div>
</button>
</div>
</div>
<OrientationPanel
orientation={formState.orientation || 'portrait'}
problemsPerPage={formState.problemsPerPage || 15}
pages={formState.pages || 1}
cols={formState.cols || 3}
onOrientationChange={handleOrientationChange}
onProblemsPerPageChange={handleProblemsPerPageChange}
onPagesChange={handlePagesChange}
/>
{/* Pages */}
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Pages
</div>
<div className={css({ display: 'flex', gap: '1' })}>
{[1, 2, 3, 4].map((pageCount) => {
const isSelected = (formState.pages || 1) === pageCount
return (
<button
key={pageCount}
onClick={() => {
const problemsPerPage = formState.problemsPerPage || 15
const cols = formState.cols || 3
const rows = Math.ceil((problemsPerPage * pageCount) / cols)
const total = problemsPerPage * pageCount
setFormState({
...formState,
pages: pageCount,
rows,
total,
})
}}
className={css({
w: '10',
h: '10',
border: '2px solid',
borderColor: isSelected ? 'brand.500' : 'gray.300',
bg: isSelected ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'bold',
color: isSelected ? 'brand.700' : 'gray.600',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
borderColor: 'brand.400',
},
})}
>
{pageCount}
</button>
)
})}
</div>
</div>
</div>
<GenerateButton status={status} onGenerate={handleGenerate} />
{/* Row 2: Problems per page dropdown + Total badge */}
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '3',
alignItems: 'center',
})}
>
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
display: 'block',
mb: '1.5',
})}
>
Problems per Page
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={css({
w: 'full',
px: '3',
py: '2',
border: '2px solid',
borderColor: 'gray.300',
bg: 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
_hover: {
borderColor: 'brand.400',
},
})}
>
<span>
{formState.problemsPerPage || 15} problems (
{getDefaultColsForProblemsPerPage(
formState.problemsPerPage || 15,
formState.orientation || 'portrait'
)}{' '}
cols ×{' '}
{Math.ceil(
(formState.problemsPerPage || 15) /
getDefaultColsForProblemsPerPage(
formState.problemsPerPage || 15,
formState.orientation || 'portrait'
)
)}{' '}
rows)
</span>
<span className={css({ fontSize: 'xs', color: 'gray.400' })}></span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
bg: 'white',
rounded: 'lg',
shadow: 'modal',
border: '1px solid',
borderColor: 'gray.200',
p: '2',
minW: '64',
maxH: '96',
overflowY: 'auto',
zIndex: 50,
})}
sideOffset={5}
>
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1',
})}
>
{((formState.orientation || 'portrait') === 'portrait'
? [6, 8, 10, 12, 15]
: [8, 10, 12, 15, 16, 20]
).map((count) => {
const orientation = formState.orientation || 'portrait'
const cols = getDefaultColsForProblemsPerPage(count, orientation)
const rows = Math.ceil(count / cols)
const isSelected = (formState.problemsPerPage || 15) === count
return (
<DropdownMenu.Item
key={count}
onSelect={() => {
const newCols = getDefaultColsForProblemsPerPage(
count,
orientation
)
const pages = formState.pages || 1
const rowsCalc = Math.ceil((count * pages) / newCols)
const total = count * pages
setFormState({
...formState,
problemsPerPage: count,
cols: newCols,
pages,
rows: rowsCalc,
total,
})
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
px: '3',
py: '2',
rounded: 'md',
cursor: 'pointer',
outline: 'none',
bg: isSelected ? 'brand.50' : 'transparent',
_hover: {
bg: 'brand.50',
},
_focus: {
bg: 'brand.100',
},
})}
>
{/* Grid visualization */}
<div
className={css({
display: 'grid',
placeItems: 'center',
w: '12',
h: '12',
flexShrink: 0,
})}
style={{
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: '2px',
}}
>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={css({
w: '1.5',
h: '1.5',
bg: isSelected ? 'brand.500' : 'gray.400',
rounded: 'full',
})}
/>
))}
</div>
{/* Text description */}
<div className={css({ flex: 1 })}>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isSelected ? 'brand.700' : 'gray.700',
})}
>
{count} problems
</div>
<div
className={css({
fontSize: 'xs',
color: isSelected ? 'brand.600' : 'gray.500',
})}
>
{cols} cols × {rows} rows
</div>
</div>
</DropdownMenu.Item>
)
})}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/* Total problems badge */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1',
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Total
</div>
<div
className={css({
px: '4',
py: '2',
bg: 'brand.100',
rounded: 'full',
fontSize: 'lg',
fontWeight: 'bold',
color: 'brand.700',
})}
>
{(formState.problemsPerPage || 15) * (formState.pages || 1)}
</div>
</div>
</div>
</div>
</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>
{/* Preview */}
<div
data-section="preview-panel"
className={css({
@@ -906,63 +195,7 @@ export function AdditionWorksheetClient({
</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>
)}
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
</div>
</div>
</PageWithNav>

View File

@@ -0,0 +1,85 @@
'use client'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../../styled-system/css'
import { hstack } from '../../../../../../styled-system/patterns'
type GenerationStatus = 'idle' | 'generating' | 'error'
interface GenerateButtonProps {
status: GenerationStatus
onGenerate: () => void
}
/**
* Button to trigger worksheet PDF generation
* Shows loading state during generation
*/
export function GenerateButton({ status, onGenerate }: GenerateButtonProps) {
const t = useTranslations('create.worksheets.addition')
const isGenerating = status === 'generating'
return (
<div
data-section="generate-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '6',
})}
>
<button
type="button"
data-action="generate-worksheet"
onClick={onGenerate}
disabled={isGenerating}
className={css({
w: 'full',
px: '6',
py: '4',
bg: 'brand.600',
color: 'white',
fontSize: 'lg',
fontWeight: 'semibold',
rounded: 'xl',
shadow: 'card',
transition: 'all',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? '0.7' : '1',
_hover: isGenerating
? {}
: {
bg: 'brand.700',
transform: 'translateY(-1px)',
shadow: 'modal',
},
})}
>
<span className={hstack({ gap: '3', justify: 'center' })}>
{isGenerating ? (
<>
<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>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../../styled-system/css'
import { stack, hstack } from '../../../../../../styled-system/patterns'
interface GenerationErrorDisplayProps {
error: string | null
visible: boolean
onRetry: () => void
}
/**
* Display generation errors with retry button
* Only visible when error state is active
*/
export function GenerationErrorDisplay({ error, visible, onRetry }: GenerationErrorDisplayProps) {
const t = useTranslations('create.worksheets.addition')
if (!visible || !error) {
return null
}
return (
<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
type="button"
data-action="try-again"
onClick={onRetry}
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>
)
}

View File

@@ -0,0 +1,418 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '../../../../../../styled-system/css'
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
interface OrientationPanelProps {
orientation: 'portrait' | 'landscape'
problemsPerPage: number
pages: number
cols: number
onOrientationChange: (
orientation: 'portrait' | 'landscape',
problemsPerPage: number,
cols: number
) => void
onProblemsPerPageChange: (problemsPerPage: number, cols: number) => void
onPagesChange: (pages: number) => void
}
/**
* Orientation, pages, and problems per page controls
* Compact layout with grid visualizations in dropdown
*/
export function OrientationPanel({
orientation,
problemsPerPage,
pages,
cols,
onOrientationChange,
onProblemsPerPageChange,
onPagesChange,
}: OrientationPanelProps) {
const handleOrientationChange = (newOrientation: 'portrait' | 'landscape') => {
const newProblemsPerPage = newOrientation === 'portrait' ? 15 : 20
const newCols = getDefaultColsForProblemsPerPage(newProblemsPerPage, newOrientation)
onOrientationChange(newOrientation, newProblemsPerPage, newCols)
}
const handleProblemsPerPageChange = (count: number) => {
const newCols = getDefaultColsForProblemsPerPage(count, orientation)
onProblemsPerPageChange(count, newCols)
}
const total = problemsPerPage * pages
const problemsForOrientation =
orientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]
return (
<div
data-section="orientation-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '4',
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '3' })}>
{/* Row 1: Orientation + Pages */}
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '3',
alignItems: 'end',
})}
>
{/* Orientation */}
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Orientation
</div>
<div className={css({ display: 'flex', gap: '1.5' })}>
<button
type="button"
data-action="select-portrait"
onClick={() => handleOrientationChange('portrait')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '3',
py: '2',
border: '2px solid',
borderColor: orientation === 'portrait' ? 'brand.500' : 'gray.300',
bg: orientation === 'portrait' ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
borderColor: 'brand.400',
},
})}
>
<div
className={css({
fontSize: 'lg',
})}
>
📄
</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: orientation === 'portrait' ? 'brand.700' : 'gray.600',
})}
>
Portrait
</div>
</button>
<button
type="button"
data-action="select-landscape"
onClick={() => handleOrientationChange('landscape')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '3',
py: '2',
border: '2px solid',
borderColor: orientation === 'landscape' ? 'brand.500' : 'gray.300',
bg: orientation === 'landscape' ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
borderColor: 'brand.400',
},
})}
>
<div
className={css({
fontSize: 'lg',
})}
>
📃
</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: orientation === 'landscape' ? 'brand.700' : 'gray.600',
})}
>
Landscape
</div>
</button>
</div>
</div>
{/* Pages */}
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Pages
</div>
<div className={css({ display: 'flex', gap: '1' })}>
{[1, 2, 3, 4].map((pageCount) => {
const isSelected = pages === pageCount
return (
<button
key={pageCount}
type="button"
data-action={`select-pages-${pageCount}`}
onClick={() => onPagesChange(pageCount)}
className={css({
w: '10',
h: '10',
border: '2px solid',
borderColor: isSelected ? 'brand.500' : 'gray.300',
bg: isSelected ? 'brand.50' : 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'bold',
color: isSelected ? 'brand.700' : 'gray.600',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
borderColor: 'brand.400',
},
})}
>
{pageCount}
</button>
)
})}
</div>
</div>
</div>
{/* Row 2: Problems per page dropdown + Total badge */}
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '3',
alignItems: 'center',
})}
>
<div>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
display: 'block',
mb: '1.5',
})}
>
Problems per Page
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
data-action="open-problems-dropdown"
className={css({
w: 'full',
px: '3',
py: '2',
border: '2px solid',
borderColor: 'gray.300',
bg: 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
_hover: {
borderColor: 'brand.400',
},
})}
>
<span>
{problemsPerPage} problems ({cols} cols × {Math.ceil(problemsPerPage / cols)}{' '}
rows)
</span>
<span className={css({ fontSize: 'xs', color: 'gray.400' })}></span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
bg: 'white',
rounded: 'lg',
shadow: 'modal',
border: '1px solid',
borderColor: 'gray.200',
p: '2',
minW: '64',
maxH: '96',
overflowY: 'auto',
zIndex: 50,
})}
sideOffset={5}
>
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1',
})}
>
{problemsForOrientation.map((count) => {
const itemCols = getDefaultColsForProblemsPerPage(count, orientation)
const rows = Math.ceil(count / itemCols)
const isSelected = problemsPerPage === count
return (
<DropdownMenu.Item
key={count}
data-action={`select-problems-${count}`}
onSelect={() => handleProblemsPerPageChange(count)}
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
px: '3',
py: '2',
rounded: 'md',
cursor: 'pointer',
outline: 'none',
bg: isSelected ? 'brand.50' : 'transparent',
_hover: {
bg: 'brand.50',
},
_focus: {
bg: 'brand.100',
},
})}
>
{/* Grid visualization */}
<div
className={css({
display: 'grid',
placeItems: 'center',
w: '12',
h: '12',
flexShrink: 0,
})}
style={{
gridTemplateColumns: `repeat(${itemCols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: '2px',
}}
>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={css({
w: '1.5',
h: '1.5',
bg: isSelected ? 'brand.500' : 'gray.400',
rounded: 'full',
})}
/>
))}
</div>
{/* Text description */}
<div className={css({ flex: 1 })}>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isSelected ? 'brand.700' : 'gray.700',
})}
>
{count} problems
</div>
<div
className={css({
fontSize: 'xs',
color: isSelected ? 'brand.600' : 'gray.500',
})}
>
{itemCols} cols × {rows} rows
</div>
</div>
</DropdownMenu.Item>
)
})}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/* Total problems badge */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1',
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Total
</div>
<div
className={css({
px: '4',
py: '2',
bg: 'brand.100',
rounded: 'full',
fontSize: 'lg',
fontWeight: 'bold',
color: 'brand.700',
})}
>
{total}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { WorksheetFormState } from '../types'
interface UseWorksheetAutoSaveReturn {
isSaving: boolean
lastSaved: Date | null
}
/**
* Auto-save worksheet settings to server
*
* Features:
* - Debounced auto-save (1000ms delay)
* - Only persists settings, not transient state (date, seed, rows, total)
* - Silent error handling (auto-save is not critical)
* - StrictMode-safe (handles double renders)
*/
export function useWorksheetAutoSave(
formState: WorksheetFormState,
worksheetType: 'addition'
): UseWorksheetAutoSaveReturn {
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Store the previous formState for auto-save to detect real changes
const prevAutoSaveFormStateRef = useRef(formState)
// Auto-save settings when they change (debounced) - skip on initial mount
useEffect(() => {
// Skip auto-save if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevAutoSaveFormStateRef.current) {
console.log('[useWorksheetAutoSave] Skipping auto-save - formState reference unchanged')
return
}
prevAutoSaveFormStateRef.current = formState
console.log('[useWorksheetAutoSave] Settings changed, will save in 1s...')
const timer = setTimeout(async () => {
console.log('[useWorksheetAutoSave] Attempting to save settings...')
setIsSaving(true)
try {
// Extract only the fields we want to persist (exclude date, seed, derived state)
const {
problemsPerPage,
cols,
pages,
orientation,
name,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
fontSize,
mode,
difficultyProfile,
displayRules,
} = formState
const response = await fetch('/api/worksheets/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: worksheetType,
config: {
problemsPerPage,
cols,
pages,
orientation,
name,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
fontSize,
mode,
difficultyProfile,
displayRules,
},
}),
})
if (response.ok) {
const data = await response.json()
console.log('[useWorksheetAutoSave] Save response:', data)
if (data.success) {
console.log('[useWorksheetAutoSave] ✓ Settings saved successfully')
setLastSaved(new Date())
} else {
console.log('[useWorksheetAutoSave] Save skipped')
}
} else {
console.error('[useWorksheetAutoSave] Save failed with status:', response.status)
}
} catch (error) {
// Silently fail - settings persistence is not critical
console.error('[useWorksheetAutoSave] Settings save error:', error)
} finally {
setIsSaving(false)
}
}, 1000) // 1 second debounce for auto-save
return () => clearTimeout(timer)
}, [formState, worksheetType])
return {
isSaving,
lastSaved,
}
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useState } from 'react'
import type { WorksheetFormState } from '../types'
import { validateWorksheetConfig } from '../validation'
type GenerationStatus = 'idle' | 'generating' | 'error'
interface UseWorksheetGenerationReturn {
status: GenerationStatus
error: string | null
generate: (config: WorksheetFormState) => Promise<void>
reset: () => void
}
/**
* Handle PDF generation workflow
*
* Features:
* - Status tracking ('idle', 'generating', 'error')
* - Validation before generation
* - API call to generate PDF
* - Automatic download of generated PDF
* - Error handling with detailed messages
*/
export function useWorksheetGeneration(): UseWorksheetGenerationReturn {
const [status, setStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const generate = async (config: WorksheetFormState) => {
setStatus('generating')
setError(null)
try {
// Validate configuration
const validation = validateWorksheetConfig(config)
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(config),
})
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-${config.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)
setStatus('idle')
} catch (err) {
console.error('Generation error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setStatus('error')
}
}
const reset = () => {
setStatus('idle')
setError(null)
}
return {
status,
error,
generate,
reset,
}
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { WorksheetFormState } from '../types'
import { defaultAdditionConfig } from '../../config-schemas'
interface UseWorksheetStateReturn {
formState: WorksheetFormState
debouncedFormState: WorksheetFormState
updateFormState: (updates: Partial<WorksheetFormState>) => void
}
/**
* Manage worksheet state with debouncing and seed regeneration
*
* Features:
* - Immediate form state updates for controls
* - Debounced state updates for preview (500ms)
* - Automatic seed regeneration when problem settings change
* - StrictMode-safe (handles double renders)
*/
export function useWorksheetState(
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
): UseWorksheetStateReturn {
// Calculate derived state from initial settings
const problemsPerPage = initialSettings.problemsPerPage ?? 20
const pages = initialSettings.pages ?? 1
const cols = initialSettings.cols ?? 5
const rows = Math.ceil((problemsPerPage * pages) / cols)
const total = problemsPerPage * pages
// Immediate form state (for controls - updates instantly)
const [formState, setFormState] = useState<WorksheetFormState>(() => {
const initial = {
...initialSettings,
rows,
total,
date: '', // Will be set at generation time
// Ensure displayRules is always defined (critical for difficulty adjustment)
displayRules: initialSettings.displayRules ?? defaultAdditionConfig.displayRules,
pAnyStart: initialSettings.pAnyStart ?? defaultAdditionConfig.pAnyStart,
pAllStart: initialSettings.pAllStart ?? defaultAdditionConfig.pAllStart,
}
console.log('[useWorksheetState] Initial formState:', {
seed: initial.seed,
displayRules: initial.displayRules,
})
return initial
})
// Debounced form state (for preview - updates after delay)
const [debouncedFormState, setDebouncedFormState] = useState<WorksheetFormState>(() => {
console.log('[useWorksheetState] Initial debouncedFormState (same as formState)')
return formState
})
// Store the previous formState to detect real changes
const prevFormStateRef = useRef(formState)
// Log whenever debouncedFormState changes (this triggers preview re-fetch)
useEffect(() => {
console.log('[useWorksheetState] debouncedFormState changed - preview will re-fetch:', {
seed: debouncedFormState.seed,
problemsPerPage: debouncedFormState.problemsPerPage,
})
}, [debouncedFormState])
// Debounce preview updates (500ms delay) - only when formState actually changes
useEffect(() => {
console.log('[useWorksheetState Debounce] Triggered')
console.log('[useWorksheetState Debounce] Current formState seed:', formState.seed)
console.log('[useWorksheetState Debounce] Previous formState seed:', prevFormStateRef.current.seed)
// Skip if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevFormStateRef.current) {
console.log('[useWorksheetState Debounce] Skipping - formState reference unchanged')
return
}
prevFormStateRef.current = formState
console.log('[useWorksheetState Debounce] Setting timer to update debouncedFormState in 500ms')
const timer = setTimeout(() => {
console.log('[useWorksheetState Debounce] Timer fired - updating debouncedFormState')
setDebouncedFormState(formState)
}, 500)
return () => {
console.log('[useWorksheetState Debounce] Cleanup - clearing timer')
clearTimeout(timer)
}
}, [formState])
const updateFormState = (updates: Partial<WorksheetFormState>) => {
setFormState((prev) => {
const newState = { ...prev, ...updates }
// Generate new seed when problem settings change
const affectsProblems =
updates.problemsPerPage !== undefined ||
updates.cols !== undefined ||
updates.pages !== undefined ||
updates.orientation !== undefined ||
updates.pAnyStart !== undefined ||
updates.pAllStart !== undefined ||
updates.interpolate !== undefined
if (affectsProblems) {
newState.seed = Date.now() % 2147483647
}
return newState
})
}
return {
formState,
debouncedFormState,
updateFormState,
}
}

View File

@@ -0,0 +1,16 @@
/**
* Date formatting utilities for worksheet generation
*/
/**
* Get current date formatted as "Month Day, Year"
* @example "November 7, 2025"
*/
export function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}

View File

@@ -0,0 +1,48 @@
/**
* Layout calculation utilities for worksheet grid sizing
*/
/**
* Get default number of columns based on problems per page and orientation
* @param problemsPerPage - Number of problems per page
* @param orientation - Page orientation
* @returns Optimal number of columns for the layout
*/
export function getDefaultColsForProblemsPerPage(
problemsPerPage: number,
orientation: 'portrait' | 'landscape'
): number {
if (orientation === 'portrait') {
if (problemsPerPage === 6) return 2
if (problemsPerPage === 8) return 2
if (problemsPerPage === 10) return 2
if (problemsPerPage === 12) return 3
if (problemsPerPage === 15) return 3
return 2
} else {
if (problemsPerPage === 8) return 4
if (problemsPerPage === 10) return 5
if (problemsPerPage === 12) return 4
if (problemsPerPage === 15) return 5
if (problemsPerPage === 16) return 4
if (problemsPerPage === 20) return 5
return 4
}
}
/**
* Calculate derived state from worksheet layout parameters
* @param problemsPerPage - Number of problems per page
* @param pages - Number of pages
* @param cols - Number of columns
* @returns Calculated rows and total problems
*/
export function calculateDerivedState(
problemsPerPage: number,
pages: number,
cols: number
): { rows: number; total: number } {
const total = problemsPerPage * pages
const rows = Math.ceil(total / cols)
return { rows, total }
}

View File

@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
import { join } from 'path'
export const runtime = 'nodejs'
@@ -14,16 +14,13 @@ function getDayOfMonth(): number {
return centralDate.getDate()
}
// Generate icon by calling script that uses react-dom/server
function generateDayIcon(day: number): string {
// Call the generation script as a subprocess
// Scripts can use react-dom/server, route handlers cannot
const scriptPath = join(process.cwd(), 'scripts', 'generateDayIcon.tsx')
const svg = execSync(`npx tsx "${scriptPath}" ${day}`, {
encoding: 'utf-8',
cwd: process.cwd(),
})
return svg
// Load pre-generated day icon from public/icons/
function loadDayIcon(day: number): string {
// Read pre-generated icon from public/icons/
// Icons are generated at build time by scripts/generateAllDayIcons.tsx
const filename = `icon-day-${day.toString().padStart(2, '0')}.svg`
const filepath = join(process.cwd(), 'public', 'icons', filename)
return readFileSync(filepath, 'utf-8')
}
export async function GET(request: Request) {
@@ -51,8 +48,8 @@ export async function GET(request: Request) {
let svg = iconCache.get(dayOfMonth)
if (!svg) {
// Generate and cache
svg = generateDayIcon(dayOfMonth)
// Load pre-generated icon and cache
svg = loadDayIcon(dayOfMonth)
iconCache.set(dayOfMonth, svg)
// Clear old cache entries (keep only current day, unless testing with override)