feat(worksheets): implement auto-save and load for worksheet settings

API Routes:
- GET /api/worksheets/settings?type=addition - Load user's saved config
- POST /api/worksheets/settings - Save current config (upsert)
- Uses parseAdditionConfig() for validation and migration
- Returns defaults if no saved settings exist

UI Integration:
- Load saved settings on mount (preserves derived state)
- Auto-save settings 1 second after changes (debounced)
- Only save after initial load completes (prevents overwriting on mount)
- Exclude transient fields (date, seed, rows, total) from persistence
- Show "Saving settings..." and "✓ Settings saved at HH:MM:SS" indicator

User Experience:
- Settings persist across sessions automatically
- New seed generated on each page load (fresh problems)
- Date field always starts empty (filled at generation time)
- Derived state (rows, total) recalculated from primary state

Technical Details:
- Type-safe with Zod validation
- Automatic schema migration (v1 → v2 when we add fields)
- Graceful fallback to defaults on errors
- 1s debounce prevents excessive saves during rapid changes

🤖 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-06 06:47:45 -06:00
parent 42e1a71292
commit 186fa81b08
2 changed files with 289 additions and 10 deletions

View File

@ -0,0 +1,155 @@
import { eq, and } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
import {
parseAdditionConfig,
serializeAdditionConfig,
defaultAdditionConfig,
type AdditionConfigV1,
} from '@/app/create/worksheets/config-schemas'
/**
* GET /api/worksheets/settings?type=addition
* Load user's saved worksheet settings
*
* Query params:
* - type: 'addition' | 'subtraction' | etc.
*
* Returns:
* - config: Parsed and validated config (latest version)
* - exists: boolean (true if user has saved settings)
*/
export async function GET(req: NextRequest) {
try {
const viewerId = await getViewerId()
const { searchParams } = new URL(req.url)
const worksheetType = searchParams.get('type')
if (!worksheetType) {
return NextResponse.json({ error: 'Missing type parameter' }, { status: 400 })
}
// Only 'addition' is supported for now
if (worksheetType !== 'addition') {
return NextResponse.json({ error: `Unsupported worksheet type: ${worksheetType}` }, { status: 400 })
}
// Look up user's saved settings
const [row] = await db
.select()
.from(schema.worksheetSettings)
.where(
and(
eq(schema.worksheetSettings.userId, viewerId),
eq(schema.worksheetSettings.worksheetType, worksheetType)
)
)
.limit(1)
if (!row) {
// No saved settings, return defaults
return NextResponse.json({
config: defaultAdditionConfig,
exists: false,
})
}
// Parse and validate config (auto-migrates to latest version)
const config = parseAdditionConfig(row.config)
return NextResponse.json({
config,
exists: true,
})
} catch (error: any) {
console.error('Failed to load worksheet settings:', error)
return NextResponse.json({ error: 'Failed to load worksheet settings' }, { status: 500 })
}
}
/**
* POST /api/worksheets/settings
* Save user's worksheet settings
*
* Body:
* - type: 'addition' | 'subtraction' | etc.
* - config: Config object (version will be added automatically)
*
* Returns:
* - success: boolean
* - id: string (worksheet_settings row id)
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
const { type: worksheetType, config } = body
if (!worksheetType) {
return NextResponse.json({ error: 'Missing type field' }, { status: 400 })
}
if (!config) {
return NextResponse.json({ error: 'Missing config field' }, { status: 400 })
}
// Only 'addition' is supported for now
if (worksheetType !== 'addition') {
return NextResponse.json({ error: `Unsupported worksheet type: ${worksheetType}` }, { status: 400 })
}
// Serialize config (adds version automatically)
const configJson = serializeAdditionConfig(config)
// Check if user already has settings for this type
const [existing] = await db
.select()
.from(schema.worksheetSettings)
.where(
and(
eq(schema.worksheetSettings.userId, viewerId),
eq(schema.worksheetSettings.worksheetType, worksheetType)
)
)
.limit(1)
const now = new Date()
if (existing) {
// Update existing row
await db
.update(schema.worksheetSettings)
.set({
config: configJson,
updatedAt: now,
})
.where(eq(schema.worksheetSettings.id, existing.id))
return NextResponse.json({
success: true,
id: existing.id,
})
} else {
// Insert new row
const id = crypto.randomUUID()
await db.insert(schema.worksheetSettings).values({
id,
userId: viewerId,
worksheetType,
config: configJson,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({
success: true,
id,
})
}
} catch (error: any) {
console.error('Failed to save worksheet settings:', error)
return NextResponse.json({ error: 'Failed to save worksheet settings' }, { status: 500 })
}
}

View File

@ -28,6 +28,9 @@ export default function AdditionWorksheetPage() {
const t = useTranslations('create.worksheets.addition')
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [settingsLoaded, setSettingsLoaded] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Immediate form state (for controls - updates instantly)
// PRIMARY state: problemsPerPage, cols, pages (what user controls)
@ -70,6 +73,104 @@ export default function AdditionWorksheetPage() {
return () => clearTimeout(timer)
}, [formState])
// Load saved settings on mount
useEffect(() => {
async function loadSettings() {
try {
const response = await fetch('/api/worksheets/settings?type=addition')
if (!response.ok) throw new Error('Failed to load settings')
const data = await response.json()
if (data.exists && data.config) {
// Load saved config, but preserve derived state
const rows = Math.ceil((data.config.problemsPerPage * data.config.pages) / data.config.cols)
const total = data.config.problemsPerPage * data.config.pages
setFormState({
...data.config,
rows,
total,
date: '', // Always start with empty date
seed: Date.now() % 2147483647, // Generate new seed
})
}
} catch (error) {
console.error('Failed to load worksheet settings:', error)
// Continue with defaults
} finally {
setSettingsLoaded(true)
}
}
loadSettings()
}, [])
// Auto-save settings when they change (debounced, only after initial load)
useEffect(() => {
if (!settingsLoaded) return // Don't save until we've loaded initial settings
const timer = setTimeout(async () => {
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) {
setLastSaved(new Date())
}
} catch (error) {
console.error('Failed to save worksheet settings:', error)
} finally {
setIsSaving(false)
}
}, 1000) // 1 second debounce for auto-save
return () => clearTimeout(timer)
}, [formState, settingsLoaded])
const handleFormChange = (updates: Partial<WorksheetFormState>) => {
setFormState((prev) => {
const newState = { ...prev, ...updates }
@ -192,16 +293,39 @@ export default function AdditionWorksheetPage() {
})}
>
{/* Configuration Panel */}
<div
data-section="config-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '8',
})}
>
<ConfigPanel formState={formState} onChange={handleFormChange} />
<div className={stack({ gap: '3' })}>
<div
data-section="config-panel"
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '8',
})}
>
<ConfigPanel formState={formState} onChange={handleFormChange} />
</div>
{/* Settings saved indicator */}
{settingsLoaded && (
<div
data-element="settings-status"
className={css({
fontSize: 'sm',
color: 'gray.600',
textAlign: 'center',
py: '2',
})}
>
{isSaving ? (
<span className={css({ color: 'gray.500' })}>Saving settings...</span>
) : lastSaved ? (
<span className={css({ color: 'green.600' })}>
Settings saved at {lastSaved.toLocaleTimeString()}
</span>
) : null}
</div>
)}
</div>
{/* Preview & Generate Panel */}